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::functions::config::{BuiltinKind, builtin_function_kind, can_dispatch_in};
23use crate::engine::functions::{BoxedFunctionHandler, FunctionConfig, TemplateCompiler};
24use crate::engine::secrets::{SECRET_OPERATOR, Secrets};
25use crate::engine::steps::{StepKind, walk_authored_steps};
26use crate::engine::workflow::Workflow;
27use serde_json::Value;
28use std::collections::HashMap;
29use std::fmt;
30
31/// One problem with a workflow definition.
32///
33/// Shared by [`Workflow::validate_authored`] and, from the registry side,
34/// `EngineBuilder::check_workflow`. Both fill what they genuinely know: a
35/// definition check always has an authored coordinate and knows the step id
36/// when the problem concerns a step; a registry check always has a task id and
37/// reports a path relative to that task.
38#[derive(Debug, Clone, PartialEq, Eq)]
39pub struct WorkflowIssue {
40    /// Stable machine-readable classification.
41    pub code: IssueCode,
42    /// Human-readable explanation. Not stable — branch on [`Self::code`].
43    pub message: String,
44    /// Where the problem is. From [`Workflow::validate_authored`] this is the
45    /// coordinate the author typed, rooted at the workflow document:
46    /// `tasks[1].tasks[0].id`.
47    pub path: Option<String>,
48    /// The step this concerns, when it concerns one. Step ids are unique across
49    /// tasks *and* groups, so this identifies a step on its own.
50    pub task_id: Option<String>,
51}
52
53impl WorkflowIssue {
54    fn at(code: IssueCode, path: impl Into<String>, message: impl Into<String>) -> Self {
55        Self {
56            code,
57            message: message.into(),
58            path: Some(path.into()),
59            task_id: None,
60        }
61    }
62
63    fn with_step(mut self, id: Option<&str>) -> Self {
64        self.task_id = id.map(str::to_string);
65        self
66    }
67}
68
69impl fmt::Display for WorkflowIssue {
70    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
71        match &self.path {
72            Some(path) => write!(f, "{path}: {} [{}]", self.message, self.code.as_str()),
73            None => write!(f, "{} [{}]", self.message, self.code.as_str()),
74        }
75    }
76}
77
78/// Why a workflow definition is not loadable.
79///
80/// `#[non_exhaustive]`: a later minor may add a rule, and a host matching on
81/// the codes it cares about should keep compiling. Use [`Self::as_str`] to
82/// serialize.
83#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
84#[non_exhaustive]
85pub enum IssueCode {
86    /// `id` is missing or empty.
87    EmptyWorkflowId,
88    /// `name` is missing or empty.
89    EmptyWorkflowName,
90    /// `tasks` is missing, not an array, or empty.
91    NoTasks,
92    /// A step carries no `id`.
93    MissingStepId,
94    /// Two steps share an id. Groups share the task id namespace.
95    DuplicateStepId,
96    /// A group's `tasks` is not a non-empty array.
97    EmptyGroup,
98    /// A group is nested at or beyond
99    /// [`MAX_GROUP_DEPTH`](crate::engine::steps::MAX_GROUP_DEPTH).
100    GroupTooDeep,
101    /// A task carries no `function`.
102    MissingFunction,
103    /// `function` is not an object, or its `name` is missing or empty.
104    InvalidFunctionName,
105    /// `terminal` is present but not a boolean.
106    InvalidTerminal,
107    /// `loop.increment` is below 1 — the counter would never reach `max`.
108    LoopIncrementTooSmall,
109    /// `loop.max` is not greater than `loop.init` — no sweep could ever run.
110    LoopBoundEmpty,
111    /// `loop.counter` is not a non-empty dotted path.
112    LoopCounterInvalid,
113    /// No handler will dispatch this function name, and it is not a built-in.
114    /// Usually a typo or a handler the host forgot to register.
115    UnknownFunction,
116    /// The name *is* a built-in, but one that ships as a config schema only
117    /// (`http_call`, `enrich`, `publish_kafka`) and no handler is registered
118    /// under it. The workflow builds cleanly and then fails every message.
119    MissingHandler,
120    /// A custom task's `input` does not deserialize into its handler's declared
121    /// `Input` type.
122    InputParse,
123    /// A `Template` field of a custom task's input does not compile.
124    TemplateCompile,
125    /// An expression reads `{"secret": "name"}` and no secret of that name is
126    /// declared on the engine (`EngineBuilder::with_secrets`). Only literal
127    /// names are checked; a dynamic name fails at evaluation instead.
128    UnknownSecret,
129    /// An expression whose result the engine writes to the message or emits to
130    /// a log reads a secret — a `map` mapping, or a `log` message or field. The
131    /// store exists so a value is never recorded; an expression that would
132    /// record it is refused outright, derived or not. Compute derived values in
133    /// a custom handler.
134    SecretInMessageWrite,
135    /// The store passed to
136    /// [`EngineBuilder::with_secrets`](crate::EngineBuilder::with_secrets) is
137    /// not a JSON object, so no name resolves and
138    /// [`EngineBuilder::build`](crate::EngineBuilder::build) will fail.
139    /// Reported by
140    /// [`EngineBuilder::check_workflow`](crate::EngineBuilder::check_workflow)
141    /// *instead of* the [`Self::UnknownSecret`] issues every literal name would
142    /// otherwise produce — the workflow is not what is wrong.
143    InvalidSecretStore,
144    /// Two keys in one template object collapse to the same name once the
145    /// template-key escape is stripped — `{"$a": 1, "a": 2}` emits `a` twice.
146    /// The context is a `Vec` of pairs, so both survive: a later read sees only
147    /// the first while serialization emits both. Always a bug, so
148    /// [`crate::EngineBuilder::build`] refuses it.
149    DuplicateTemplateKey,
150    /// A template key carries the escape prefix, so it is emitted with one
151    /// prefix stripped: `$type` emits `type`. **Informational** — reported by
152    /// [`crate::Engine::check_workflow`] and never by
153    /// [`crate::EngineBuilder::build`].
154    ///
155    /// Exists for migration. The escape strips uniformly from every template
156    /// key, so a workflow written before 3.9 that emits genuinely `$`-prefixed
157    /// keys — MongoDB's `$set`/`$oid`, JSON Schema's `$schema`/`$ref` — changes
158    /// what it produces, silently. This lists every one so the audit is
159    /// mechanical rather than archaeological.
160    EscapedTemplateKey,
161    /// The document does not deserialize into a [`Workflow`]. Carries the
162    /// parser's own message, which names the offending field and type.
163    ParseFailed,
164    /// The document parses but [`Workflow::validate`] rejects it. A backstop:
165    /// reaching this means a rule exists that the checks above do not model.
166    ValidateFailed,
167}
168
169impl IssueCode {
170    /// The stable string form, for serializing into an API response.
171    pub fn as_str(&self) -> &'static str {
172        match self {
173            Self::EmptyWorkflowId => "EMPTY_WORKFLOW_ID",
174            Self::EmptyWorkflowName => "EMPTY_WORKFLOW_NAME",
175            Self::NoTasks => "NO_TASKS",
176            Self::MissingStepId => "MISSING_STEP_ID",
177            Self::DuplicateStepId => "DUPLICATE_STEP_ID",
178            Self::EmptyGroup => "EMPTY_GROUP",
179            Self::GroupTooDeep => "GROUP_TOO_DEEP",
180            Self::MissingFunction => "MISSING_FUNCTION",
181            Self::InvalidFunctionName => "INVALID_FUNCTION_NAME",
182            Self::InvalidTerminal => "INVALID_TERMINAL",
183            Self::LoopIncrementTooSmall => "LOOP_INCREMENT_TOO_SMALL",
184            Self::LoopBoundEmpty => "LOOP_BOUND_EMPTY",
185            Self::LoopCounterInvalid => "LOOP_COUNTER_INVALID",
186            Self::UnknownFunction => "UNKNOWN_FUNCTION",
187            Self::MissingHandler => "MISSING_HANDLER",
188            Self::InputParse => "INPUT_PARSE",
189            Self::TemplateCompile => "TEMPLATE_COMPILE",
190            Self::UnknownSecret => "UNKNOWN_SECRET",
191            Self::SecretInMessageWrite => "SECRET_IN_MESSAGE_WRITE",
192            Self::InvalidSecretStore => "INVALID_SECRET_STORE",
193            Self::DuplicateTemplateKey => "DUPLICATE_TEMPLATE_KEY",
194            Self::EscapedTemplateKey => "ESCAPED_TEMPLATE_KEY",
195            Self::ParseFailed => "PARSE_FAILED",
196            Self::ValidateFailed => "VALIDATE_FAILED",
197        }
198    }
199}
200
201impl fmt::Display for IssueCode {
202    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
203        f.write_str(self.as_str())
204    }
205}
206
207impl Workflow {
208    /// Check authored workflow JSON without building an engine.
209    ///
210    /// Returns empty **if and only if** the JSON parses into a [`Workflow`] and
211    /// that workflow validates.
212    ///
213    /// That is the *shape* question, and it is the whole of it. It is not the
214    /// same as "this engine can run it": [`EngineBuilder::build`](crate::EngineBuilder::build)
215    /// also resolves every task to a handler and parses custom inputs, so a
216    /// structurally perfect definition naming an unregistered function still
217    /// aborts a build. [`Engine::check_workflow`](crate::Engine::check_workflow)
218    /// answers that half; run both.
219    ///
220    /// # How the guarantee holds
221    ///
222    /// Three stages. A structural walk collects *every* semantic violation with
223    /// the coordinate the author typed; if it finds none, the document is then
224    /// actually parsed and validated, and either failure is reported as one
225    /// further issue. The second and third stages are what make the promise
226    /// true by construction: the crate's serde schema is far larger than any
227    /// rule list — a `"priority": "high"` or a `map` task missing `mappings`
228    /// breaks no *semantic* rule and still cannot load — and mirroring it here
229    /// would recreate the very drift this API exists to remove.
230    ///
231    /// # Example
232    ///
233    /// ```
234    /// use dataflow_rs::{IssueCode, Workflow};
235    /// use serde_json::json;
236    ///
237    /// let broken = json!({
238    ///     "id": "w", "name": "w", "priority": 0,
239    ///     "tasks": [
240    ///         {"id": "dup", "name": "a", "function": {"name": "map", "input": {"mappings": []}}},
241    ///         {"id": "dup", "name": "b", "function": {"name": "map", "input": {"mappings": []}}}
242    ///     ]
243    /// });
244    ///
245    /// let issues = Workflow::validate_authored(&broken);
246    /// assert_eq!(issues[0].code, IssueCode::DuplicateStepId);
247    /// assert_eq!(issues[0].path.as_deref(), Some("tasks[1].id"));
248    /// assert_eq!(issues[0].task_id.as_deref(), Some("dup"));
249    /// ```
250    ///
251    /// Every problem is reported, not just the first:
252    ///
253    /// ```
254    /// # use dataflow_rs::{IssueCode, Workflow};
255    /// # use serde_json::json;
256    /// let issues = Workflow::validate_authored(&json!({
257    ///     "id": "", "name": "w", "tasks": [{"id": "t", "name": "t"}]
258    /// }));
259    ///
260    /// let codes: Vec<IssueCode> = issues.iter().map(|i| i.code).collect();
261    /// assert!(codes.contains(&IssueCode::EmptyWorkflowId));
262    /// assert!(codes.contains(&IssueCode::MissingFunction));
263    /// ```
264    pub fn validate_authored(json: &Value) -> Vec<WorkflowIssue> {
265        let mut issues = check_shape(json);
266        if !issues.is_empty() {
267            return issues;
268        }
269
270        // Stage 2 — the schema is much wider than the rules above, and
271        // enumerating it here would be the mirror this API exists to delete.
272        let workflow: Self = match serde_json::from_value(json.clone()) {
273            Ok(w) => w,
274            Err(err) => {
275                issues.push(WorkflowIssue {
276                    code: IssueCode::ParseFailed,
277                    message: err.to_string(),
278                    path: None,
279                    task_id: None,
280                });
281                return issues;
282            }
283        };
284
285        // Stage 3 — a backstop. Reaching this means `check_shape` does not
286        // model some rule `validate` enforces; the caller still gets a correct
287        // answer, and the test suite is what keeps this unreachable.
288        if let Err(err) = workflow.validate() {
289            issues.push(WorkflowIssue {
290                code: IssueCode::ValidateFailed,
291                message: err.to_string(),
292                path: None,
293                task_id: None,
294            });
295        }
296        issues
297    }
298}
299
300/// Check a parsed workflow against a handler registry.
301///
302/// Shared by [`crate::EngineBuilder::check_workflow`] and
303/// [`crate::Engine::check_workflow`] so the two cannot answer differently, and
304/// run against the crate's *real* `TemplateCompiler` rather than a host's
305/// reconstruction of one.
306///
307/// `workflow.tasks` is already flattened, so iterating it covers members of
308/// task groups without any extra traversal.
309pub(crate) fn check_against_registry(
310    workflow: &Workflow,
311    registry: &std::collections::HashMap<String, BoxedFunctionHandler>,
312    template_compiler: &TemplateCompiler,
313    secrets: &Secrets,
314) -> Vec<WorkflowIssue> {
315    let mut issues = check_secrets(workflow, secrets);
316    // All three template-key findings, including the two `build()` does not
317    // refuse — this surface is where a host looks before activating a
318    // definition, and the migration audit is the point of reporting them.
319    issues.extend(check_template_keys(workflow));
320
321    for task in &workflow.tasks {
322        let name = task.function.function_name();
323
324        if !can_dispatch_in(registry, name) {
325            // Distinguish the two reasons, because the fixes differ: a
326            // `RequiresHandler` built-in is a real name awaiting a
327            // registration, while anything else is likely a typo.
328            let (code, message) = match builtin_function_kind(name) {
329                Some(BuiltinKind::RequiresHandler) => (
330                    IssueCode::MissingHandler,
331                    format!(
332                        "'{name}' ships as a config schema only — register a handler under \
333                         that name, or this workflow will build cleanly and fail every message"
334                    ),
335                ),
336                _ => (
337                    IssueCode::UnknownFunction,
338                    format!("no handler is registered for '{name}', and it is not a built-in"),
339                ),
340            };
341            issues.push(WorkflowIssue {
342                code,
343                message,
344                path: Some("function.name".to_string()),
345                task_id: Some(task.id.clone()),
346            });
347            continue;
348        }
349
350        // Only `Custom` inputs are still raw at this point: the built-in
351        // variants were typed by serde when the workflow parsed.
352        let FunctionConfig::Custom { name, input, .. } = &task.function else {
353            continue;
354        };
355        let Some(handler) = registry.get(name) else {
356            continue;
357        };
358
359        let mut parsed = match handler.parse_input_box(input) {
360            Ok(parsed) => parsed,
361            Err(err) => {
362                issues.push(WorkflowIssue {
363                    code: IssueCode::InputParse,
364                    message: format!("input does not match the handler's Input type: {err}"),
365                    path: Some("function.input".to_string()),
366                    task_id: Some(task.id.clone()),
367                });
368                continue;
369            }
370        };
371
372        if let Err(err) = handler.compile_input_box(&mut *parsed, template_compiler) {
373            issues.push(WorkflowIssue {
374                code: IssueCode::TemplateCompile,
375                message: format!("a template field does not compile: {err}"),
376                path: Some("function.input".to_string()),
377                task_id: Some(task.id.clone()),
378            });
379        }
380    }
381
382    issues
383}
384
385/// Where an expression's result ends up — what decides whether it may read a
386/// secret.
387#[derive(Clone, Copy, PartialEq, Eq)]
388enum Sink {
389    /// Collapses to a bool the engine acts on: workflow, group and task
390    /// conditions, `validation` rules, `filter`. Nothing of the value survives.
391    Bool,
392    /// Handed to a handler: a custom handler's `Template` fields and every
393    /// `http_call` / `enrich` / `publish_kafka` parameter. What happens next is
394    /// the handler's business.
395    Handler,
396    /// A custom task's whole raw input, handed to the handler untyped. The
397    /// same rule as [`Sink::Handler`]; it differs only in that the document
398    /// is not one expression, so an issue carries the deep path to the
399    /// reference rather than the field.
400    Input,
401    /// Written to the message or emitted to a log by the engine itself: a
402    /// `map` mapping's value *and* its destination, a `validation` rule's
403    /// `message`, `log` message and fields, and the `source` / `target` /
404    /// `root_element` a `parse_*` or `publish_*` names. Recorded by
405    /// construction — a destination lands in `Change.path` and the audit trail.
406    Message,
407}
408
409impl Sink {
410    /// Whether the engine itself records this expression's result. Such an
411    /// expression may not read a secret *at all* — the store exists so a value
412    /// is never recorded, and there is no static line between a copy and a
413    /// derived value.
414    ///
415    /// The four variants are a map of where an expression's result goes;
416    /// [`Sink::Bool`] and [`Sink::Handler`] answer this question the same way
417    /// today and are kept apart because the reasons differ — nothing survives
418    /// versus the handler owns what happens next.
419    fn records(self) -> bool {
420        matches!(self, Self::Message)
421    }
422
423    /// Whether an issue points at the reference's own deep path rather than at
424    /// the field. True only where the field is not itself a single expression,
425    /// so the field name alone would not locate the reference.
426    fn points_at_reference(self) -> bool {
427        matches!(self, Self::Input)
428    }
429}
430
431/// Check every expression in `workflow` for `{"secret": …}` references.
432///
433/// One implementation for two callers: `Engine::build` refuses a workflow that
434/// produces any issue here, and `check_workflow` reports the same issues, so the
435/// two cannot disagree about what is loadable.
436///
437/// A reference is any single-key object whose key is the reserved operator
438/// name — datalogic compiles exactly that shape as an operator call in
439/// templating mode, at any depth. A string (or one-element array) argument is
440/// a literal name and is checked against `secrets`; anything else is dynamic
441/// and only fails at evaluation. Both forms are refused in a
442/// [`Sink::Message`] expression.
443pub(crate) fn check_secrets(workflow: &Workflow, secrets: &Secrets) -> Vec<WorkflowIssue> {
444    let mut issues = Vec::new();
445    for_each_expression(workflow, &mut |value, field, task_id, sink| {
446        check_expression(value, field, task_id, sink, secrets, &mut issues);
447    });
448    issues
449}
450
451/// A map-valued config field's keys, in name order.
452///
453/// The maps `for_each_expression` walks (`log.fields`, `http_call.headers`) are
454/// `HashMap`s, so their iteration order is arbitrary — but issue order is what a
455/// host logs, diffs, or asserts on, and what `refuse_secret_issues` joins into a
456/// `build()` error message. Sorting is what makes that order reproducible.
457fn names_in_order<V>(map: &HashMap<String, V>) -> Vec<&String> {
458    let mut names: Vec<&String> = map.keys().collect();
459    names.sort_unstable();
460    names
461}
462
463/// Visit every JSONLogic expression in `workflow`, with where it lives and
464/// where its result goes.
465///
466/// The single enumeration of "which config fields are expressions". Both
467/// [`check_secrets`] and [`check_template_keys`] walk it, so a parameter added
468/// to a built-in cannot be checked by one and silently skipped by the other.
469fn for_each_expression(
470    workflow: &Workflow,
471    check: &mut impl FnMut(&Value, &str, Option<&str>, Sink),
472) {
473    check(&workflow.condition, "condition", None, Sink::Bool);
474
475    for task in &workflow.tasks {
476        // Groups opening at this task, outermost first — compiled alongside
477        // the task condition, so checked alongside it.
478        for group in &task.group_starts {
479            let id = Some(group.id.as_str());
480            check(&group.condition, "condition", id, Sink::Bool);
481        }
482
483        let id = Some(task.id.as_str());
484        check(&task.condition, "condition", id, Sink::Bool);
485
486        match &task.function {
487            FunctionConfig::Map { input, .. } => {
488                for (i, mapping) in input.mappings.iter().enumerate() {
489                    let field = format!("function.input.mappings[{i}].logic");
490                    check(&mapping.logic, &field, id, Sink::Message);
491                    // The destination is itself recorded — in `Change.path` and
492                    // on the audit trail — so it may not read a secret either.
493                    let field = format!("function.input.mappings[{i}].path");
494                    check(mapping.path.as_json(), &field, id, Sink::Message);
495                }
496            }
497            FunctionConfig::Validation { input, .. } => {
498                for (i, rule) in input.rules.iter().enumerate() {
499                    let field = format!("function.input.rules[{i}].logic");
500                    check(&rule.logic, &field, id, Sink::Bool);
501                    // The message is `Sink::Message`, not `Sink::Bool`: it is
502                    // recorded in `Message::errors`, which is serialized. A
503                    // rule may *test* a secret; it may not *report* one.
504                    let field = format!("function.input.rules[{i}].message");
505                    check(rule.message.as_json(), &field, id, Sink::Message);
506                }
507            }
508            FunctionConfig::Filter { input, .. } => {
509                check(&input.condition, "function.input.condition", id, Sink::Bool);
510            }
511            FunctionConfig::Log { input, .. } => {
512                check(&input.message, "function.input.message", id, Sink::Message);
513                for name in names_in_order(&input.fields) {
514                    let field = format!("function.input.fields.{name}");
515                    check(&input.fields[name], &field, id, Sink::Message);
516                }
517            }
518            FunctionConfig::HttpCall { input, .. } => {
519                // Everything here is handed to the host's handler, so a secret
520                // is allowed — an `Authorization` header reading
521                // `{"secret": "api_token"}` is the reason headers became
522                // expressions at all. What the handler does with it from there
523                // is the handler's business.
524                check(
525                    input.connector.as_json(),
526                    "function.input.connector",
527                    id,
528                    Sink::Handler,
529                );
530                check(
531                    input.timeout_ms.as_json(),
532                    "function.input.timeout_ms",
533                    id,
534                    Sink::Handler,
535                );
536                for name in names_in_order(&input.headers) {
537                    let field = format!("function.input.headers.{name}");
538                    check(input.headers[name].as_json(), &field, id, Sink::Handler);
539                }
540                for (name, template) in [
541                    ("path", &input.path),
542                    ("body", &input.body),
543                    ("body_format", &input.body_format),
544                    ("response_path", &input.response_path),
545                    ("response_format", &input.response_format),
546                ] {
547                    if let Some(t) = template {
548                        let field = format!("function.input.{name}");
549                        check(t.as_json(), &field, id, Sink::Handler);
550                    }
551                }
552            }
553            FunctionConfig::Enrich { input, .. } => {
554                for (name, template) in [
555                    ("connector", &input.connector),
556                    ("merge_path", &input.merge_path),
557                    ("timeout_ms", &input.timeout_ms),
558                ] {
559                    let field = format!("function.input.{name}");
560                    check(template.as_json(), &field, id, Sink::Handler);
561                }
562                if let Some(t) = &input.path {
563                    check(t.as_json(), "function.input.path", id, Sink::Handler);
564                }
565            }
566            FunctionConfig::PublishKafka { input, .. } => {
567                for (name, template) in [("connector", &input.connector), ("topic", &input.topic)] {
568                    let field = format!("function.input.{name}");
569                    check(template.as_json(), &field, id, Sink::Handler);
570                }
571                for (name, template) in [("key", &input.key), ("value", &input.value)] {
572                    if let Some(t) = template {
573                        let field = format!("function.input.{name}");
574                        check(t.as_json(), &field, id, Sink::Handler);
575                    }
576                }
577            }
578            FunctionConfig::Custom { input, .. } => {
579                check(input, "function.input", id, Sink::Input);
580            }
581            // `Sink::Message`, not `Sink::Handler`: these expressions name
582            // where the engine itself writes, and the destination is recorded
583            // in `Change.path` and the audit trail. `root_element` goes further
584            // — it is written into the serialized document that lands in
585            // `data.{target}`.
586            FunctionConfig::ParseJson { input, .. } | FunctionConfig::ParseXml { input, .. } => {
587                check(
588                    input.source.as_json(),
589                    "function.input.source",
590                    id,
591                    Sink::Message,
592                );
593                check(
594                    input.target.as_json(),
595                    "function.input.target",
596                    id,
597                    Sink::Message,
598                );
599            }
600            FunctionConfig::PublishJson { input, .. }
601            | FunctionConfig::PublishXml { input, .. } => {
602                check(
603                    input.source.as_json(),
604                    "function.input.source",
605                    id,
606                    Sink::Message,
607                );
608                check(
609                    input.target.as_json(),
610                    "function.input.target",
611                    id,
612                    Sink::Message,
613                );
614                check(
615                    input.root_element.as_json(),
616                    "function.input.root_element",
617                    id,
618                    Sink::Message,
619                );
620            }
621        }
622    }
623}
624
625/// Check every expression in `workflow` for object keys the template-key
626/// escape makes newly significant.
627///
628/// Three findings, and only the first is fatal:
629///
630/// - [`IssueCode::DuplicateTemplateKey`] — two keys in one object that collapse
631///   to the same name after the escape is stripped. Always a bug, so
632///   `Engine::build` refuses it.
633/// - [`IssueCode::EscapedTemplateKey`] — a `$`-prefixed key, reported so a host
634///   migrating to 3.9 can audit every place the escape changed what a template
635///   emits. Informational: after migration these are deliberate.
636///
637/// A third check — flagging a single-key object whose key names no live
638/// operator — was designed and then dropped, because it cannot be made
639/// precise. In templating mode an unrecognised single key is *not* inert: it
640/// evaluates its argument and emits a structured object, so
641/// `{"result": {"var": "x"}}` yields `{"result": 5}`. That is the ordinary
642/// single-key output template and the most common shape in a `map` mapping,
643/// indistinguishable from a misspelled `lenght`. Flagging it would fire on
644/// almost every correct workflow.
645pub(crate) fn check_template_keys(workflow: &Workflow) -> Vec<WorkflowIssue> {
646    let mut issues = Vec::new();
647    for_each_expression(workflow, &mut |value, field, task_id, sink| {
648        // A custom task's `input` is a config document, not an expression: only
649        // the `Template` fields inside it are JSONLogic, and which those are is
650        // the handler's business. Treating the whole document as a template
651        // would flag ordinary config keys — and `DuplicateTemplateKey` is
652        // fatal, so a false positive there refuses a valid workflow.
653        if sink != Sink::Input {
654            walk_template_keys(value, field, task_id, &mut issues);
655        }
656    });
657    issues
658}
659
660/// The fatal subset of [`check_template_keys`] — what `Engine::build` refuses.
661pub(crate) fn refusing_template_key_issues(workflow: &Workflow) -> Vec<WorkflowIssue> {
662    let mut issues = check_template_keys(workflow);
663    issues.retain(|i| i.code == IssueCode::DuplicateTemplateKey);
664    issues
665}
666
667/// Recursive half of [`check_template_keys`].
668fn walk_template_keys(
669    value: &Value,
670    path: &str,
671    task_id: Option<&str>,
672    issues: &mut Vec<WorkflowIssue>,
673) {
674    match value {
675        Value::Array(items) => {
676            for (i, item) in items.iter().enumerate() {
677                walk_template_keys(item, &format!("{path}[{i}]"), task_id, issues);
678            }
679        }
680        Value::Object(map) => {
681            report_escaped_and_duplicate_keys(map, path, task_id, issues);
682            for (key, child) in map {
683                walk_template_keys(child, &format!("{path}.{key}"), task_id, issues);
684            }
685        }
686        _ => {}
687    }
688}
689
690/// The two key-level findings for one template object: escaped keys, and keys
691/// that collide once the escape is stripped.
692fn report_escaped_and_duplicate_keys(
693    map: &serde_json::Map<String, Value>,
694    path: &str,
695    task_id: Option<&str>,
696    issues: &mut Vec<WorkflowIssue>,
697) {
698    let mut emitted: HashMap<String, &str> = HashMap::new();
699    for key in map.keys() {
700        if let Some(stripped) = key.strip_prefix(TEMPLATE_KEY_ESCAPE) {
701            issues.push(
702                WorkflowIssue::at(
703                    IssueCode::EscapedTemplateKey,
704                    format!("{path}.{key}"),
705                    format!(
706                        "'{key}' is emitted as '{stripped}' — one \
707                         '{TEMPLATE_KEY_ESCAPE}' is stripped from every template key. Double it \
708                         to '{TEMPLATE_KEY_ESCAPE}{key}' to emit '{key}' itself"
709                    ),
710                )
711                .with_step(task_id),
712            );
713        }
714        // What this key actually emits, which is what can collide.
715        let out = key
716            .strip_prefix(TEMPLATE_KEY_ESCAPE)
717            .unwrap_or(key)
718            .to_string();
719        if let Some(other) = emitted.insert(out.clone(), key) {
720            issues.push(
721                WorkflowIssue::at(
722                    IssueCode::DuplicateTemplateKey,
723                    format!("{path}.{key}"),
724                    format!(
725                        "'{other}' and '{key}' both emit the key '{out}', so this object would \
726                         carry it twice — later reads see only the first while serialization \
727                         emits both"
728                    ),
729                )
730                .with_step(task_id),
731            );
732        }
733    }
734}
735
736/// One expression's worth of [`check_secrets`].
737fn check_expression(
738    value: &Value,
739    field: &str,
740    task_id: Option<&str>,
741    sink: Sink,
742    secrets: &Secrets,
743    issues: &mut Vec<WorkflowIssue>,
744) {
745    let mut refs = Vec::new();
746    collect_secret_refs(value, field, &mut refs);
747    if refs.is_empty() {
748        return;
749    }
750    if sink.records() {
751        issues.push(
752            WorkflowIssue::at(
753                IssueCode::SecretInMessageWrite,
754                field,
755                "reads a secret, and the engine records this expression's result — \
756                 compute derived values in a custom handler instead",
757            )
758            .with_step(task_id),
759        );
760        return;
761    }
762    for (path, key) in &refs {
763        let Some(key) = key else { continue };
764        if secrets.get(key).is_some() {
765            continue;
766        }
767        let at = if sink.points_at_reference() {
768            path.as_str()
769        } else {
770            field
771        };
772        issues.push(
773            WorkflowIssue::at(
774                IssueCode::UnknownSecret,
775                at,
776                format!("secret '{key}' is not declared on the engine"),
777            )
778            .with_step(task_id),
779        );
780    }
781}
782
783/// Collect every `{"secret": …}` reference under `value` as
784/// `(path, literal name)` — `None` for a dynamic name. Descends into the
785/// argument too, since a dynamic name may itself contain a reference.
786fn collect_secret_refs<'v>(value: &'v Value, path: &str, out: &mut Vec<(String, Option<&'v str>)>) {
787    match value {
788        Value::Object(map) => {
789            if map.len() == 1 {
790                if let Some(arg) = map.get(SECRET_OPERATOR) {
791                    let literal = match arg {
792                        Value::String(s) => Some(s.as_str()),
793                        Value::Array(items) if items.len() == 1 => items[0].as_str(),
794                        _ => None,
795                    };
796                    out.push((path.to_string(), literal));
797                    collect_secret_refs(arg, &format!("{path}.{SECRET_OPERATOR}"), out);
798                    return;
799                }
800            }
801            for (key, child) in map {
802                collect_secret_refs(child, &format!("{path}.{key}"), out);
803            }
804        }
805        Value::Array(items) => {
806            for (i, child) in items.iter().enumerate() {
807                collect_secret_refs(child, &format!("{path}[{i}]"), out);
808            }
809        }
810        _ => {}
811    }
812}
813
814/// Stage 1: every semantic violation, with authored coordinates.
815fn check_shape(json: &Value) -> Vec<WorkflowIssue> {
816    let mut issues = Vec::new();
817
818    if non_empty_str(json.get("id")).is_none() {
819        issues.push(WorkflowIssue::at(
820            IssueCode::EmptyWorkflowId,
821            "id",
822            "workflow id must be a non-empty string",
823        ));
824    }
825    if non_empty_str(json.get("name")).is_none() {
826        issues.push(WorkflowIssue::at(
827            IssueCode::EmptyWorkflowName,
828            "name",
829            "workflow name must be a non-empty string",
830        ));
831    }
832
833    match json.get("tasks").and_then(Value::as_array) {
834        Some(tasks) if !tasks.is_empty() => {}
835        _ => issues.push(WorkflowIssue::at(
836            IssueCode::NoTasks,
837            "tasks",
838            "workflow must have at least one task",
839        )),
840    }
841
842    check_steps(json.get("tasks").unwrap_or(&Value::Null), &mut issues);
843
844    if let Some(loop_config) = json.get("loop") {
845        check_loop(loop_config, &mut issues);
846    }
847
848    issues
849}
850
851/// Walk the authored step tree, checking each node and the id namespace.
852///
853/// Built on [`walk_authored_steps`], so the group test, the traversal order and
854/// the depth cap have exactly one definition shared with the parser.
855fn check_steps(tasks: &Value, issues: &mut Vec<WorkflowIssue>) {
856    // Step id -> the path that first claimed it.
857    let mut seen: HashMap<&str, String> = HashMap::new();
858
859    for step in walk_authored_steps(tasks) {
860        let id = non_empty_str(step.node.get("id"));
861
862        match id {
863            None => issues.push(
864                WorkflowIssue::at(
865                    IssueCode::MissingStepId,
866                    format!("{}.id", step.path),
867                    "every step needs a non-empty id",
868                )
869                .with_step(None),
870            ),
871            Some(id) => {
872                if let Some(first) = seen.get(id) {
873                    issues.push(
874                        WorkflowIssue::at(
875                            IssueCode::DuplicateStepId,
876                            format!("{}.id", step.path),
877                            format!(
878                                "step id '{id}' is already used at {first} — task groups \
879                                 share the task id namespace"
880                            ),
881                        )
882                        .with_step(Some(id)),
883                    );
884                } else {
885                    seen.insert(id, step.path.clone());
886                }
887            }
888        }
889
890        if let Some(terminal) = step.node.get("terminal") {
891            if !terminal.is_boolean() {
892                issues.push(
893                    WorkflowIssue::at(
894                        IssueCode::InvalidTerminal,
895                        format!("{}.terminal", step.path),
896                        "terminal must be a boolean",
897                    )
898                    .with_step(id),
899                );
900            }
901        }
902
903        match step.kind {
904            StepKind::Leaf => check_function(&step.path, step.node, id, issues),
905            StepKind::Group => {
906                // The parser rejects a group whose `tasks` is not a non-empty
907                // array; the walker reports the node so we can say which.
908                let has_members = step
909                    .node
910                    .get("tasks")
911                    .and_then(Value::as_array)
912                    .is_some_and(|members| !members.is_empty());
913                if !has_members {
914                    issues.push(
915                        WorkflowIssue::at(
916                            IssueCode::EmptyGroup,
917                            format!("{}.tasks", step.path),
918                            "a task group's tasks must be a non-empty array — \
919                             an empty group can only be a mistake",
920                        )
921                        .with_step(id),
922                    );
923                }
924            }
925            StepKind::TooDeep => issues.push(
926                WorkflowIssue::at(
927                    IssueCode::GroupTooDeep,
928                    step.path.clone(),
929                    format!(
930                        "task groups nested deeper than {} levels",
931                        crate::engine::steps::MAX_GROUP_DEPTH
932                    ),
933                )
934                .with_step(id),
935            ),
936        }
937    }
938}
939
940/// A leaf must carry a `function` object with a non-empty `name`.
941fn check_function(path: &str, node: &Value, id: Option<&str>, issues: &mut Vec<WorkflowIssue>) {
942    let Some(function) = node.get("function") else {
943        issues.push(
944            WorkflowIssue::at(
945                IssueCode::MissingFunction,
946                format!("{path}.function"),
947                "a task needs a function — an element with neither `function` nor \
948                 `tasks` is neither a task nor a group",
949            )
950            .with_step(id),
951        );
952        return;
953    };
954
955    if !function.is_object() || non_empty_str(function.get("name")).is_none() {
956        issues.push(
957            WorkflowIssue::at(
958                IssueCode::InvalidFunctionName,
959                format!("{path}.function.name"),
960                "function must be an object with a non-empty name",
961            )
962            .with_step(id),
963        );
964    }
965}
966
967/// The three `LoopConfig::validate` rules, against the authored JSON.
968fn check_loop(config: &Value, issues: &mut Vec<WorkflowIssue>) {
969    // Absent fields take their serde defaults, which are valid; only a present
970    // field can be wrong here. A non-integer is a *type* error and belongs to
971    // stage 2, so it is deliberately not reported twice.
972    if let Some(increment) = config.get("increment").and_then(Value::as_i64) {
973        if increment < 1 {
974            issues.push(WorkflowIssue::at(
975                IssueCode::LoopIncrementTooSmall,
976                "loop.increment",
977                format!(
978                    "loop increment must be >= 1, got {increment} \
979                     (a non-advancing counter would never reach max)"
980                ),
981            ));
982        }
983    }
984
985    let init = config.get("init").and_then(Value::as_i64).unwrap_or(0);
986    if let Some(max) = config.get("max").and_then(Value::as_i64) {
987        if max <= init {
988            issues.push(WorkflowIssue::at(
989                IssueCode::LoopBoundEmpty,
990                "loop.max",
991                format!(
992                    "loop max ({max}) must be greater than init ({init}) — \
993                     the bound is half-open, so this could never run a sweep"
994                ),
995            ));
996        }
997    }
998
999    if let Some(counter) = config.get("counter") {
1000        if let Some(counter) = counter.as_str() {
1001            if counter.is_empty() || counter.split('.').any(str::is_empty) {
1002                issues.push(WorkflowIssue::at(
1003                    IssueCode::LoopCounterInvalid,
1004                    "loop.counter",
1005                    format!(
1006                        "loop counter must be a non-empty temp_data field path, got {counter:?}"
1007                    ),
1008                ));
1009            }
1010        }
1011    }
1012}
1013
1014/// The value at `field` as a non-empty string, if it is one.
1015fn non_empty_str(field: Option<&Value>) -> Option<&str> {
1016    field.and_then(Value::as_str).filter(|s| !s.is_empty())
1017}