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