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