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}