Authoring-Time Validation
The engine checks a workflow when Engine::build() runs. For a service that
stores definitions (accepting them from an API, holding them in a database,
building one engine over many rows), that is the wrong moment. One bad row
aborts the whole build, at reload, for every workflow in the process, and the
author who submitted it got no feedback at all.
Two APIs move those checks to submission time.
Checking a definition
Workflow::validate_authored takes the JSON a host stored and returns every
problem with it:
#![allow(unused)]
fn main() {
use dataflow_rs::{IssueCode, Workflow};
use serde_json::json;
let submitted = json!({
"id": "", "name": "checkout",
"tasks": [
{"id": "charge", "name": "charge",
"function": {"name": "map", "input": {"mappings": []}}},
{"id": "charge", "name": "again",
"function": {"name": "map", "input": {"mappings": []}}}
]
});
let issues = Workflow::validate_authored(&submitted);
// Both problems, not just the first.
let codes: Vec<IssueCode> = issues.iter().map(|i| i.code).collect();
assert!(codes.contains(&IssueCode::EmptyWorkflowId));
assert!(codes.contains(&IssueCode::DuplicateStepId));
}
Each issue carries the coordinate the author typed, so a 400 can point at the
exact field:
#![allow(unused)]
fn main() {
use dataflow_rs::{IssueCode, Workflow};
use serde_json::json;
let nested = json!({
"id": "w", "name": "w",
"tasks": [
{"id": "first", "name": "first",
"function": {"name": "map", "input": {"mappings": []}}},
{"id": "guard", "condition": true, "tasks": [
{"id": "first", "name": "collides",
"function": {"name": "map", "input": {"mappings": []}}}
]}
]
});
let issues = Workflow::validate_authored(&nested);
assert_eq!(issues[0].code, IssueCode::DuplicateStepId);
assert_eq!(issues[0].path.as_deref(), Some("tasks[1].tasks[0].id"));
assert_eq!(issues[0].task_id.as_deref(), Some("first"));
}
The path is tasks[1].tasks[0], where the author wrote it, not the flat
index that task ends up at once the engine flattens the group.
The guarantee
validate_authoredreturns empty if and only if the JSON parses into aWorkflowand that workflow validates.
That answers the shape question completely, but shape is not the same as
“this engine can run it”. Engine::build() also resolves every task to
a handler, so a structurally perfect definition naming an unregistered function
still aborts a build. The next section covers that half.
The guarantee still matters, because the schema is much larger than the semantic
rules: "priority": "high", a map task missing its mappings, a misspelled
status: none of these break a rule, and none of them can load.
Rather than mirror the whole schema, validate_authored finishes by
parsing the document and reports any failure as IssueCode::ParseFailed,
carrying the parser’s own message:
#![allow(unused)]
fn main() {
use dataflow_rs::{IssueCode, Workflow};
use serde_json::json;
let issues = Workflow::validate_authored(&json!({
"id": "w", "name": "w", "priority": 0,
"tasks": [{"id": "t", "name": "t", "function": {"name": "map", "input": {}}}]
}));
assert_eq!(issues[0].code, IssueCode::ParseFailed);
assert!(issues[0].message.contains("mappings"));
}
A host therefore does not need its own round-trip check as a safety net: this is that safety net, inside the crate where it cannot drift.
Issue codes
IssueCode is #[non_exhaustive] (a later minor may add a rule), so match the
codes you care about and let the rest fall through. as_str() gives the stable
string form for an API response:
#![allow(unused)]
fn main() {
use dataflow_rs::IssueCode;
assert_eq!(IssueCode::DuplicateStepId.as_str(), "DUPLICATE_STEP_ID");
}
| Code | Severity | Means |
|---|---|---|
EMPTY_WORKFLOW_ID / EMPTY_WORKFLOW_NAME | Rejected | Required identity field missing or blank |
NO_TASKS | Rejected | tasks missing, not an array, or empty |
MISSING_STEP_ID | Rejected | A task or group carries no id |
DUPLICATE_STEP_ID | Rejected | Two steps share an id; groups share the task namespace |
EMPTY_GROUP | Rejected | A group’s tasks is not a non-empty array |
GROUP_TOO_DEEP | Rejected | Groups nested past MAX_GROUP_DEPTH |
MISSING_FUNCTION | Rejected | A task carries no function |
INVALID_FUNCTION_NAME | Rejected | function is not an object with a non-empty name |
INVALID_TERMINAL | Rejected | terminal is present but not a boolean |
INVALID_HALT_ON | Rejected | halt_on is not "never"/"failure", or is on a group |
INVALID_MAPPING | Rejected | A map mapping has neither or both of logic and unset, carries on_null or mode without logic, or removes or appends to a context root; see Removing a Path |
GROUP_CONTINUE_ON_ERROR | Advisory | A group carries continue_on_error, which the engine does not honour |
UNGUARDED_VALIDATION | Advisory | A validation whose failure stops nothing |
NULL_MAPPING | Advisory | A map mapping whose logic is always null, so it never writes; use "unset": true to remove a path |
LOOP_INCREMENT_TOO_SMALL | Rejected | increment < 1: the counter would never reach max |
LOOP_BOUND_EMPTY | Rejected | max <= init: no sweep could ever run |
LOOP_COUNTER_INVALID | Rejected | counter is not a non-empty dotted path |
LOOP_SLOT_INVALID | Rejected | as or scratch is not a non-empty dotted path |
LOOP_ITEM_WITHOUT_OVER | Rejected | as is set but there is no over to take elements from |
LOOP_SLOT_COLLISION | Rejected | Two of counter / as / scratch name the same path, or one lies inside the other |
LOOP_OVER_INVALID | Rejected | over is a scalar literal that can never be an array, or init < 0 alongside over; see Iterating an array |
INVALID_FOR_EACH | Rejected | A task’s for_each breaks one of its rules, or sits on a built-in or a group (reported at the offending key); see Fan-Out |
PARSE_FAILED | Rejected | Does not deserialize; message carries the field and type |
VALIDATE_FAILED | Rejected | Backstop: parses, but validate() still rejects it |
UNKNOWN_FUNCTION | Rejected | No handler registered, and not a built-in; usually a typo |
MISSING_HANDLER | Defect | A config-only integration with nothing registered under its name |
INPUT_PARSE | Rejected | A custom task’s input does not match its handler’s Input type |
TEMPLATE_COMPILE | Rejected | A handler rejected the input at construction time |
UNKNOWN_SECRET | Rejected | An expression names a secret the engine does not declare; see Secrets |
SECRET_IN_MESSAGE_WRITE | Rejected | An expression whose result the engine records reads a secret; see Secrets for the full set |
INVALID_SECRET_STORE | Rejected | The store given to with_secrets is not an object (reported in place of the UNKNOWN_SECRET issues every name would otherwise produce) |
DUPLICATE_TEMPLATE_KEY | Rejected | Two keys in one template object collapse to the same name once the $ escape is stripped |
ESCAPED_TEMPLATE_KEY | Advisory | A $-prefixed template key, reported as a migration audit and never refused |
Severity
Not every code is a refusal, and the two sets have grown apart: before 3.9 every
code check_workflow reported was also one build() refused, so “has an issue”
and “cannot run” were the same question, and they no longer are. Ask Severity
rather than keeping a list: a list can only ever be wrong in one direction,
silently, on upgrade.
#![allow(unused)]
fn main() {
use dataflow_rs::{IssueCode, Severity};
// Reported, and the workflow still builds and runs.
assert_eq!(IssueCode::UnguardedValidation.severity(), Severity::Advisory);
// Builds cleanly, then fails every message.
assert_eq!(IssueCode::MissingHandler.severity(), Severity::Defect);
// `build()` refuses it.
assert_eq!(IssueCode::DuplicateTemplateKey.severity(), Severity::Rejected);
}
Severity::Advisory is the only class a host may safely ignore, so screening a
definition before activating it is one pass:
#![allow(unused)]
fn main() {
use dataflow_rs::{Engine, Severity, Workflow};
let workflow = Workflow::from_json(r#"{
"id": "w", "name": "w", "priority": 0,
"tasks": [{"id": "t", "name": "t",
"function": {"name": "map", "input": {"mappings": []}}}]
}"#).unwrap();
let issues = Engine::builder().check_workflow(&workflow);
let usable = issues.iter().all(|i| i.severity() == Severity::Advisory);
assert!(usable);
}
Unlike IssueCode, Severity is not #[non_exhaustive]: the axis is when
a definition goes wrong (build time, first message, never), which is closed by
construction, so you can match it exhaustively and stay correct.
Severity needs engine 3.11.0 or newer. Before it, the distinction existed
only in prose, and a host had to carry its own list of the codes build() does
not refuse, a list that could only ever be wrong in one direction, silently, on
upgrade.
Why each advisory code is advisory
ESCAPED_TEMPLATE_KEY: stripping the$escape is uniform rather than conditional on a collision, so every escaped key is worth seeing once when upgrading to 3.9; after that they are deliberate.UNGUARDED_VALIDATION: validating to record errors rather than to gate is a legitimate shape, so the ungated form is reported, not refused. Seehalt_on.GROUP_CONTINUE_ON_ERROR: the key is real on a task and on a workflow, so a host may already carry it on group nodes; refusing it would abort every workflow in the build over a key that was never honoured anyway.NULL_MAPPING:"logic": nullloaded long beforeunsetexisted, and the mapping is a no-op rather than an error, so refusing it would fail builds that run today. It is almost always an attempted clear, so the message points atunset. Only logic that folds tonullis reported:{"var": "data.x"}is null only when the path misses, and stays silent.
The one Defect
MISSING_HANDLER is the other code Engine::build() accepts, and it is the
reason severity has three classes rather than two. It names a real defect the
builder cannot catch: the config parses into a typed variant, so nothing fails
until a message arrives. A host screening on “would this build” alone waves it
through and then serves a channel that fails every message. See
Why MISSING_HANDLER is its own code.
Every remaining code in the table is a Rejected. One stands out:
INVALID_SECRET_STORE is a rejection, but the definition is not what is wrong.
Quarantining the workflow will not make the build succeed, because the store is
the broken part.
Checking against the handlers
Shape is only half the question. The other half needs the registry: will every
task name a function this engine can run, with an input its handler can parse?
Engine::build() decides that, and a host that lets it decide finds out at
reload, when one bad row takes down every workflow in the process.
check_workflow asks the same questions and reports instead of aborting:
#![allow(unused)]
fn main() {
use dataflow_rs::{Engine, IssueCode, Workflow};
let workflow = Workflow::from_json(r#"{
"id": "w", "name": "w", "priority": 0,
"tasks": [{"id": "lookup", "name": "lookup",
"function": {"name": "enrich",
"input": {"connector": "c", "merge_path": "data.out"}}}]
}"#).unwrap();
let issues = Engine::builder().check_workflow(&workflow);
assert_eq!(issues[0].code, IssueCode::MissingHandler);
assert_eq!(issues[0].task_id.as_deref(), Some("lookup"));
}
Both EngineBuilder and Engine carry it, with identical semantics: screen
before you build, or against the engine you are already running. The second is
usually what a live host wants: the submission endpoint holds a built engine
behind its reload mechanism, not the builder that made it.
Why MISSING_HANDLER is its own code
enrich, http_call and publish_kafka ship as config schemas with no
implementation. A workflow using one deserializes into a typed variant, so
Engine::build() accepts it without complaint, and then every message fails
with FunctionNotFound. Reporting that as UNKNOWN_FUNCTION would send the
author hunting for a typo that isn’t there; the fix is a registration, and the
code says so.
Anchoring and paths
check_workflow anchors each issue on task_id (step ids are unique across
tasks and groups), with a path relative to that task:
task_id: "lookup"
path: "function.input"
To point at the authored document, join it with the coordinate
walk_authored_steps reports for that id:
tasks[1].tasks[0] + function.input
check_workflow works this way because it receives an already-parsed
Workflow, whose tasks is flattened; the authored nesting is gone. Emitting a
flat tasks[3] would point at the wrong element in the author’s document, which
is worse than not pointing at all.
The flattening also means check_workflow checks tasks inside groups
automatically, with no extra traversal.
Walking the authored tree yourself
For anything the checks above do not cover (your own lint rules, dependency
extraction, a renderer), walk_authored_steps gives you the authored tree with
the engine’s own grammar:
#![allow(unused)]
fn main() {
use dataflow_rs::engine::steps::{StepKind, walk_authored_steps};
use serde_json::json;
let tasks = json!([
{"id": "load", "function": {"name": "map", "input": {"mappings": []}}},
{"id": "guard", "condition": true, "tasks": [
{"id": "greet", "function": {"name": "map", "input": {"mappings": []}}}
]}
]);
let leaves: Vec<&str> = walk_authored_steps(&tasks)
.filter(|s| s.kind == StepKind::Leaf)
.map(|s| s.node["id"].as_str().unwrap())
.collect();
assert_eq!(leaves, vec!["load", "greet"]);
}
A loop’s setup is a second step list on the same workflow, with the same
grammar. walk_authored_steps_at(setup, "loop.setup") walks it with paths
rooted where the author typed them, loop.setup[1].tasks[0], and is what
validate_authored itself uses:
#![allow(unused)]
fn main() {
use dataflow_rs::engine::steps::walk_authored_steps_at;
use serde_json::json;
let workflow = json!({
"loop": {"max": 10, "setup": [
{"id": "claim", "function": {"name": "map", "input": {"mappings": []}}}
]}
});
let paths: Vec<String> = walk_authored_steps_at(&workflow["loop"]["setup"], "loop.setup")
.map(|s| s.path)
.collect();
assert_eq!(paths, vec!["loop.setup[0]"]);
}
See Control Flow for the full walker contract.
Putting it together
A submission endpoint checks in this order, stopping at the first stage that reports anything:
Workflow::validate_authored(&json): shape, with field paths. Reject with a400listing every issue.Workflow::from_json(&text): now guaranteed to succeed if step 1 was empty, sounwrapis honest here if you prefer.engine.check_workflow(&workflow): will this engine run it? Reject with a400naming the tasks and what each needs.
Only then store and activate the definition. Engine::build() stays
deliberately permissive. It is not a validation gate, and a host that treats it
as one discovers its problems at reload rather than at submission.