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