Dataflow-rs
A high-performance rules engine for IFTTT-style automation in Rust with zero-overhead JSONLogic evaluation.
Dataflow-rs is a lightweight, embeddable rules engine that lets you define IF β THEN β THAT automation in JSON. At build time, the engine parses, validates, and compiles your workflow definitions into an immutable program so runtime execution has zero parsing overhead.
Use it to route events, validate and transform payloads, power dynamic APIs, or build multi-step automation pipelines.
β‘ Performance
Dataflow-rs is designed for high-throughput workloads:
- Compiled at build time. The compiler validates workflow definitions, folds constant expressions, and pre-splits field paths before any message is processed.
- Single context view per run. Built-in functions run inline over a single arena view of the message context, while custom host functions write to a transactional per-call overlay.
- Single clock read per run.
metadata.processed_at, audit entries, and error records share a single timestamp. Additional clock reads happen only when an observer or trace explicitly requests per-task timings. - Zero overhead when unwatched. When tracing, observers, and error-context mirroring are disabled, execution runs on a streamlined fast path.
See Performance for benchmark results and methodology.
π§© Why Choose dataflow-rs?
Dynamic business rules and user-customizable workflows quickly outgrow hardcoded if/else statements, while distributed workflow orchestrators (like Temporal or Zeebe) introduce extra infrastructure and network latency. Dataflow-rs sits cleanly between the two:
| Feature | Hardcoded Rust | dataflow-rs | Orchestrators (Temporal/Zeebe) |
|---|---|---|---|
| Hot-Reload Rules | β Recompile & redeploy | β Compile new JSON and swap the program | β Deploy new worker code |
| Execution Overhead | None | Zero (pre-compiled) | β DB reads/writes (tens of ms) |
| Browser Execution | β Large WASM bundle | β Run identical rules via WASM | β Server round-trip required |
| Visual Debugger | β Build your own UI | β Included React UI components | β Included Dashboard |
| Infrastructure | None | None (embeddable library) | β Server clusters & DBs |
Key Features
- IF β THEN β THAT Model β Define workflows with JSONLogic conditions, execute tasks sequentially, and chain workflows in priority order.
- Parse and Lint in One Pass β
def::parsevalidates workflow JSON and reports issues at their exact JSON paths before compilation. - Independent Workflow Compilation β
Compilercompiles each workflow individually so you can quarantine invalid definitions while linking valid ones into an immutableProgram. - Complete Run Reports β
engine.run(&mut message).awaitalways returns aRundescribing how execution finished, which workflows matched, and how each exited. - Full Context Access β Conditions and mappings can read any field in
data,metadata, andtemp_data. - Typed Async Host Functions β Implement
Functionwith#[derive(FunctionConfig)]. Configs are compiled at build time, and writes are committed to the message only when the call returnsOk. - Call Layers (Middleware) β Wrap host function calls at the registry or per-run level for logging, mocking, or dry runs.
- Deadlines and Cancellation β Enforce per-run timeouts (
TimedOut) or cancellation tokens (Cancelled) that automatically race against in-flight async host calls. - Fan-Out (
for_each) β Run a host function across array elements with isolated element bindings and deterministic in-order folding. - Task Groups β Nest tasks under a shared condition, with optional
terminalgroups for early exit (if / else if / else). - Outcome Policies β Configure
on_failureandon_rejectionon tasks, andon_failureon workflows, to control error recovery. - Bounded Loops β Repeat a workflow's task list while a condition holds, up to a configured maximum iteration count.
- Traffic Splits (Canary Rollouts) β Route a percentage of a channel's traffic to a workflow version using
rolloutbucket ranges. - Lightweight Observers β Subscribe to execution events via a single
Observer::on(&Event)callback with configurableInterestflags. - Replayable Traces β Capture the initial context and per-step diffs on any run outcome, allowing step-by-step context replay in debuggers.
- Error Context Mirroring β Mirror task failure codes into the message context so subsequent conditions can branch on failure causes.
- Isolated Secrets β Read sensitive values via
{"secret": "name"}from an engine-scoped store that never leaks into messages, traces, or logs. - Built-in Functions β
parse_json,parse_xml,map,validation,filter,log,publish_json, andpublish_xml. - Channel Routing & Lifecycle β Route runs to specific channels and manage
active,paused, orarchivedworkflows. - 3.x Compatibility β Automatically loads and rewrites 3.x workflow syntax with
V3_SPELLINGadvisories. - WebAssembly & React UI β Run workflows in browsers and Node.js with
@goplasmatic/dataflow-wasmand visualize/debug them with@goplasmatic/dataflow-ui. - Audit Trails β Track every context modification made by each task.
Try It Now
Run dataflow-rs right in your browser. Edit the workflow or payload below to see the output update live.
Open the Full Debugger UI for step-by-step execution and workflow visualization.
How It Works
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Workflow β
β β
β IF condition matches β JSONLogic against any field β
β THEN execute tasks β map, validation, host logic β
β THAT chain more workflows β priority-ordered execution β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
- Define Workflows β Write JSON workflow definitions with conditions and tasks.
- Build an Engine β
Engine::builder().workflow_json(&json).build()parses, compiles, and links your definitions, rejecting invalid workflows at startup with detailed issues. - Run Messages β Call
engine.run(&mut message).awaitto process a message through all matching workflows. - Inspect the Result β Check
Runfor the overall status and per-workflow outcomes, and read the updated context, audit trail, and errors fromMessage.
Next Steps
- Installation β Add
dataflow-rsto your Rust project - Quick Start β Build and run your first workflow
- Playground β Experiment with workflows interactively in your browser
- Migrating from 3.x β Learn what changed in 4.0 and how the compatibility reader helps you upgrade
Playground
Experiment with dataflow-rs directly in your browser. Define workflows, supply a JSON payload, and inspect the resulting message and run report.
Open the Full Debugger UI for visual workflow diagrams and step-by-step trace inspection.
How to Use
- Select an Example β Pick a starter workflow from the dropdown or write your own from scratch.
- Edit Workflows β Modify the workflow JSON in the left editor.
- Edit Payload β Customize the input payload in the right editor.
- Process β Click Process (or press
Ctrl+Enter/Cmd+Enterinside either editor). - View Results β Inspect the run status (
completed,completed_with_errors, orstopped), per-workflow outcomes, and the final message context, error records, and audit trail.
Tips
- Parse the Payload First β
payloadis stored separately from the JSONLogic evaluation context (data,metadata,temp_data). Start your workflow with aparse_jsontask ({"source": "payload", "target": "input"}) so subsequent tasks and conditions can readdata.input.*. - Upfront Validation β If a workflow definition has syntax or schema errors, the engine reports each issue with its code, JSON path, and description before running any message.
- JSONLogic Expressions β Use JSONLogic in conditions and mappings to read and transform fields dynamically.
- Chaining Tasks & Workflows β Add multiple tasks to run sequentially within a workflow, or define multiple workflows ordered by
priority. - Conditional Execution β Add a
conditionto any workflow, task, or task group to control when it runs based ondata,metadata, ortemp_data. - Failure & Rejection Policies β By default, an unhandled task failure (
5xxor error) stops the run. Set"on_failure": "continue"on a task to record the error and continue to the next task, or set it on a workflow to continue to the next workflow. Failedvalidationrules return400(a rejection), which continues to the next task by default ("on_rejection": "continue")βset"on_rejection": "halt"on the validation task if you want to stop the workflow when validation fails. - Audit Trail β Check the output's
audit_trailto see every field modification recorded during the run.
Installation
Add dataflow-rs to your Rust project using Cargo.
Requirements
- Rust 1.98 or later (Edition 2024)
- Cargo (included with Rust)
Add to Cargo.toml
[dependencies]
dataflow-rs = "4.0"
serde_json = "1.0"
tokio = { version = "1", features = ["rt-multi-thread", "macros", "time"] }
serde_jsonprovides thejson!macro used to construct workflow definitions and payloads in the examples.tokioprovides the async runtime forEngine::run. Enabling Tokio'stimefeature is required if you useRunOptions::deadlineto enforce timeouts on async host calls.
Operator Families and Cargo Features
By default, optional Cargo features are disabled and ExprEngine::default() enables only the standard JSONLogic operators. Six pure-operator extension families (ExtString, ExtArray, ExtMath, ExtControl, ExtObject, ErrorHandling) are compiled in by default and can be enabled at runtime on ExprEngineBuilder via .family(...), .families(...), or .all_operators().
| Cargo Feature | Description |
|---|---|
datetime | Unlocks OperatorFamily::Datetime (datetime, timestamp, parse_date, format_date, date_diff, now). Also changes == and comparison operators when comparing ISO-8601 date/duration strings. |
tensor | Unlocks OperatorFamily::Tensor (tensor, zeros, full, scatter, rle_expand, one_hot, stack, concat, unstack, reshape, transpose, pad, crop, cast, normalize, argmax, gather, to_list, shape, dtype). Not included in ExprEngineBuilder::all_operators() because several operator names collide with common JSON object keys. |
budget | Unlocks ExprEngineBuilder::ops_budget, which enforces a maximum operation count per JSONLogic evaluation. |
wasm-web | Required when compiling for wasm32-unknown-unknown. |
ts | Generates TypeScript type declarations for wire types (internal tooling; not covered by semver guarantees). |
[dependencies]
dataflow-rs = { version = "4.0", features = ["datetime", "budget"] }
Note: Enabling an operator family changes how single-key JSON objects with matching keys are evaluated. When a family is disabled, its operator names are treated as literal object keys (for example,
{"length": ...}produces a literal object untilOperatorFamily::ExtStringis enabled, after whichlengthis evaluated as an operator). You can always prefix a key with$(for example,{"$length": ...}) to force a literal object. See JSONLogic β Operator Families for details.
Verify Installation
Create a minimal program to verify that everything compiles:
use dataflow_rs::Engine; fn main() { // Build an engine with no workflows. `build()` fails only when a workflow // definition is rejected. let engine = Engine::builder().build().expect("no workflow to reject"); println!( "Engine built with {} workflows", engine.program().workflows().len() ); }
Run it with:
cargo run
You should see:
Engine built with 0 workflows
Optional Dependencies
If your workflows use the built-in log function, add a log implementation such as env_logger to view its output:
[dependencies]
log = "0.4"
env_logger = "0.11"
Custom host functions use native async fn in the Function trait, so you do not need the async-trait crate.
Next Steps
- Quick Start β Build and run your first workflow
- Basic Concepts β Learn the core architecture
Quick Start
Build and run your first workflow in a few minutes.
Prerequisite reading: Workflow conditions and mappings use JSONLogic. If you are new to JSONLogic, skim the Data Access, Comparison Operators, and Conditionals sections firstβthey cover everything used in the examples below.
Create a Simple Workflow
Workflows are defined in JSON. Each workflow contains an ordered list of tasks that process a message.
use dataflow_rs::{Engine, Message}; use serde_json::json; #[tokio::main] async fn main() -> Result<(), Box<dyn std::error::Error>> { // Define a workflow that copies the incoming payload into `data.input` // and then constructs a greeting. Because `payload` is outside the // JSONLogic evaluation context, starting with `parse_json` is the // standard pattern. let workflow = json!({ "id": "greeting", "name": "Greeting", "tasks": [ { "id": "load", "function": { "name": "parse_json", "input": {"source": "payload", "target": "input"} } }, { "id": "greet", "function": { "name": "map", "input": {"mappings": [ { "path": "data.greeting", "logic": {"cat": ["Hello, ", {"var": "data.input.name"}, "!"]} } ]} } } ] }); // The builder parses, compiles, and links every workflow definition. // If any definition has an error (such as a typo or unknown function), // `build()` fails upfront with detailed issues rather than failing at runtime. let engine = match Engine::builder().workflow_json(&workflow).build() { Ok(engine) => engine, Err(issues) => { for issue in &issues { eprintln!("{issue}"); } return Err("the workflow was rejected".into()); } }; // Create a message from a JSON payload. When the workflow runs, // `parse_json` copies this payload into `data.input`. let mut message = Message::from_json(&json!({"name": "World"})); // Run the message through the engine. `run` always returns a `Run` report. let run = engine.run(&mut message).await; println!("status: {:?}", run.status()); println!("data: {}", serde_json::to_string(message.context().data())?); assert!(matches!(run.status(), dataflow_rs::run::RunStatus::Completed)); assert_eq!( serde_json::to_string(message.context().data())?, r#"{"input":{"name":"World"},"greeting":"Hello, World!"}"# ); Ok(()) }
Running this program prints:
status: Completed
data: {"input":{"name":"World"},"greeting":"Hello, World!"}
Try It Interactively
Open the Full Debugger UI for step-by-step execution and workflow visualization.
How It Works
- Workflow Definition β A JSON object with a unique
idand a list oftasksto execute. - Engine Creation β
Engine::builder()parses and validates each definition, compiles all JSONLogic expressions and function configs, and links them into an immutable program. - Message Creation β
Message::from_jsonplaces your input inpayload, while the evaluation context (data,metadata,temp_data) starts empty. - Execution β
engine.run(&mut message).awaitevaluates each active workflow's condition in priority order and runs the tasks of matching workflows. - Inspecting Results β
Runreports the finalRunStatusand per-workflow execution records, whileMessageholds the updated context, audit trail, and any recorded errors.
Add Validation
Next, add a validation task before the map task to ensure data.input.name is present:
{
"id": "validated_greeting",
"name": "Validated Greeting",
"tasks": [
{
"id": "load",
"function": {
"name": "parse_json",
"input": {"source": "payload", "target": "input"}
}
},
{
"id": "validate_input",
"on_rejection": "halt",
"function": {
"name": "validation",
"input": {
"rules": [
{
"logic": {"!!": {"var": "data.input.name"}},
"message": "Name is required"
}
]
}
}
},
{
"id": "greet",
"function": {
"name": "map",
"input": {
"mappings": [
{
"path": "data.greeting",
"logic": {"cat": ["Hello, ", {"var": "data.input.name"}, "!"]}
}
]
}
}
}
]
}
When a validation rule fails, it records a VALIDATION_ERROR on message.errors() and returns status 400 (a rejection). Because a task's default on_rejection policy is "continue", omitting "on_rejection": "halt" would allow greet to run even when validation fails. Setting "on_rejection": "halt" stops the current workflow immediately upon rejection while still allowing later workflows to run.
Here is what happens when you run that workflow with an empty payload:
use dataflow_rs::run::{RunStatus, WorkflowExit}; use dataflow_rs::{Engine, Message}; use serde_json::json; #[tokio::main] async fn main() { let workflow = json!({ "id": "validated_greeting", "tasks": [ {"id": "load", "function": {"name": "parse_json", "input": {"source": "payload", "target": "input"}}}, {"id": "validate_input", "on_rejection": "halt", "function": {"name": "validation", "input": {"rules": [{"logic": {"!!": {"var": "data.input.name"}}, "message": "Name is required"}]}}}, {"id": "greet", "function": {"name": "map", "input": {"mappings": [ {"path": "data.greeting", "logic": {"cat": ["Hello, ", {"var": "data.input.name"}, "!"]}}]}}} ] }); let engine = Engine::builder().workflow_json(&workflow).build().expect("the workflow builds"); let mut message = Message::from_json(&json!({})); let run = engine.run(&mut message).await; // The run finished, and recorded the validation error on the message. assert!(matches!(run.status(), RunStatus::CompletedWithErrors)); let record = &message.errors()[0]; assert_eq!(record.code, "VALIDATION_ERROR"); assert_eq!(record.message, "Name is required"); // The workflow halted at `validate_input`, so `greet` never ran. let workflow = run.workflows().next().expect("one workflow was considered"); assert_eq!(workflow.exit, Some(WorkflowExit::Halted)); assert!(message.context().get(&"data.greeting".parse().unwrap()).is_none()); }
Why is
parse_jsonneeded first?payloadis not part of the JSONLogic evaluation context (data,metadata,temp_data). Withoutparse_json,{"var": "data.input.name"}evaluates tonull(falsy) and validation fails on every message. In addition,Compiler::checkflags any{"var": "payload..."}expression as aPAYLOAD_VARdefect.
Next Steps
- Basic Concepts β Understand the core architecture
- JSONLogic Reference β Explore supported JSONLogic operators
- Map Function β Learn how to transform data
- Validation β Learn how to validate data
Basic Concepts
An overview of the core building blocks in dataflow-rs.
The IF β THEN β THAT Model
Dataflow-rs follows an IFTTT-style rules engine pattern:
- IF: Evaluate JSONLogic conditions against
data,metadata, andtemp_data. - THEN: Execute tasks such as data transformation, validation, or custom async host functions.
- THAT: Chain multiple tasks and workflows in priority order so later steps build on earlier writes.
Architecture Overview
Dataflow-rs operates in two distinct phases:
- Build β
def::parseparses and lints JSON definitions,Compilercompiles expressions and function configs, andlinkassembles an immutableProgram. - Run β
Engineprocesses messages against the compiled program.
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Build β
β ββββββββββββ βββββββββ ββββββββββββ βββββββββββ βββββββ β
β β JSON β β β parse β β β Compiler β β β Program β β βEngineβ β
β β (def) β β +lint β β β β β β β β
β ββββββββββββ βββββββββ ββββββββββββ βββββββββββ βββββββ β
β issues issues β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β
v
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Run β
β βββββββββββ βββββββββββββββββ βββββββββββββββββββββββββββ β
β β Message β β β engine.run() β β β Run (how it ended) and β β
β βββββββββββ βββββββββββββββββ β the message (context, β β
β β audit trail, errors) β β
β βββββββββββββββββββββββββββ β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Core Components
| Component | Type | Description |
|---|---|---|
| Workflow | WorkflowDef | A condition plus a list of tasks: IF the condition matches, THEN the tasks run |
| Task | TaskDef | A single function call with an optional condition and outcome policies |
| Registry | Registry | Holds the functions a program can call (the 8 built-ins plus your custom host functions) |
| Program | Program | The compiled, immutable set of workflows executed by an engine |
| Engine | Engine | Executes messages against a Program using an EngineConfig |
| Message | Message | Carries the input payload, the {data, metadata, temp_data} context, the audit trail, and errors |
| Run | Run | Reports the outcome of a single execution: final status, matched workflows, and optional trace |
Engine
The Engine processes messages through its compiled program.
#![allow(unused)] fn main() { use dataflow_rs::Message; use serde_json::Value; async fn _demo(definitions: Vec<Value>, mut message: Message) -> Result<(), Vec<dataflow_rs::Issue>> { use dataflow_rs::Engine; // Parse, compile, and link all workflow definitions. `build()` returns // all issues if any definition is rejected. Use `.register("name", func)` // to register custom host functions before building. let mut builder = Engine::builder(); for definition in &definitions { builder = builder.workflow_json(definition); } let engine = builder.build()?; // Process a message. `Run` describes how execution finished and what each workflow did. let run = engine.run(&mut message).await; println!("{} workflow(s) matched", run.matched()); Ok(()) } }
Workflow
A workflow is an ordered sequence of tasks that run when its condition matches:
- Priority β Controls execution order across workflows (lower numbers run first).
- Condition β A JSONLogic expression evaluated against the full message context (
data,metadata,temp_data). If omitted, the workflow always runs. - Failure Policy β
on_failuredetermines whether an unhandled task failure stops the entire run ("fail", the default) or continues to the next workflow ("continue").
{
"id": "premium_order",
"name": "Premium Order Processing",
"priority": 1,
"condition": { ">=": [{"var": "data.order.total"}, 1000] },
"tasks": [...]
}
Task
A task is an individual processing step within a workflow:
- Calls a single functionβeither one of the 8 built-ins (
map,validation,filter,log,parse_json,parse_xml,publish_json,publish_xml) or a custom host function registered by name. - Optionally specifies a
conditionto control whether the task runs. - Configures outcome policies:
on_failurefor failures (5xxstatus orErr) andon_rejectionfor rejections (4xxstatus).
{
"id": "apply_discount",
"name": "Apply Discount",
"condition": { "!!": {"var": "data.order.total"} },
"function": {
"name": "map",
"input": { ... }
}
}
Message
The Message is the data container passed through the engine:
idβ Unique message ID (defaults to a generated UUID v7).payloadβ The raw input body received by the engine. It is read-only and separate from the JSONLogic evaluation context; use aparse_jsontask to copy it intodata.context.dataβ Primary business data read and modified by tasks.context.metadataβ Message and execution metadata (including engine stamps likeprocessed_at,engine_version, andprogress).context.temp_dataβ Scratch space for intermediate values shared between tasks.audit_trailβ Chronological log of executed tasks and the field changes they made.errorsβ List of all errors recorded during execution.
#![allow(unused)] fn main() { use dataflow_rs::Message; use serde_json::json; // `from_json` sets the message *payload*. The three context roots (`data`, // `metadata`, `temp_data`) start empty until populated by a task like `parse_json`. let message = Message::from_json(&json!({ "name": "John", "email": "john@example.com" })); // After a run, inspect the context, audit trail, and recorded errors. println!("data: {:?}", message.context().data()); println!("audit entries: {}", message.audit().len()); println!("errors: {}", message.errors().len()); assert!(message.audit().is_empty() && message.errors().is_empty()); }
Run
engine.run(&mut message).await always returns a Run value, whether execution succeeded, recorded validation errors, stopped on a task failure, or hit a timeout:
#![allow(unused)] fn main() { use dataflow_rs::{Engine, Message}; async fn _demo(engine: Engine, mut message: Message) { use dataflow_rs::run::RunStatus; let run = engine.run(&mut message).await; match run.status() { RunStatus::Completed => println!("every workflow finished, no errors"), RunStatus::CompletedWithErrors => println!("finished; see message.errors()"), RunStatus::Stopped(error) => println!("a task stopped the run: {error}"), RunStatus::TimedOut => println!("the run's deadline passed"), RunStatus::Cancelled => println!("the run's token was cancelled"), _ => {} } for workflow in run.workflows() { println!("{}: {:?} -> {:?}", workflow.id, workflow.selection, workflow.exit); } } }
Data Flow
- Input β Construct a
Messagewith your input payload. - Workflow Selection β The engine evaluates active workflow conditions in priority order.
- Task Execution β Matching workflows run their tasks sequentially.
- Output β
Messagecontains the updated context, audit trail, and error list, whileRunreports how execution completed.
Message (input)
β
v
βββββββββββββββββββββββββββββββββββββββββββ
β Workflow 1 (priority: 1) β
β Task 1 -> Task 2 -> Task 3 β
βββββββββββββββββββββββββββββββββββββββββββ
β
v
βββββββββββββββββββββββββββββββββββββββββββ
β Workflow 2 (priority: 2) β
β Task 1 -> Task 2 β
βββββββββββββββββββββββββββββββββββββββββββ
β
v
Message (output with audit trail) + Run
JSONLogic
Dataflow-rs uses JSONLogic for:
- Conditions β Deciding when workflows, task groups, and tasks execute.
- Data Access β Reading fields from
data,metadata, andtemp_data. - Transformations β Computing new values in
maptasks and custom function configs.
Common examples:
// Read a field from `data`
{"var": "data.name"}
// Concatenate strings
{"cat": ["Hello, ", {"var": "data.name"}]}
// Conditional expression
{"if": [{"var": "data.premium"}, "VIP", "Standard"]}
// Numeric comparison
{">=": [{"var": "data.order.total"}, 1000]}
Next Steps
- Engine β Deep dive into building, running, and reloading the engine
- JSONLogic β Complete JSONLogic operator reference
- Custom Functions β Extend the engine with async Rust functions
Core Concepts Overview
Dataflow-rs is built around a small set of core types that compile JSON definitions into an immutable program and run messages through it.
The Big Picture
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Build β
β β
β ββββββββββββββββββ ββββββββββββββββββββββββββββββββββββββββββββ β
β β Registry β β Compiler β β
β β built-ins β β β one workflow at a time: every β β
β β host functionsβ β expression and config compiled, every β β
β β ExprEngine β β issue at its authored path β β
β ββββββββββββββββββ ββββββββββββββββββββββββββββββββββββββββββββ β
β β link β
β v β
β ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
β β Program (immutable) β β
β β βββββββββββββββ βββββββββββββββ βββββββββββββββ β β
β β β Workflow 1 β β Workflow 2 β β Workflow N β β β
β β β Task 1 β β Task 1 β β Task 1 β β β
β β β Task 2 β β Task 2 β β ... β β β
β β βββββββββββββββ βββββββββββββββ βββββββββββββββ β β
β ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
β β + EngineConfig β
β v β
β ββββββββββββββββ β
β β Engine β β
β ββββββββββββββββ β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β run(&mut message)
v
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Message Run β
β ββββββββ βββββββββββ βββββββββββ βββββββββββββ status β
β β data β βmetadata β βtemp_dataβ βaudit_trailβ matched β
β ββββββββ βββββββββββ βββββββββββ βββββββββββββ workflows β
β ββββββββββ ββββββββββ trace (opt.) β
β βpayload β β errors β β
β ββββββββββ ββββββββββ β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Component Summary
| Component | Purpose | Key Features |
|---|---|---|
Workflow (WorkflowDef) | Groups related tasks | Priority ordering, condition, failure policy, channel, lifecycle |
Task (TaskDef) | Executes one function call | Built-in or host function, condition, outcome policies, for_each fan-out |
| Registry | Holds callable functions and operators | 8 built-in functions, custom host functions, call layers, ExprEngine |
| Compiler & Program | Compiles and links definitions | Compiles one workflow at a time, reports issues by JSON path, produces an immutable Program |
| Engine | Executes messages against a program | Run options, observers, traces, error-context mirroring, hot reload |
| Message | Carries input and runtime state | Raw payload, {data, metadata, temp_data} context, audit trail, error list |
| Run | Reports the outcome of a run | Final status, matched workflow count, per-workflow selection and exit, optional trace |
The Message flows through the engine, carrying the raw payload, the {data, metadata, temp_data} context evaluated by JSONLogic conditions, an audit trail, and a list of recorded errors.
Processing Flow
-
Build
- Parse and lint each workflow definition with
def::parse, which reports issues at their exact JSON paths. - Compile expressions and function configs one workflow at a time with
Compiler. - Link the compiled workflows into an immutable
Programsorted by priority. - Combine the
Programwith anEngineConfigto create anEngine.
- Parse and lint each workflow definition with
-
Run
- Create a
Messagewith an input payload. - At the start of
run, the engine stampsmetadata.processed_atandmetadata.engine_version. - The engine evaluates each active workflow's condition against
{data, metadata, temp_data}in priority order.
- Create a
-
Task Execution
- Tasks inside each matching workflow execute sequentially.
- Built-in functions run inline on the context view; custom host functions receive a
Ctxand write to a per-call overlay that commits when the call returnsOk. - Each task's changes are appended to the message's audit trail, and
metadata.progressis updated after every executed task.
-
Result
Runreports the final execution status and what each workflow did.Messageholds the updated context, audit trail, and any recorded errors.
Key Design Principles
Compile Once
The compiler validates JSONLogic expressions and function configs when building the program. Invalid definitions fail at build time with structured issues, and constant expressions are folded once ahead of time.
Immutability & Hot Reload
A linked Program is immutable. Engine wraps its Program and EngineConfig in an Arc, making Engine::clone cheap and thread-safe. Hot-reloading creates a new Engine via Engine::with_program while in-flight runs finish undisturbed on the previous program.
Separation of Concerns
defβ Parses and lints JSON definitions into typed data structures (WorkflowDef).exprβ Compiles and evaluates JSONLogic expressions (ExprEngine,Expr,Path).functionβ Defines the custom function trait, context, registry, and call layers (Function,Ctx,Registry,CallLayer).programβ Compiles and links workflows (Compiler,Program,Issue).runβ Executes messages and reports outcomes (Engine,RunOptions,Message,Run).observeβ Provides execution tracing and metrics hooks (Observer,Event,Trace).
Auditability
The engine records every context write made by tasks so you can inspect each processing step for debugging or compliance. Enabling tracing also captures the initial context and per-step diffs so you can replay the exact state after any step.
Detailed Documentation
- Engine β Building, running, observing, and reloading the engine
- Workflows β Defining conditions, priorities, channels, and rollouts
- Tasks β Configuring function calls, conditions, and outcome policies
- Message β Working with payloads, context roots, audit trails, and errors
- Error Handling β Understanding failures, rejections, and recovery policies
Engine
The Engine executes messages against a compiled Program. It is the central component that selects matching workflows and orchestrates task execution.
Overview
The Engine is responsible for:
- Running messages through active workflows in priority order
- Evaluating workflow and task conditions against the full message context (
data,metadata,temp_data) - Applying task and workflow outcome policies and recording audit entries and errors
- Filtering workflows by
channeland evaluating canaryrolloutbuckets - Reporting execution outcomes via
Run, emitting events toObservers, and capturingTraces when requested - Providing a pre-configured
Compiler(engine.compiler()) to build updated programs for hot-reloading
Compilation happens ahead of time: workflow definitions are parsed and linted by def::parse, compiled by a Compiler created from a Registry, and linked into an immutable Program. The Engine pairs that Program with an EngineConfig.
Creating an Engine
Engine::builder() handles parsing, compilation, and linking in one step. If any workflow definition is invalid, build() returns Err with all collected issues.
use dataflow_rs::Engine; use serde_json::json; fn main() -> Result<(), Vec<dataflow_rs::Issue>> { let first = json!({ "id": "first", "priority": 1, "tasks": [ {"id": "t", "function": {"name": "map", "input": { "mappings": [{"path": "data.first", "logic": true}]}}} ] }); let second = json!({ "id": "second", "priority": 2, "tasks": [ {"id": "t", "function": {"name": "map", "input": { "mappings": [{"path": "data.second", "logic": true}]}}} ] }); let engine = Engine::builder() .workflow_json(&first) .workflow_json(&second) // .register("my_function", MyFunction) // register custom host functions here .build()?; // All expressions and function configs are compiled upfront. println!("Loaded {} workflows", engine.program().workflows().len()); assert_eq!(engine.program().workflows().len(), 2); Ok(()) }
Builder Methods
.workflow_json(&value)/.workflow(def)β.workflow_jsonparses a JSON definition with 3.x compatibility enabled;.workflowadds aWorkflowDefyou parsed yourself..register("name", function)/.layer(layer)/.registry(registry)β.registeradds a customFunction;.layerwraps host calls in a middleware layer;.registryreplaces the entireRegistry(including itsExprEngineoperators, secrets, operation budget, and any previously registered functionsβso call.registry(...)before.register(...))..options(CompileOptions)/.config(EngineConfig)β Configure compilation limits and runtime engine settings..build()β ReturnsResult<Engine, Vec<Issue>>. If any workflow is rejected,build()returns all issues from the rejected definitions in input order (parse issues followed by compile issues). If all workflows compile, non-fatalDefectandAdvisoryissues are discarded; useCompiler::checkwhen you want to inspect warnings on valid definitions.
#![allow(unused)] fn main() { use dataflow_rs::Engine; use dataflow_rs::program::IssueCode; use serde_json::json; let issues = Engine::builder() .workflow_json(&json!({"id": "w", "tasks": [ {"id": "t", "function": {"name": "fetch"}} ]})) .build() .unwrap_err(); assert_eq!(issues[0].code, IssueCode::UnknownFunction); assert_eq!(issues[0].path.to_string(), "tasks[0].function.name"); println!("{}", issues[0]); }
Quarantining Invalid Definitions
When loading workflows from a database or external store, you often want valid workflows to run even if one definition is broken. Use Compiler::compile_one and Engine::new(program, config) to compile workflows individually and skip any that fail:
#![allow(unused)] fn main() { use dataflow_rs::def::{ParseOptions, parse}; use dataflow_rs::program::CompileOptions; use dataflow_rs::run::EngineConfig; use dataflow_rs::{Engine, Registry}; use serde_json::json; let definitions = vec![ json!({"id": "good", "tasks": [ {"id": "t", "function": {"name": "map", "input": { "mappings": [{"path": "data.ok", "logic": true}]}}} ]}), json!({"id": "bad", "tasks": [ {"id": "t", "function": {"name": "no_such_function"}} ]}), ]; let compiler = Registry::default().compiler(CompileOptions::default()); let mut workflows = Vec::new(); for raw in &definitions { // `parse` returns `Some(def)` whenever there are no `Rejected` parse issues. let parsed = parse(raw, ParseOptions::default()); let Some(def) = parsed.def else { for issue in &parsed.issues { eprintln!("{issue}"); } continue; }; match compiler.compile_one(&def) { Ok(compiled) => workflows.push(compiled.workflow), Err(issues) => { for issue in &issues { eprintln!("{}: {issue}", def.id()); } } } } let engine = Engine::new(compiler.link(workflows), EngineConfig::default()); assert_eq!(engine.program().workflows().len(), 1); }
compile_one returns Err(issues) if any issue has Severity::Rejected, or Ok(Compiled) containing the compiled workflow along with any Defect and Advisory issues. link never fails: it sorts the compiled workflows by priority (preserving input order for ties) and indexes their tasks.
Running Messages
#![allow(unused)] fn main() { use dataflow_rs::Engine; async fn _demo(engine: Engine) { use dataflow_rs::Message; use dataflow_rs::run::RunStatus; use serde_json::json; // Create a message from a JSON payload. let mut message = Message::from_json(&json!({ "user": "john", "action": "login" })); // Process the message through all active workflows. let run = engine.run(&mut message).await; match run.status() { RunStatus::Completed => {} RunStatus::CompletedWithErrors => eprintln!("{} error(s)", message.errors().len()), RunStatus::Stopped(error) => eprintln!("stopped: {error}"), RunStatus::TimedOut | RunStatus::Cancelled => eprintln!("cut short"), _ => {} } println!("{} workflow(s) ran", run.matched()); println!("data: {:?}", message.context().data()); println!("audit entries: {}", message.audit().len()); } }
What Run Reports
status()βCompleted,CompletedWithErrors(all considered workflows finished, with one or more errors recorded onmessage.errors()),Stopped(error)(an unhandled task failure stopped the run),TimedOut, orCancelled.matched()β Number of workflows that matched and ran.workflows()β Per-workflow execution records (WorkflowRecord) in priority order up to the point the run ended, includingselection(Ran,SkippedByCondition,ConditionFailed(error),SkippedByRollout),sweeps(iteration count), andexit(Completed,Halted,Failed,Stopped).partial()&retryable()βpartial()istrueif the run stopped before considering all workflows;retryable()indicates whether retrying the message may succeed.trace()&take_trace()β Returns the executionTraceif tracing was enabled inRunOptions.into_result()β ConvertsRunintoResult<Run, Error>, returningOk(run)forCompleted/CompletedWithErrorsandErrforStopped,TimedOut, orCancelled(useful when you want to propagate fatal stops with?).
If you already have an OwnedDataValue (or Arc<OwnedDataValue>), pass it to Message::new to share the payload without cloning:
#![allow(unused)] fn main() { use dataflow_rs::Message; use dataflow_rs::datavalue::OwnedDataValue; use serde_json::json; use std::sync::Arc; let payload = Arc::new(OwnedDataValue::from(&json!({"user": "john"}))); let a = Message::new(Arc::clone(&payload)); let b = Message::new(payload); assert_eq!(a.payload(), b.payload()); }
Run Options
Use engine.run_with(&mut message, &RunOptions) to customize a single run:
| Option | Default | Description |
|---|---|---|
channel(&str) | All active workflows | Run only active workflows assigned to this channel (also stamps metadata.channel). |
routing_bucket(u8) | None | Canary rollout bucket (0..=99). When omitted, all workflow rollouts match. |
capture_changes(bool) | true | Record old and new field values (Change) in each task's audit entry. |
audit(AuditMode) | Full | Which audit entries to keep on the message: Full, Last(n), or Off. |
deadline(Instant) | None | End the run with TimedOut once the deadline passes. Checked at every host call, fan-out element, loop sweep, and every 64th expression op; async host calls are raced against a Tokio timer. |
cancel(&CancelToken) | None | End the run with Cancelled when the token is triggered (checked at the same points as deadline). |
layers(&[Arc<dyn CallLayer>]) | None | Wrap host calls for this run in additional middleware layers outside the registry's layers. |
observer(&dyn Observer) | None | Send execution events for this run to an additional observer alongside the engine's observer. |
trace(TraceOptions) | None | Record an execution trace accessible via Run::trace(). |
#![allow(unused)] fn main() { use dataflow_rs::{Engine, Message}; async fn _demo(engine: Engine, mut message: Message) { use dataflow_rs::run::{AuditMode, RunOptions}; let opts = RunOptions::default() .channel("orders") .audit(AuditMode::Last(10)); let run = engine.run_with(&mut message, &opts).await; let _ = run; } }
Note: Using
RunOptions::deadlinerequires a Tokio runtime with thetimefeature enabled, because async host calls are raced againsttokio::time::sleep_until. Only the run's own deadline or cancel token setsRunStatus::TimedOutorRunStatus::Cancelled; if a custom host function returns its own timeout error, it is handled like any other task failure under the task'son_failurepolicy.
Execution Tracing
Enable tracing on a run with RunOptions::trace(TraceOptions). The returned Run will carry a Trace containing:
initial: the message context right after initial run stamps (processed_at,engine_version,channel) and before any workflow runs.steps: oneTraceStepfor every task call, skipped step, failure, and engine write, along with thechangesit made.
Traces are returned on every run status, including Stopped, TimedOut, and Cancelled.
#![allow(unused)] fn main() { use dataflow_rs::{Engine, Message}; async fn _demo(engine: Engine, mut message: Message) { use dataflow_rs::observe::TraceOptions; use dataflow_rs::run::RunOptions; let opts = RunOptions::default().trace(TraceOptions::default()); let run = engine.run_with(&mut message, &opts).await; let trace = run.trace().expect("a trace was asked for"); for step in &trace.steps { println!( "{}/{}: {:?}, status {:?}, {} change(s)", step.workflow_id, step.task_id.as_deref().unwrap_or("-"), step.result, step.status, step.changes.len() ); } // Replay step changes starting from `trace.initial` to inspect the context after each step. for (step, context) in trace.steps.iter().zip(trace.contexts()) { println!("after {:?}: {:?}", step.task_id, context.data()); } } }
Each step's result is Executed (the task or engine write ran and execution continued), Skipped (skipped due to a false condition or rollout mismatch), or Failed (the failure was handled by the workflow policy or stopped the run). step.errors holds any error records added during that step. For map tasks, step.mapping_ends marks the boundary after each mapping so Trace::mapping_contexts can reconstruct the context after individual mappings.
Controlling Trace Overhead and Redaction
TraceOptions provides four settings to bound and sanitize traces:
#![allow(unused)] fn main() { use dataflow_rs::{Engine, Message}; async fn _demo(engine: Engine, mut message: Message) -> dataflow_rs::Result<()> { use dataflow_rs::observe::TraceOptions; use dataflow_rs::run::RunOptions; let trace = TraceOptions::default() // Measure each task's execution duration in `step.elapsed` (off by default). .timings(true) // Redact sensitive subtrees in the trace: `initial` and any changes at or // below these paths are replaced with `null` in the trace while the live // message retains the real values. .redact(vec!["data.card.pan".parse()?]) // Cap the total byte size of recorded changes. Once exceeded, subsequent // steps keep their metadata and status but omit `changes`, and `trace.truncated` is set. .max_bytes(256 * 1024); let run = engine.run_with(&mut message, &RunOptions::default().trace(trace)).await; if run.trace().is_some_and(|t| t.truncated) { println!("byte budget hit: later steps carry no changes"); } Ok(()) } }
The fourth setting, .changes(false), records step statuses and timings without capturing context diffs (useful for lightweight profiling, though the resulting trace cannot replay contexts). Note that trace change capture is independent of RunOptions::capture_changes, which controls only message.audit().
Always-On Metrics with Observer
While a Trace allocates a per-run log for debugging, an Observer provides zero-allocation, inline callbacks for metrics (counters, histograms, tracing spans) across every run:
use dataflow_rs::observe::{Event, Interest, Observer}; use dataflow_rs::run::EngineConfig; use dataflow_rs::{Engine, Message}; use serde_json::json; use std::sync::Arc; use std::sync::atomic::{AtomicU64, Ordering}; #[derive(Default)] struct Metrics { tasks: AtomicU64, total_us: AtomicU64, } impl Observer for Metrics { // Include `Interest::TIMINGS` only when you need `elapsed` durations; // omitting it avoids per-task clock reads. fn interest(&self) -> Interest { Interest::TASKS | Interest::TIMINGS } fn on(&self, event: &Event<'_>) { // Runs synchronously on the caller's thread: keep it fast and non-blocking. if let Event::TaskFinished { elapsed, .. } = event { self.tasks.fetch_add(1, Ordering::Relaxed); if let Some(elapsed) = elapsed { self.total_us .fetch_add(elapsed.as_micros() as u64, Ordering::Relaxed); } } } } #[tokio::main] async fn main() { let metrics = Arc::new(Metrics::default()); let engine = Engine::builder() .config(EngineConfig::default().observer(metrics.clone())) .workflow_json(&json!({"id": "w", "tasks": [ {"id": "t", "function": {"name": "map", "input": { "mappings": [{"path": "data.a", "logic": 1}]}}} ]})) .build() .unwrap(); engine.run(&mut Message::from_json(&json!({}))).await; assert_eq!(metrics.tasks.load(Ordering::Relaxed), 1); }
An observer configured on EngineConfig::observer receives events for every run of the engine, while RunOptions::observer(&obs) attaches an observer to a single run. interest() is checked once at the start of each run:
Interest::WORKFLOWS: enablesWorkflowStartedandWorkflowFinished.Interest::TASKS: enablesTaskFinished.Interest::TIMINGS: populateselapsedon workflow and task finish events.RunStartedandRunFinishedare always delivered to every active observer.- The default
InterestisWORKFLOWS | TASKS(no per-step clock reads).
Event Details
Event::TaskFinishedcarriesinfo(workflow_id,task_id,function,loop_counter,element_index),status, andelapsed:statusisNonewhen the function returnedOutcome::Skip, andSome(500)when the function returnedErr.- Tasks skipped because their condition evaluated to
falsedo not emitTaskFinished. - A
for_eachfan-out task emitsTaskFinishedonce for each element it folds. elapsedmeasures the function call itself and isSomeonly whenInterest::TIMINGSis set.
Event::WorkflowStarted&Event::WorkflowFinishedfire only for workflows that actually run (workflows skipped by condition or rollout do not emit events). A looping workflow emits a singleWorkflowFinishedevent covering all sweeps.Event::RunStartedreportsconsidered(the number of active workflows in the target channel before rollout or condition checks).Event::RunFinishedreports the finalRunStatusand total error count on the message.
Important:
Observer::onruns synchronously on the execution path. Keep it non-blocking, do not call back into the engine, and do not panic. Always include..when pattern-matchingEventvariants, as they are marked#[non_exhaustive].
Workflow Execution Order
Workflows execute in ascending priority order (lowest number first), with ties broken by the order they were added to the program. Only active workflows run; paused and archived workflows remain in the compiled program for inspection but are skipped during execution.
[
{"id": "high", "priority": 1, "tasks": [...]},
{"id": "low", "priority": 10, "tasks": [...]}
]
When RunOptions::channel is set, only active workflows in that channel are considered. When a workflow specifies a rollout range, it is skipped if the run's routing_bucket falls outside that range (runs without a routing_bucket match all rollouts). See Workflows for details.
Workflow Conditions
A workflow's condition determines whether it runs. The engine evaluates it against the full message context (data, metadata, and temp_data):
{
"id": "premium_order",
"name": "Premium Order Processing",
"condition": { ">=": [{"var": "data.order.total"}, 1000] },
"tasks": [...]
}
A condition matches only when it evaluates to boolean true (truthy values like 1 or non-empty strings do not select the workflow). If a condition fails with an evaluation error, it is treated as false and recorded on Run as Selection::ConditionFailed(error).
Custom Host Functions
Register custom async functions on EngineBuilder with .register("name", func). Each task's input is compiled into your function's Function::Config type at build time, catching configuration errors before any message is processed:
use dataflow_rs::{Ctx, Engine, Function, Outcome, Result}; use serde_json::json; /// A simple host function that takes no config and makes no writes. struct Ping; impl Function for Ping { type Config = (); async fn call(&self, _ctx: &mut Ctx<'_>, _cfg: &()) -> Result<Outcome> { Ok(Outcome::Ok) } } fn main() -> std::result::Result<(), Vec<dataflow_rs::Issue>> { let engine = Engine::builder() .register("ping", Ping) .workflow_json(&json!({"id": "w", "tasks": [ {"id": "t", "function": {"name": "ping"}} ]})) .build()?; let _ = engine; Ok(()) }
During execution, a host function's writes are buffered in a per-call overlay and committed to the message only when call returns Ok. Returning Err discards all context writes from that call while preserving any explicit error records added via Ctx::record_error. Note that the 8 built-in function names are reserved; attempting to register a custom function under a built-in name will panic.
Thread Safety & Concurrency
Engine is Send + Sync, and Engine::clone is a cheap Arc reference count increment that shares the compiled program and configuration across threads. Because all mutable state lives on the Message, you can process many messages concurrently across Tokio tasks:
#![allow(unused)] fn main() { use dataflow_rs::{Engine, Message}; async fn _demo(engine: Engine, messages: Vec<Message>) { let handles: Vec<_> = messages .into_iter() .map(|mut message| { let engine = engine.clone(); tokio::spawn(async move { let run = engine.run(&mut message).await; (message, run) }) }) .collect(); for handle in handles { let (message, run) = handle.await.expect("the task ran"); println!("{}: {:?}", message.id(), run.status()); } } }
The future returned by engine.run / engine.run_with is Send.
Hot-Reloading Workflows
Because a Program is immutable, you reload workflows by compiling a new Program with engine.compiler()βwhich reuses the engine's Registry (custom functions, layers, operators, and secrets) and CompileOptionsβand swapping it in with engine.with_program:
#![allow(unused)] fn main() { use dataflow_rs::Engine; use serde_json::Value; fn _demo(engine: Engine, new_definitions: Vec<Value>) -> Result<(), Vec<dataflow_rs::Issue>> { use dataflow_rs::def::{ParseOptions, parse}; let compiler = engine.compiler(); let mut workflows = Vec::new(); for raw in &new_definitions { let parsed = parse(raw, ParseOptions::default()); let def = parsed.def.ok_or(parsed.issues)?; workflows.push(compiler.compile_one(&def)?.workflow); } let reloaded = engine.with_program(compiler.link(workflows)); // `engine` is unchanged and finishes any in-flight runs; `reloaded` // serves new runs with the updated program and the same `EngineConfig`. let _ = reloaded; Ok(()) } }
If any new definition fails to parse or compile, you catch the issues before swapping, so a bad update never disrupts a running engine.
API Summary
Engine::builder()
Returns an EngineBuilder. Chain .workflow_json(&value), .workflow_json_owned(value), .workflow(def), .register("name", function), .layer(layer), .registry(registry), .options(CompileOptions), and .config(EngineConfig), then call .build() -> Result<Engine, Vec<Issue>>.
EngineConfig
Holds engine-wide runtime settings preserved across reloads:
.observer(Arc<dyn Observer>): attaches an always-on observer to every run..error_context(ErrorContext::new(path, limit)?): mirrors task failure codes intopathon the message context (retaining up tolimitnewest records). See Error Handling.
Engine::new(program, config)
Constructs an Engine from a pre-linked Program and an EngineConfig.
engine.run(&mut message) & engine.run_with(&mut message, &opts)
Executes message through active workflows in priority order and returns a Run. Mutates message in place (updating its context, audit trail, and error list).
engine.compiler()
Returns a Compiler sharing this engine's Registry and CompileOptions.
engine.with_program(program)
Returns a new Engine that pairs program with this engine's EngineConfig.
engine.program()
Returns a reference to the compiled Program. program().workflows() lists metadata for all linked workflows (id, channel, priority, status, version, including paused and archived workflows), and program().tasks() lists all compiled tasks:
#![allow(unused)] fn main() { fn _demo(engine: dataflow_rs::Engine) { for workflow in engine.program().workflows() { println!("{} on {} at priority {}", workflow.id, workflow.channel, workflow.priority); } } }
Migrating from 3.x
In 3.x, the engine was constructed from Workflow structs and executed via the process_message* methods (process_message_with_trace, process_message_for_channel, etc.), observed via a five-method ExecutionObserver trait, traced with full-message snapshots, and reloaded with with_new_workflows.
In 4.0:
- Every execution goes through
runorrun_withand returns aRun. - Channels, traces, deadlines, and per-run observers are configured on
RunOptions. - Traces store compact per-step diffs instead of full message snapshots.
- Hot-reloading uses
engine.compiler(),compiler.link(...), andengine.with_program(...).
See Migrating from 3.x for the complete migration guide.
Workflows
A workflow is an ordered sequence of tasks that run when a condition matches. It is the core IF β THEN unit in dataflow-rs: IF the workflow's condition evaluates to true, THEN its tasks execute.
Overview
Workflows provide:
- Conditional Execution β Run only when a JSONLogic condition matches against
{data, metadata, temp_data}. - Priority Ordering β Control the order in which workflows run across a message pipeline.
- Task Organization β Group related steps, nest them under shared conditions (
GroupDef), or repeat them in a bounded loop. - Failure Policy β Control whether an unhandled task failure stops the entire run (
"fail") or continues to the next workflow ("continue").
Workflow Structure
{
"id": "premium_order",
"name": "Premium Order Processing",
"priority": 1,
"channel": "orders",
"version": 2,
"status": "active",
"tags": ["premium", "high-priority"],
"condition": { ">=": [{"var": "data.order.total"}, 1000] },
"on_failure": "fail",
"tasks": [
{
"id": "apply_discount",
"name": "Apply Discount",
"function": { ... }
},
{
"id": "notify_manager",
"name": "Notify Manager",
"function": { ... }
}
]
}
Fields
| Field | Type | Required | Description |
|---|---|---|---|
id | string | Yes | Unique workflow identifier (must be non-empty) |
name | string | No | Human-readable display name |
description | string | No | Free-text description |
priority | number | No | Execution order (default: 0; lower numbers run first, ties keep input order) |
condition | JSONLogic | No | Condition evaluated against {data, metadata, temp_data}; when omitted, the workflow always runs |
on_failure | string | No | What happens when a task failure reaches the workflow: "fail" (default) stops the run; "continue" moves to the next workflow |
tasks | array | Yes | Non-empty list of tasks or task groups (see Control Flow) |
channel | string | No | Routing channel name (default: "default") |
version | number | No | Workflow version number (default: 1) |
status | string | No | Lifecycle status: "active" (default), "paused", or "archived" |
tags | array | No | Arbitrary string tags for organization (default: []) |
rollout | object | No | Traffic split {bucket_start, bucket_end} over 0..100 (default: none) |
loop | object | No | Bounded loop configuration; see Loops (default: none) |
ext | object | No | Custom host or UI metadata preserved verbatim by the parser |
created_at | datetime | No | ISO 8601 creation timestamp (metadata only) |
updated_at | datetime | No | ISO 8601 update timestamp (metadata only) |
When the compatibility reader is enabled (the default), unrecognized keys are moved into the node's ext map rather than rejecting the workflow. They are reported as UNKNOWN_KEY advisoriesβor as LIKELY_TYPO defects if they are within edit distance of a known field name (for example, conditon instead of condition). If you disable compatibility mode via ParseOptions::default().compat(false), unrecognized keys are rejected.
Creating Workflows
From JSON
Pass workflow JSON directly to Engine::builder():
#![allow(unused)] fn main() { fn _demo() -> Result<(), Vec<dataflow_rs::Issue>> { use dataflow_rs::Engine; use serde_json::json; let engine = Engine::builder() .workflow_json(&json!({ "id": "my_workflow", "name": "My Workflow", "tasks": [ {"id": "t", "function": {"name": "map", "input": { "mappings": [{"path": "data.seen", "logic": true}]}}} ] })) .build()?; let _ = engine; Ok(()) } }
Parsing a Definition Manually
def::parse parses and lints a JSON value into a WorkflowDef. It never panics or short-circuits on the first error: it collects every structural problem as an Issue with its exact JSON path, and returns Some(WorkflowDef) whenever no issue has Severity::Rejected:
#![allow(unused)] fn main() { use dataflow_rs::def::{ParseOptions, parse}; use dataflow_rs::program::{IssueCode, Severity}; use serde_json::json; let parsed = parse( &json!({"id": "w", "tasks": [ {"id": "t", "conditon": true, "function": {"name": "map", "input": {"mappings": []}}} ]}), ParseOptions::default(), ); let def = parsed.def.expect("nothing was rejected"); assert_eq!(def.id(), "w"); assert_eq!(def.tasks().len(), 1); // The typo `conditon` is preserved in `ext` and flagged as a `LikelyTypo` defect. assert_eq!(parsed.issues[0].code, IssueCode::LikelyTypo); assert_eq!(parsed.issues[0].severity, Severity::Defect); assert_eq!(parsed.issues[0].path.to_string(), "tasks[0].conditon"); }
You can pass a parsed WorkflowDef to .workflow(def) on EngineBuilder, or compile it individually with Compiler::compile_one.
Loading from a File
#![allow(unused)] fn main() { fn _demo() -> Result<(), Box<dyn std::error::Error>> { use dataflow_rs::def::{ParseOptions, parse}; let text = std::fs::read_to_string("workflows/premium_order.json")?; let raw: serde_json::Value = serde_json::from_str(&text)?; let parsed = parse(&raw, ParseOptions::default()); for issue in &parsed.issues { eprintln!("{issue}"); } Ok(()) } }
Loading 3.x Definitions
Existing 3.x workflow definitions load automatically through the compatibility reader (enabled by default). The parser rewrites 3.x field names to their 4.0 equivalents and emits a V3_SPELLING advisory at each legacy key:
#![allow(unused)] fn main() { use dataflow_rs::def::{FailurePolicy, ParseOptions, StepDef, parse}; use dataflow_rs::program::{IssueCode, Severity}; use serde_json::json; // In 3.x, `continue_on_error: true` on a task maps to `on_failure: "continue"` in 4.0. let parsed = parse( &json!({"id": "w", "tasks": [ {"id": "t", "continue_on_error": true, "function": {"name": "map", "input": {"mappings": []}}} ]}), ParseOptions::default(), ); let def = parsed.def.expect("the 3.x definition loads"); let StepDef::Task(task) = &def.tasks()[0] else { panic!("a task") }; assert_eq!(task.on_failure(), FailurePolicy::Continue); assert_eq!(parsed.issues[0].code, IssueCode::V3Spelling); assert_eq!(parsed.issues[0].severity, Severity::Advisory); assert_eq!(parsed.issues[0].path.to_string(), "tasks[0].continue_on_error"); }
To permanently convert a 3.x JSON definition into 4.0 format, call dataflow_rs::compat::migrate_v3. See Migrating from 3.x for the complete mapping table.
Priority Ordering (Chaining Workflows)
Workflows execute in ascending priority order (lowest number runs first). This powers the THAT (chaining) in the IF β THEN β THAT model:
// Runs first (priority 1): validate input
{
"id": "validation",
"priority": 1,
"tasks": [...]
}
// Runs second (priority 2): transform data
{
"id": "transformation",
"priority": 2,
"tasks": [...]
}
// Runs last (priority 10): send notifications
{
"id": "notification",
"priority": 10,
"tasks": [...]
}
Each workflow sees all context changes made by earlier workflows, including metadata.progress. After every executed task, the engine updates metadata.progress to {"workflow_id", "task_id", "status_code"}, allowing later workflows to condition their execution on the outcome of earlier steps.
Conditional Execution
Use JSONLogic conditions to control when a workflow runs. Conditions can read any field in data, metadata, and temp_data, as well as secrets from the engine's secret store via {"secret": "name"}:
{
"id": "premium_user_workflow",
"condition": {
"and": [
{">=": [{"var": "data.order.total"}, 500]},
{"==": [{"var": "data.user.is_vip"}, true]}
]
},
"tasks": [...]
}
A workflow is selected only when its condition evaluates to boolean true. If evaluation fails with an error, the condition is treated as false and the workflow is recorded on Run with Selection::ConditionFailed(error).
Common Condition Patterns
// Numeric comparison on `data`
{">=": [{"var": "data.order.total"}, 1000]}
// Check that a field is present and truthy
{"!!": {"var": "data.email"}}
// All conditions must hold
{"and": [
{">=": [{"var": "data.amount"}, 100]},
{"==": [{"var": "data.currency"}, "USD"]}
]}
// At least one condition must hold
{"or": [
{"==": [{"var": "metadata.source"}, "api"]},
{"==": [{"var": "metadata.source"}, "webhook"]}
]}
Failure Policy
Stop the Run ("fail", Default)
{
"id": "strict_workflow",
"on_failure": "fail",
"tasks": [...]
}
When a task fails (returns Err or a 5xx status) and its own on_failure policy is "fail" (the default), the failure escalates to the workflow. Under the workflow's default on_failure: "fail", the entire run stops immediately: Run::status() becomes Stopped(error), subsequent workflows are skipped, and the error is recorded in message.errors().
Continue to the Next Workflow ("continue")
{
"id": "resilient_workflow",
"on_failure": "continue",
"tasks": [...]
}
A workflow's on_failure policy controls what happens after the workflow itself fails:
- With
"continue", the current workflow stops at the failing task (recordingWorkflowExit::Failed), but the engine continues to the next workflow in the program and finishes withRunStatus::CompletedWithErrors. - Note that a workflow's
on_failureis not inherited by its tasks. Whether remaining tasks inside the same workflow continue after a task failure is controlled by each task's ownon_failurefield. See Error Handling for the full policy matrix.
In all cases, the error is recorded in message.errors().
Passing Data Between Tasks
Tasks within a workflow run sequentially, so later tasks can read fields written by earlier tasks:
{
"id": "pipeline",
"name": "Pipeline",
"tasks": [
{
"id": "fetch_data",
"function": {
"name": "map",
"input": {
"mappings": [
{"path": "temp_data.fetched", "logic": {"var": "data.source"}}
]
}
}
},
{
"id": "process_data",
"function": {
"name": "map",
"input": {
"mappings": [
{"path": "data.result", "logic": {"var": "temp_data.fetched"}}
]
}
}
}
]
}
Workflow Lifecycle
Workflows include optional fields for lifecycle management, routing, versioning, and traffic splitting.
Status
Control whether a workflow is active using the status field:
{"id": "my_workflow", "status": "active", "tasks": [...]}
{"id": "old_workflow", "status": "paused", "tasks": [...]}
{"id": "legacy_workflow", "status": "archived", "tasks": [...]}
active(default) β Considered on every matching run.pausedβ Compiled and linked intoProgram, but skipped during execution.archivedβ Same runtime behavior aspaused; marks a permanently retired workflow.
Program::workflows() includes active, paused, and archived workflows so admin UIs can list them, while Run::workflows() includes only active workflows that were considered during the run.
Channel Routing
Assign workflows to named channels to partition execution by event or message type:
[
{"id": "order_validation", "channel": "orders", "priority": 1, "tasks": [...]},
{"id": "order_process", "channel": "orders", "priority": 2, "tasks": [...]},
{"id": "user_notify", "channel": "notifications", "priority": 1, "tasks": [...]}
]
Then target a specific channel on RunOptions:
#![allow(unused)] fn main() { use dataflow_rs::{Engine, Message}; async fn _demo(engine: Engine, mut message: Message) { use dataflow_rs::RunOptions; // Runs only active workflows in the "orders" channel. let run = engine .run_with(&mut message, &RunOptions::default().channel("orders")) .await; let _ = run; } }
When a channel is specified on RunOptions, the engine writes its name to metadata.channel. If no channel is specified on RunOptions, all active workflows across all channels are considered.
Version and Tags
Use version and tags to organize and track workflow revisions:
{
"id": "discount_workflow",
"version": 3,
"tags": ["finance", "discount", "v3"],
"tasks": [...]
}
Traffic Splits (Canary Rollouts)
Route a percentage of a channel's traffic to a workflow using a half-open bucket range over 0..100:
{
"id": "checkout_v2",
"channel": "checkout",
"rollout": { "bucket_start": 0, "bucket_end": 10 },
"tasks": [...]
}
This workflow matches buckets 0..=9 (10% of traffic). Pair it with a sibling workflow configured with {"bucket_start": 10, "bucket_end": 100} to route the remaining 90% of traffic to the existing version. Because bucket_start is inclusive and bucket_end is exclusive, the two ranges partition 0..=99 cleanly without gaps or overlap. An empty or inverted range (bucket_end <= bucket_start) matches no traffic.
You supply the bucket for each run via RunOptions::routing_bucket:
#![allow(unused)] fn main() { use dataflow_rs::{Engine, Message}; async fn _demo(engine: Engine, mut message: Message) { use dataflow_rs::RunOptions; let opts = RunOptions::default().routing_bucket(7); assert_eq!(opts.routing_bucket, Some(7)); let run = engine.run_with(&mut message, &opts).await; let _ = run; } }
Your application decides how to compute the bucket (0..=99, e.g., hash(user_id) % 100, random sampling, or round-robin). In Rust, values above 99 are clamped to 99 (Rollout::MAX_BUCKET); in @goplasmatic/dataflow-wasm, out-of-range bucket numbers throw an error.
Key rollout rules:
- Runs without a
routing_bucketmatch all workflows, whether or not they have arollout. Make sure to setrouting_bucketonRunOptionswhenever you have multiple rollout versions active, or both versions will run on the same message. - Excluded workflows are skipped before condition evaluation: they leave no audit entry, do not update
metadata.progress, recordSelection::SkippedByRolloutonRun, and appear as aSkippedstep in traces. - Custom host functions can inspect the current bucket via
ctx.info().routing_bucketand forward it to child runs.
Partitioning and Validating Rollout Ranges
When splitting traffic across multiple versions of a workflow, their bucket ranges should partition 0..100 with no gaps (which would drop traffic) and no overlaps (which would run multiple versions on the same message).
Use Rollout::partition to convert percentage weights into contiguous Rollout ranges:
#![allow(unused)] fn main() { use dataflow_rs::def::{Rollout, RolloutError}; let split = Rollout::partition(&[90, 10]).unwrap(); assert_eq!(split[0], Rollout::new(0, 90)); assert_eq!(split[1], Rollout::new(90, 100)); // Percentages must sum to 100; otherwise `Under` or `Over` is returned. assert_eq!(Rollout::partition(&[90, 9]), Err(RolloutError::Under { total: 99 })); assert_eq!(Rollout::partition(&[90, 11]), Err(RolloutError::Over { total: 101 })); }
A 0% weight produces an empty range that matches no traffic, which is useful for staging a new version before routing traffic to it.
Use Rollout::validate_set to verify that a set of Rollout ranges forms a valid partition of 0..100:
#![allow(unused)] fn main() { use dataflow_rs::def::{Rollout, RolloutError}; let good = Rollout::partition(&[50, 50]).unwrap(); assert!(Rollout::validate_set(&good).is_ok()); // Range order does not matter. let reversed: Vec<_> = good.iter().rev().copied().collect(); assert!(Rollout::validate_set(&reversed).is_ok()); // Missing buckets are reported at the lowest gap. let gapped = [Rollout::new(0, 40), Rollout::new(41, 100)]; assert_eq!(Rollout::validate_set(&gapped), Err(RolloutError::Gap { bucket: 40 })); }
Try It
Open the Full Debugger UI for step-by-step execution and workflow visualization.
Try changing "role" from "admin" to "user" in the payload to see conditional_workflow get skipped.
Tasks
A task is an individual processing step within a workflow that calls a single function. Tasks represent the THEN in the IF β THEN model.
Overview
Each task in a workflow:
- Calls one function (either a built-in function or a registered custom host function)
- Optionally evaluates a JSONLogic
conditionto decide whether to run - Can read and modify the message context (
data,metadata,temp_data) - Records its changes in the message's audit trail
- Configures how failures and rejections are handled via
on_failureandon_rejection
Task Structure
{
"id": "apply_discount",
"name": "Apply Discount",
"condition": { ">=": [{"var": "data.order.total"}, 100] },
"on_failure": "fail",
"on_rejection": "continue",
"function": {
"name": "map",
"input": {
"mappings": [
{
"path": "data.order.discount",
"logic": {"*": [{"var": "data.order.total"}, 0.1]}
}
]
}
}
}
Fields
| Field | Type | Required | Description |
|---|---|---|---|
id | string | Yes | Unique step identifier within the workflow (shared across tasks and task groups) |
name | string | No | Human-readable display name |
description | string | No | Free-text description |
condition | JSONLogic | No | Condition evaluated against {data, metadata, temp_data}; when omitted, the task always runs |
function | object | Yes | Function call specification: {"name": "...", "input": {...}} (input defaults to {}) |
on_failure | string | No | Behavior on failure (5xx status or Err): "fail" (default) escalates to the workflow's on_failure, "continue" records the error and continues to the next task, "halt" stops the current workflow |
on_rejection | string | No | Behavior on rejection (4xx status): "continue" (default) continues to the next task, "halt" stops the current workflow |
terminal | boolean | No | If true, ends the current workflow after this task runs (default: false); see Control Flow |
for_each | object | No | Fan-out configuration to run a host function once per array element; see Fan-Out (not supported on built-in functions) |
ext | object | No | Custom host or UI metadata preserved verbatim by the parser |
Task Groups: An entry in a workflow's
tasksarray that contains a nestedtaskskey (instead offunction) is parsed as a task group (GroupDef)βa sequence of steps gated by a shared condition. See Control Flow β Groups.
Inspecting Parsed Tasks in Rust
def::parse produces a WorkflowDef whose tasks() slice contains StepDef::Task(TaskDef) and StepDef::Group(GroupDef) items. Use WorkflowDef::walk to iterate through all steps in pre-order along with their JSON paths:
#![allow(unused)] fn main() { use dataflow_rs::def::{FailurePolicy, ParseOptions, RejectionPolicy, StepDef, parse}; use serde_json::json; let parsed = parse( &json!({"id": "w", "tasks": [ {"id": "apply_discount", "on_failure": "continue", "function": {"name": "map", "input": {"mappings": []}}}, {"id": "guard", "condition": {"var": "data.vip"}, "tasks": [ {"id": "perk", "function": {"name": "log", "input": {"message": "vip"}}} ]} ]}), ParseOptions::default(), ); let def = parsed.def.expect("nothing was rejected"); let StepDef::Task(task) = &def.tasks()[0] else { panic!("a task") }; assert_eq!(task.function().name(), "map"); assert_eq!(task.on_failure(), FailurePolicy::Continue); assert_eq!(task.on_rejection(), RejectionPolicy::Continue); assert!(task.condition().is_none()); // Walk every step in pre-order (each group is visited before its child steps). let paths: Vec<String> = def.walk().map(|(path, _, step)| format!("{path} = {}", step.id())).collect(); assert_eq!(paths, ["tasks[0] = apply_discount", "tasks[1] = guard", "tasks[1].tasks[0] = perk"]); }
Definition structs are marked #[non_exhaustive]; construct them by parsing JSON via def::parse so all structural validation rules are enforced.
Function Configuration
The function block specifies which function to call and its input configuration:
{
"function": {
"name": "function_name",
"input": { ... }
}
}
Built-in Functions
| Function | Purpose |
|---|---|
map | Transform data and write values to data, metadata, or temp_data |
validation | Validate context fields against JSONLogic rules and record custom error messages |
filter | Control pipeline execution: halt the workflow or skip the current task when a condition is false |
log | Emit structured log messages with dynamic JSONLogic fields |
parse_json | Parse or copy JSON from payload (or a context path) into data |
parse_xml | Parse an XML string from payload (or a context path) into data |
publish_json | Serialize a data object to a JSON string |
publish_xml | Serialize a data object to an XML string |
All eight built-in functions execute inline on the engine's context view. Their names are reserved and cannot be overwritten by custom host functions.
Custom Host Functions
Custom host functions implement the Function trait with a typed Config struct (derived with #[derive(FunctionConfig)]) that is compiled from the task's input at build time:
use dataflow_rs::datavalue::OwnedDataValue; use dataflow_rs::expr::Output; use dataflow_rs::function::FunctionConfig; use dataflow_rs::{Ctx, Engine, Expr, Function, Outcome, Result}; use serde_json::json; #[derive(FunctionConfig)] struct GreetConfig { /// Name to greet (can be a literal string or a JSONLogic expression). name: Expr<String>, /// Destination path where the greeting is written. #[output(default = "data.greeting")] output: Output, } struct Greet; impl Function for Greet { type Config = GreetConfig; async fn call(&self, ctx: &mut Ctx<'_>, cfg: &GreetConfig) -> Result<Outcome> { let name = ctx.eval(&cfg.name)?; ctx.emit(&cfg.output, OwnedDataValue::String(format!("Hello, {name}!")))?; Ok(Outcome::Ok) } } fn main() -> std::result::Result<(), Vec<dataflow_rs::Issue>> { let engine = Engine::builder() .register("greet", Greet) .workflow_json(&json!({"id": "w", "tasks": [ {"id": "t", "function": {"name": "greet", "input": { "name": {"var": "data.user"}}}} ]})) .build()?; let _ = engine; Ok(()) }
Reference your registered function by name in any task:
{
"function": {
"name": "greet",
"input": { "name": {"var": "data.user"} }
}
}
If a task references a function.name that is neither a built-in nor registered in the Registry, compilation rejects the workflow with an UNKNOWN_FUNCTION issue. See Custom Functions for details.
Conditional Execution
A task's condition is evaluated against {data, metadata, temp_data} (and can also read {"secret": "name"} from the engine's secret store). Because condition evaluation produces only a boolean, secret values used in conditions are never written to the message or trace:
{
"id": "premium_greeting",
"name": "Premium greeting",
"condition": { "==": [{"var": "data.tier"}, "premium"] },
"function": {
"name": "map",
"input": {
"mappings": [
{"path": "data.greeting", "logic": "Welcome, VIP member!"}
]
}
}
}
When a task's condition evaluates to false, the task is skipped: no audit entry is added, metadata.progress is not modified, and no TaskFinished event is sent to observers.
Common Condition Patterns
// Run only if field exists and is truthy
{"!!": {"var": "data.email"}}
// Run only if field equals a value
{"==": [{"var": "data.status"}, "active"]}
// Run only if numeric threshold is met
{">=": [{"var": "data.amount"}, 100]}
// Combine multiple conditions
{"and": [
{"!!": {"var": "data.email"}},
{"==": [{"var": "data.verified"}, true]}
]}
Outcomes and Policies
Every task execution finishes with an Outcome or an Err, which the engine classifies as follows:
| Function Result | Audit Status | Classification | Next Step |
|---|---|---|---|
Outcome::Ok | 200 | Success | Run the next task |
Outcome::Status(2xx or 3xx) | 2xx / 3xx | Success | Run the next task |
Outcome::Status(4xx) | 4xx | Rejection | Governed by on_rejection |
Outcome::Status(5xx) | 5xx | Failure | Records TASK_STATUS_ERROR; governed by on_failure |
Err(error) | 500 | Failure | Records error under error.code(); governed by on_failure |
Outcome::Skip | (no entry) | Skipped | Nothing recorded; run the next task |
Outcome::Halt | 299 | Halted | Ends the current workflow; subsequent workflows still run |
Failure Policy (on_failure)
{
"id": "optional_task",
"on_failure": "continue",
"function": { ... }
}
"fail"(default) β Escalates the failure to the workflow'son_failurepolicy (which either stops the run or moves to the next workflow)."continue"β Records the failure inmessage.errors()and continues to the next task in the workflow."halt"β Records the failure and ends the current workflow (WorkflowExit::Halted), while allowing subsequent workflows to run.
Rejection Policy (on_rejection)
{
"id": "validate_input",
"on_rejection": "halt",
"function": { "name": "validation", "input": { ... } }
}
"continue"(default) β Continues to the next task. Note that a failedvalidationtask returns status400(a rejection) and recordsVALIDATION_ERRORentries; if you leaveon_rejectionat"continue"and have tasks after it, those tasks will still run (Compiler::checkflags this pattern with anUNGUARDED_VALIDATIONadvisory)."halt"β Stops the current workflow when the task returns a4xxrejection, while allowing subsequent workflows to run.
Interaction with
terminal: true:terminalis evaluated after outcome policies. If aterminal: truetask fails, it still follows itson_failurepolicy; if the task succeeds (or continues after a rejection/failure),terminal: truehalts the rest of the current workflow. See Error Handling for the complete policy matrix.
Sequential Execution
Tasks execute sequentially in the order they appear in tasks. Each task immediately sees writes committed by earlier tasks:
{
"tasks": [
{
"id": "step1",
"function": {
"name": "map",
"input": {
"mappings": [
{"path": "temp_data.intermediate", "logic": {"var": "data.raw"}}
]
}
}
},
{
"id": "step2",
"function": {
"name": "map",
"input": {
"mappings": [
{"path": "data.final", "logic": {"var": "temp_data.intermediate"}}
]
}
}
}
]
}
Inside a custom host function, any write made via Ctx is immediately readable by later ctx.get / ctx.eval calls within the same invocation, and is committed to the shared message context once call returns Ok.
Try It
Open the Full Debugger UI for step-by-step execution and workflow visualization.
Try changing "tier" to "standard" in the payload to see a different discount task execute.
Best Practices
- Use Descriptive, Unique IDs β Task IDs appear in audit entries, error records,
metadata.progress, and traces. - Keep Tasks Focused β Give each task a clear, single responsibility.
- Use
temp_datafor Scratch State β Store intermediate values intemp_datarather than clutteringdata. - Set Explicit Policies β Use
"on_failure": "continue"for non-critical enrichment tasks, and"on_rejection": "halt"onvalidationtasks that should block subsequent steps in the workflow.
Migrating from 3.x
In 3.x, task error handling used continue_on_error and halt_on, and tasks were constructed in Rust via Task::action. In 4.0, tasks use on_failure and on_rejection and are parsed via def::parse into TaskDef. The compatibility reader automatically rewrites 3.x task keys:
continue_on_error: trueβon_failure: "continue"halt_on: "failure"βon_rejection: "halt"- Both together β
on_failure: "halt"andon_rejection: "halt"
See Migrating from 3.x for details.
Message
A Message is the data container that flows through the engine, carrying the input payload, the mutable evaluation context, an audit trail of changes, and any recorded errors.
Overview
Every Message contains:
idβ Unique message identifier (defaults to a generated UUID v7 unless set explicitly).payloadβ The raw input value passed into the engine (read-only and separate from the JSONLogic evaluation context).context.dataβ Primary business data read and transformed by workflows.context.metadataβ Message and execution metadata (routing headers, source info, and engine stamps).context.temp_dataβ Temporary scratch data shared between tasks during processing.audit_trailβ Ordered list ofAuditEntryrecords, one per executed task, detailing the changes made.errorsβ Ordered list ofErrorRecordentries recorded during execution.
Message Structure and Serialization
Message fields are accessed via methods and serialize cleanly to and from JSON as {id, payload, context, audit_trail, errors} in that exact key order:
#![allow(unused)] fn main() { use dataflow_rs::Message; use dataflow_rs::datavalue::OwnedDataValue; use serde_json::json; let message = Message::from_json(&json!({"name": "John"})).with_id("order-7"); assert_eq!(message.id(), "order-7"); assert_eq!(message.payload(), &OwnedDataValue::from(&json!({"name": "John"}))); assert!(message.audit().is_empty()); assert!(message.errors().is_empty()); // The context always serializes as `data`, `metadata`, and `temp_data` in order. let wire = serde_json::to_string(&message).unwrap(); assert!(wire.starts_with(r#"{"id":"order-7","payload":{"name":"John"},"context":{"data":{},"metadata":{},"temp_data":{}}"#)); // Messages round-trip through JSON byte-for-byte. let read: Message = serde_json::from_str(&wire).unwrap(); assert_eq!(read, message); }
Main accessor methods on Message:
id()payload()context()andcontext_mut()audit()errors()to_json()(converts the message to aserde_json::Value)
The evaluation Context always consists of three object roots:
{
"data": { ... },
"metadata": { ... },
"temp_data": { ... }
}
Deserializing a Message whose context is not an object or contains any top-level key other than data, metadata, and temp_data returns an error.
Creating Messages
From JSON (Message::from_json)
#![allow(unused)] fn main() { use dataflow_rs::Message; use serde_json::json; // `from_json` sets the message `payload`. The three context roots (`data`, // `metadata`, `temp_data`) start empty; use a `parse_json` task to copy the // payload into `data`. let message = Message::from_json(&json!({ "name": "John", "email": "john@example.com" })); assert_eq!(serde_json::to_string(message.context()).unwrap(), r#"{"data":{},"metadata":{},"temp_data":{}}"#); }
From an OwnedDataValue (Message::new)
Message::new accepts an OwnedDataValue or an Arc<OwnedDataValue>. Passing an Arc<OwnedDataValue> shares the payload across multiple messages without cloning:
#![allow(unused)] fn main() { use dataflow_rs::Message; use dataflow_rs::datavalue::OwnedDataValue; use serde_json::json; use std::sync::Arc; let payload = Arc::new(OwnedDataValue::from(&json!({"name": "John"}))); let first = Message::new(Arc::clone(&payload)); let second = Message::new(Arc::clone(&payload)); assert!(std::ptr::eq(first.payload(), second.payload())); }
Pre-Populating data and metadata (Message::from_parts)
If your application has already parsed the input and wants to populate data and metadata directly before running workflows, use Message::from_parts (any non-object argument for data or metadata is ignored and replaced with {}):
#![allow(unused)] fn main() { use dataflow_rs::Message; use dataflow_rs::datavalue::OwnedDataValue; use serde_json::json; let message = Message::from_parts( OwnedDataValue::Null, OwnedDataValue::from(&json!({"order": {"total": 1200}})), OwnedDataValue::from(&json!({"source": "api"})), ) .with_id("correlation-123"); assert_eq!(message.context().data(), &OwnedDataValue::from(&json!({"order": {"total": 1200}}))); assert_eq!(message.context().metadata(), &OwnedDataValue::from(&json!({"source": "api"}))); }
Modifying the Context from Rust
During a workflow run, built-in tasks like parse_json and map write to the context automatically, while custom host functions write through Ctx::set / Ctx::emit, which record audit-trail changes and commit writes when the call returns Ok:
#![allow(unused)] fn main() { use dataflow_rs::datavalue::OwnedDataValue; use dataflow_rs::{Ctx, Function, Outcome, Result}; struct Stamp; impl Function for Stamp { type Config = (); async fn call(&self, ctx: &mut Ctx<'_>, _cfg: &()) -> Result<Outcome> { ctx.set(&"metadata.source".parse()?, OwnedDataValue::from("api"))?; ctx.set(&"metadata.type".parse()?, OwnedDataValue::from("user"))?; Ok(Outcome::Ok) } } }
Outside of a run, you can read or modify the context directly via message.context() and message.context_mut() (direct mutations outside a run do not create audit entries):
use dataflow_rs::datavalue::OwnedDataValue; use dataflow_rs::{Message, Path}; use serde_json::json; fn main() -> dataflow_rs::Result<()> { let mut message = Message::from_json(&json!({})); let path: Path = "data.order.total".parse()?; message.context_mut().set(&path, OwnedDataValue::from(1200))?; assert_eq!(message.context().get(&path), Some(&OwnedDataValue::from(1200))); // Top-level roots (`data`, `metadata`, `temp_data`) must remain JSON objects. let root: Path = "data".parse()?; assert!(message.context_mut().set(&root, OwnedDataValue::from(1)).is_err()); Ok(()) }
Context Roots
data
The main business data container where your domain fields live and get transformed. Workflows typically populate data using parse_json and map, while custom host functions read it via ctx.get(&path) or ctx.eval(&expr):
#![allow(unused)] fn main() { use dataflow_rs::Message; fn _demo(message: &Message) -> dataflow_rs::Result<()> { // Read a field from `data` after a run completes. let total = message.context().get(&"data.order.total".parse()?); println!("total: {total:?}"); Ok(()) } }
metadata
Holds metadata about the message and the current run (such as headers, source identifiers, and routing attributes).
Several paths under metadata are managed automatically by the engineβtreat them as reserved:
metadata.processed_at&metadata.engine_versionβ Stamped at the start of every run before any workflow executes. The engine reads the clock once at run start and uses that timestamp forprocessed_at, audit entries, and error records.metadata.channelβ Stamped at run start whenRunOptions::channelis set.metadata.progressβ Updated after every executed task to{"workflow_id": ..., "task_id": ..., "status_code": ...}. Subsequent workflows and tasks can inspect{"var": "metadata.progress.task_id"}or{"var": "metadata.progress.status_code"}to branch on earlier steps. Avoid writing tometadata.progressyourself, as the engine overwrites it after each task.- Configured
ErrorContextpath β IfErrorContextis enabled onEngineConfig, the engine clears that path at run start and appends{workflow_id, task_id, code, status}whenever a task records an error.
temp_data
Scratch space for intermediate values passed between tasks within a workflow. Loop counters (loop) and fan-out element bindings (for_each) also live under temp_data.
{"path": "temp_data.calculated_value", "logic": {"*": [{"var": "data.qty"}, 2]}}
Subsequent tasks can read that value with {"var": "temp_data.calculated_value"}.
Note:
temp_datais serialized as part ofMessagejust likedataandmetadata. Do not store sensitive secrets intemp_dataβuse the engine's Secrets store instead.
Audit Trail
The engine records one AuditEntry in message.audit() for each executed task:
| Field | Description |
|---|---|
workflow_id, task_id | The workflow and task that produced the entry |
timestamp | RFC 3339 timestamp of the run |
status | HTTP-style status code (200 for Ok, custom status code, 500 for Err, 299 for Halt) |
changes | List of context writes (Change) committed by the task (when capture_changes is enabled) |
loop_counter | Loop sweep index (present only inside a looping workflow) |
element_index | Fan-out element index (present only inside a for_each task) |
Each Change contains the dotted path, old_value (null if the field did not exist), new_value (null if removed), and removed (true when the field was deleted rather than set to null).
#![allow(unused)] fn main() { fn _demo(message: &dataflow_rs::Message) { for entry in message.audit() { println!("{}/{}: status {}", entry.workflow_id, entry.task_id, entry.status); for change in &entry.changes { if change.removed { println!(" removed {}", change.path); } else { println!(" {} = {:?}", change.path, change.new_value); } } } } }
Tasks skipped because their condition evaluated to false or because their function returned Outcome::Skip do not add an audit entry.
Controlling Audit Trail Size
Capturing old_value and new_value snapshots for every write has a memory cost, especially in looping workflows. You can bound or disable audit capture per run via RunOptions:
#![allow(unused)] fn main() { use dataflow_rs::{Engine, Message}; async fn _demo(engine: Engine, mut message: Message) { use dataflow_rs::run::{AuditMode, RunOptions}; let opts = RunOptions::default() // Keep audit entries (workflow_id, task_id, status), but omit `changes` diffs. .capture_changes(false) // Retain only the 10 most recent audit entries (`AuditMode::Off` retains none). .audit(AuditMode::Last(10)); engine.run_with(&mut message, &opts).await; } }
Disabling change capture or audit entries does not affect context updates or metadata.progress.
Errors
Every error recorded during a run is appended to message.errors() as an ErrorRecord containing code, message, workflow_id, task_id, timestamp, path (for mapping errors), and element_index (for for_each errors):
#![allow(unused)] fn main() { fn _demo(message: &dataflow_rs::Message) { for error in message.errors() { println!( "{} in {}/{}: {}", error.code, error.workflow_id.as_deref().unwrap_or("-"), error.task_id.as_deref().unwrap_or("-"), error.message ); } } }
To check whether an error stopped the run early, inspect Run::status(). See Error Handling for details.
Accessing Fields in JSONLogic
In workflow conditions, task conditions, and mappings, read context fields using {"var": "..."}:
// Read from `data`
{"var": "data.name"}
{"var": "data.user.email"}
// Read from `metadata`
{"var": "metadata.type"}
{"var": "metadata.source"}
// Read from `temp_data`
{"var": "temp_data.intermediate_result"}
payloadis not in the JSONLogic context:{"var": "payload.foo"}always evaluates tonull(andCompiler::checkflags it as aPAYLOAD_VARdefect). Copy the payload intodatafirst usingparse_jsonorparse_xml(custom host functions can also read the raw payload viaCtx::payload).- Secrets are read via
{"secret": "name"}: Secrets live in an isolated store onExprEngineso they are never serialized with the message. See Secrets.
Try It
Open the Full Debugger UI for step-by-step execution and workflow visualization.
Here, set_temp writes an intermediate string to temp_data.full_name, and use_temp reads it to build data.greeting.
Best Practices
- Separate Concerns Across Roots
- Use
datafor domain and business fields. - Use
metadatafor routing headers, environment info, and workflow conditions. - Use
temp_datafor temporary intermediate calculations.
- Use
- Do Not Overwrite Reserved
metadataKeys- Leave
metadata.progress,metadata.processed_at,metadata.engine_version, andmetadata.channelto the engine.
- Leave
- Keep Context Roots as Objects
- Writes directly to
data,metadata, ortemp_datamust always be JSON objects.
- Writes directly to
Migrating from 3.x
In 3.x, messages were constructed with Message::from_value, Message::new, or Message::builder() (where capture_changes, audit_mode, and routing_bucket lived on the message), message.context was a public field, and custom handlers wrote via TaskContext::set.
In 4.0:
- Use
Message::from_json,Message::new, orMessage::from_parts. - Configure
capture_changes,audit, androuting_bucketper run onRunOptions. - Access the context via
message.context()andmessage.context_mut(), and write inside host functions viaCtx::setorCtx::emit.
See Migrating from 3.x for the complete guide.
Error Handling
Dataflow-rs provides structured error handling across tasks, workflows, and runs.
Two Error Channels: message.errors() and Run::status()
Every run reports errors through two complementary places:
message.errors()β Contains everyErrorRecordrecorded during the run: failedvalidationrules,5xxstatus outcomes,Errreturns from host functions, and errors explicitly recorded viaCtx::record_error. Each record includescode,message,workflow_id,task_id, andtimestamp.Run::status()β Describes how the overall run ended:Completed: All matching workflows finished with no error records on the message.CompletedWithErrors: All matching workflows finished, and one or more errors were recorded inmessage.errors().Stopped(error): A task failed and its failure policy stopped the run before all workflows finished.TimedOut/Cancelled: The run hit itsRunOptions::deadlineorCancelToken.
When a run stops early, no duplicate wrapper error is added to message.errors()βthe failing task's own ErrorRecord is on the message, and RunStatus::Stopped(error) carries the stopping Error.
#![allow(unused)] fn main() { use dataflow_rs::{Engine, Message}; async fn _demo(engine: Engine, mut message: Message) { use dataflow_rs::run::RunStatus; let run = engine.run(&mut message).await; for error in message.errors() { println!( "{} in {}/{}: {}", error.code, error.workflow_id.as_deref().unwrap_or("-"), error.task_id.as_deref().unwrap_or("-"), error.message ); } // Check if an unhandled failure stopped the run early: if let RunStatus::Stopped(error) = run.status() { eprintln!("stopped early: {error} ({})", error.code()); } // Or convert fatal stops (`Stopped`, `TimedOut`, `Cancelled`) into `Result::Err`: let run = run.into_result(); let _ = run; } }
run.into_result() returns Ok(run) for Completed and CompletedWithErrors, and Err(error) when the run was Stopped, TimedOut, or Cancelled.
Failures vs. Rejections
Before any policy is applied, the engine classifies each task's return value:
| Task Return Value | Classification | Error Recorded on message.errors() |
|---|---|---|
Ok(Outcome::Ok) or status < 400 | Success | None |
Ok(Outcome::Status(4xx)) | Rejection | None by the engine (validation records its own failing rules before returning 400) |
Ok(Outcome::Status(5xx)) | Failure | TASK_STATUS_ERROR: Task <id> returned status <s> |
Err(error) | Failure | error.code(): Task <id> error: <message> |
Ok(Outcome::Skip) | Skipped | None (and no audit entry is written) |
Ok(Outcome::Halt) | Halted | None (ends the current workflow with status 299) |
Tasks configure how rejections and failures are handled using on_rejection and on_failure, while workflows configure what happens when a task failure escalates to the workflow using workflow-level on_failure.
Task-Level Policies
on_failure
Controls what happens when a task returns Err or Outcome::Status(5xx):
{
"id": "optional_task",
"on_failure": "continue",
"function": { ... }
}
"fail"(default) β Stops the current workflow and escalates the failure to the workflow'son_failurepolicy."continue"β Records the failure inmessage.errors()and continues to the next task in the workflow."halt"β Records the failure and ends the current workflow (WorkflowExit::Halted), while allowing subsequent workflows to run.
on_rejection
Controls what happens when a task returns Outcome::Status(4xx):
{
"id": "validate_input",
"on_rejection": "halt",
"function": { "name": "validation", "input": { ... } }
}
"continue"(default) β Continues to the next task in the workflow."halt"β Ends the current workflow immediately (WorkflowExit::Halted), while allowing subsequent workflows to run.
Note: A
4xxrejection from a custom host function (such asOutcome::Status(404)) records status404on the audit trail without adding an entry tomessage.errors(). The built-invalidationfunction both records aVALIDATION_ERRORper failing rule onmessage.errors()and returns400.
Workflow-Level Policy
A workflow's on_failure field answers a different question than a task's on_failure fieldβit is not a default inherited by tasks:
| Policy Field | Question It Answers |
|---|---|
on_failure on a task | When this task fails, should the remaining tasks in this workflow still run? |
on_failure on a workflow | When a task failure stops this workflow, should subsequent workflows still run, or should the entire run stop? |
(Task groups only share a condition; they do not have their own failure policy.)
The Policy Matrix
When a task fails (returns Err or a 5xx status), the combination of task on_failure and workflow on_failure determines what runs next:
Task on_failure | Workflow on_failure | Remaining Tasks in Workflow | Subsequent Workflows | Final Run::status() |
|---|---|---|---|---|
"fail" (default) | "fail" (default) | Stop | Stop | Stopped(error) |
"fail" | "continue" | Stop | Run | CompletedWithErrors (workflow exit is Failed) |
"halt" | (either) | Stop | Run | CompletedWithErrors (workflow exit is Halted) |
"continue" | (either) | Run | Run | CompletedWithErrors |
In every case above, the failure is recorded in message.errors().
For example, if a workflow sets "on_failure": "continue" while its tasks keep the default "on_failure": "fail", a failure in task1 stops that workflow immediately, but subsequent workflows in the engine still run:
{
"id": "resilient_workflow",
"on_failure": "continue",
"tasks": [
{"id": "task1", "on_failure": "continue", "function": { ... }},
{"id": "task2", "on_failure": "continue", "function": { ... }},
{"id": "task3", "function": { ... }}
]
}
Here, task1 and task2 explicitly opt into "on_failure": "continue", so the workflow keeps running even if they fail. task3 uses the default "fail", so if task3 fails, resilient_workflow exits with Failed and the engine moves on to the next workflow.
Policy Matrix Example in Rust
use dataflow_rs::run::{RunStatus, WorkflowExit}; use dataflow_rs::{Ctx, Engine, Error, Function, Message, Outcome, Result}; use serde_json::json; /// A custom function that always fails with a service error code. struct Flaky; impl Function for Flaky { type Config = (); async fn call(&self, _ctx: &mut Ctx<'_>, _cfg: &()) -> Result<Outcome> { Err(Error::service("PARTNER_DOWN", "the partner is down").with_retryable(true)) } } #[tokio::main] async fn main() { let engine = |on_failure: &str| { Engine::builder() .register("flaky", Flaky) .workflow_json(&json!({"id": "w", "tasks": [ {"id": "call", "on_failure": on_failure, "function": {"name": "flaky"}}, {"id": "after", "function": {"name": "map", "input": { "mappings": [{"path": "data.after", "logic": true}]}}} ]})) .workflow_json(&json!({"id": "next", "priority": 1, "tasks": [ {"id": "t", "function": {"name": "map", "input": { "mappings": [{"path": "data.next", "logic": true}]}}} ]})) .build() .expect("the workflows build") }; // 1. Default ("fail"): stops the entire run. Neither `after` nor `next` runs. let mut message = Message::from_json(&json!({})); let run = engine("fail").run(&mut message).await; let RunStatus::Stopped(error) = run.status() else { panic!("stopped") }; assert_eq!(error.code(), "PARTNER_DOWN"); assert!(run.retryable() && run.partial()); assert_eq!(message.errors()[0].code, "PARTNER_DOWN"); assert_eq!(message.errors()[0].message, "Task call error: the partner is down"); assert_eq!(message.errors()[0].task_id.as_deref(), Some("call")); assert!(message.context().get(&"data.next".parse().unwrap()).is_none()); // 2. "continue": records the error and runs both `after` and `next`. let mut message = Message::from_json(&json!({})); let run = engine("continue").run(&mut message).await; assert!(matches!(run.status(), RunStatus::CompletedWithErrors)); assert_eq!(message.errors().len(), 1); assert_eq!(serde_json::to_string(message.context().data()).unwrap(), r#"{"after":true,"next":true}"#); // 3. "halt": records the error, ends workflow `w` (skipping `after`), and runs `next`. let mut message = Message::from_json(&json!({})); let run = engine("halt").run(&mut message).await; assert_eq!(run.workflows().next().unwrap().exit, Some(WorkflowExit::Halted)); assert_eq!(serde_json::to_string(message.context().data()).unwrap(), r#"{"next":true}"#); }
Error Codes
Common error codes recorded in message.errors():
VALIDATION_ERRORβ Recorded byvalidationfor each failing rule, and byparse_xml,publish_json, andpublish_xmlwhen input data is missing or invalid.EVALUATION_ERRORβ Recorded when avalidationrule orfiltercondition fails to evaluate.MAPPING_ERRORβ Recorded for each individualmapmapping that fails (with the mapping's target path inrecord.path), followed byTASK_STATUS_ERRORfor the task.TASK_STATUS_ERRORβ Recorded when a task returnsOutcome::Status(s)withs >= 500.
When a custom host function returns Err(error), the record uses error.code():
- The custom code passed to
Error::service(code, message)(or"TASK_ERROR"ifcodeis empty). - Built-in engine error codes:
LOGIC_ERROR(ErrorKind::Eval),BUDGET_EXCEEDED(Budget),TIMEOUT_ERROR(TimedOut),CANCELLED,CONFIG_ERROR(Config), andCOMPILATION_ERROR(Compile).
Because custom functions can define their own service error codes, always include a wildcard/default arm when matching on error.code.
Custom Service Errors (Error::service)
Built-in ErrorKind variants cover engine-level errors. When a custom host function fails for a domain- or service-specific reason (such as an open circuit breaker, upstream 503, or rate limit), construct an error with Error::service:
#![allow(unused)] fn main() { use dataflow_rs::Error; use dataflow_rs::ErrorKind; let error = Error::service("circuit_open", "upstream unavailable") .with_retryable(true) .with_source(std::io::Error::other("connection refused")); assert_eq!(error.code(), "circuit_open"); assert_eq!(error.to_string(), "upstream unavailable"); assert!(error.is_retryable()); assert!(matches!(error.kind(), ErrorKind::Service { .. })); }
Key behaviors:
- Exact code preservation: The engine keeps your
codestring as-is (without altering case) inErrorRecord::codeandErrorContext. An empty string falls back to"TASK_ERROR". - Explicit retryability: Only engine timeouts default to
retryable = true. For service errors, call.with_retryable(true)when the caller can safely retry the run (Run::retryable()reflects the stopping error's flag). - Error chaining: Use
.with_source(err)to attach an underlyingstd::error::Errorcause, and.wrap("context")to prefix the message while preserving the error kind, code, retryability, and source.
Branching on Task Failures (ErrorContext)
By default, message.errors() is only accessible from Rust after (or outside) JSONLogic evaluation, because JSONLogic expressions only see {data, metadata, temp_data}.
If you want subsequent tasks or workflows to branch on why an earlier task failed, configure an ErrorContext path on EngineConfig:
use dataflow_rs::run::{EngineConfig, ErrorContext}; use dataflow_rs::Engine; fn main() -> dataflow_rs::Result<()> { let config = EngineConfig::default() .error_context(ErrorContext::new("metadata.errors".parse()?, 32)?); let builder = Engine::builder().config(config); let _ = builder; Ok(()) }
When ErrorContext is configured, each error recorded by a task is mirrored as a compact object at the configured path:
{ "workflow_id": "place_order", "task_id": "charge_payment",
"code": "TIMEOUT_ERROR", "status": 500 }
Subsequent task or workflow conditions can then inspect the error code directly:
{ "in": [ { "var": "metadata.errors.0.code" }, ["TIMEOUT_ERROR", "IO_ERROR"] ] }
End-to-End Example
use dataflow_rs::run::{EngineConfig, ErrorContext}; use dataflow_rs::{Engine, Message}; use serde_json::json; #[tokio::main] async fn main() -> dataflow_rs::Result<()> { let engine = Engine::builder() .config(EngineConfig::default() .error_context(ErrorContext::new("metadata.errors".parse()?, 32)?)) .workflow_json(&json!({"id": "w", "tasks": [ {"id": "check", "function": {"name": "validation", "input": {"rules": [ {"logic": {"!!": {"var": "data.email"}}, "message": "Email is required"} ]}}}, {"id": "route", "condition": {"in": [{"var": "metadata.errors.0.code"}, ["VALIDATION_ERROR"]]}, "function": {"name": "map", "input": { "mappings": [{"path": "data.routed", "logic": "rejected"}]}}} ]})) .build() .expect("the workflow builds"); let mut message = Message::from_json(&json!({})); engine.run(&mut message).await; let records = message.context().get(&"metadata.errors".parse()?).expect("one record"); assert_eq!( serde_json::to_string(records).unwrap(), r#"[{"workflow_id":"w","task_id":"check","code":"VALIDATION_ERROR","status":400}]"# ); let routed = message.context().get(&"data.routed".parse()?).expect("routed"); assert_eq!(serde_json::to_string(routed).unwrap(), r#""rejected""#); Ok(()) }
ErrorContext Details and Rules
- Mirrored events: Mirrors the exact same events as
message.errors()(Errreturns,5xxoutcomes, failedvalidationrules, andCtx::record_errorcalls). - Omitted message text: Mirrored records include
{workflow_id, task_id, code, status}but intentionally omit the free-text errormessageso internal details are not exposed in serialized message contexts. Read full error messages frommessage.errors()in Rust. - Reset at run start: The engine clears the configured path at the start of every run. If no errors occur during a run, the path remains absent rather than
[]. - Bounded capacity: Retains up to
limitnewest records (ErrorContext::DEFAULT_LIMITis32; minimum is1), preventing unbounded growth in loops. - Reserved path validation:
ErrorContext::newrejects bare roots (data,metadata,temp_data) and reservedmetadatakeys (progress,processed_at,engine_version,channel). - Audit vs. Trace: Mirrored error-context writes do not clutter task
AuditEntry::changes, but are recorded inTraceStep::changesso trace replay accurately reconstructsmetadata.errors.
Error Behavior by Task Type
Validation Errors
Generated by the validation built-in when rules do not evaluate to true:
{
"function": {
"name": "validation",
"input": {
"rules": [
{
"logic": {"!!": {"var": "data.email"}},
"message": "Email is required"
}
]
}
}
}
A rule passes only when logic evaluates to boolean true. The message expression is evaluated only when the rule fails. If logic itself fails to evaluate, EVALUATION_ERROR is recorded instead.
Expression Evaluation Errors
log: If amessageorfieldsexpression fails to evaluate,"<eval error>"is logged and the task still succeeds with status200.map: If a mapping fails,MAPPING_ERRORis recorded onmessage.errors()(withpathset to the mapping's target), remaining mappings still execute, and the task finishes with status500(governed byon_failure).filter: If the condition fails to evaluate,EVALUATION_ERRORis recorded and the filter treats the condition as rejected (299underhalt, orSkipunderskip).- Operation budget (
ops_budget): Exceeding the configured operation budget invalidation,map, orfilterfails the task withBUDGET_EXCEEDEDunderon_failure. - Workflow & task conditions: If a workflow or task
conditionfails to evaluate, it is treated asfalse(and recorded onRunasSelection::ConditionFailed(error)for workflows).
Custom Host Function Errors
Return Err(...) for failures or Ok(Outcome::Status(4xx)) for rejections:
#![allow(unused)] fn main() { use dataflow_rs::{Ctx, Error, Function, Outcome, Result}; struct Charge; impl Function for Charge { type Config = (); async fn call(&self, ctx: &mut Ctx<'_>, _cfg: &()) -> Result<Outcome> { let amount = ctx.get(&"data.amount".parse()?).and_then(|v| v.as_f64()); let Some(amount) = amount else { return Err(Error::service("MISSING_AMOUNT", "data.amount is not a number")); }; if amount > 10_000.0 { // Return a 4xx rejection (governed by `on_rejection`). return Ok(Outcome::Status(422)); } Ok(Outcome::Ok) } } }
When a host function returns Err, all context writes made during that call are discarded, while any error records added via Ctx::record_error are kept alongside the returned Err.
Common Error Recovery Patterns
Fallback Values
Combine "on_failure": "continue" with a conditional fallback task:
{
"tasks": [
{
"id": "try_primary",
"on_failure": "continue",
"function": {
"name": "fetch_primary",
"input": { "output": "temp_data.result" }
}
},
{
"id": "use_fallback",
"condition": {"!": {"var": "temp_data.result"}},
"function": {
"name": "map",
"input": {
"mappings": [
{"path": "data.result", "logic": "default_value"}
]
}
}
}
]
}
Because a failed host function call discards its writes, temp_data.result remains absent when try_primary fails, causing use_fallback to run.
Halting on Failed Validation
Set "on_rejection": "halt" on a validation task to prevent subsequent tasks in the workflow from running on invalid data:
{
"tasks": [
{
"id": "validate_input",
"on_rejection": "halt",
"function": {
"name": "validation",
"input": {
"rules": [
{"logic": {"!!": {"var": "data.required_field"}}, "message": "Required field missing"}
]
}
}
},
{
"id": "process",
"function": { ... }
}
]
}
Try It
Open the Full Debugger UI for step-by-step execution and workflow visualization.
Notice how validate_email records a VALIDATION_ERROR, and greet still runs because on_rejection defaults to "continue".
Best Practices
- Validate Early and Set
on_rejection: "halt"β Place validation tasks at the beginning of a workflow and set"on_rejection": "halt"whenever invalid data should block downstream tasks. - Use
on_failure: "continue"Selectively β Reserve"continue"for optional enrichment or non-critical side effects. - Check Both
Run::status()andmessage.errors()β InspectRun::status()to see if execution stopped early, and iterate overmessage.errors()to see all recorded validation and task errors.
Migrating from 3.x
In 3.x, process_message returned Result<(), DataflowError>, stopping runs added an extra WORKFLOW_ERROR wrapper record to message.errors, and error policies were configured with continue_on_error and halt_on.
In 4.0:
Engine::runalways returns aRun(userun.status()orrun.into_result()).- No duplicate
WORKFLOW_ERRORrecord is added when a run stops. - Policies use
on_failureandon_rejection(3.x keys are automatically rewritten by the compatibility reader). - Errors use
ErrorandErrorKindinstead ofDataflowError.
See Migrating from 3.x for full details.
Built-in Functions
Dataflow-rs includes eight built-in functions. They execute inline inside the engine with no external I/O and no registration step required.
| Function | Purpose | Modifies Context |
|---|---|---|
parse_json | Reads or parses JSON from payload (or a context path) into data | Yes |
parse_xml | Parses an XML string from payload (or a context path) into data | Yes |
map | Evaluates JSONLogic expressions and writes or removes context fields | Yes |
validation | Evaluates validation rules against the message and records errors for failing rules | No |
filter | Halts the workflow or skips the current task when a condition is not met | No |
log | Emits a structured log message at a configured log level | No |
publish_json | Serializes a value under data into a JSON string under data | Yes |
publish_xml | Serializes a value under data into an XML string under data | Yes |
Any other function name referenced by a task must be registered on the Registry as a custom host function. All eight built-in names are reserved (Registry::register panics if you try to register one). Note that validateβthe 3.x spelling of validationβis automatically rewritten to validation when loading 3.x definitions; see Migrating from 3.x.
Calling a Built-in Function
{
"id": "transform",
"function": {
"name": "map",
"input": {
"mappings": [
{"path": "data.total", "logic": {"*": [{"var": "data.price"}, {"var": "data.quantity"}]}}
]
}
}
}
The input object configures the function and is compiled once when the engine is built. If the configuration is invalid, compilation fails upfront with an Issue pointing to the exact JSON path (such as tasks[0].function.input.mappings[0].path):
- Missing required fields, wrong types, or invalid enum variants are reported as
INPUT_PARSE(for example,missing field `path`orunknown variant `push`, expected one of `set`, `append`, `extend`). - Literal context paths outside
data,metadata, ortemp_dataare reported asINVALID_PATH. - Unrecognized keys are preserved in the task's
extmap and reported asUNKNOWN_KEYadvisoriesβor asLIKELY_TYPOdefects if they are close to a known field name.
Because built-in functions run inline over the engine's context view, for_each is not supported on built-in tasks (INVALID_FOR_EACH). To transform arrays in a built-in task, use JSONLogic's map, filter, or reduce operators; to fan out async work across array elements, use for_each on a custom host function.
Dynamic Parameters with JSONLogic
Every parameter of every built-in function accepts a JSONLogic expressionβincluding target paths (map path, parse_* / publish_* source and target), validation error messages, and publish_xml root_element.
Because any plain JSON literal is valid JSONLogic that evaluates to itself, static strings like "path": "data.total" are folded to constants at build time with zero runtime overhead. Dynamic expressions are evaluated per message only when they reference message fields.
Escaping literal single-key objects: In templating mode, a single-key JSON object whose key matches an enabled JSONLogic operator is evaluated as that operator. Prefix the key with
$(for example,{"$cat": ...}) when you want to write a literal object. See Literal keys and the$escape.
Secrets protection: Any parameter whose output is written to the message, audit trail, or logs (such as a
mapexpression/path,validationmessage, orlogfield) cannot reference{"secret": "..."}βcompilation rejects it withSECRET_IN_MESSAGE_WRITE. See Secrets.
Return Outcomes and Policies
Each built-in function returns an outcome that is handled by the task's on_rejection and on_failure policies:
| Function | Outcome |
|---|---|
map | Returns Ok (200), or status 500 if any individual mapping fails (recording MAPPING_ERROR for each failed mapping while still executing remaining mappings). |
validation | Returns Ok (200) if all rules pass, or status 400 (a rejection) if any rule fails, recording one VALIDATION_ERROR (or EVALUATION_ERROR) per failing rule. |
filter | Returns Ok (200) if condition is true; otherwise returns Halt (status 299) or Skip (no audit entry) depending on on_reject (records EVALUATION_ERROR if the condition fails to evaluate). |
log | Always returns Ok (200). |
parse_json | Returns Ok (200). Fails only if a dynamic source or target expression fails to evaluate or resolves to an invalid root. |
parse_xml | Returns Ok (200), or fails with VALIDATION_ERROR (500) if the source is not a valid XML string. |
publish_json, publish_xml | Returns Ok (200), or fails with VALIDATION_ERROR (500) if the source path is missing or null. |
- Rejections (
400) are governed by the task'son_rejectionpolicy ("continue"by default, or"halt"to stop the workflow). - Failures (
500orErr) record an error onmessage.errors()and are governed by the task'son_failurepolicy ("fail"by default, which escalates to the workflow'son_failurepolicy).
See Tasks and Error Handling for full details.
Example Pipeline: Parse, Transform, Validate, Publish
{
"id": "intake",
"tasks": [
{
"id": "parse_input",
"function": {
"name": "parse_json",
"input": {"source": "payload", "target": "input"}
}
},
{
"id": "transform",
"function": {
"name": "map",
"input": {
"mappings": [
{
"path": "data.user.full_name",
"logic": {"cat": [{"var": "data.input.first_name"}, " ", {"var": "data.input.last_name"}]}
}
]
}
}
},
{
"id": "check",
"on_rejection": "halt",
"function": {
"name": "validation",
"input": {
"rules": [
{
"logic": {"and": [{"!!": {"var": "data.input.first_name"}}, {"!!": {"var": "data.input.last_name"}}]},
"message": "Full name required"
}
]
}
}
},
{
"id": "publish",
"function": {
"name": "publish_json",
"input": {"source": "user", "target": "response", "pretty": true}
}
}
]
}
-
Valid payload (
{"first_name": "Ada", "last_name": "Lovelace"}): Every task succeeds with status200, anddatafinishes as:{ "input": {"first_name": "Ada", "last_name": "Lovelace"}, "user": {"full_name": "Ada Lovelace"}, "response": "{\n \"full_name\": \"Ada Lovelace\"\n}" } -
Invalid payload (
{"last_name": "Lovelace"}):transformwrites" Lovelace",checkrecords aVALIDATION_ERROR("Full name required") with status400, and"on_rejection": "halt"stops the workflow beforepublishruns.
Conditional Tasks
Any built-in task can include a condition to control whether it executes:
{
"id": "premium_discount",
"condition": {"==": [{"var": "data.tier"}, "premium"]},
"function": {
"name": "map",
"input": {
"mappings": [
{"path": "data.discount", "logic": 20}
]
}
}
}
Inspecting the Function Catalogue in Rust
Registry::catalogue() lists all eight built-in functions (including their categories and config field schemas) followed by any registered custom host functions:
#![allow(unused)] fn main() { use dataflow_rs::Registry; use dataflow_rs::def::BUILTIN_FUNCTIONS; let registry = Registry::default(); let builtins: Vec<(&str, &str)> = registry .catalogue() .filter(|f| f.builtin) .map(|f| (f.name, f.spec.category.as_ref())) .collect(); assert_eq!( builtins, [ ("map", "transform"), ("validation", "validate"), ("filter", "control"), ("log", "observe"), ("parse_json", "parse"), ("parse_xml", "parse"), ("publish_json", "publish"), ("publish_xml", "publish"), ] ); let names: Vec<&str> = registry.catalogue().map(|f| f.name).collect(); assert_eq!(names, BUILTIN_FUNCTIONS); // Inspect config field specifications (useful for UI forms or linters). let map = registry.catalogue().find(|f| f.name == "map").unwrap(); let fields: Vec<(&str, bool)> = map.fields.iter().map(|f| (&*f.name, f.required)).collect(); assert_eq!(fields, [("mappings", true)]); }
Learn More
- Parse β
parse_jsonandparse_xml - Map β Transforming fields, removing keys, and appending to arrays
- Validation β Rule-based data validation and error records
- Filter β Halting workflows or skipping tasks
- Log β Structured logging with JSONLogic fields
- Publish β
publish_jsonandpublish_xml - Host Functions β Extending the engine with custom async functions
Parse Functions
parse_json and parse_xml load input data into the data context. Because a message's raw payload sits outside the JSONLogic evaluation context (data, metadata, and temp_data), workflows typically begin with one of these two functions. parse_json and parse_xml are the only built-in functions that can read from payload.
Both functions accept the same two parameters:
| Parameter | Type | Required | Description |
|---|---|---|---|
source | string | JSONLogic | Yes | Source location to read from: "payload", "payload.<path>", or a context path such as "data.raw" |
target | string | JSONLogic | Yes | Destination path relative to data (for example, "request" writes to data.request) |
sourcealways names a path (not an inline value).targetis always resolved relative todata(so"target": "data.t"writes todata.data.t).- Both parameters can be static strings or dynamic JSONLogic expressions evaluated against the message context.
parse_json
Reads the value at source and writes it to data.<target>:
{
"function": {
"name": "parse_json",
"input": {
"source": "payload",
"target": "request"
}
}
}
How parse_json handles different source values:
Value at source | Written to data.<target> |
|---|---|
| A valid JSON string | The parsed JSON value (duplicate object keys keep their first position and last value) |
| Any non-JSON string | The original string, unchanged |
An object, array, number, boolean, or null | A copy of the value as-is |
| Missing path (does not exist) | null |
Because non-JSON strings and already-parsed objects are copied as-is, parse_json never fails on malformed JSON textβa workflow works identically whether the caller passes a raw JSON string or a pre-parsed JSON object in payload.
Examples
Reading the entire payload, a nested path inside the payload, a JSON string inside data, a plain string, a temp_data field, and a missing path:
{
"id": "w",
"tasks": [
{"id": "whole", "function": {"name": "parse_json", "input": {"source": "payload", "target": "request"}}},
{"id": "nested", "function": {"name": "parse_json", "input": {"source": "payload.body.user", "target": "user"}}},
{"id": "string", "function": {"name": "parse_json", "input": {"source": "data.raw", "target": "parsed"}}},
{"id": "plain", "function": {"name": "parse_json", "input": {"source": "data.plain", "target": "kept"}}},
{"id": "context", "function": {"name": "parse_json", "input": {"source": "temp_data.t", "target": "data.t"}}},
{"id": "missing", "function": {"name": "parse_json", "input": {"source": "payload.nothing", "target": "absent"}}}
]
}
When run with payload {"body": {"user": {"id": 123, "name": "Bob"}}, "n": 1} and initial data {"raw": "{\"r\": 1, \"s\": [true]}", "plain": "not json"}, every task succeeds with status 200 and data finishes as:
{
"raw": "{\"r\": 1, \"s\": [true]}",
"plain": "not json",
"request": {"body": {"user": {"id": 123, "name": "Bob"}}, "n": 1},
"user": {"id": 123, "name": "Bob"},
"parsed": {"r": 1, "s": [true]},
"kept": "not json",
"data": {"t": null},
"absent": null
}
Notice data.data.t: because target is always relative to data, "target": "data.t" writes to data.data.t. And because temp_data.t was absent, null was written.
Dynamic source and target Expressions
Because both source and target accept JSONLogic expressions, a task can compute them dynamically per message:
{
"function": {
"name": "parse_json",
"input": {
"source": {"cat": ["data.batches.", {"var": "temp_data.i"}]},
"target": {"cat": ["batch_", {"var": "temp_data.i"}]}
}
}
}
When temp_data.i is 2 and data.batches is ["a", "b", {"k": "c"}], this writes data.batch_2 = {"k": "c"}.
- Static
sourcevalidation: A literalsourceis validated at build time. For example,"source": "extra.x"does not start withpayload,data,metadata, ortemp_data, so compilation rejects the definition withINVALID_PATHattasks[0].function.input.source. (A root with a trailing dotβsuch as"payload."or"data."βrefers to the root itself.) - Dynamic
sourcevalidation: A computedsourceis validated at runtime when evaluated. If it resolves to an invalid root like"extra.x", the task fails withCONFIG_ERROR(status500, governed byon_failure). If a dynamic expression fails to evaluate, the task fails withLOGIC_ERROR. - No secrets in paths: Because
sourceandtargetdetermine the destination path recorded in the audit trail, neither expression may reference{"secret": "..."}. See Secrets.
parse_xml
Reads an XML string from source, parses it into a JSON structure, and writes the result to data.<target>:
{
"function": {
"name": "parse_xml",
"input": {
"source": "payload",
"target": "request"
}
}
}
Unlike parse_json, parse_xml requires the value at source to be a valid XML string; otherwise the task fails with VALIDATION_ERROR (status 500, governed by on_failure):
Value at source | Result |
|---|---|
| Valid XML document string | Parsed JSON object written to data.<target> |
| Malformed XML string | Fails with VALIDATION_ERROR: Validation error: Failed to parse XML: ... |
| Non-string or missing value | Fails with VALIDATION_ERROR: Validation error: ParseXml: Source '...' is not a string |
XML-to-JSON Conversion Rules
parse_xml uses quick-xml's Serde deserializer:
- Element text content is placed under
"$text". - XML attributes are prefixed with
@(for example,"@id"). - The outer root element tag is stripped, so the object written to
data.<target>contains the root element's children and attributes.
| XML Input | JSON Written to data.<target> |
|---|---|
<root><name>Alice</name></root> | {"name": {"$text": "Alice"}} |
<root><person id="1" role="admin"/></root> | {"person": {"@id": "1", "@role": "admin"}} |
<root><person id="7">Ann</person></root> | {"person": {"@id": "7", "$text": "Ann"}} |
<root><empty/></root> | {"empty": {}} |
<root><a><b>x</b></a></root> | {"a": {"b": {"$text": "x"}}} |
<root><z>1</z><a>2</a><m>3</m></root> | {"z": {"$text": "1"}, "a": {"$text": "2"}, "m": {"$text": "3"}} |
<root><item>a</item><item>b</item></root> | {"item": {"$text": "b"}} |
Keep four rules in mind when working with parse_xml output:
- Leaf elements are objects containing
"$text", not bare strings. To read<name>Alice</name>, reference{"var": "data.request.name.$text"}(comparingdata.request.namedirectly to"Alice"evaluates tofalse). - All text and attribute values are strings.
<age>30</age>parses as{"$text": "30"}. - Child element order is preserved.
- Repeated sibling tags overwrite earlier ones. As shown in the last row above, repeated
<item>tags keep the first key position and the last value ("b"). If you need to parse repeated XML elements into JSON arrays, register a custom host function with your own XML schema/parser.
Example: Parsing and Flattening XML
A common pattern is to follow parse_xml with a map task that extracts $text values into flat fields:
{
"id": "w",
"tasks": [
{
"id": "parse",
"function": {"name": "parse_xml", "input": {"source": "payload", "target": "request"}}
},
{
"id": "flatten",
"function": {
"name": "map",
"input": {
"mappings": [
{"path": "data.user.name", "logic": {"var": "data.request.name.$text"}},
{"path": "data.user.email", "logic": {"var": "data.request.email.$text"}}
]
}
}
}
]
}
Given the payload "<user><name>Alice</name><email>alice@example.com</email></user>", data finishes as:
{
"request": {
"name": {"$text": "Alice"},
"email": {"$text": "alice@example.com"}
},
"user": {
"name": "Alice",
"email": "alice@example.com"
}
}
Handling Parse Failures
When parse_xml fails (or when a dynamic source/target expression fails in parse_json), the task records status 500 on the audit trail, appends an ErrorRecord to message.errors(), and follows the task's on_failure policy:
{
"id": "w",
"tasks": [
{
"id": "broken",
"on_failure": "continue",
"function": {"name": "parse_xml", "input": {"source": "data.broken", "target": "doc"}}
},
{
"id": "after",
"function": {"name": "map", "input": {"mappings": [{"path": "data.after", "logic": true}]}}
}
]
}
If data.broken is "<a><b></a>", broken records status 500 and adds a VALIDATION_ERROR:
Task broken error: Validation error: Failed to parse XML: ill-formed document: expected `</b>`, but `</a>` was found
Because "on_failure": "continue" is set on broken, the after task still runs. Without "on_failure": "continue", the failure stops the run at broken.
Next Steps
- Map β Transform parsed data
- Validation β Validate parsed fields
- Publish β Serialize data back to JSON or XML
Map Function
The map function evaluates JSONLogic expressions and writes the results to paths in the message context (or removes paths). It is the primary built-in function for data transformation.
{
"function": {
"name": "map",
"input": {
"mappings": [
{
"path": "data.full_name",
"logic": {"cat": [{"var": "data.first_name"}, " ", {"var": "data.last_name"}]}
}
]
}
}
}
Configuration
| Field | Type | Required | Description |
|---|---|---|---|
mappings | array | Yes | Ordered list of mappings to apply |
Mapping Fields
| Field | Type | Required | Description |
|---|---|---|---|
path | string | JSONLogic | Yes | Destination path in the context (data.*, metadata.*, or temp_data.*), or a JSONLogic expression that computes one; see Paths |
logic | JSONLogic | Required unless unset: true | Expression computing the value to write. By default, a null result is skipped; see Handling null Results |
unset | boolean | No | When true, removes the field at path instead of writing a value (cannot be combined with logic); see Removing a Path |
on_null | "skip" | "unset" | No | Behavior when logic evaluates to null: "skip" (default) leaves path unchanged; "unset" removes path |
mode | "set" | "append" | "extend" | No | Write mode: "set" (default) assigns the value at path; "append" and "extend" push onto an array at path; see Appending to an Array |
Mappings execute sequentially in the order they are listed, and each mapping immediately sees the changes made by earlier mappings in the same task:
{
"mappings": [
{"path": "temp_data.full_name", "logic": {"cat": [{"var": "data.first"}, " ", {"var": "data.last"}]}},
{"path": "data.greeting", "logic": {"cat": ["Hello, ", {"var": "temp_data.full_name"}]}}
]
}
Paths
Every destination path must start with one of the three context roots (data, metadata, or temp_data). Missing intermediate objects or arrays are created automatically:
{"path": "data.user.profile.name", "logic": "John"}
writes {"data": {"user": {"profile": {"name": "John"}}}}.
Object Keys vs. Array Indices
Path segments behave according to the container they traverse:
-
On an object: A segment is treated as an object key.
-
On an array (or missing container when the next segment is numeric): A numeric segment is treated as an array index (
0through1,048,575). Writing todata.list.2sets the third element ofdata.list, padding any missing earlier elements withnull:{"path": "data.list.0", "logic": "zero"}, {"path": "data.list.2", "logic": "two"}produces
data.list = ["zero", null, "two"]. -
Forcing an object key with
#: To write a numeric-looking object key instead of an array index, prefix the segment with#(one leading#is stripped):{"path": "data.items.#0", "logic": "first"}produces
{"items": {"0": "first"}}.
Numbers above 1,048,575 are always treated as object keys so a path can never allocate an unbounded array. Paths can have at most 64 segments.
Literal paths outside data, metadata, and temp_data are rejected at build time with INVALID_PATH.
Writing to a Context Root
Writing directly to a root ("path": "data", "metadata", or "temp_data") merges the returned object's keys into that root:
{"path": "data", "logic": {"merged": true}}
adds "merged": true to data while preserving existing keys in data. In the audit trail, new_value records the merged object ({"merged": true}) and old_value records the root prior to the merge.
Root paths only accept JSON objects: if logic is a non-object constant, compilation rejects the mapping at build time (INVALID_MAPPING); if a dynamic expression evaluates to a non-object at runtime, that mapping fails with MAPPING_ERROR and leaves the root unchanged.
Dynamic Destination Paths
Because path can be a JSONLogic expression, a single mapping can compute its destination path dynamically from the message:
{
"path": {"cat": ["data.accounts.", {"var": "data.id"}, ".balance"]},
"logic": {"var": "data.amount"}
}
When data.id is "ACC7" and data.amount is 42, this writes data.accounts.ACC7.balance = 42, and the audit entry records the resolved path ("data.accounts.ACC7.balance"). Static string paths are folded at build time with zero per-message cost.
Expressions (logic)
logic can be any literal JSON value or JSONLogic expression:
{"path": "data.copy", "logic": {"var": "data.original"}}
{"path": "data.status", "logic": "active"}
{
"path": "data.tier",
"logic": {"if": [
{">=": [{"var": "data.points"}, 1000]}, "gold",
{">=": [{"var": "data.points"}, 500]}, "silver",
"bronze"
]}
}
{
"path": "data.count",
"logic": {"reduce": [{"var": "data.items"}, {"+": [{"var": "accumulator"}, 1]}, 0]}
}
{
"path": "data.name",
"logic": {"if": [{"!!": {"var": "data.name"}}, {"var": "data.name"}, "Unknown"]}
}
Writing Literal Objects ($ Escape)
map evaluates expressions in templating mode: multi-key objects are treated as object templates whose values are evaluated recursively, while a single-key object whose key matches an enabled JSONLogic operator is evaluated as that operator. To write a literal object whose key collides with an operator name (or starts with $), prefix the key with $:
logic | Value Written |
|---|---|
{"cat": ["a", "b"]} | "ab" |
{"$cat": ["a", "b"]} | {"cat": ["a", "b"]} |
{"$$oid": "x"} | {"$oid": "x"} |
{"result": {"var": "data.n"}} | {"result": 600} (when data.n is 600) |
One leading $ is stripped from any template key, while single-key objects whose key is not an operator (like {"result": ...}) require no escape. See Literal keys and the $ escape.
Secrets Are Not Allowed in map
Because map writes its output into the Message (which is serialized and traced), neither logic nor path may reference {"secret": "name"}βthe compiler rejects it at build time with SECRET_IN_MESSAGE_WRITE. To compute a value derived from a secret (such as an HMAC signature), use a custom host function. See Secrets.
Handling null Results
By default ("on_null": "skip"), if logic evaluates to null, the mapping is skipped: the target path retains its existing value (or remains absent) and no audit change is recorded:
{"path": "data.copy", "logic": {"var": "data.optional"}}
This default makes {"if": [cond, value, null]} naturally express "set when cond is true, otherwise leave unchanged." Note that a constant "logic": null with the default on_null is a no-op and is flagged by Compiler::check as a NULL_MAPPING advisory.
Removing a Path
To unconditionally delete a field or array element at path, set "unset": true (with no logic key):
{"path": "temp_data.retry", "unset": true}
To conditionally delete path whenever logic evaluates to null, keep logic and set "on_null": "unset":
{
"path": "temp_data.reason",
"logic": {"if": [{"var": "data.ok"}, null, "FAILED"]},
"on_null": "unset"
}
-
Removing a key that is already absent is a no-op and records no audit change.
-
Removing an existing key records a
Changewithremoved: true,old_valueset to the removed value, andnew_value: null:{"path": "data.y", "old_value": 2, "new_value": null, "removed": true} -
Removing an array index (such as
{"path": "data.gone.0", "unset": true}) removes that element and shifts subsequent elements down.
Why use
unsetinstead offalse? Setting a field tofalseleaves the key present in the object, somissingandexistsreport it as present,{"var": ["temp_data.x", "fallback"]}and??returnfalseinstead of"fallback", and the key stays in the serialized message. This matters especially in loops, wheretemp_datapersists across iterationsβuseunset: trueoron_null: "unset"to clear per-iteration state cleanly.
Build-Time Mapping Validation Rules
The compiler checks each mapping at build time and reports INVALID_MAPPING if any of these rules are violated:
| Rule | Reported Path | Explanation |
|---|---|---|
Must have logic or "unset": true | logic | Every mapping must either compute a value with logic or remove the path with "unset": true. |
Cannot set both logic and "unset": true | unset | unset: true takes no logic. To remove a path when logic is null, use "on_null": "unset" instead. |
on_null requires logic | on_null | on_null only applies when evaluating logic. |
mode requires logic | mode | mode only applies when writing a logic result. |
| Cannot unset a context root | path | Top-level roots (data, metadata, temp_data) cannot be removed; unset specific keys inside them instead. |
| Cannot append/extend a context root | path | Top-level roots are objects, not arrays; specify an array path under a root instead. |
| Root writes must be objects | logic | Writing to data, metadata, or temp_data requires a JSON object. |
Appending to an Array (mode: "append" and "extend")
-
"mode": "append"pushes the evaluated result as a single element onto the end of the array atpath:{ "path": "data.log", "logic": {"turn": {"var": "temp_data.turn"}, "move": {"var": "temp_data.move"}}, "mode": "append" } -
"mode": "extend"takes an array result fromlogicand pushes each of its elements onto the array atpath(for example,{"logic": [2, 3], "mode": "extend"}appends two elements rather than a nested array).
How append and extend handle the existing value at path:
- Missing or
null: Creates a new array atpathcontaining the appended element(s). - Existing array: Appends the element(s) in place at the end of the array.
- Any other type (object, string, number, boolean): Fails the mapping with
MAPPING_ERRORwithout modifyingpath. (Similarly,extendfails iflogicdoes not evaluate to an array ornull.)
Each appended element records a compact Change at its new index (data.log.0, data.log.1, etc.):
{"path": "data.log.0", "old_value": null, "new_value": {"turn": 1}},
{"path": "data.log.1", "old_value": null, "new_value": {"turn": 2}}
Performance tip: Always prefer
"mode": "append"over{"merge": [{"var": "data.log"}, [item]]}when accumulating itemsβespecially inside a loop.mergeclones the entire array on every iteration ($O(n^2)$ over $n$ iterations), whereasappendmutates the array in place and records only the newly added element ($O(1)$ per append).
Handling Mapping Failures
A mapping fails at runtime if:
- Its
logicexpression fails to evaluate. - A dynamic
pathexpression fails to evaluate or resolves outsidedata,metadata, andtemp_data. - It attempts to unset, append to, or write a non-object to a context root.
- It uses
append/extendon a target path that holds a non-array value.
When a mapping fails:
- The engine records a
MAPPING_ERRORonmessage.errors()withpathset to the mapping's target path and a descriptive message naming the mapping index. - Any remaining mappings in the
maptask still execute. - The
maptask finishes with status500(which adds aTASK_STATUS_ERRORrecord and triggers the task'son_failurepolicy).
Note: If a mapping exceeds the configured operation budget (
ops_budget), the task aborts immediately withBUDGET_EXCEEDEDwithout running subsequent mappings.
Example of a partial mapping failure:
{
"id": "t",
"function": {
"name": "map",
"input": {
"mappings": [
{"path": "data.a", "logic": 1},
{"path": "data.s", "logic": "x", "mode": "append"},
{"path": "data.c", "logic": 3}
]
}
}
}
If data.s holds "str" (a string rather than an array), mapping 1 fails, while data.a = 1 and data.c = 3 are both written. The audit entry records status 500 with those two changes, message.errors() receives MAPPING_ERROR (for mappings[1]) and TASK_STATUS_ERROR (for task t), and the task's on_failure policy decides whether the workflow continues or stops.
Audit Trail
When RunOptions::capture_changes is enabled (the default), every write or removal made by map adds a Change to the task's AuditEntry containing path, old_value, new_value, and removed. See Audit Trails.
Try It
Open the Full Debugger UI for step-by-step execution and workflow visualization.
Best Practices
- Use
temp_datafor Intermediate Values β Keep temporary calculations intemp_dataand write final outputs todata. - Order Mappings Intentionally β Later mappings in the same task can read fields written by earlier mappings.
- Use
unsetto Delete Keys β Remember thatnullresults are skipped by default (on_null: "skip") andfalsekeeps the key present; use"unset": trueor"on_null": "unset"to remove a field. - Use
"mode": "append"for Growing Arrays β Avoid cloning arrays withmergeinside loops.
Validation Function
The validation function evaluates a list of JSONLogic rules against the message context and records an error on message.errors() for each rule that fails. It is read-only and never modifies the context.
{
"function": {
"name": "validation",
"input": {
"rules": [
{
"logic": {"!!": {"var": "data.email"}},
"message": "Email is required"
},
{
"logic": {">": [{"var": "data.age"}, 0]},
"message": "Age must be positive"
}
]
}
}
}
Configuration
| Field | Type | Required | Description |
|---|---|---|---|
rules | array | Yes | List of validation rules evaluated in order |
Rule Fields
| Field | Type | Required | Description |
|---|---|---|---|
logic | JSONLogic | Yes | Validation expression; the rule passes only when it evaluates to boolean true |
message | string | JSONLogic | Yes | Error message recorded when the rule fails (evaluated only on failure) |
Both logic and message are required on every rule; omitting either produces an INPUT_PARSE issue at build time.
How Rules Are Evaluated
All rules in the rules list are evaluated in orderβa failing rule does not short-circuit subsequent rules in the same task:
logic Result | Recorded on message.errors() |
|---|---|
true | None (rule passes) |
Any other value (false, null, 1, "yes", [1], {}) | VALIDATION_ERROR with the rule's message |
| Expression evaluation error | EVALUATION_ERROR (Failed to evaluate rule <index>: <reason>) |
Exceeds ExprEngine ops_budget (in logic or message) | Aborts the task immediately with BUDGET_EXCEEDED (governed by on_failure) |
Important: A rule passes only when
logicevaluates to booleantrue. Truthy non-boolean values (such as a non-empty string or non-zero number) fail the rule. Always wrap presence checks in"!!": write{"!!": {"var": "data.email"}}rather than{"var": "data.email"}.
If every rule passes, the validation task finishes with status 200. If one or more rules fail, the task finishes with status 400 (a rejection). By default, on_rejection is "continue", so the next task still runs unless you set "on_rejection": "halt"; see Stopping the Workflow on Validation Failure.
Dynamic Error Messages
Because message can be a JSONLogic expression, error messages can include dynamic values from the message context:
{
"logic": {">": [{"var": "data.age"}, 0]},
"message": {"cat": ["Age ", {"var": "data.age"}, " must be positive"]}
}
When data.age is 0, this records "Age 0 must be positive". The message expression is evaluated lazily only when logic fails, so dynamic messages add zero cost when validation passes. (If a dynamic message expression fails to evaluate, "Validation failed" is recorded; if it evaluates to a non-string value, it is formatted as JSON.)
No secrets in error messages: Because validation error messages are stored in
message.errors(), a rule'smessagecannot reference{"secret": "..."}(SECRET_IN_MESSAGE_WRITE). A rule'slogiccan compare against a secret, sincelogicreduces to a boolean. See Secrets.
Error Records
Each failing rule appends an ErrorRecord to message.errors():
{"code": "VALIDATION_ERROR", "message": "Email is required", "path": null, "workflow_id": "w", "task_id": "check", "timestamp": "2026-10-16T19:12:26.123456+00:00"}
You can inspect all recorded validation errors in Rust after a run:
#![allow(unused)] fn main() { fn _demo(message: &dataflow_rs::Message) { for error in message.errors() { println!("{}: {}", error.code, error.message); } } }
For example, running the following task against data = {"age": 0, "status": "unknown", "word": "not"}:
{
"id": "check",
"function": {
"name": "validation",
"input": {
"rules": [
{"logic": {"!!": {"var": "data.email"}}, "message": "Email is required"},
{"logic": {">": [{"var": "data.age"}, 0]}, "message": {"cat": ["Age ", {"var": "data.age"}, " must be positive"]}},
{"logic": {"in": [{"var": "data.status"}, ["active", "pending"]]}, "message": "Invalid status"},
{"logic": {"+": [{"var": "data.word"}, "x"]}, "message": "never shown"},
{"logic": 1, "message": "truthy but not true"}
]
}
}
}
returns status 400 and appends five error records in rule order:
VALIDATION_ERROR Email is required
VALIDATION_ERROR Age 0 must be positive
VALIDATION_ERROR Invalid status
EVALUATION_ERROR Failed to evaluate rule 3: Thrown: {"type":"NaN"} (in operator: +)
VALIDATION_ERROR truthy but not true
Stopping the Workflow on Validation Failure
By default, a failing validation task does not stop the workflow. It returns status 400 (a rejection), and a task's default on_rejection policy is "continue" so that workflows can collect validation errors across multiple tasks.
If a validation task is followed by other tasks that do not check metadata.progress.status_code, Compiler::check emits an UNGUARDED_VALIDATION advisory on the validation task to remind you:
a failing rule here records status 400 and task `process` still runs:
`on_rejection` is `continue`. Add `"on_rejection": "halt"` to stop the
workflow, or gate what follows on `metadata.progress.status_code`
Note that setting on_failure on a validation task has no effect on failed validation rules, because on_failure applies only to 5xx statuses and Err outcomesβnot 400 rejections.
Option 1: Set "on_rejection": "halt" on the Validation Task
To stop the current workflow as soon as validation fails, set "on_rejection": "halt" on the validation task:
{
"id": "intake",
"tasks": [
{
"id": "check",
"on_rejection": "halt",
"function": {
"name": "validation",
"input": {"rules": [{"logic": {"!!": {"var": "data.email"}}, "message": "Email is required"}]}
}
},
{
"id": "process",
"function": {"name": "map", "input": {"mappings": [{"path": "data.processed", "logic": true}]}}
}
]
}
When data.email is missing, check records status 400 and the VALIDATION_ERROR, process is skipped, the workflow exits with WorkflowExit::Halted, and the engine moves on to the next workflow (finishing with RunStatus::CompletedWithErrors).
Option 2: Gate a Subsequent Task on metadata.progress.status_code
Because the engine updates metadata.progress ({workflow_id, task_id, status_code}) after every task, a subsequent task can check whether the preceding validation returned 400:
{
"id": "process",
"condition": {"!=": [{"var": "metadata.progress.status_code"}, 400]},
"function": {"name": "map", "input": {"mappings": [{"path": "data.processed", "logic": true}]}}
}
If you want a later workflow to branch on validation errors, configure EngineConfig::error_context; see Error Handling β Branching on Task Failures.
Migrating from 3.x
In 3.x, this function was named validate and halting on failure used "halt_on": "failure". When a 3.x definition is loaded in 4.0, the compatibility reader automatically rewrites validate to validation and "halt_on": "failure" to "on_rejection": "halt". See Migrating from 3.x.
Common Validation Patterns
Required Field
{"logic": {"!!": {"var": "data.email"}}, "message": "Email is required"}
Numeric Range
{
"logic": {"and": [
{">=": [{"var": "data.age"}, 18]},
{"<=": [{"var": "data.age"}, 120]}
]},
"message": "Age must be between 18 and 120"
}
Allowed Values (in)
{
"logic": {"in": [{"var": "data.status"}, ["active", "pending", "suspended"]]},
"message": "Invalid status value"
}
Conditional Requirement
{
"logic": {"or": [
{"!": {"var": "data.is_business"}},
{"!!": {"var": "data.company_name"}}
]},
"message": "Company name required for business accounts"
}
String Length
Requires enabling OperatorFamily::ExtString on ExprEngineBuilder (see Operator Families):
{
"logic": {">=": [{"length": {"var": "data.password"}}, 8]},
"message": "Password must be at least 8 characters"
}
Substring / Prefix Matching
Core JSONLogic provides in for substring checks, and OperatorFamily::ExtString adds starts_with and ends_with:
{"logic": {"in": ["@", {"var": "data.email"}]}, "message": "Invalid email format"}
For full regular expression matching, register a custom host function or custom operator in Rust.
Try It
Open the Full Debugger UI for step-by-step execution and workflow visualization.
Best Practices
- Validate Early β Place
validationtasks right after parsing input data. - Use
"on_rejection": "halt"When Gating Downstream Tasks β Remember thatvalidationreturns400, which continues by default unless"on_rejection": "halt"is set. - Wrap Presence Checks in
"!!"β Rules require booleantrueto pass. - Include Field Values in Error Messages β Use dynamic JSONLogic expressions in
messagewhen helpful for debugging.
Filter Function
The filter function acts as a control-flow gate inside a workflow: it evaluates a JSONLogic condition and, when the condition is not true, either halts the rest of the workflow or skips the current task. It is read-only and never modifies the message context.
{
"function": {
"name": "filter",
"input": {
"condition": {"==": [{"var": "data.status"}, "active"]},
"on_reject": "halt"
}
}
}
Configuration
| Field | Type | Required | Description |
|---|---|---|---|
condition | JSONLogic | Yes | Condition expression; passes only when it evaluates to boolean true |
on_reject | "halt" | "skip" | No | Action when condition is not true: "halt" (default) stops the current workflow; "skip" skips only this task |
- Strict boolean check: The filter passes only when
conditionevaluates to booleantrue. Values likefalse,null,1,"true", or[1]are rejected. Always write presence checks as{"!!": {"var": "data.email"}}rather than{"var": "data.email"}. - Evaluation errors: If
conditionfails to evaluate, the filter rejects the message and records anEVALUATION_ERROR(Filter condition failed to evaluate: ...) onmessage.errors()so broken expressions are visible. - Operation budget: If
conditionexceeds theExprEngine's operation budget (ops_budget), the task fails withBUDGET_EXCEEDEDunderon_failure.
Rejection Behavior (on_reject)
"halt" (Default)
Stops the current workflow immediatelyβno subsequent tasks in the workflow run. The audit trail records status 299, the workflow exits with WorkflowExit::Halted, and execution continues to the next workflow in the program:
[
{
"id": "orders",
"priority": 1,
"tasks": [
{
"id": "require_active",
"function": {
"name": "filter",
"input": {"condition": {"==": [{"var": "data.status"}, "active"]}}
}
},
{
"id": "process",
"function": {"name": "map", "input": {"mappings": [{"path": "data.processed", "logic": true}]}}
}
]
},
{
"id": "audit",
"priority": 2,
"tasks": [
{
"id": "mark",
"function": {"name": "map", "input": {"mappings": [{"path": "data.audited", "logic": true}]}}
}
]
}
]
When data.status is "closed", require_active records status 299, process is skipped, and the audit workflow runs normally, leaving data as {"status": "closed", "audited": true}.
"skip"
Skips only the filter task itself and continues to the next task in the workflow. A skipped filter writes no audit entry and does not update metadata.progress (so metadata.progress.task_id still reflects the task that ran before the filter). Note that terminal: true on a skipped filter does not trigger.
{
"id": "w",
"tasks": [
{
"id": "first",
"function": {"name": "map", "input": {"mappings": [{"path": "data.first", "logic": true}]}}
},
{
"id": "coupon",
"function": {
"name": "filter",
"input": {"condition": {"!!": {"var": "data.coupon_code"}}, "on_reject": "skip"}
}
},
{
"id": "next",
"function": {"name": "map", "input": {"mappings": [{"path": "data.next", "logic": true}]}}
}
]
}
When data.coupon_code is absent, the audit trail contains first (200) and next (200), with no entry for coupon.
Status Codes
| Audit Status | Meaning |
|---|---|
200 | condition evaluated to true; workflow execution continues |
299 | Rejected with on_reject: "halt"; remaining tasks in the current workflow are skipped |
| (no entry) | Rejected with on_reject: "skip"; no audit entry is written and the next task runs |
Examples
Guard Clause
Stop a workflow early if a required field is missing:
{
"id": "pipeline",
"tasks": [
{
"id": "parse",
"function": {"name": "parse_json", "input": {"source": "payload", "target": "input"}}
},
{
"id": "require_email",
"function": {"name": "filter", "input": {"condition": {"!!": {"var": "data.input.email"}}}}
},
{
"id": "process",
"function": {
"name": "map",
"input": {"mappings": [{"path": "data.result", "logic": {"cat": ["Processed: ", {"var": "data.input.email"}]}}]}
}
}
]
}
Combining Multiple Conditions
Use and and or for compound guard conditions:
{
"id": "complex_gate",
"function": {
"name": "filter",
"input": {
"condition": {
"and": [
{">=": [{"var": "data.order.total"}, 100]},
{"==": [{"var": "data.order.currency"}, "USD"]},
{"!!": {"var": "data.order.shipping_address"}}
]
}
}
}
}
Choosing Between filter, Task condition, and validation
- To skip a single task: Put a
conditiondirectly on that task (or wrap a span of tasks in a task group). - To exit the rest of a workflow silently when a precondition is not met: Use a
filtertask with"on_reject": "halt"(or aterminal: truetask/group). - To validate data and record error messages when rules fail: Use a
validationtask with"on_rejection": "halt". Do not place afilteraftervalidationjust to halt, as the filter's299status would overwrite the validation's400status inmetadata.progress.
Log Function
The log function emits structured log messages through the Rust log crate, with a dynamic message string and optional key-value fields evaluated against the message context. It never modifies the context and always succeeds with status 200.
{
"function": {
"name": "log",
"input": {
"level": "info",
"message": {"cat": ["Processing order ", {"var": "data.order.id"}]},
"fields": {
"order_id": {"var": "data.order.id"},
"total": {"var": "data.order.total"}
}
}
}
}
Configuration
| Field | Type | Required | Description |
|---|---|---|---|
level | string | No | Log level: "trace", "debug", "info" (default), "warn", or "error" |
message | JSONLogic | Yes | Expression computing the log message text |
fields | object | No | Map of structured field names to JSONLogic expressions (emitted sorted by key) |
Output Format
Each log entry is formatted as message [key=value, ...] with fields sorted alphabetically by key. String values are formatted directly, while non-string values (numbers, booleans, arrays, objects) are serialized as compact JSON.
For example, given data = {"order": {"id": "ORD-7", "total": 99.5, "items": [1, 2]}, "customer": {"name": "Ada"}}:
{
"level": "info",
"message": {"cat": ["Processing order ", {"var": "data.order.id"}, " for ", {"var": "data.order.total"}]},
"fields": {
"order_id": {"var": "data.order.id"},
"customer": {"var": "data.customer.name"},
"total": {"var": "data.order.total"},
"items": {"var": "data.order.items"}
}
}
emits the following log line at info level under the dataflow::log target:
Processing order ORD-7 for 99.5 [customer=Ada, items=[1,2], order_id=ORD-7, total=99.5]
If an expression in message or fields fails to evaluate, "<eval error>" is written in its place, the evaluation error is logged at error level by the engine, and the log task still finishes with status 200:
<eval error> [x=<eval error>]
Log Levels
| Level | Typical Use |
|---|---|
trace | Detailed step-by-step values during deep debugging |
debug | Intermediate state while developing workflows |
info | Key workflow milestones |
warn | Unexpected but recoverable conditions |
error | Handled errors or fallback paths |
Zero overhead when filtered out: Before evaluating
messageorfields,logcheckslog::log_enabled!for the task's level on targetdataflow::log. When that level is disabled by your logger configuration (for example, adebugtask when running atinfolevel), no expressions are evaluated.
Configuring Your Logger (dataflow::log Target)
All workflow log tasks emit under the dataflow::log target so you can filter workflow logs independently from internal engine logs.
With env_logger via environment variable:
RUST_LOG=dataflow::log=info cargo run
Or in Rust code:
#![allow(unused)] fn main() { env_logger::Builder::new() .filter_module("dataflow::log", log::LevelFilter::Debug) .init(); }
Examples
Static Log Message
{
"id": "log_start",
"function": {"name": "log", "input": {"message": "Starting order processing"}}
}
Conditional Warning
Combine log with a task condition to log only when an edge case occurs:
{
"id": "warn_missing",
"condition": {"!": {"var": "data.shipping_address"}},
"function": {
"name": "log",
"input": {
"level": "warn",
"message": {"cat": ["Order ", {"var": "data.order.id"}, " has no shipping address"]}
}
}
}
Dumping Context State for Debugging
{
"id": "debug_state",
"function": {
"name": "log",
"input": {
"level": "debug",
"message": "Current state",
"fields": {
"data": {"var": "data"},
"has_email": {"!!": {"var": "data.email"}}
}
}
}
}
Secrets Protection
Because log lines leave the engine, neither message nor any expression in fields may reference {"secret": "name"}βcompilation rejects the workflow at build time with SECRET_IN_MESSAGE_WRITE. See Secrets.
Publish Functions
publish_json and publish_xml serialize a value from data into a formatted JSON or XML string under data. Workflows typically use them as a final step to prepare an output payload.
Both functions share a common configuration structure:
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
source | string | JSONLogic | Yes | β | Source path relative to data (for example, "response" reads data.response, and "response.user" reads data.response.user) |
target | string | JSONLogic | Yes | β | Destination path relative to data where the serialized string is written |
pretty | boolean | No | false | Format the output as pretty-printed, indented JSON (publish_json only) |
root_element | string | JSONLogic | No | "root" | Name of the outer XML root element (publish_xml only) |
-
Both
sourceandtargetname paths relative todata(not inline values), and either can be a dynamic JSONLogic expression. -
For compatibility with 3.x workflows, a
sourcethat starts with"data."(such as"data.response") first checksdata.data.responseand falls back todata.responseif absent; an emptysource("") or"data."serializes the entiredataobject. -
Specifying
root_elementonpublish_jsonorprettyonpublish_xmlis ignored and reported as anUNKNOWN_KEYadvisory. -
Missing or
nullsource: The value atsourcemust exist and not benull; otherwise the task fails withVALIDATION_ERROR(status500, governed byon_failure):Validation error: PublishJson: Source 'data.absent' not found or is null -
No secrets: Because
source,target, androot_elementaffect data written to the message and audit trail, none of them may reference{"secret": "..."}(SECRET_IN_MESSAGE_WRITE). See Secrets.
publish_json
Serializes the value at data.<source> into a JSON string at data.<target>:
{
"function": {
"name": "publish_json",
"input": {
"source": "response",
"target": "body"
}
}
}
When data.response is {"status": "success", "count": 42}, this writes:
{
"response": {"status": "success", "count": 42},
"body": "{\"status\":\"success\",\"count\":42}"
}
With "pretty": true:
{
"pretty_body": "{\n \"status\": \"success\",\n \"count\": 42\n}"
}
Scalar values (strings, numbers, booleans) and arrays can also be serialized (for example, "source": "response.count" writes "42"). Both compact and pretty JSON output preserve the source object's key insertion order.
Dynamic source and target
{
"source": {"cat": ["outputs.", {"var": "data.format"}]},
"target": {"cat": ["rendered_", {"var": "data.format"}]}
}
When data.format is "a", this serializes data.outputs.a into data.rendered_a.
publish_xml
Serializes the value at data.<source> into an XML document string wrapped in <root_element>:
{
"function": {
"name": "publish_xml",
"input": {
"source": "user",
"target": "user_xml",
"root_element": "User"
}
}
}
When data.user is {"name": "Alice", "age": 30, "tags": ["a", "b"], "active": true, "note": null}, this writes:
{
"user_xml": "<User><name>Alice</name><age>30</age><tags><item>a</item><item>b</item></tags><active>true</active><note></note></User>"
}
If root_element is omitted, the document is wrapped in <root>. Because root_element accepts a JSONLogic expression, you can also compute the root tag name dynamically from the message:
{"source": "user", "target": "doc", "root_element": {"var": "data.doc_type"}}
JSON-to-XML Conversion Rules
| JSON Value | XML Output |
|---|---|
| Object | One child element per key (in key order), named after the key |
| Array | One <item> element per array element |
| String | Escaped text content (&, <, >, ", and ' are XML-escaped) |
| Number or boolean | Text representation (30, true, false) |
null | Empty element (<key></key>) |
Object keys and root_element names are automatically sanitized into valid XML element names so untrusted or dynamic keys can never inject XML tags:
- If the first character is not an ASCII letter or
_, a leading_is prepended. - Any character other than an ASCII letter, digit,
-,_, or.is replaced with_. - An empty key becomes
_element.
| JSON Key | Sanitized XML Tag |
|---|---|
123field | <_123field> |
field name | <field_name> |
field@attr | <field_attr> |
ok-name_1.x | <ok-name_1.x> |
"" (empty) | <_element> |
Note:
publish_xmlis not a direct inverse ofparse_xml. The$textand@attrkeys produced byparse_xmlare treated as ordinary object keys bypublish_xmland sanitized into<_text>and<_attr>elements ({"name": {"$text": "Alice"}}serializes as<root><name><_text>Alice</_text></name></root>). Use amaptask to flatten$textfields into plain values before callingpublish_xml; see Parse Functions β XML-to-JSON Conversion Rules.
Common Patterns
JSON Request In, JSON Response Out
{
"tasks": [
{
"id": "parse_request",
"function": {"name": "parse_json", "input": {"source": "payload", "target": "request"}}
},
{
"id": "process",
"function": {
"name": "map",
"input": {
"mappings": [
{"path": "data.response.message", "logic": {"cat": ["Hello, ", {"var": "data.request.name"}]}}
]
}
}
},
{
"id": "publish_response",
"function": {"name": "publish_json", "input": {"source": "response", "target": "body"}}
}
]
}
XML In, XML Out
{
"tasks": [
{
"id": "parse",
"function": {"name": "parse_xml", "input": {"source": "payload", "target": "input"}}
},
{
"id": "transform",
"function": {
"name": "map",
"input": {
"mappings": [
{"path": "data.output.result", "logic": {"var": "data.input.value.$text"}}
]
}
}
},
{
"id": "publish",
"function": {"name": "publish_xml", "input": {"source": "output", "target": "xml", "root_element": "Result"}}
}
]
}
Publishing Both JSON and XML
{
"tasks": [
{
"id": "publish_json",
"function": {"name": "publish_json", "input": {"source": "response", "target": "json_output"}}
},
{
"id": "publish_xml",
"function": {"name": "publish_xml", "input": {"source": "response", "target": "xml_output", "root_element": "Response"}}
}
]
}
Continuing When the Source Is Missing
{
"id": "w",
"tasks": [
{
"id": "publish",
"on_failure": "continue",
"function": {"name": "publish_json", "input": {"source": "absent", "target": "body"}}
},
{
"id": "after",
"function": {"name": "map", "input": {"mappings": [{"path": "data.after", "logic": true}]}}
}
]
}
Here, publish records status 500 and a VALIDATION_ERROR (Task publish error: Validation error: PublishJson: Source 'data.absent' not found or is null), and because "on_failure": "continue" is set, after still runs and the run finishes as CompletedWithErrors.
Next Steps
- Parse β Parse incoming JSON and XML payloads
- Map β Transform and shape data before publishing
- Validation β Validate fields before publishing
Host Functions
Dataflow-rs ships with eight built-in functions and performs no external I/O on its own. Everything else a workflow callsβsuch as an HTTP request, database query, queue publish, or ML model inferenceβis a host function: a custom Function implementation that you register on the engine by name. Its configuration is compiled once when the engine is built, and its async fn call method is invoked whenever a task executes it.
Migrating from 3.x: Version 3.x included config structs for
http_call,enrich, andpublish_kafkawithout built-in runtime implementations. In 4.0, you register your ownFunctionimplementations under those names using#[derive(FunctionConfig)]. See Migrating from 3.x.
This guide walks through building a complete http_call host function from scratch. To keep all examples self-contained and runnable without network access, HttpCall echoes back a structured description of the request; in production, you would hold an Arc<reqwest::Client> (or similar client) on the struct.
A Host Function in Three Parts
Every custom host function consists of:
- A config struct with
#[derive(FunctionConfig)] - A struct implementing
Function - Registration on
EngineBuilder(orRegistry)
#![allow(unused)] fn main() { use dataflow_rs::datavalue::OwnedDataValue; use dataflow_rs::expr::{CompileCx, Interpolated, Output}; use dataflow_rs::function::{FunctionConfig, FunctionSpec, Prepare, Reference, RetrySafety}; use dataflow_rs::program::{AuthoredPath, IssueCode, Severity}; use dataflow_rs::{Ctx, Error, Expr, Function, Issue, Outcome, Result}; use serde_json::{Value, json}; use std::collections::BTreeMap; /// The `input` configuration for an `http_call` task, compiled once at build time. #[derive(FunctionConfig)] #[config(check = HttpCallConfig::rules)] struct HttpCallConfig { /// Name of the configured connector to send the request through. connector: String, /// HTTP method. #[config(default = "GET")] method: String, /// Request URL path (static string or JSONLogic expression). path: Expr<String>, /// Request headers, where each value is a static string or JSONLogic expression. #[config(default_json = "{}")] headers: BTreeMap<String, Expr<String>>, /// Optional request body template with `{"var": "..."}` placeholders. body: Option<Interpolated>, /// Request timeout in milliseconds. #[config(default = 30000)] timeout_ms: Expr<u64>, /// Destination path where the response is written. #[output(default = "data.response")] output: Output, } const METHODS: [&str; 5] = ["GET", "POST", "PUT", "PATCH", "DELETE"]; impl HttpCallConfig { /// Custom build-time validation rule checked after all fields compile. fn rules(&self, cx: &mut CompileCx<'_>) { if !METHODS.contains(&self.method.as_str()) { cx.at("method", |cx| { let message = format!("`{}` is not one of {METHODS:?}", self.method); cx.issue(IssueCode::InputParse, message); }); } } } /// Sends HTTP requests through named connectors. struct HttpCall { /// Map of connector name to base URL. connectors: BTreeMap<String, String>, } impl HttpCall { fn new(connectors: &[(&str, &str)]) -> Self { Self { connectors: connectors .iter() .map(|(name, base)| (name.to_string(), base.to_string())) .collect(), } } } impl Function for HttpCall { type Config = HttpCallConfig; /// Metadata describing this function for linters, UIs, and catalogues. fn spec(&self) -> FunctionSpec { FunctionSpec::default() .with_description("Sends an HTTP request through a named connector and writes the response.") .with_category("io") .with_retry(RetrySafety::Unknown) .with_references(vec![Reference::new("connector", "connector")]) } /// Deployment-specific build-time check: verify that `cfg.connector` exists /// in `self.connectors`. Returning a `Rejected` issue rejects the workflow at build time. fn prepare( &self, cfg: &mut HttpCallConfig, p: &Prepare<'_>, ) -> std::result::Result<(), Vec<Issue>> { if self.connectors.contains_key(&cfg.connector) { return Ok(()); } let message = format!( "task `{}` names connector `{}`, which this deployment does not have", p.task_id(), cfg.connector ); let issue = Issue::new( IssueCode::UnresolvedReference, AuthoredPath::root().join("connector"), message, ) .with_severity(Severity::Rejected); Err(vec![issue]) } /// Execute a single task call against the current message context. async fn call(&self, ctx: &mut Ctx<'_>, cfg: &HttpCallConfig) -> Result<Outcome> { let base = &self.connectors[&cfg.connector]; let path = ctx.eval(&cfg.path)?; let timeout_ms = ctx.eval(&cfg.timeout_ms)?; let mut headers = BTreeMap::new(); for (name, value) in &cfg.headers { headers.insert(name.clone(), ctx.eval(value)?.into_owned()); } let body = match &cfg.body { Some(doc) => serde_json::to_value(ctx.render(doc)) .map_err(|e| Error::service("BODY_ENCODING", e.to_string()))?, None => Value::Null, }; // In production, send `cfg.method` to `{base}{path}` with `headers` and `body`. // Note: we record only header names (not values) in the response because a // header may contain a secret, and anything written via `ctx.emit` is recorded // on the message. let response = json!({ "status": 200, "url": format!("{base}{path}"), "method": cfg.method, "headers": headers.keys().collect::<Vec<_>>(), "body": body, "timeout_ms": timeout_ms, }); ctx.emit(&cfg.output, OwnedDataValue::from(&response))?; Ok(Outcome::Ok) } } }
1. The Config Struct (#[derive(FunctionConfig)])
#[derive(FunctionConfig)] generates the build-time compiler, schema specification, and runtime resolver for a struct with named fields. Each field's Rust type determines how the corresponding JSON key in function.input is compiled and evaluated:
| Field Type | What the JSON Key Accepts | How It Is Evaluated at Runtime |
|---|---|---|
String, bool, integer/float primitives, serde_json::Value, OwnedDataValue | Static JSON literal of that type | Read directly from the config struct (never evaluated as JSONLogic) |
Expr<T> (OwnedDataValue, String, u64, i64, f64, bool, Path) | Static literal or JSONLogic expression resolving to T | Evaluated per call with ctx.eval(&field). Static literals fold to constants at build time and are borrowed with zero overhead |
Interpolated | JSON document template with {"var": "..."} placeholders ($ keys are kept as literal data) | Rendered per call with ctx.render(&field), looking up each placeholder once |
Output | Destination context path (static string or dynamic JSONLogic expression) | Written to via ctx.emit(&field, value) or ctx.emit_default(value) |
Option<T> | Optional field | None when the key is omitted (or null when T does not accept null) |
Vec<T>, BTreeMap<String, T> | Array or object map of any supported config field type | Compiled element-by-element with full JSON path tracking |
Nested #[derive(FunctionConfig)] struct | Nested JSON object | Compiled recursively with issues reported at their nested paths |
Any Deserialize + Serialize type with #[config(serde)] | Any JSON value matching the Serde type | Deserialized at build time via serde_json |
Supported attributes on #[derive(FunctionConfig)]:
| Attribute | Target | Description |
|---|---|---|
#[config(rename = "key")] | Field | Override the JSON property name (defaults to the Rust field name) |
#[config(default = <literal>)] | Field | Default string, integer, float, or boolean value when the JSON key is omitted |
#[config(default_json = "<json>")] | Field | Default JSON value (parsed from the string literal) when the key is omitted |
#[config(serde)] | Field | Deserialize the field at build time using serde_json (errors are reported as INPUT_PARSE) |
#[config(records)] | Field | Marks a field whose result is recorded on the message, forbidding {"secret": "..."} in that field |
#[output(default = "data.x")] | Output field | Default destination path when the key is omitted or null |
#[output(primary)] | Output field | Marks the primary output used by ctx.emit_default(value) when a config has multiple Output fields |
#[config(check = path::to::fn)] | Struct | Runs a custom fn(&Self, &mut CompileCx<'_>) validator after all fields compile |
At build time, config compilation collects all issues across all fields at once, reporting each at its exact path under tasks[i].function.input:
- Unrecognized keys are reported as
UNKNOWN_KEY(orLIKELY_TYPOfor near misses likepahtinstead ofpath). - Missing required fields or wrong types are reported as
INPUT_PARSE. - Literal paths outside
data,metadata, andtemp_dataare reported asINVALID_PATH. - Doc comments on struct fields are captured automatically as field descriptions in
Registry::catalogue().
After FunctionConfig compiles the struct, Function::prepare runs so your function instance can validate external references (such as checking cfg.connector against self.connectors). You can also validate declared References across all functions using a program::ResourceOracle configured on CompileOptions::resources.
2. The call Method and Ctx
Function::call receives &mut Ctx<'_> and &Self::Config, and returns Result<Outcome>. Its returned future must be Send.
Ctx Method | Description |
|---|---|
eval(&expr) | Evaluates an Expr<T> against the current context (borrows folded constants directly; evaluates dynamic expressions in a pooled arena). |
render(&doc) | Renders an Interpolated document template (serializable directly or convertible to OwnedDataValue via .to_owned()). |
get(&path) | Borrows the current value at path (including uncommitted writes made earlier in the same call). |
payload() | Borrows the message's read-only payload. |
secret("name") | Looks up a secret value from the engine's Secrets store. |
info() | Returns CallIdentity metadata: function, workflow_id, task_id, channel, routing_bucket, loop_counter, and element_index. |
emit(&output, value), emit_default(value) | Evaluates output (or the config's primary output) and writes value to that path. |
set(&path, value), remove(&path) | Writes or removes a value at path. |
record_error(record) | Appends an ErrorRecord to message.errors() (preserved even if call later returns Err). |
input() | Evaluates the config into an OwnedDataValue with all secret-reading expressions replaced by null (safe for layers and dry runs). |
resolve(&cfg) | Evaluates the entire config into an OwnedDataValue with secrets included (for use inside call). |
deadline(), cancel() | Returns the run's Instant deadline or CancelToken so your function can forward them to downstream I/O. |
How the engine handles the return value of call:
| Return Value | Audit Status | Recorded Error | Next Action |
|---|---|---|---|
Ok(Outcome::Ok) | 200 | None | Continue to the next task |
Ok(Outcome::Status(s)) (s < 400) | s | None | Continue to the next task |
Ok(Outcome::Status(s)) (400 <= s < 500) | s | None | Treated as a rejection: governed by on_rejection ("continue" by default) |
Ok(Outcome::Status(s)) (s >= 500) | s | TASK_STATUS_ERROR | Treated as a failure: governed by on_failure ("fail" by default) |
Ok(Outcome::Skip) | (none) | None | Skips audit entry and metadata.progress; continues to the next task |
Ok(Outcome::Halt) | 299 | None | Stops the current workflow (WorkflowExit::Halted); subsequent workflows still run |
Err(e) | 500 | e.code() (Task <id> error: <message>) | Treated as a failure (discards all context writes from this call) |
Transactional Overlay Writes
All writes made during a host function call (emit, emit_default, set, remove) are buffered in a per-call overlay:
- Your function can immediately read its own uncommitted writes via
ctx.getandctx.eval. - When
callreturnsOk(...), the engine commits those writes to the message context in order and records theirChangeentries on the audit trail. - When
callreturnsErr(...), the engine discards all context writes andChangeentries from that call, ensuring a failed function never leaves the message in a half-updated state (any explicit error records added viactx.record_errorare still preserved).
Note on context roots (data, metadata, temp_data):
- Root paths must always hold JSON objects; calling
ctx.setat"data"with a non-object returns aCONFIG_ERROR, and root paths cannot be removed. - Calling
ctx.setat a root replaces the root object (unlikemap, which merges keys into the root).
3. Registering and Running the Function
Use Engine::builder().register(name, func) (or Registry::register) to register your function. Built-in function names (and "validate") are reservedβregistering a reserved name with register panics; use Registry::try_register or def::is_reserved_function when registering dynamic plugin names.
use dataflow_rs::datavalue::OwnedDataValue; use dataflow_rs::expr::{CompileCx, Interpolated, Output}; use dataflow_rs::function::{FunctionConfig, FunctionSpec, Prepare, Reference, RetrySafety}; use dataflow_rs::program::{AuthoredPath, Severity}; use dataflow_rs::{Ctx, Error, Expr, Function, Issue, Outcome, Result}; use std::collections::BTreeMap; #[derive(FunctionConfig)] #[config(check = HttpCallConfig::rules)] struct HttpCallConfig { connector: String, #[config(default = "GET")] method: String, path: Expr<String>, #[config(default_json = "{}")] headers: BTreeMap<String, Expr<String>>, body: Option<Interpolated>, #[config(default = 30000)] timeout_ms: Expr<u64>, #[output(default = "data.response")] output: Output, } const METHODS: [&str; 5] = ["GET", "POST", "PUT", "PATCH", "DELETE"]; impl HttpCallConfig { fn rules(&self, cx: &mut CompileCx<'_>) { if !METHODS.contains(&self.method.as_str()) { cx.at("method", |cx| { let message = format!("`{}` is not one of {METHODS:?}", self.method); cx.issue(IssueCode::InputParse, message); }); } } } struct HttpCall { connectors: BTreeMap<String, String>, } impl HttpCall { fn new(connectors: &[(&str, &str)]) -> Self { Self { connectors: connectors .iter() .map(|(name, base)| (name.to_string(), base.to_string())) .collect(), } } } impl Function for HttpCall { type Config = HttpCallConfig; fn spec(&self) -> FunctionSpec { FunctionSpec::default() .with_category("io") .with_retry(RetrySafety::Unknown) .with_references(vec![Reference::new("connector", "connector")]) } fn prepare( &self, cfg: &mut HttpCallConfig, p: &Prepare<'_>, ) -> std::result::Result<(), Vec<Issue>> { if self.connectors.contains_key(&cfg.connector) { return Ok(()); } let message = format!( "task `{}` names connector `{}`, which this deployment does not have", p.task_id(), cfg.connector ); let issue = Issue::new( IssueCode::UnresolvedReference, AuthoredPath::root().join("connector"), message, ) .with_severity(Severity::Rejected); Err(vec![issue]) } async fn call(&self, ctx: &mut Ctx<'_>, cfg: &HttpCallConfig) -> Result<Outcome> { let base = &self.connectors[&cfg.connector]; let path = ctx.eval(&cfg.path)?; let timeout_ms = ctx.eval(&cfg.timeout_ms)?; let mut headers = BTreeMap::new(); for (name, value) in &cfg.headers { headers.insert(name.clone(), ctx.eval(value)?.into_owned()); } let body = match &cfg.body { Some(doc) => serde_json::to_value(ctx.render(doc)) .map_err(|e| Error::service("BODY_ENCODING", e.to_string()))?, None => Value::Null, }; let response = json!({ "status": 200, "url": format!("{base}{path}"), "method": cfg.method, "headers": headers.keys().collect::<Vec<_>>(), "body": body, "timeout_ms": timeout_ms, }); ctx.emit(&cfg.output, OwnedDataValue::from(&response))?; Ok(Outcome::Ok) } } use dataflow_rs::program::IssueCode; use dataflow_rs::{Engine, Message}; use serde_json::{Value, json}; #[tokio::main] async fn main() { let workflow = json!({ "id": "lookup", "tasks": [ { "id": "load", "function": {"name": "parse_json", "input": {"source": "payload", "target": "request"}} }, { "id": "fetch_user", "function": {"name": "http_call", "input": { "connector": "user_service", "path": {"cat": ["/users/", {"var": "data.request.user_id"}]}, "headers": { "Accept": "application/json", "X-Trace": {"cat": ["req-", {"var": "data.request.user_id"}]} }, "output": "data.user" }} }, { "id": "summarise", "function": {"name": "map", "input": {"mappings": [ {"path": "data.summary.user_url", "logic": {"var": "data.user.url"}} ]}} } ] }); let engine = Engine::builder() .register("http_call", HttpCall::new(&[("user_service", "https://users.internal")])) .workflow_json(&workflow) .build() .expect("the engine builds"); let mut message = Message::from_json(&json!({"user_id": 7})); let run = engine.run(&mut message).await; run.into_result().expect("the run completes"); let data = Value::from(message.context().data()); assert_eq!(data["user"]["url"], json!("https://users.internal/users/7")); assert_eq!(data["user"]["method"], json!("GET")); assert_eq!(data["user"]["headers"], json!(["Accept", "X-Trace"])); assert_eq!(data["user"]["timeout_ms"], json!(30000)); assert_eq!(data["summary"]["user_url"], json!("https://users.internal/users/7")); // Referencing an unknown connector fails at build time via `prepare`: let issues = Engine::builder() .register("http_call", HttpCall::new(&[("user_service", "https://users.internal")])) .workflow_json(&json!({"id": "w", "tasks": [ {"id": "fetch", "function": {"name": "http_call", "input": {"connector": "billing", "path": "/x"}}} ]})) .build() .unwrap_err(); let issue = issues .iter() .find(|i| i.code == IssueCode::UnresolvedReference) .expect("the connector is reported"); assert_eq!(issue.path.to_string(), "tasks[0].function.input.connector"); assert_eq!(issue.step_id.as_deref(), Some("fetch")); // Specifying an unsupported HTTP method fails at build time via `#[config(check = ...)]`: let issues = Engine::builder() .register("http_call", HttpCall::new(&[("user_service", "https://users.internal")])) .workflow_json(&json!({"id": "w", "tasks": [ {"id": "fetch", "function": {"name": "http_call", "input": { "connector": "user_service", "method": "FETCH", "path": "/x"}}} ]})) .build() .unwrap_err(); assert_eq!(issues[0].code, IssueCode::InputParse); assert_eq!(issues[0].path.to_string(), "tasks[0].function.input.method"); }
Fan-Out (for_each) on a Host Function
Adding a for_each block to a task executes its host function once per element of a source array:
- Each element call sees its item at
temp_data.<as>and its zero-based index attemp_data.<as>_index(andctx.info().element_index), isolated from sibling element calls. - Up to
max_concurrencycalls run concurrently, and their writes are folded into the message in element order once all calls finishβso changingmax_concurrencyaffects latency without changing the resulting message state. - When
collectandintoare configured, the value each call writes tocollectis gathered into an array atintoin element order.
use dataflow_rs::datavalue::OwnedDataValue; use dataflow_rs::expr::{CompileCx, Interpolated, Output}; use dataflow_rs::function::{FunctionConfig, FunctionSpec, Prepare, Reference, RetrySafety}; use dataflow_rs::program::{AuthoredPath, IssueCode, Severity}; use dataflow_rs::{Ctx, Error, Expr, Function, Issue, Outcome, Result}; use std::collections::BTreeMap; #[derive(FunctionConfig)] struct HttpCallConfig { connector: String, #[config(default = "GET")] method: String, path: Expr<String>, #[config(default_json = "{}")] headers: BTreeMap<String, Expr<String>>, body: Option<Interpolated>, #[config(default = 30000)] timeout_ms: Expr<u64>, #[output(default = "data.response")] output: Output, } struct HttpCall { connectors: BTreeMap<String, String>, } impl HttpCall { fn new(connectors: &[(&str, &str)]) -> Self { Self { connectors: connectors .iter() .map(|(name, base)| (name.to_string(), base.to_string())) .collect(), } } } impl Function for HttpCall { type Config = HttpCallConfig; async fn call(&self, ctx: &mut Ctx<'_>, cfg: &HttpCallConfig) -> Result<Outcome> { let base = &self.connectors[&cfg.connector]; let path = ctx.eval(&cfg.path)?; let timeout_ms = ctx.eval(&cfg.timeout_ms)?; let mut headers = BTreeMap::new(); for (name, value) in &cfg.headers { headers.insert(name.clone(), ctx.eval(value)?.into_owned()); } let body = match &cfg.body { Some(doc) => serde_json::to_value(ctx.render(doc)) .map_err(|e| Error::service("BODY_ENCODING", e.to_string()))?, None => Value::Null, }; let response = json!({ "status": 200, "url": format!("{base}{path}"), "method": cfg.method, "headers": headers.keys().collect::<Vec<_>>(), "body": body, "timeout_ms": timeout_ms, "element": ctx.info().element_index, }); ctx.emit(&cfg.output, OwnedDataValue::from(&response))?; Ok(Outcome::Ok) } } use dataflow_rs::{Engine, Message}; use serde_json::{Value, json}; #[tokio::main] async fn main() { let workflow = json!({ "id": "orders", "tasks": [ { "id": "load", "function": {"name": "parse_json", "input": {"source": "payload", "target": "request"}} }, { "id": "fetch_orders", "for_each": { "over": {"var": "data.request.order_ids"}, "as": "order_id", "max_concurrency": 4, "collect": "temp_data.order", "into": "data.orders" }, "function": {"name": "http_call", "input": { "connector": "orders", "path": {"cat": ["/orders/", {"var": "temp_data.order_id"}]}, "output": "temp_data.order" }} } ] }); let engine = Engine::builder() .register("http_call", HttpCall::new(&[("orders", "https://orders.internal")])) .workflow_json(&workflow) .build() .expect("the engine builds"); let mut message = Message::from_json(&json!({"order_ids": [11, 12, 13]})); engine.run(&mut message).await.into_result().expect("the run completes"); let data = Value::from(message.context().data()); let orders = data["orders"].as_array().expect("one result per element"); assert_eq!(orders.len(), 3); assert_eq!(orders[0]["url"], json!("https://orders.internal/orders/11")); assert_eq!(orders[2]["url"], json!("https://orders.internal/orders/13")); assert_eq!(orders[2]["element"], json!(2)); }
See Fan-Out (for_each) for complete details on element folding and error policies.
Middleware Layers (CallLayer)
A CallLayer wraps host function calls, receiving the call's Ctx and the remainder of the call chain (Next). A layer can inspect or modify ctx, time the call, delegate via next.run(ctx).await, or short-circuit with a synthetic response. (Built-in functions run inline and do not pass through CallLayers.)
You can attach layers at two scopes:
- Engine-wide:
EngineBuilder::layer(orRegistry::layer) wraps every host call across all runs. - Per-run:
RunOptions::layerswraps host calls for a single run, executing outside the registry's layers.
function::layers::Answer is a built-in helper layer for dry runs and tests: you pass a closure that inspects ctx (ctx.info(), ctx.input(), ctx.primary_output()) and returns Some(value) to write value to the primary output and short-circuit with Outcome::Ok, or None to pass the call through. Because ctx.input() redacts any field that references a secret to null, dry-run layers can safely log or record resolved task inputs without leaking credentials:
use dataflow_rs::expr::{CompileCx, Interpolated, Output}; use dataflow_rs::function::{FunctionConfig, FunctionSpec, Prepare, Reference, RetrySafety}; use dataflow_rs::program::{AuthoredPath, IssueCode, Severity}; use dataflow_rs::{Error, Expr, Function, Issue}; use std::collections::BTreeMap; #[derive(FunctionConfig)] struct HttpCallConfig { connector: String, #[config(default = "GET")] method: String, path: Expr<String>, #[config(default_json = "{}")] headers: BTreeMap<String, Expr<String>>, body: Option<Interpolated>, #[config(default = 30000)] timeout_ms: Expr<u64>, #[output(default = "data.response")] output: Output, } struct HttpCall { connectors: BTreeMap<String, String>, } impl HttpCall { fn new(connectors: &[(&str, &str)]) -> Self { Self { connectors: connectors .iter() .map(|(name, base)| (name.to_string(), base.to_string())) .collect(), } } } impl Function for HttpCall { type Config = HttpCallConfig; async fn call(&self, ctx: &mut Ctx<'_>, cfg: &HttpCallConfig) -> Result<Outcome> { let base = &self.connectors[&cfg.connector]; let path = ctx.eval(&cfg.path)?; let timeout_ms = ctx.eval(&cfg.timeout_ms)?; let mut headers = BTreeMap::new(); for (name, value) in &cfg.headers { headers.insert(name.clone(), ctx.eval(value)?.into_owned()); } let body = match &cfg.body { Some(doc) => serde_json::to_value(ctx.render(doc)) .map_err(|e| Error::service("BODY_ENCODING", e.to_string()))?, None => Value::Null, }; let response = json!({ "status": 200, "url": format!("{base}{path}"), "method": cfg.method, "headers": headers.keys().collect::<Vec<_>>(), "body": body, "timeout_ms": timeout_ms, }); ctx.emit(&cfg.output, OwnedDataValue::from(&response))?; Ok(Outcome::Ok) } } use dataflow_rs::datavalue::OwnedDataValue; use dataflow_rs::expr::{ExprEngine, Secrets}; use dataflow_rs::function::{BoxFuture, CallLayer, Next, layers::Answer}; use dataflow_rs::{Ctx, Engine, Message, Outcome, Registry, Result, RunOptions}; use serde_json::{Value, json}; use std::sync::Arc; use std::sync::atomic::{AtomicU64, Ordering}; use std::time::Instant; /// Middleware layer that counts and times host function calls. struct Metered { calls: Arc<AtomicU64>, nanos: Arc<AtomicU64>, } impl CallLayer for Metered { fn call<'a>(&'a self, ctx: &'a mut Ctx<'_>, next: Next<'a>) -> BoxFuture<'a, Result<Outcome>> { Box::pin(async move { let start = Instant::now(); let outcome = next.run(ctx).await; self.nanos.fetch_add(start.elapsed().as_nanos() as u64, Ordering::Relaxed); self.calls.fetch_add(1, Ordering::Relaxed); outcome }) } } #[tokio::main] async fn main() { // Configure a secret store so the Authorization header can read a token. let secrets = Secrets::new(OwnedDataValue::from(&json!({"user_service_token": "s3cr3t"}))) .expect("an object"); let exprs = ExprEngine::builder().secrets(secrets).build().expect("the expression engine builds"); let calls = Arc::new(AtomicU64::new(0)); let engine = Engine::builder() .registry(Registry::new(exprs)) .register("http_call", HttpCall::new(&[("user_service", "https://users.internal")])) .layer(Metered { calls: Arc::clone(&calls), nanos: Arc::new(AtomicU64::new(0)) }) .workflow_json(&json!({"id": "w", "tasks": [ {"id": "fetch", "function": {"name": "http_call", "input": { "connector": "user_service", "path": "/users/7", "headers": {"Authorization": {"cat": ["Bearer ", {"secret": "user_service_token"}]}}, "output": "data.user" }}} ]})) .build() .expect("the engine builds"); // 1. Live run: executes `HttpCall` inside the `Metered` layer. let mut message = Message::new(OwnedDataValue::Null); engine.run(&mut message).await.into_result().expect("the run completes"); assert_eq!(calls.load(Ordering::Relaxed), 1); let data = Value::from(message.context().data()); assert_eq!(data["user"]["headers"], json!(["Authorization"])); assert!(!message.to_json().to_string().contains("s3cr3t")); // 2. Dry run: `Answer` intercepts `http_call` and writes the resolved input // without calling `HttpCall` or inner registry layers. let dry_run: Vec<Arc<dyn CallLayer>> = vec![Arc::new(Answer::new(|ctx| { if ctx.info().function != "http_call" || ctx.primary_output().is_none() { return None; } let input = ctx.input().ok()?; Some(OwnedDataValue::from(&json!({"status": "dry run", "request": Value::from(&input)}))) }))]; let opts = RunOptions::default().layers(&dry_run); let mut message = Message::new(OwnedDataValue::Null); engine.run_with(&mut message, &opts).await.into_result().expect("the run completes"); let data = Value::from(message.context().data()); assert_eq!(data["user"]["status"], json!("dry run")); assert_eq!(data["user"]["request"]["path"], json!("/users/7")); assert_eq!(data["user"]["request"]["timeout_ms"], json!(30000)); // The secret-bearing header is automatically redacted to `null` in `ctx.input()`. assert_eq!(data["user"]["request"]["headers"]["Authorization"], Value::Null); // Because the per-run `Answer` layer short-circuited outside `Metered`, `calls` is still 1. assert_eq!(calls.load(Ordering::Relaxed), 1); }
Unit-Testing a Host Function (function::testing)
function::testing::call(func, input_json) lets you unit-test a single host function call directlyβrunning real config compilation, prepare, Ctx overlay buffering, and any layersβwithout needing to construct a full workflow or Engine.
- Chain
.message(msg),.layer(layer), or.name("name")on the builder, then.awaitit to get aCalledreport containingissues,outcome,writes,changes,errors, and the updatedmessage. - Use
function::testing::config(input_json)to test config compilation in isolation.
#![allow(unused)] fn main() { use dataflow_rs::expr::{CompileCx, Interpolated, Output}; use dataflow_rs::function::{FunctionConfig, FunctionSpec, Prepare, Reference, RetrySafety}; use dataflow_rs::program::{AuthoredPath, Severity}; use dataflow_rs::{Ctx, Error, Expr, Function, Issue, Result}; use std::collections::BTreeMap; #[derive(FunctionConfig)] struct HttpCallConfig { connector: String, #[config(default = "GET")] method: String, path: Expr<String>, #[config(default_json = "{}")] headers: BTreeMap<String, Expr<String>>, body: Option<Interpolated>, #[config(default = 30000)] timeout_ms: Expr<u64>, #[output(default = "data.response")] output: Output, } struct HttpCall { connectors: BTreeMap<String, String>, } impl HttpCall { fn new(connectors: &[(&str, &str)]) -> Self { Self { connectors: connectors .iter() .map(|(name, base)| (name.to_string(), base.to_string())) .collect(), } } } impl Function for HttpCall { type Config = HttpCallConfig; fn prepare( &self, cfg: &mut HttpCallConfig, p: &Prepare<'_>, ) -> std::result::Result<(), Vec<Issue>> { if self.connectors.contains_key(&cfg.connector) { return Ok(()); } let message = format!( "task `{}` names connector `{}`, which this deployment does not have", p.task_id(), cfg.connector ); let issue = Issue::new( IssueCode::UnresolvedReference, AuthoredPath::root().join("connector"), message, ) .with_severity(Severity::Rejected); Err(vec![issue]) } async fn call(&self, ctx: &mut Ctx<'_>, cfg: &HttpCallConfig) -> Result<Outcome> { let base = &self.connectors[&cfg.connector]; let path = ctx.eval(&cfg.path)?; let timeout_ms = ctx.eval(&cfg.timeout_ms)?; let mut headers = BTreeMap::new(); for (name, value) in &cfg.headers { headers.insert(name.clone(), ctx.eval(value)?.into_owned()); } let body = match &cfg.body { Some(doc) => serde_json::to_value(ctx.render(doc)) .map_err(|e| Error::service("BODY_ENCODING", e.to_string()))?, None => Value::Null, }; let response = json!({ "status": 200, "url": format!("{base}{path}"), "method": cfg.method, "headers": headers.keys().collect::<Vec<_>>(), "body": body, "timeout_ms": timeout_ms, }); ctx.emit(&cfg.output, OwnedDataValue::from(&response))?; Ok(Outcome::Ok) } } use dataflow_rs::datavalue::OwnedDataValue; use dataflow_rs::function::testing; use dataflow_rs::program::IssueCode; use dataflow_rs::{Message, Outcome}; use serde_json::{Value, json}; tokio::runtime::Builder::new_current_thread().build().unwrap().block_on(async { let http_call = || HttpCall::new(&[("user_service", "https://users.internal")]); // 1. Test a single call against a seeded message: let input = json!({ "connector": "user_service", "method": "POST", "path": {"cat": ["/users/", {"var": "data.id"}]}, "body": {"include": ["orders"], "since": {"var": "data.since"}} }); let data = OwnedDataValue::from(&json!({"id": 7, "since": "2026-01-01"})); let called = testing::call(http_call(), input) .message(Message::from_parts(OwnedDataValue::Null, data, OwnedDataValue::Null)) .await; assert!(called.issues.is_empty(), "{:?}", called.issues); assert_eq!(called.outcome.unwrap(), Outcome::Ok); assert_eq!(called.writes.len(), 1); let at = "data.response".parse().unwrap(); let response = Value::from(called.message.context().get(&at).unwrap()); assert_eq!(response["url"], json!("https://users.internal/users/7")); assert_eq!(response["method"], json!("POST")); assert_eq!(response["body"], json!({"include": ["orders"], "since": "2026-01-01"})); // 2. Test an invalid config (reports `LikelyTypo` for `paht` and `InputParse` for missing `path`): let called = testing::call(http_call(), json!({"connector": "user_service", "paht": "/x"})).await; let codes: Vec<IssueCode> = called.issues.iter().map(|i| i.code).collect(); assert_eq!(codes, [IssueCode::LikelyTypo, IssueCode::InputParse]); assert!(called.outcome.is_err()); assert!(called.writes.is_empty()); // 3. Test config compilation and default values on their own: let cfg: HttpCallConfig = testing::config(json!({"connector": "user_service", "path": "/x"})).ok().unwrap(); assert_eq!(cfg.method, "GET"); assert_eq!(cfg.timeout_ms.constant(), Some(&30000)); assert_eq!(cfg.output.constant().unwrap().to_string(), "data.response"); }); }
Dynamic Plugins (Templated Configs)
If a function's configuration schema is loaded dynamically at runtime (for example, from a plugin manifest rather than a compile-time Rust struct), set type Config = function::Templated and declare its fields via FunctionSpec::with_runtime_fields(vec![FieldSpec::...]). The engine will validate and compile the fields according to their declared FieldKinds and allow call to evaluate them by field name. See the Custom Functions guide for an example.
Next Steps
- Custom Functions β Deep dive into config types,
Interpolateddocuments, and dynamic plugins - Fan-Out (
for_each) β Concurrent per-element execution and folding rules - Secrets β Working with secrets safely inside host functions
- API Reference β Complete type and method reference
WebAssembly Package
The @goplasmatic/dataflow-wasm package is the dataflow-rs 4.0 rules engine compiled to WebAssembly (wasm32-unknown-unknown). It provides a WasmEngine class that runs messages through JSON workflow definitions in browsers and Node.js using the exact same engine and wire types as a Rust backend.
Installation
npm install @goplasmatic/dataflow-wasm
Quick Start
import init, { WasmEngine } from '@goplasmatic/dataflow-wasm';
// Initialize the WebAssembly module once before calling any other function.
await init();
const engine = new WasmEngine([
{
id: 'greeting',
tasks: [
{
// Copy `payload` into `data.input` so JSONLogic expressions can access it.
id: 'parse',
function: { name: 'parse_json', input: { source: 'payload', target: 'input' } },
},
{
id: 'greet',
function: {
name: 'map',
input: {
mappings: [{ path: 'data.greeting', logic: { cat: ['Hello, ', { var: 'data.input.name' }] } }],
},
},
},
],
},
]);
const { message, run } = await engine.run({ name: 'World' });
console.log(run.status); // "completed"
// Narrow the `JsonValue` context root to read typed properties under strict TypeScript.
const data = message.context.data as { greeting: string };
console.log(data.greeting); // "Hello, World"
Data crosses the JS/WASM boundary as native JavaScript values rather than serialized JSON strings: you can pass workflow definitions as an array of objects (or a JSON string), pass any JSON value as the payload, and receive { message, run, trace? } as typed JavaScript objects.
Two important notes about payload:
- Objects stay objects; strings stay strings. A
parse_jsontask readingpayloadcopies an object directly and parses a JSON string, so the same workflow handles both seamlessly. payloadis separate from the JSONLogic evaluation context. Conditions and mappings evaluate against{ data, metadata, temp_data }, so{"var": "payload.x"}always resolves tonull. Either copy the payload intodatawithparse_json, or seeddatadirectly viaRunOptions:engine.run(null, { data: { name: 'World' } }).
API Reference
new WasmEngine(workflows, options?)
class WasmEngine {
/**
* Compiles workflow definitions (passed as a JSON string or an array of objects).
* `options.secrets`: optional secret store read via `{"secret": "name"}`.
* Throws an `EngineError` with `code: "REJECTED"` and `issues` when any
* definition is invalid, or `code: "INVALID_ARGUMENT"` when an argument is malformed.
*/
constructor(workflows: string | JsonValue[], options?: EngineOptions);
/** Runs a message through the engine; resolves on every run status with `{ message, run, trace? }`. */
run(payload: JsonValue | undefined, options?: RunOptions): Promise<RunResult>;
/** Validates a single workflow definition against this engine and returns all issues. */
check(definition: JsonValue): Issue[];
/** Returns the number of compiled workflows in the engine. */
workflow_count(): number;
/** Returns the IDs of all compiled workflows in priority execution order. */
workflow_ids(): string[];
/** Frees the WebAssembly memory held by this engine instance. */
free(): void;
}
Configuring Secrets
const engine = new WasmEngine(workflows, {
secrets: { api_key: 'k-123' },
});
Workflows read secrets using {"secret": "api_key"}. Secret values are held inside the engine and never enter the Message, so they cannot leak into serialized messages, traces, or error records. Referencing an unknown secret name or attempting to write a secret into the message causes new WasmEngine to reject the definition at build time. See Secrets for details.
Handling Rejected Definitions
When a workflow definition fails validation, new WasmEngine throws an EngineErrorβa JavaScript Error with code: "REJECTED" and an issues array containing every problem at its exact JSON path:
import type { EngineError } from '@goplasmatic/dataflow-wasm';
try {
new WasmEngine(workflows);
} catch (e) {
const err = e as EngineError;
if (err.code === 'REJECTED') {
for (const issue of err.issues ?? []) {
console.error(issue.severity, issue.code, 'at', issue.path, ':', issue.message);
// "REJECTED UNKNOWN_FUNCTION at tasks[0].function.name : ..."
}
}
}
Each Issue has the shape { code, severity, path, step_kind, step_id, field, message }, where severity is "REJECTED", "DEFECT", or "ADVISORY" and path is the authored JSON path (such as "tasks[1].tasks[0].id"). Advisories do not prevent a definition from loading on their own, though they are included in issues when another issue rejects the definition. See Authoring-Time Validation for all issue codes.
engine.run(payload, options?)
Runs a message through the engine and resolves with { message, run, trace? } on every run status. The promise rejects only if passed a malformed argument ("INVALID_ARGUMENT"); runs that stop on a task failure or hit a timeout still resolve normally with run.status describing the outcome.
interface RunOptions {
id?: string; // Message ID (defaults to a generated UUID v7)
data?: JsonValue; // Initial `data` root object (ignored if not an object)
metadata?: JsonValue; // Initial `metadata` root object (ignored if not an object)
channel?: string; // Run only active workflows in this channel (runs all active workflows when omitted)
routing_bucket?: number; // Canary rollout bucket from 0 to 99 (all rollouts match when omitted)
capture_changes?: boolean; // Record old/new values in audit trail entries (true by default)
audit?: 'full' | 'off' | { last: number }; // Audit retention policy ("full" by default)
trace?: boolean | TraceOptions; // Capture an execution trace (false by default)
timeout_ms?: number; // Execution deadline in milliseconds; ends the run as "timed_out" if exceeded
}
interface TraceOptions {
timings?: boolean; // Record per-step execution time in `elapsed_us` (false by default)
changes?: boolean; // Record per-step context diffs so the trace can be replayed (true by default)
redact?: string[]; // Context paths whose values are replaced with `null` in the trace
max_bytes?: number; // Maximum byte budget for trace changes (sets `trace.truncated` when exceeded)
}
interface RunResult {
message: Message; // The message after execution
run: Run; // Execution status and per-workflow summary
trace?: Trace; // Execution trace (present when `trace` was enabled)
}
timeout_ms enforces a per-run deadline checked at every loop iteration and every 64th expression operation. If exceeded, the run finishes with status: "timed_out", retryable: true, partial: true, and the partial message and trace up to that point.
Message Shape
interface Message {
id: string;
payload: JsonValue; // The input payload passed to `run`, unchanged
context: {
data: JsonValue;
metadata: JsonValue;
temp_data: JsonValue;
};
audit_trail: AuditEntry[]; // One entry per executed task (subject to `audit` retention)
errors: ErrorRecord[]; // All error records added during the run
}
Note that data, metadata, and temp_data are nested under message.context. At the start of every run, the engine stamps metadata.processed_at and metadata.engine_version (plus metadata.channel when a channel is specified), and updates metadata.progress ({ workflow_id, task_id, status_code }) after each task.
Run Shape
interface Run {
status: 'completed' | 'completed_with_errors' | 'stopped' | 'timed_out' | 'cancelled';
error?: DataflowError; // Present only when status is "stopped"
retryable: boolean; // True for timeouts and retryable stop errors
partial: boolean; // True if the run ended before evaluating all workflows
matched: number; // Number of workflows that matched and ran
workflows: WorkflowRecord[]; // Per-workflow execution records in priority order
}
interface WorkflowRecord {
id: string;
index: number;
selection: 'ran' | 'skipped_by_condition' | 'condition_failed' | 'skipped_by_rollout';
error?: DataflowError; // Present only when selection is "condition_failed"
sweeps: number; // 1 for normal workflows, or the iteration count for a loop
exit: 'completed' | 'halted' | 'failed' | 'stopped' | null; // Null when not selected
}
interface DataflowError {
code: string;
message: string;
retryable: boolean;
}
completed_with_errors: Every matching workflow finished, and one or more tasks recorded errors onmessage.errors(such as failedvalidationrules or tasks configured withon_failure: "continue").stopped: An unhandled task failure stopped the run early (run.errorcontains the stopping error).
Inspecting a Failed Run
const { message, run, trace } = await engine.run(payload, { trace: true });
if (run.status === 'stopped' && trace) {
console.error(run.error?.code, run.error?.message);
const failed = trace.steps.find((step) => step.result === 'failed');
if (failed) {
console.error('in', failed.workflow_id, failed.task_id, failed.errors);
}
}
for (const record of run.workflows) {
console.log(record.id, record.selection, record.exit);
}
// Errors from tasks that continued (e.g., validation rejections) are on `message.errors`.
console.log(message.errors);
engine.check(definition)
Parses and validates a single workflow definition against this engine's functions and secrets, returning all issues (including advisories) at their authored paths. An empty array means the definition is valid and ready to load:
const issues = engine.check({
id: 'draft',
tasks: [{ id: 't', function: { name: 'nope', input: {} } }],
});
// [{ code: "UNKNOWN_FUNCTION", severity: "REJECTED", path: "tasks[0].function.name", ... }]
Module-Level Functions
import init, {
engine_version,
engine_facts,
parse,
contexts,
mapping_contexts,
TraceReplay,
} from '@goplasmatic/dataflow-wasm';
/** Initializes the WASM module. Call once before any other API. */
await init();
/** Returns the semantic version of the compiled engine (e.g., "4.0.0"). */
engine_version(): string;
/** Returns engine metadata so UIs and linters do not need to hardcode schema constants. */
engine_facts(): {
version: string;
builtin_functions: string[];
max_group_depth: number;
loop_defaults: { init: number; increment: number };
for_each_defaults: { max_concurrency: number };
definition_keys: {
workflow: string[];
task: string[];
group: string[];
loop: string[];
for_each: string[];
rollout: string[];
function: string[];
};
};
/** Parses and normalizes a definition into 4.0 format and returns all parse issues. */
parse(definition: JsonValue): { definition: JsonValue | null; issues: Issue[] };
/** Reconstructs the context after each step of a trace. */
contexts(trace: Trace): Context[];
/** Reconstructs the context after each individual mapping of a `map` step. */
mapping_contexts(trace: Trace, step: number): Context[];
parse(definition)rewrites legacy 3.x keys (continue_on_error: trueβon_failure: "continue",halt_on: "failure"βon_rejection: "halt",validateβvalidation) and reports aV3_SPELLINGadvisory at each 3.x key. Unrecognized keys are moved intoextand reported. If the definition is rejected,definitionisnull.- Compare
engine_version()against the version your frontend expects so you fail early if an outdated WASM binary is loaded.
Execution Traces and Replay
A Trace has the shape { initial, steps, truncated }: the initial context before any workflow ran (with any configured redact paths replaced by null), an ordered list of TraceStep records, and a boolean truncated flag indicating whether max_bytes was exceeded. Rather than storing full message snapshots at every step, each step stores its changes:
interface TraceStep {
workflow: number; // Workflow index in program order
workflow_id: string;
task: number | null; // Null for workflow-level steps (skips, condition failures, engine writes)
task_id: string | null;
result: 'executed' | 'skipped' | 'failed';
status: number | null; // HTTP-style status code (null for skips and engine steps)
changes: Change[]; // Ordered context diffs produced by this step
mapping_ends?: number[]; // For `map` steps: end index in `changes` for each mapping
errors?: ErrorRecord[]; // Error records added during this step
elapsed_us?: number; // Step duration in microseconds (when `timings: true`)
loop_counter?: number; // Loop iteration counter (inside a loop)
element_index?: number; // Fan-out element index (inside a `for_each` task)
}
Replaying Contexts
Use contexts and mapping_contexts (or TraceReplay) to reconstruct the context at any step:
import { contexts, mapping_contexts } from '@goplasmatic/dataflow-wasm';
const { trace } = await engine.run(payload, { trace: true });
if (!trace) throw new Error('the run was not traced');
const after = contexts(trace); // One Context per step, in order
trace.initial; // Context before step 0
after[0]; // Context after step 0
after.at(-1); // Final context (matches message.context unless trace.truncated)
for (const [i, step] of trace.steps.entries()) {
if (step.mapping_ends) {
// Reconstruct the context after each individual mapping in a `map` step:
const perMapping = mapping_contexts(trace, i);
}
}
contexts and mapping_contexts validate the trace structure before replaying and throw code: "INVALID_TRACE" if a stored trace is malformed.
For long traces or interactive step debuggers, use TraceReplay to retain a single working context in WASM memory instead of materializing an array of snapshots for every step:
import { TraceReplay } from '@goplasmatic/dataflow-wasm';
const replay = new TraceReplay(trace);
try {
const before = replay.context_at(-1); // initial context
const after = replay.context_at(0); // context after step 0
const mapping = replay.mapping_context_at(0, 0); // after step 0's first mapping (or null)
} finally {
replay.free();
}
Seeking forward applies only the intervening step diffs, while seeking backward replays from trace.initial. Each call returns an independent JavaScript object.
TypeScript Types
dataflow_wasm.d.ts provides end-to-end TypeScript definitions for all wire types generated from the Rust crate (Message, Context, Change, AuditEntry, ErrorRecord, Trace, TraceStep, StepResult, Run, RunStatus, WorkflowRecord, Selection, WorkflowExit, DataflowError, Issue, Severity, JsonValue) as well as the WASM package's own types (WasmEngine, TraceReplay, EngineOptions, RunOptions, TraceOptions, AuditMode, RunResult, EngineError, EngineFacts, ParseResult). Because a Rust backend serializes the exact same wire formats, you can use these types for server responses as well.
Migrating from 3.x
| 3.x | 4.0 |
|---|---|
new WasmEngine(workflowsJson) | Unchanged (also accepts an array of workflow objects) |
WasmEngine.with_secrets(workflowsJson, secretsJson) | new WasmEngine(workflows, { secrets }) |
engine.process(payload) resolving to a JSON string | engine.run(payload) resolving to { message, run } objects |
engine.process_with_trace(payload) | engine.run(payload, { trace: true }) (result.trace) |
process_message(workflowsJson, payload) | Construct a WasmEngine and call engine.run(payload) |
engine.workflow_ids() returning a JSON string | Returns string[] |
| Payload coerced to a string | Accepts any JSON value; strings stay strings |
| Failed runs rejected the promise with a string | Promise resolves; inspect run.status and run.error |
| Invalid definitions threw a string | Throws an EngineError with code and issues |
Per-step message snapshots in traces | Reconstruct via contexts(trace) and mapping_contexts(trace, step) |
Trace step duration_us and started_at | elapsed_us when run with trace: { timings: true } |
trace.truncated omitted when false | Always present as a boolean |
Enabled JSONLogic Operators
@goplasmatic/dataflow-wasm is compiled with the datetime feature and ExprEngineBuilder::all_operators(), enabling ExtString, ExtArray, ExtObject, ExtMath, ExtControl, ErrorHandling, and Datetime out of the box (Tensor is excluded because operator names like shape, cast, and concat collide with common JSON keys).
In Rust, ExprEngine::default() enables only core JSONLogic operators by default. If you share workflows between @goplasmatic/dataflow-wasm and a Rust backend, enable the same operator families on ExprEngineBuilder in your Rust service so expressions evaluate identically in both environments. See JSONLogic β Operator Families.
Building from Source
Requirements:
- Rust 1.98+
wasm-pack
cd wasm
wasm-pack test --node # run tests on wasm32 under Node.js
wasm-pack build --target web --out-dir pkg # build package into wasm/pkg/
node scripts/verify-wasm.mjs pkg # verify WASM features and .d.ts exports
Browser Compatibility
The published WebAssembly binary is compiled with WebAssembly reference types, bulk memory, non-trapping float-to-int, and sign-extension instructions enabled:
- Chrome / Edge 96+
- Firefox 79+
- Safari 15+
Next Steps
- UI Package β React visualization and step-by-step debugging components
- Built-in Functions β Reference for
map,validation, and other built-ins
UI Package
The @goplasmatic/dataflow-ui package provides React components for visualizing and debugging dataflow-rs 4.0 workflows: an interactive tree of workflows, tasks, and conditions; a flow diagram for each workflow; and a step-by-step debugger that runs messages through the engine and replays execution traces.
Installation
npm install @goplasmatic/dataflow-ui
Peer Dependencies
npm install react react-dom
Supports React 18.x and 19.x.
Quick Start
import { WorkflowVisualizer } from '@goplasmatic/dataflow-ui';
import '@goplasmatic/dataflow-ui/styles.css';
const workflows = [
{
id: 'my-workflow',
name: 'My Workflow',
tasks: [
{
id: 'task-1',
name: 'Transform Data',
function: {
name: 'map',
input: {
mappings: [
{ path: 'data.output', logic: { var: 'data.input' } }
]
}
}
}
]
}
];
function App() {
return (
<WorkflowVisualizer
workflows={workflows}
theme="system"
onTaskSelect={(task, workflow) => {
console.log('Selected task:', task.name);
}}
/>
);
}
Viewing workflow definitions does not require an engine instance; interactive debugging does (see Debug Mode).
Components
WorkflowVisualizer
The primary component for browsing, visualizing, and debugging workflows.
interface WorkflowVisualizerProps {
/** Array of workflow definitions to display */
workflows: Workflow[];
/** Initial selection when mounted in uncontrolled mode (default: none) */
initialSelection?: InitialVisualizerSelection;
/** Controlled selection state */
selection?: VisualizerSelection | null;
/** Callback fired when the selection changes */
onSelectionChange?: (selection: VisualizerSelection | null) => void;
/** Callback fired when a workflow is selected */
onWorkflowSelect?: (workflow: Workflow) => void;
/** Callback fired when a task is selected */
onTaskSelect?: (task: Task, workflow: Workflow) => void;
/** Color theme: 'light', 'dark', or 'system' */
theme?: Theme;
/** Additional CSS class for the root container */
className?: string;
/** Optional processed message to display in the result panel */
executionResult?: Message | null;
/** Integrated step-by-step debugger configuration; see Debug Mode below */
debugConfig?: DebugConfig;
/** Input payload for debugging (overrides debugConfig.initialPayload) */
debugPayload?: Record<string, unknown>;
}
TreeView
Standalone tree navigation component for custom layouts:
import { TreeView } from '@goplasmatic/dataflow-ui';
<TreeView
workflows={workflows}
selection={currentSelection}
onSelect={handleSelect}
debugMode={false}
/>
Debug Mode
Pass debugConfig to WorkflowVisualizer to enable integrated step-by-step execution and trace playback. When debugConfig.enabled is true, WorkflowVisualizer sets up its own DebuggerProvider and renders playback controls in the header:
import { WorkflowVisualizer, defaultEngineFactory } from '@goplasmatic/dataflow-ui';
function DebugView() {
return (
<WorkflowVisualizer
workflows={workflows}
debugConfig={{
enabled: true,
engineFactory: defaultEngineFactory,
autoExecute: true,
onExecutionComplete: ({ message, run, trace }) => console.log(run.status, trace?.steps.length, 'steps'),
onExecutionError: (error) => console.error(error.code, error.message, error.issues),
}}
debugPayload={{ greeting: 'hello' }}
/>
);
}
engineFactoryprovides the engine used to execute messages (whenundefined, the Run button stays disabled).defaultEngineFactoryuses@goplasmatic/dataflow-wasm.debugPayloadis passed to the engine as the messagepayload. Becausepayloadis separate from{ data, metadata, temp_data }, your workflow should start with aparse_jsontask ({"source": "payload", "target": "input"}) so expressions can readdata.input.*.
Initializing the WASM Module
Before running workflows or replaying traces, you must initialize @goplasmatic/dataflow-wasm once by awaiting its default export (initWasm()). This is required both when using defaultEngineFactory and when using a custom server engine, because the debugger replays trace diffs locally through @goplasmatic/dataflow-wasm's TraceReplay.
Pass undefined for engineFactory until initWasm() resolves so the Run button remains disabled until the module is ready:
import { useEffect, useState } from 'react';
import initWasm from '@goplasmatic/dataflow-wasm';
import { defaultEngineFactory } from '@goplasmatic/dataflow-ui';
function useEngineFactory() {
const [ready, setReady] = useState(false);
useEffect(() => {
initWasm().then(() => setReady(true));
}, []);
return ready ? defaultEngineFactory : undefined;
}
DebugConfig and ExecutionError
interface DebugConfig {
enabled: boolean;
engineFactory?: EngineFactory;
initialPayload?: Record<string, unknown>;
autoExecute?: boolean; // default: false
onExecutionComplete?: (result: RunResult) => void;
onExecutionError?: (error: ExecutionError) => void;
}
interface ExecutionError {
code: string; // "REJECTED", "INVALID_ARGUMENT", "INVALID_TRACE", or "ERROR"
message: string;
issues?: Issue[]; // Present when `code === "REJECTED"`: all definition issues with JSON paths
}
onExecutionCompletefires whenever a run finishesβeven if it stopped on an error or timed out (result.run.statusindicates how the run ended).onExecutionErrorfires when a run could not start (for example, if a definition was rejected with"REJECTED", an argument was malformed, or a custom engine returned an invalid trace).
What the Debugger Shows
A dataflow-rs 4.0 trace stores initial (the context before any workflow ran) and one TraceStep per task call, skip, failure, or engine write along with its changes. Instead of storing full message snapshots at every step, the UI reconstructs the context on demand using a TraceReplay cursor from @goplasmatic/dataflow-wasm:
- Toolbar β Displays
run.statusand any errors on the final message. - Step Context β
contextAt(i)reconstructs the context after stepi(andtrace.initialat index-1before the first step), displayed alongside the step's status code and error records. - Failed & Skipped Steps β Steps with
result: "failed"or executed steps that recorded errors (such as failedvalidationrules) are highlighted as errors. Workflows skipped due to a false condition or rollout mismatch are shown as skipped. - Per-Mapping Inspection β For
maptasks,mappingContextAt(i, m)displays the context after each individual mapping. - Final State β Shows the final message's
errorsandaudit_trail.
If a trace exceeded its max_bytes budget (trace.truncated === true), diffs are incomplete and traceIsReplayable(trace) returns false; the state panel displays trace.initial and notes that the trace was truncated.
Using debugConfig vs. DebuggerProvider
WorkflowVisualizer enables debug mode only when debugConfig.enabled is true, creating its own internal DebuggerProvider. Wrapping WorkflowVisualizer in an external <DebuggerProvider> without setting debugConfig will not enable debug mode on the visualizer, and any external debug panels placed outside WorkflowVisualizer will not share state with the visualizer's internal provider.
- For standard use, pass
debugConfigtoWorkflowVisualizerand use its built-in toolbar and panels. - For a completely custom layout, compose the standalone components (
TreeView,WorkflowFlowView,DebuggerControls,MessageInputPanel,MessageStatePanel) inside a single<DebuggerProvider>.
Engine Version Check
When WasmEngineAdapter is constructed, it calls assertEngineVersion() and throws an error if the loaded @goplasmatic/dataflow-wasm module is older than the @goplasmatic/dataflow-ui build. Newer WASM versions are accepted.
Custom Engine (Server-Side Execution)
To run workflows on a Rust backend (for example, when workflows invoke custom server-side host functions) while stepping through the resulting trace in the UI, implement DataflowEngine:
import type { RunOptions, RunResult } from '@goplasmatic/dataflow-wasm';
interface DataflowEngine {
/** Execute a message and resolve with `{ message, run, trace }`. */
run(payload: unknown, options?: RunOptions): Promise<RunResult>;
/** Release any resources when workflows change or the debugger unmounts. */
dispose?(): void;
}
type EngineFactory = (workflows: Workflow[]) => DataflowEngine;
Example implementation calling a backend /run endpoint:
import type { RunOptions, RunResult } from '@goplasmatic/dataflow-wasm';
import {
WorkflowVisualizer,
type DataflowEngine,
type EngineFactory,
type Workflow,
} from '@goplasmatic/dataflow-ui';
class ServerEngine implements DataflowEngine {
constructor(private workflows: Workflow[]) {}
async run(payload: unknown, options?: RunOptions): Promise<RunResult> {
const response = await fetch('/run', {
method: 'POST',
headers: { 'content-type': 'application/json' },
// Always request a trace unless the caller explicitly disabled it.
body: JSON.stringify({ workflows: this.workflows, payload, options: { trace: true, ...options } }),
});
// Rust `Message`, `Run`, and `Trace` serialize directly to the expected wire types.
return (await response.json()) as RunResult;
}
dispose() {}
}
const serverEngineFactory: EngineFactory = (workflows) => new ServerEngine(workflows);
function CustomDebugView() {
return (
<WorkflowVisualizer
workflows={workflows}
debugConfig={{ enabled: true, engineFactory: serverEngineFactory, autoExecute: true }}
debugPayload={{ input: 'test' }}
/>
);
}
Requirements for a custom DataflowEngine:
runmust resolve on every run status (including"stopped"and"timed_out") with{ message, run, trace }.- If workflow definitions are rejected, throw an
EngineErrorwithcode: "REJECTED"andissuesso the UI can display them. @goplasmatic/dataflow-wasmmust still be initialized withinitWasm(), as the UI uses itsTraceReplaycursor to replay the trace.
DebuggerControls and useDebugger
For custom layouts under a <DebuggerProvider>, use <DebuggerControls /> and the useDebugger() hook:
import { useDebugger } from '@goplasmatic/dataflow-ui';
function MyComponent() {
const {
state,
currentStep,
currentContext,
contextAt,
mappingContextAt,
stepForward,
stepBackward,
goToStep,
play,
pause,
reset,
} = useDebugger();
// state: DebuggerState
// trace: Trace | null Trace of the last run
// run: Run | null Run status and per-workflow summary
// message: Message | null Final message after the run
// replay: TraceReplay | null On-demand WASM trace replay cursor
// currentStepIndex: number Current step index (-1 is initial context)
// executionError: ExecutionError | null
// isExecuting, playbackState, playbackSpeed, inputPayload, skipFailedConditions
const step = currentStep;
const context = currentContext; // or contextAt(i) for any step (-1 is initial)
// ...
}
useDebugger() throws if called outside a DebuggerProvider; use useDebuggerOptional() (which returns null outside a provider) for components that render in both static and debug modes.
Workflow and Task Definition Types
Workflow and Task support both 4.0 policy fields (on_failure, on_rejection) and legacy 3.x fields (continue_on_error, halt_on). Unrecognized keys are preserved in ext (for example, the folder tree checks workflow.ext?.path ?? workflow.path):
interface Workflow {
on_failure?: 'fail' | 'continue'; // "fail" (default) stops the run; "continue" moves to the next workflow
continue_on_error?: boolean; // 3.x equivalent: true maps to on_failure: "continue"
ext?: Record<string, unknown>; // Unrecognized keys preserved by the parser (e.g., `path`)
// ...
}
interface Task {
on_failure?: 'fail' | 'halt' | 'continue'; // "fail" escalates to workflow policy; "halt" ends current workflow
on_rejection?: 'continue' | 'halt'; // Behavior on 4xx rejection (e.g., failed validation); "continue" by default
continue_on_error?: boolean; // 3.x equivalent
halt_on?: 'never' | 'failure'; // 3.x equivalent
ext?: Record<string, unknown>;
// ...
}
Helper functions (taskContinuesOnFailure, taskHaltsOnFailure, workflowContinuesOnFailure) read policies from either 4.0 or 3.x fields, and built-in function metadata is loaded dynamically from engine_facts().
Theming
WorkflowVisualizer supports 'light', 'dark', and 'system' themes:
// Light theme
<WorkflowVisualizer workflows={workflows} theme="light" />
// Dark theme
<WorkflowVisualizer workflows={workflows} theme="dark" />
// System preference (default)
<WorkflowVisualizer workflows={workflows} theme="system" />
Access or update the current theme from custom components with useTheme():
import { useTheme } from '@goplasmatic/dataflow-ui';
function MyComponent() {
const { theme, setTheme, resolvedTheme } = useTheme();
// resolvedTheme is always 'light' or 'dark'
}
Package Exports
Components
| Export | Description |
|---|---|
WorkflowVisualizer | Main visualization component with optional integrated debugger |
TreeView | Standalone workflow and task tree view |
RulesListView | Flat list view of workflows |
WorkflowFlowView | Interactive flow diagram for a single workflow |
WorkflowCard, TaskRow | Card and row UI primitives |
FunctionTypeBadge, ConditionBadge | Badges displaying a task's function type and condition |
DebuggerControls | Standalone playback controls |
IntegratedDebugToolbar | Toolbar used inside WorkflowVisualizer debug mode |
MessageInputPanel, MessageStatePanel | Input payload editor and context state inspector panels |
DebugInfoBubble, DebugStateBadge | Per-node execution status indicators |
JsonViewer, SearchInput, ErrorBoundary | Shared UI building blocks |
Providers and Hooks
| Export | Description |
|---|---|
ThemeProvider, useTheme | Theme context provider and hook |
DebuggerProvider, useDebugger | Debugger state provider and hook |
useDebuggerOptional | Same as useDebugger, but returns null outside a DebuggerProvider |
useTreeNodeDebugState | Debug state hook for any tree node |
useWorkflowDebugState, useWorkflowConditionDebugState | Per-workflow debug state hooks |
useTaskDebugState, useTaskConditionDebugState | Per-task debug state hooks |
Engine Adapters
| Export | Description |
|---|---|
WasmEngineAdapter | Default DataflowEngine implementation backed by @goplasmatic/dataflow-wasm |
defaultEngineFactory | Factory function returning a WasmEngineAdapter |
assertEngineVersion | Verifies that the loaded WASM module version is compatible with this UI build |
engineFacts | Cached engine_facts() metadata from the WASM module (available after initWasm()) |
DataflowEngine, EngineFactory | Interfaces for custom engine implementations |
Helpers and Types
- Steps:
isTaskGroup,groupMembers,flattenSteps,countLeafSteps - Folders:
workflowFolderPath - Functions:
isBuiltinFunction,getFunctionDisplayInfo - Policies:
taskContinuesOnFailure,taskHaltsOnFailure,workflowContinuesOnFailure - Loops & Fan-Out:
loopBadgeLabel,loopGuardLabel,loopStepLabel,loopDescription,forEachBadgeLabel,forEachDescription - Debug:
contextAtStep,stepState,stepErrors,isWorkflowStep,isEngineStep,traceIsReplayable,findTaskStepIndex,getWorkflowState,getTaskState,isTaskCurrent,toExecutionError - Types: All workflow/task definition types, debugger state types, and re-exported wire types from
@goplasmatic/dataflow-wasm.
Migrating from 3.x
| 3.x | 4.0 |
|---|---|
DataflowEngine.processWithTrace(payload) | run(payload, options?) resolving with RunResult on every status |
onExecutionComplete(trace: ExecutionTrace) | onExecutionComplete(result: RunResult) (result.trace) |
onExecutionError(error: string) | onExecutionError(error: ExecutionError) with code, message, and issues |
ExecutionTrace, ExecutionStep | Trace, TraceStep from @goplasmatic/dataflow-wasm |
ErrorInfo, AuditTrail | ErrorRecord, AuditEntry |
getMessageAtStep(trace, i) | useDebugger().contextAt(i) or contextAtStep(trace, contexts, i) (returns the replayed Context) |
getChangesAtStep(trace, i) | trace.steps[i].changes |
traceHasSnapshots(trace) | traceIsReplayable(trace) |
createEmptyMessage, cloneMessage | Removed |
INTEGRATION_FUNCTION_NAMES; validate, http_call, enrich, publish_kafka in BuiltinFunctionType | Removed; isBuiltinFunction queries engine_facts() |
| Payload serialized as a JSON string | Passed directly as a JavaScript value |
Building from Source
cd ui
npm install
npm run wasm:local # build ../wasm and copy it into node_modules
npm test # run unit and component tests against the local WASM build
npm run build:lib
Built output is written to ui/dist/.
Next Steps
- WASM Package β Run workflows in the browser and Node.js
- Core Concepts β Learn how workflows, tasks, and traces work
Custom Functions
Every task executes a function. Eight functions are built in (map,
validation, filter, log, parse_json, parse_xml, publish_json, and
publish_xml) and execute inline. Any other function is a host function: a
Rust type you write that implements Function, which the engine calls once per
task execution. This guide covers how to implement, test, and register custom
functions.
The Function Trait
pub trait Function: Send + Sync + 'static {
type Config: FunctionConfig;
fn spec(&self) -> FunctionSpec { FunctionSpec::default() }
fn prepare(&self, cfg: &mut Self::Config, p: &Prepare<'_>)
-> Result<(), Vec<Issue>> { Ok(()) }
async fn call(&self, ctx: &mut Ctx<'_>, cfg: &Self::Config)
-> Result<Outcome>;
}
The trait consists of three main components:
Config: Represents the task'sinputas a typed Rust struct. The engine compiles the configuration once when the workflow is compiled, so malformed inputs are reported at their authored JSON path at compile time rather than failing when a message is processed. Use#[derive(FunctionConfig)]to generate the implementation automatically.Ctx: The execution context for the call. Use it to read message context data, evaluate configuration expressions, write output values, and record errors.Outcome: The execution result returned to the engine:Ok,Status(u16),Skip, orHalt.
call is a standard async fn and does not require #[async_trait] or manual
future boxing.
Your First Custom Function
#![allow(unused)] fn main() { tokio::runtime::Builder::new_current_thread().build().unwrap().block_on(async { use dataflow_rs::datavalue::OwnedDataValue; use dataflow_rs::expr::Output; use dataflow_rs::function::FunctionConfig; use dataflow_rs::{Ctx, Engine, Expr, Function, Message, Outcome, Result}; use serde_json::json; /// The task's `input`: `{"name": <expression>, "output": <path>}`. #[derive(FunctionConfig)] struct GreetConfig { /// Who to greet. Accepts any JSONLogic expression that produces a string. name: Expr<String>, /// Destination path for the greeting. Optional in the input; defaults to `data.greeting`. #[output(default = "data.greeting")] output: Output, } struct Greet; impl Function for Greet { type Config = GreetConfig; async fn call(&self, ctx: &mut Ctx<'_>, cfg: &GreetConfig) -> Result<Outcome> { let name = ctx.eval(&cfg.name)?; let greeting = OwnedDataValue::from(&json!(format!("hello, {name}"))); ctx.emit(&cfg.output, greeting)?; Ok(Outcome::Ok) } } let engine = Engine::builder() .register("greet", Greet) .workflow_json(&json!({"id": "w", "tasks": [ {"id": "t", "function": {"name": "greet", "input": {"name": {"var": "data.name"}}}} ]})) .build() .unwrap(); let mut message = Message::from_parts( OwnedDataValue::Null, OwnedDataValue::from(&json!({"name": "ada"})), OwnedDataValue::Null, ); let run = engine.run(&mut message).await; assert_eq!(run.matched(), 1); assert_eq!( message.to_json()["context"]["data"]["greeting"], json!("hello, ada") ); }); }
Engine::builder().register("greet", Greet) registers the function under the
name referenced by function.name in workflow tasks. Registering the same name
twice replaces the previous implementation.
The eight built-in function namesβplus validate (the 3.x alias for
validation)βare reserved. Attempting to register a reserved name with
register panics. If your application registers function names dynamically from
external configuration, check them first with def::is_reserved_function or call
Registry::try_register, which returns an Err of kind Config instead of
panicking.
If a function takes no input parameters, set type Config = ();. Its input
in the workflow JSON must then be {}, null, or omitted.
Function Configuration
#[derive(FunctionConfig)] can be applied to any struct with named fields. Each
field is compiled from the matching JSON key, and any validation issue is
reported at its exact path (such as tasks[2].function.input.timeout_ms).
Unknown keys are rejected: keys close to a known field name are reported as
LIKELY_TYPO (along with the suggested field name), and all other unknown keys
are reported as UNKNOWN_KEY.
Supported Field Types
| Rust Type | In Workflow input JSON | In call |
|---|---|---|
Expr<T> | Any JSONLogic expression (literals evaluate to themselves) | ctx.eval(&field) returns T |
Output | A literal or computed context path | ctx.emit(&field, value) writes to that path |
Interpolated | A JSON document containing {"var": β¦} placeholders | ctx.render(&field) substitutes the placeholders |
bool, String, u8βu64, usize, i32, i64, f64 | A literal of that type (not evaluated as JSONLogic) | Used directly as a value |
serde_json::Value, OwnedDataValue | Any raw JSON value, kept verbatim | Used directly as a value |
Option<T> | Omitted, null, or a valid T | None or Some(T) |
Vec<T> | A JSON array of T | Vec<T> |
BTreeMap<String, T> | A JSON object whose values are T | BTreeMap<String, T> |
Another #[derive(FunctionConfig)] struct | A nested JSON object | The nested config struct |
In Expr<T>, T can be:
OwnedDataValue(the default when you writeExprwithout a type parameter, accepting any JSON value)String(returns string contents directly, or formats any other value as compact JSON)u64,i64,f64,bool, orPath
Any expression that does not read from the message context is constant-folded at
compile time and accessible via Expr::constant(). If a folded constant has the
wrong type, the compiler reports an INPUT_PARSE issue when building the
workflow rather than failing at runtime.
An explicit null in an Option<T> field becomes None, unless T itself can
represent null (Expr, Interpolated, serde_json::Value, and
OwnedDataValue), in which case it becomes Some containing a constant null.
Configuration Attributes
| Attribute | Target | Description |
|---|---|---|
#[config(rename = "k")] | Field | Sets the JSON key name (defaults to the Rust field name). |
#[config(default = <literal>)] | Field | Provides a default string, integer, float, or boolean literal when the JSON key is omitted. |
#[config(default_json = "<json>")] | Field | Provides an arbitrary JSON default as a string literal; invalid JSON causes a Rust compile error. |
#[config(serde)] | Field | Deserializes the field with serde_json instead of ConfigField (requires Deserialize + Serialize). |
#[config(records)] | Field | Marks the field's output as recorded by the engine, disallowing secret reads (see Secrets). |
#[output(default = "p")] | Output field | Provides a default target path when the key is omitted or null. |
#[output(primary)] | Output field | Marks which Output field ctx.emit_default writes to (required on exactly one field when a struct has multiple Output fields). |
#[config(check = path)] | Struct | Calls fn(&Self, &mut CompileCx<'_>) after all fields are compiled to validate cross-field constraints. |
Each field's Rust doc comment (/// ...) becomes its description in the
function catalogue.
#![allow(unused)] fn main() { use dataflow_rs::expr::{CompileCx, Output}; use dataflow_rs::function::{FunctionConfig, testing}; use dataflow_rs::program::IssueCode; use dataflow_rs::Expr; use serde::{Deserialize, Serialize}; use serde_json::json; #[derive(Deserialize, Serialize)] #[serde(rename_all = "lowercase")] enum Method { Get, Post, } #[derive(FunctionConfig)] #[config(check = HttpConfig::check)] struct HttpConfig { /// The URL to call. url: Expr<String>, #[config(serde, default = "get")] method: Method, #[config(rename = "timeout_ms", default = 5000)] timeout: Expr<u64>, /// Extra headers, where each value is an expression. headers: Option<std::collections::BTreeMap<String, Expr<String>>>, #[output(default = "data.response")] output: Output, } impl HttpConfig { fn check(&self, cx: &mut CompileCx<'_>) { if self.timeout.constant().is_some_and(|ms| *ms > 60_000) { cx.at("timeout_ms", |cx| { cx.issue(IssueCode::InputParse, "at most 60000"); }); } } } // Compile a config standalone; issues are reported relative to the input object. let cfg: HttpConfig = testing::config(json!({"url": "https://example.com"})).unwrap(); assert_eq!(cfg.timeout.constant(), Some(&5000)); assert_eq!(cfg.output.constant().unwrap().to_string(), "data.response"); let issues = testing::config::<HttpConfig>(json!({ "url": "https://example.com", "timeout_ms": 90000, "ulr": "x" })).err().unwrap(); let codes: Vec<(IssueCode, String)> = issues.iter().map(|i| (i.code, i.path.to_string())).collect(); assert_eq!(codes, [ (IssueCode::LikelyTypo, "ulr".to_owned()), (IssueCode::InputParse, "timeout_ms".to_owned()), ]); }
Custom Field Types
You can create your own reusable field types by implementing ConfigField. This
trait defines how the field compiles, which expressions it contains (so secret
and template-key checks inspect them), how it resolves during a call, and how it
is described for authoring tools. A common pattern is a newtype wrapper around
Expr (for example, a connector name); see the ConfigField API documentation
for a complete example.
Runtime-Defined Fields (Templated)
If your application loads function definitions dynamically from plugin manifests
rather than fixed Rust structs, use Templated as your Function::Config. A
Templated function declares its expected fields at runtime via
FunctionSpec::runtime_fields. Each FieldSpec defines the field's name, kind
(typed expression, output path, interpolated document, or literal), whether it
is required, and an optional default value. Inside call, read individual
fields by name with Templated::get or evaluate all fields at once with
Templated::resolved.
#![allow(unused)] fn main() { tokio::runtime::Builder::new_current_thread().build().unwrap().block_on(async { use dataflow_rs::datavalue::OwnedDataValue; use dataflow_rs::expr::{FieldKind, FieldSpec, ValueKind}; use dataflow_rs::function::{FunctionSpec, Templated, testing}; use dataflow_rs::{Ctx, Function, Outcome, Result}; use serde_json::json; /// A manifest-defined function with a required `url` and an optional `out` defaulting to `data.out`. struct Plugin; impl Function for Plugin { type Config = Templated; fn spec(&self) -> FunctionSpec { FunctionSpec::default().with_runtime_fields(vec![ FieldSpec::runtime("url", FieldKind::Expr(ValueKind::String), true), FieldSpec::runtime("out", FieldKind::Output, false).with_default("\"data.out\""), ]) } async fn call(&self, ctx: &mut Ctx<'_>, cfg: &Templated) -> Result<Outcome> { let url = cfg.get(ctx, "url")?.expect("url is required").into_owned(); ctx.emit_default(url)?; Ok(Outcome::Ok) } } let called = testing::call(Plugin, json!({"url": "https://example.com"})).await; assert_eq!(called.outcome.unwrap(), Outcome::Ok); let out = called.message.context().get(&"data.out".parse().unwrap()); assert_eq!(out, Some(&OwnedDataValue::from(&json!("https://example.com")))); }); }
Registering a Templated function without runtime fieldsβor declaring runtime
fields on a function whose Config is not Templatedβpanics during
registration, since every workflow compilation would otherwise fail.
The Call Context (Ctx)
Ctx is passed to call and to every CallLayer. It reads from the run's
shared context view without copying it per call, and buffers the current call's
writes in an isolated overlay. Reads within the same call see those buffered
writes immediately, while the underlying Message is only updated after the
call returns Ok.
Reading from Ctx
| Method | Returns |
|---|---|
ctx.eval(&expr) | Evaluates the expression against the call's view of the context (buffered writes first, then fan-out element bindings, then the message context). |
ctx.get(&path) | Borrows the value at a Path. |
ctx.render(&doc) | Renders an Interpolated document with its placeholders filled in as a Rendered value that serializes directly. |
ctx.payload() | Returns the message's raw payload (which JSONLogic expressions cannot read). |
ctx.secret(name) | Looks up a secret value from the engine's secret store by dotted name. |
ctx.info() | Returns CallInfo: workflow and task IDs, registered function name, loop counter, fan-out element index, channel, and routing bucket. |
ctx.input() | Evaluates the entire config into an object while replacing secret-reading fields with null without evaluating them (intended for logging layers and dry runs). |
ctx.resolve(&cfg) | Evaluates the entire config into an object, including secrets (intended for the function's own use). |
ctx.deadline(), ctx.cancel() | Returns the run's deadline and cancellation token so you can forward them to child operations or sub-runs. |
ctx.exprs() | Returns the ExprEngine used to compile the program, allowing the function to compile ad-hoc expressions. |
Writing to Ctx
| Method | Action |
|---|---|
ctx.set(&path, value) | Writes value at path, creating intermediate objects as needed. |
ctx.emit(&output, value) | Writes value to the literal or computed path specified by an Output field. |
ctx.emit_default(value) | Writes value to the config's primary output field. |
ctx.remove(&path) | Removes the key at path (does nothing if the path does not exist). |
ctx.record_error(record) | Appends an ErrorRecord to message.errors(). |
Three rules govern how writes are committed to the message:
- Context writes are committed only when
callreturnsOk. Regardless of whichOutcomevariant is returned insideOk(...), buffered writes are applied in order at their target paths. IfcallreturnsErr(...), all buffered context writes are discarded, leaving the message context untouched. - Recorded errors are always kept. Any error added via
ctx.record_errorremains onmessage.errors()even ifcallreturnsErr. If the record has no timestamp, the engine stamps it with the run's timestamp. - Context roots must remain objects. Calling
ctx.setatdata,metadata, ortemp_datawith a non-object valueβor callingctx.removeon a rootβreturns aConfigerror.
Writing beneath a scalar value or to a named key on an array is a no-op and
returns Ok(()) (matching Path::set). When change capture is enabled, each
committed write records a Change entry (see Audit Trails).
#![allow(unused)] fn main() { tokio::runtime::Builder::new_current_thread().build().unwrap().block_on(async { use dataflow_rs::datavalue::OwnedDataValue; use dataflow_rs::function::testing::{self, Write}; use dataflow_rs::{Ctx, Function, Message, Outcome, Path, Result}; use serde_json::json; /// Increments `data.visits` and removes stale `data.cache`. struct Visit; impl Function for Visit { type Config = (); async fn call(&self, ctx: &mut Ctx<'_>, _cfg: &()) -> Result<Outcome> { let visits: Path = "data.visits".parse()?; let seen = ctx.get(&visits).and_then(|v| v.as_i64()).unwrap_or(0); ctx.set(&visits, OwnedDataValue::from(&json!(seen + 1)))?; // Subsequent reads in the same call see the write immediately. assert_eq!(ctx.get(&visits).and_then(|v| v.as_i64()), Some(seen + 1)); ctx.remove(&"data.cache".parse()?)?; Ok(Outcome::Ok) } } let data = OwnedDataValue::from(&json!({"visits": 1, "cache": "stale"})); let called = testing::call(Visit, json!({})) .message(Message::from_parts(OwnedDataValue::Null, data, OwnedDataValue::Null)) .await; assert_eq!(called.writes[0].1, Write::Set(OwnedDataValue::from(&json!(2)))); assert_eq!(called.writes[1].1, Write::Removed); assert_eq!( called.message.context().data(), &OwnedDataValue::from(&json!({"visits": 2})) ); }); }
Interpolated Documents
When building a query or payload for an external system (such as a MongoDB
filter), you often want a mostly literal JSON document with a few dynamic
valuesβand without $ keys being treated as JSONLogic escape prefixes.
Use Interpolated for this: it compiles a JSON document where only
{"var": "β¦"} nodes are evaluated as placeholders and all other keys (including
$oid, $set, etc.) are kept verbatim. Calling ctx.render(&field) resolves
each placeholder once and returns a Rendered value that serializes directly
without allocating a new document tree (or call Rendered::to_owned() if you
need an owned tree).
#![allow(unused)] fn main() { use dataflow_rs::expr::Interpolated; use dataflow_rs::function::FunctionConfig; use dataflow_rs::{Ctx, Function, Outcome, Result}; use serde_json::json; #[derive(FunctionConfig)] struct UpdateConfig { filter: Interpolated, } struct Update; impl Function for Update { type Config = UpdateConfig; async fn call(&self, ctx: &mut Ctx<'_>, cfg: &UpdateConfig) -> Result<Outcome> { let filter = ctx.render(&cfg.filter); // `filter` serializes directly as `{"_id":{"$oid":"<data.id>"}}`. // Pass it to your driver or call `filter.to_owned()` for an owned value. let _bson = serde_json::to_string(&filter); Ok(Outcome::Ok) } } let _ = (Update, json!({"filter": {"_id": {"$oid": {"var": "data.id"}}}})); }
Only var placeholders are evaluated inside an Interpolated document:
- Other JSONLogic operator-shaped objects remain literal data and trigger an
OPERATOR_IN_DOCUMENTadvisory at compile time. {"secret": β¦}nodes are rejected withSECRET_IN_DOCUMENT.varplaceholders rooted atpayloadalways resolve to their default value and trigger aPAYLOAD_VARdefect.
Return Values and Outcomes
call Return Value | Recorded Status | Error Record | Next Step |
|---|---|---|---|
Ok(Outcome::Ok) | 200 | None | Runs the next task |
Ok(Outcome::Status(s)) (s < 400) | s | None | Runs the next task |
Ok(Outcome::Status(s)) (400 <= s < 500) | s | None | Handled by the task's on_rejection policy |
Ok(Outcome::Status(s)) (s >= 500) | s | TASK_STATUS_ERROR | Handled by the task's on_failure policy |
Ok(Outcome::Skip) | None (no audit entry) | None | Runs the next task |
Ok(Outcome::Halt) | 299 | None | Stops the current workflow |
Err(e) | 500 | e.code() | Handled by the task's on_failure policy |
Task-level on_failure and on_rejection policies control how rejections and
failures affect workflow execution; see Control Flow.
Outcome::Skipindicates that the task was a no-op: no audit entry is added andmetadata.progressis not updated.Outcome::Haltstops the current workflow immediately, while allowing subsequent workflows in the engine to run.
When returning Err, use Error::service(code, message) to attach a
domain-specific error code (for example,
Error::service("PARTNER_DOWN", "the partner API returned 503") records the
code "PARTNER_DOWN"). Engine-generated errors use fixed codes: LOGIC_ERROR,
BUDGET_EXCEEDED, TIMEOUT_ERROR, CANCELLED, CONFIG_ERROR, and
COMPILATION_ERROR. By default, only timeout errors are marked retryable; call
.with_retryable(true) when a failure can be retried, and .with_source(err)
to attach an underlying error cause.
#![allow(unused)] fn main() { use dataflow_rs::{Error, Result}; fn partner_down(status: u16) -> Result<()> { Err(Error::service("PARTNER_DOWN", format!("the partner returned {status}")) .with_retryable(status == 503)) } let e = partner_down(503).unwrap_err(); assert_eq!(e.code(), "PARTNER_DOWN"); assert!(e.is_retryable()); }
Function Metadata and Compile-Time Preparation
Function::spec returns a FunctionSpec that is read once at registration and
exposed via Registry::catalogue for UI and authoring tools. You can use it to
specify:
- A human-readable description and category
- Retry safety (
RetrySafety::Idempotent,NotIdempotent, orUnknown) - External resource references (
Reference::new("connector", "connector")), which aResourceOraclevalidates at compile time (see Authoring-Time Validation) - Runtime field specifications for
Templatedfunctions
Function::prepare runs once per task at compile time, right after Config has
been compiled. It receives a &mut Self::Config and a Prepare handle
containing context that the config struct alone cannot see (the workflow ID,
task ID, and FunctionSpec). Return any validation issues with paths relative
to the config object; the compiler automatically prefixes them with
function.input, and any issue with Rejected severity fails workflow
compilation.
#![allow(unused)] fn main() { tokio::runtime::Builder::new_current_thread().build().unwrap().block_on(async { use dataflow_rs::function::{FunctionConfig, FunctionSpec, Prepare, RetrySafety, testing}; use dataflow_rs::program::IssueCode; use dataflow_rs::{Ctx, Expr, Function, Issue, Outcome, Result}; use serde_json::json; #[derive(FunctionConfig)] struct ChargeConfig { amount: Expr<f64>, } struct Charge; impl Function for Charge { type Config = ChargeConfig; fn spec(&self) -> FunctionSpec { FunctionSpec::default() .with_description("Charges the customer") .with_category("payments") .with_retry(RetrySafety::NotIdempotent) } fn prepare(&self, cfg: &mut ChargeConfig, p: &Prepare<'_>) -> std::result::Result<(), Vec<Issue>> { // Validate that literal amounts are positive. Paths are relative to the config. if cfg.amount.constant().is_some_and(|a| *a <= 0.0) { let message = format!("task `{}` charges a non-positive amount", p.task_id()); return Err(vec![Issue::new(IssueCode::InputParse, "amount".parse().unwrap(), message)]); } Ok(()) } async fn call(&self, _ctx: &mut Ctx<'_>, _cfg: &ChargeConfig) -> Result<Outcome> { Ok(Outcome::Ok) } } let called = testing::call(Charge, json!({"amount": -1})).await; assert_eq!(called.issues[0].code, IssueCode::InputParse); assert_eq!(called.issues[0].path.to_string(), "amount"); assert!(called.outcome.is_err()); }); }
Call Layers (Middleware)
A CallLayer wraps host function invocations like middleware. It receives the
call's Ctx and the remainder of the execution chain (Next), and can inspect
or modify the context, call next.run(ctx).await, or short-circuit the call and
return an Outcome directly. Built-in functions run inline and are never
wrapped by layers.
Layers can be attached at two scopes:
- Engine-wide:
Engine::builder().layer(l)(orRegistry::layer) wraps every host function call across all runs. - Per-run:
RunOptions::layerswraps host function calls for a single run, executing outside the registry's layers.
Within each list, the first layer added is the outermost wrapper.
#![allow(unused)] fn main() { tokio::runtime::Builder::new_current_thread().build().unwrap().block_on(async { use dataflow_rs::function::{BoxFuture, CallLayer, Next, testing}; use dataflow_rs::{Ctx, Function, Outcome, Result}; use serde_json::json; /// Rewrites any `5xx` status into `503`. struct Unavailable; impl CallLayer for Unavailable { fn call<'a>(&'a self, ctx: &'a mut Ctx<'_>, next: Next<'a>) -> BoxFuture<'a, Result<Outcome>> { Box::pin(async move { Ok(match next.run(ctx).await? { Outcome::Status(500..) => Outcome::Status(503), outcome => outcome, }) }) } } /// Returns status `500` on every call. struct Down; impl Function for Down { type Config = (); async fn call(&self, _ctx: &mut Ctx<'_>, _cfg: &()) -> Result<Outcome> { Ok(Outcome::Status(500)) } } let called = testing::call(Down, json!({})).layer(Unavailable).await; assert_eq!(called.outcome.unwrap(), Outcome::Status(503)); }); }
Inside a layer, you can inspect ctx.info() and ctx.input(). Because
ctx.input() resolves any secret-reading field to null without evaluating it,
logging layers cannot accidentally leak secrets. Layers can also write outputs
using ctx.emit and ctx.primary_output() (the target path used by
ctx.emit_default).
For dry runs and mocking, use the built-in function::layers::Answer layer. It
passes each call's Ctx to a closure: when the closure returns Some(value),
the layer writes value to the function's primary output and returns
Ok(Outcome::Ok) without invoking the underlying function.
#![allow(unused)] fn main() { tokio::runtime::Builder::new_current_thread().build().unwrap().block_on(async { use dataflow_rs::datavalue::OwnedDataValue; use dataflow_rs::expr::Output; use dataflow_rs::function::{CallLayer, FunctionConfig, layers::Answer}; use dataflow_rs::{Ctx, Engine, Expr, Function, Message, Outcome, Result, RunOptions}; use serde_json::json; use std::sync::Arc; #[derive(FunctionConfig)] struct HttpConfig { url: Expr<String>, #[output(default = "data.response")] output: Output, } /// A network function that is intercepted during dry runs. struct HttpCall; impl Function for HttpCall { type Config = HttpConfig; async fn call(&self, _ctx: &mut Ctx<'_>, _cfg: &HttpConfig) -> Result<Outcome> { unreachable!("answered by the dry-run layer") } } let engine = Engine::builder() .register("http_call", HttpCall) .workflow_json(&json!({"id": "w", "tasks": [ {"id": "fetch", "function": {"name": "http_call", "input": {"url": "https://example.com/orders"}}} ]})) .build() .unwrap(); // Stub out `http_call` with a synthetic response while letting other functions run normally. let dry_run: Vec<Arc<dyn CallLayer>> = vec![Arc::new(Answer::new(|ctx| { if ctx.info().function != "http_call" || ctx.primary_output().is_none() { return None; } let input = ctx.input().ok()?; Some(OwnedDataValue::Object(vec![ ("status".into(), "dry run".into()), ("request".into(), input), ])) }))]; let mut message = Message::from_json(&json!({})); let run = engine.run_with(&mut message, &RunOptions::default().layers(&dry_run)).await; assert_eq!(run.matched(), 1); assert_eq!( message.to_json()["context"]["data"]["response"], json!({"status": "dry run", "request": {"url": "https://example.com/orders", "output": "data.response"}}) ); }); }
Testing Custom Functions
function::testing::call(f, input) executes a single function call using the
same pipeline as the engineβcompiling the config, providing a real Ctx over a
test message, running any attached layers, and committing writes on Ok or
discarding them on Errβwithout needing to set up a Registry, Compiler, or
Engine.
You can customize the test call with builder methods before awaiting it:
.message(m).layer(l).exprs(engine)(for custom operators or secrets).name(n).capture_changes(on).channel(c).deadline(at).cancel(token)
Awaiting the builder returns a Called struct containing:
issues: Any compile-time issues produced by the config orprepareoutcome: The call result (or aCompileerror if the config was rejected)writes: All writes performed by the call, in orderchanges: Change records captured during the callerrors: Error records added viactx.record_errormessage: The resultingMessageafter writes are committed
To test configuration parsing and validation on its own, use
testing::config::<C>(input) (or testing::config_with(&exprs, input) when
using a custom ExprEngine).
Inspecting Call Metadata
ctx.info() returns a CallInfo struct identifying the current execution step:
workflow_idandtask_id(both default to"test"undertesting::call)function: The registered name used to invoke the functionchannel: The active channel name, if anyloop_counter:Some(n)on iterationnof a workflowloopelement_index:Some(i)when executing inside a task'sfor_each
Execution Inside for_each (Fan-Out)
When a task has a for_each block, the engine calls the function once per
array element. Each invocation sees its own element bound at temp_data.<as>
and its index at temp_data.<as>_index, layered on top of the shared message
context without copying it. Concurrent element calls cannot see each other's
writes; after all element calls complete, their writes are committed to the
message in element order. See Fan-Out for details.
Deadlines and Cancellation
A run can be configured with a deadline (RunOptions::deadline) and a
cancellation token (RunOptions::cancel). The engine checks both before each
host function call and races them against the call's future (on native targets).
If a deadline expires or the token is cancelled while a call is running, the
call future is dropped, its uncommitted writes are discarded, and the run ends
with TimedOut or Cancelled.
If your function starts a nested engine run or async sub-operation, pass along
ctx.deadline() and ctx.cancel().child() so cancelling the parent run also
cancels the child operation. Note that if a function itself returns an Err of
kind TimedOut or Cancelled, the engine treats it as a task failure governed
by the task's on_failure policy.
Migrating from 3.x
In 3.x, custom functions implemented AsyncFunctionHandler using
#[async_trait], took a TaskContext<'_>, returned TaskOutcome, performed
infallible writes via TaskContext::set, and manually compiled Template
fields in compile_input.
In 4.0:
- The trait is
Function(using nativeasync fn, without#[async_trait]). - The context type is
Ctx, and write operations returnResult. - The return type is
Outcome(with the same four variants). - Configurations use
#[derive(FunctionConfig)], whereExprfields compile automatically.
See Migrating from 3.x for a step-by-step migration guide.
JSONLogic
Dataflow-rs uses JSONLogic for workflow and task
conditions, map transformations, and function input parameters.
Overview
JSONLogic lets you write expressions and rules as standard JSON. Dataflow-rs uses JSONLogic for:
- Workflow conditions, which decide whether a message enters a workflow,
evaluated against the full message context (
data,metadata,temp_data) - Task and group conditions, which decide whether a step executes
mapmappings, which compute and transform values- Function input parameters, for both built-in and custom host functions:
any plain JSON literal evaluates to itself (so
"path": "data.out"and"timeout_ms": 5000work as literals), while an object with a recognized operator key is evaluated dynamically
Every expression is compiled once when the workflow is built using the
ExprEngine owned by the engine's Registry. Any expression that does not read
from the message context or secrets is constant-folded at compile time and never
re-evaluated at runtime.
Basic Syntax
A JSONLogic operation is a single-key JSON object where the key is the operator name and the value is its argument (or array of arguments):
{"operator": [argument1, argument2, ...]}
Accessing Data
var β Read Context Fields
// Read a top-level field
{"var": "data.name"}
// Read a nested field
{"var": "data.user.profile.email"}
// Read an array element by index
{"var": "data.items.0"}
// Provide a default value if the path is missing or null
{"var": ["data.optional", "default value"]}
Context Structure
Expressions evaluate against a single context object with three root namespaces:
{
"data": { ... },
"metadata": { ... },
"temp_data": { ... }
}
Reference fields by starting the path with one of the three roots:
{"var": "data.field"}
{"var": "metadata.type"}
{"var": "temp_data.intermediate"}
The message's raw payload is not part of the evaluation context. Writing
{"var": "payload.x"} always resolves to null. Parse the payload into data
first using parse_json or parse_xml. Compiler::check flags any literal
payload variable read as a PAYLOAD_VAR defect so you can catch it at
authoring time.
Secrets (such as signing keys and API tokens) are also kept out of the context
tree so they are never serialized or recorded in traces. Store secrets in the
expression engine's secret store and read them with {"secret": "name"}; see
Secrets.
Comparison Operators
Equality
{"==": [{"var": "data.status"}, "active"]}
{"===": [{"var": "data.count"}, 0]} // Strict equality
{"!=": [{"var": "data.status"}, "deleted"]}
{"!==": [{"var": "data.count"}, null]} // Strict inequality
Numeric Comparisons
{">": [{"var": "data.age"}, 18]}
{">=": [{"var": "data.score"}, 60]}
{"<": [{"var": "data.price"}, 100]}
{"<=": [{"var": "data.quantity"}, 10]}
Range Comparisons (Between)
{"<=": [1, {"var": "data.x"}, 10]} // 1 <= x <= 10
{"<": [1, {"var": "data.x"}, 10]} // 1 < x < 10
Boolean Logic
and, or, !
{"and": [
{">=": [{"var": "data.age"}, 18]},
{"==": [{"var": "data.verified"}, true]}
]}
{"or": [
{"==": [{"var": "data.status"}, "active"]},
{"==": [{"var": "data.status"}, "pending"]}
]}
{"!": {"var": "data.disabled"}}
Truthiness (!! and !)
// Check if a value is truthy (not null, false, 0, "", [], or {})
{"!!": {"var": "data.email"}}
// Check if a value is falsy
{"!": {"var": "data.deleted"}}
Note that an empty object {} is treated as falsy (just like an empty array
[]). Some other JSONLogic implementations treat all objects as truthy.
Conditionals
Multi-Branch if
{"if": [
{">=": [{"var": "data.score"}, 90]}, "A",
{">=": [{"var": "data.score"}, 80]}, "B",
{">=": [{"var": "data.score"}, 70]}, "C",
"F"
]}
Ternary if
{"if": [
{"var": "data.premium"},
"VIP Customer",
"Standard Customer"
]}
String Operations
cat β Concatenation
{"cat": ["Hello, ", {"var": "data.name"}, "!"]}
substr β Substring
{"substr": [{"var": "data.text"}, 0, 10]} // First 10 characters
{"substr": [{"var": "data.text"}, -5]} // Last 5 characters
in β Substring or Element Membership
// Check if a substring exists in a string
{"in": ["@", {"var": "data.email"}]}
// Check if a value exists in an array
{"in": [{"var": "data.status"}, ["active", "pending"]]}
Numeric Operations
Arithmetic
{"+": [{"var": "data.a"}, {"var": "data.b"}]}
{"-": [{"var": "data.total"}, {"var": "data.discount"}]}
{"*": [{"var": "data.price"}, {"var": "data.quantity"}]}
{"/": [{"var": "data.total"}, {"var": "data.count"}]}
{"%": [{"var": "data.n"}, 2]} // Modulo
min and max
{"min": [{"var": "data.a"}, {"var": "data.b"}, 100]}
{"max": [{"var": "data.x"}, 0]} // Ensure non-negative
Array Operations
merge β Combine Arrays
{"merge": [
{"var": "data.list1"},
{"var": "data.list2"}
]}
map β Transform Elements
{"map": [
{"var": "data.items"},
{"*": [{"var": ""}, 2]} // Double each item
]}
filter β Keep Matching Elements
{"filter": [
{"var": "data.items"},
{">=": [{"var": ""}, 10]} // Keep items >= 10
]}
reduce β Fold an Array into a Single Value
{"reduce": [
{"var": "data.items"},
{"+": [{"var": "accumulator"}, {"var": "current"}]},
0 // Initial value
]}
all, some, none
{"all": [{"var": "data.items"}, {">=": [{"var": ""}, 0]}]}
{"some": [{"var": "data.items"}, {"==": [{"var": ""}, "special"]}]}
{"none": [{"var": "data.items"}, {"<": [{"var": ""}, 0]}]}
Inside the body of map, filter, reduce, all, some, and none, var
reads from the current array element rather than the top-level message context.
These are called scoping operators (Operator::scoping), and the compiler
skips PAYLOAD_VAR checks inside their bodies for that reason.
Try It
Open the Full Debugger UI for step-by-step execution and workflow visualization.
Common Patterns
Safe Field Access
// Default to an empty string if missing
{"var": ["data.optional", ""]}
// Check if truthy before using
{"if": [
{"!!": {"var": "data.optional"}},
{"var": "data.optional"},
"default"
]}
Fallback Values
{"if": [
{"!!": {"var": "data.primary"}},
{"var": "data.primary"},
{"var": "data.fallback"}
]}
Type Checking
The type-inspection operator is type (not typeof), and it requires enabling
the ExtControl operator family. It returns "null",
"boolean", "number", "string", "array", or "object".
// Check if a field is a string
{"===": [{"type": {"var": "data.field"}}, "string"]}
// Check if a field is an array
{"===": [{"type": {"var": "data.items"}}, "array"]}
When the Datetime family is also enabled, type classifies ISO-8601 date and
duration strings as "datetime" or "duration" instead of "string".
Operator Families
All operators shown in the sections above belong to core JSONLogic and are
always enabled. Extended operators are organized into [OperatorFamily]
variants. They are disabled by default in ExprEngine::default() and can be
enabled selectively on ExprEngineBuilder:
OperatorFamily | Cargo Feature | Operators |
|---|---|---|
ExtString | (always compiled in) | length, starts_with, ends_with, upper, lower, trim, split |
ExtArray | (always compiled in) | sort, slice, group_by, distinct |
ExtMath | (always compiled in) | abs, ceil, floor |
ExtControl | (always compiled in) | exists, ??, switch (alias match), type |
ExtObject | (always compiled in) | keys, values, entries |
ErrorHandling | (always compiled in) | try, throw |
Datetime | datetime | datetime, timestamp, parse_date, format_date, date_diff, now |
(via all_operators()) | None | Enables every compiled-in family listed above |
Tensor | tensor | tensor, zeros, full, scatter, rle_expand, one_hot, stack, concat, unstack, reshape, transpose, pad, crop, cast, normalize, argmax, gather, to_list, shape, dtype (excluded from all_operators(); see below) |
#![allow(unused)] fn main() { use dataflow_rs::expr::{ExprEngine, OperatorFamily}; use dataflow_rs::Registry; let exprs = ExprEngine::builder() .families([OperatorFamily::ExtString, OperatorFamily::ExtControl]) .build() .unwrap(); let registry = Registry::new(exprs); let _ = registry; }
Why Tensor Is Opt-In
ExprEngineBuilder::all_operators intentionally excludes Tensor. Several
tensor operator names overlap with common JSON field names (shape, full,
cast, pad, crop, concat, stack), and in templating mode any
single-key object matching an active operator name is evaluated as an operator
call rather than emitted as a literal object.
To use tensor operators, enable the tensor Cargo feature and add
OperatorFamily::Tensor explicitly:
[dependencies]
dataflow-rs = { version = "4.0", features = ["tensor"] }
When Tensor is enabled, prefix literal single-key objects with $ (for
example, {"$shape": ...}) when you want a literal "shape" field.
Notes on Specific Families
ErrorHandlingprovides the JSONLogictryandthrowexpression operators. It is separate from dataflow-rs's task and workflow error handling, which is always active.lengthbelongs toExtString(notExtArray), even though it works on both strings and arrays.typebelongs toExtControl.entriesbelongs toExtObject(because its input is an object, even though it returns an array of{key, value}pairs), whilegroup_byanddistinctbelong toExtArray. To turn an object into entries and group them withgroup_by, enable bothExtObjectandExtArray.
Enabling a Family Can Change Existing Rules
Dataflow-rs evaluates JSONLogic in templating mode: if a single-key object's key is not an active operator, the object is treated as literal JSON output rather than causing an error. As a result, enabling additional operator families can change the behavior of existing workflows.
For example, when OperatorFamily::ExtString is disabled, a mapping with
{"length": {"var": "data.x"}} writes an object with a "length" key. Once
ExtString is enabled, that same expression calls the length operator and
writes a number. Before enabling a new operator family, check your workflows for
single-key objects whose keys match the operators in that family.
To guarantee that a key is always treated as literal data regardless of which
families are enabled, prefix the key with $ (for example, {"$length": β¦}).
Enabling the datetime Cargo feature also affects core comparison operators:
==, <, <=, >, and >= will attempt to parse string operands as ISO-8601
datetimes or durations before comparing them:
{"==": ["2024-01-15T00:00:00Z", "2024-01-15T01:00:00+01:00"]}
Without datetime, this comparison is false (two different strings); with
datetime enabled, it evaluates to true because both strings represent the
same instant in time.
Literal Keys and the $ Escape Prefix
In templating mode, any single-key object whose key matches an active operator
is evaluated as an operator call. For example, {"cat": ["a", "b"]} calls the
cat operator and produces "ab".
To output a literal object with a key named cat, prefix the key with $:
| Expression Written | Evaluated Output |
|---|---|
{"cat": ["a", "b"]} | "ab" (evaluates the cat operator) |
{"$cat": ["a", "b"]} | {"cat": ["a", "b"]} (emits a literal object) |
{"$$oid": "abc"} | {"$oid": "abc"} |
{"$total": 1} | {"total": 1} |
Three rules govern $ escaping:
- One leading
$is stripped from every template key, whether or not the key matches an operator name ($totalbecomestotal). If your output JSON needs a literal leading$(such as$$set,$$oid,$$schema, or$$ref), double the prefix.Compiler::checkreports anESCAPED_TEMPLATE_KEYadvisory for every escaped key so you can review them. - Escaping applies at every nesting depth, including inside
mapbodies andifbranches. - Two keys in the same object cannot resolve to the same unescaped name.
The compiler rejects
{"$a": 1, "a": 2}withDUPLICATE_TEMPLATE_KEY.
Although the escape character is always $, authoring tools can query it via
ExprEngine::escape():
#![allow(unused)] fn main() { use dataflow_rs::expr::ExprEngine; use dataflow_rs::Expr; use serde_json::{Value, json}; let exprs = ExprEngine::default(); assert_eq!(exprs.escape(), '$'); let context = json!({"data": {}, "metadata": {}, "temp_data": {}}); let operator: Expr = exprs.compile(&json!({"cat": ["a", "b"]})).unwrap(); let literal: Expr = exprs.compile(&json!({"$cat": ["a", "b"]})).unwrap(); assert_eq!(Value::from(&*exprs.eval(&operator, &context).unwrap().value), json!("ab")); assert_eq!( Value::from(&*exprs.eval(&literal, &context).unwrap().value), json!({"cat": ["a", "b"]}) ); }
Multi-key objects are always treated as output templates, so their keys never
need escaping unless a key itself begins with $:
{"amount": {"var": "data.total"}, "currency": "EUR"}
Similarly, a single-key object whose key is not an active operator name (such as
{"result": {"var": "data.x"}}) evaluates its inner value and emits
{"result": β¦}. You only need the $ prefix when a single-key object's key is
an operator name or when any template key starts with a literal $.
If you are authoring a custom host function that takes a JSON template with
literal $ keys (like a MongoDB query), consider using an Interpolated field
instead: inside Interpolated, only {"var": β¦} nodes are evaluated and $
keys require no escaping. See
Interpolated Documents.
Inspecting Active Operators
Because disabled operators simply pass through as literal JSON objects, you
cannot tell from a workflow's JSON alone whether an operator is active. Use
ExprEngine::operators() to list every operator active in a given engine
(including core operators, enabled families, custom host operators, and
secret):
#![allow(unused)] fn main() { use dataflow_rs::expr::{ExprEngine, OperatorFamily}; let exprs = ExprEngine::default(); let names: Vec<&str> = exprs.operators().iter().map(|op| &*op.name).collect(); assert!(names.contains(&"var")); // Core operator: always present assert!(names.contains(&"secret")); // Built-in engine operator: always present // `length` only appears when `OperatorFamily::ExtString` is enabled. // Each operator entry reports its family, whether it scopes `var` to an // array element (`map`, `filter`, `reduce`, etc.), and whether it is deterministic. let map = exprs.operators().iter().find(|op| op.name == "map").unwrap(); assert_eq!(map.family, OperatorFamily::Core); assert!(map.scoping && map.deterministic); }
Authoring tools can inspect ExprEngine::operators() to validate operator usage
in workflows, and CompiledWorkflow::fingerprint records all operators and data
keys referenced by a compiled workflow.
Note for JavaScript/TypeScript users: The WASM package is compiled with the
datetimefeature andExprEngineBuilder::all_operators(), so every operator family in the table above (exceptTensor) is enabled out of the box in the browser and Playground. In Rust,ExprEngine::default()enables core operators only, so remember to enable any extended families you use onExprEngineBuilder.
Compiling and Evaluating Expressions Directly
The engine automatically compiles and evaluates conditions and function
configurations. If you want to evaluate standalone JSONLogic expressions in your
own application code, layers, or custom functions, use ExprEngine directly:
#![allow(unused)] fn main() { use dataflow_rs::datavalue::OwnedDataValue; use dataflow_rs::expr::ExprEngine; use dataflow_rs::Expr; use serde_json::json; let exprs = ExprEngine::default(); // `Expr<T>` specifies the expected result type (`T` defaults to `OwnedDataValue`). let limit: Expr<u64> = exprs.compile(&json!({"*": [{"var": "data.n"}, 2]})).unwrap(); // Pass an object containing the three context roots as either `serde_json::Value` // or `OwnedDataValue`; both are read in place without conversion. let context = json!({"data": {"n": 21}, "metadata": {}, "temp_data": {}}); assert_eq!(exprs.eval(&limit, &context).unwrap().value, 42); let owned = OwnedDataValue::from(&context); assert_eq!(exprs.eval(&limit, &owned).unwrap().value, 42); // Expressions that do not read context or secrets fold to a constant at compile time. let fixed: Expr<u64> = exprs.compile(&json!({"+": [40, 2]})).unwrap(); assert_eq!(fixed.constant(), Some(&42)); // A constant with an incompatible type fails at compile time with a `Config` error, // whereas a runtime type mismatch returns an `Eval` error. assert!(exprs.compile::<u64>(&json!("forty-two")).is_err()); }
Inside a custom host function, call ctx.eval(&expr) to evaluate against the
current call's context view, and use ctx.exprs() to access the program's
ExprEngine if you need to compile expressions dynamically.
Custom Operators
You can register custom JSONLogic operators on ExprEngineBuilder before
creating your Registry. Use .operator(name, op) to register an operator that
is neither scoping nor deterministic, or .operator_with(Operator, op) to
configure those properties explicitly. The builder rejects operator names that
conflict with secret, any enabled built-in operator, or an already-registered
custom operator.
#![allow(unused)] fn main() { tokio::runtime::Builder::new_current_thread().build().unwrap().block_on(async { use dataflow_rs::datalogic_rs::{self, CustomOperator}; use dataflow_rs::datavalue::NumberValue; use dataflow_rs::expr::{ExprEngine, Operator, OperatorFamily}; use dataflow_rs::{Engine, Message, Registry}; use serde_json::json; /// `{"answer": []}`: always returns 42. struct Answer42; impl CustomOperator for Answer42 { fn evaluate<'a>( &self, _args: &[&'a datalogic_rs::DataValue<'a>], _ctx: &mut datalogic_rs::operator::EvalContext<'_, 'a>, arena: &'a datalogic_rs::bumpalo::Bump, ) -> datalogic_rs::Result<&'a datalogic_rs::DataValue<'a>> { Ok(arena.alloc(datalogic_rs::DataValue::Number(NumberValue::from_i64(42)))) } } let exprs = ExprEngine::builder() .operator_with(Operator::custom("answer").deterministic(true), Answer42) .build() .unwrap(); let answer = exprs.operators().iter().find(|op| op.name == "answer").unwrap(); assert_eq!(answer.family, OperatorFamily::Custom); // Reserved names like `secret` are rejected: assert!(ExprEngine::builder().operator("secret", Answer42).build().is_err()); let engine = Engine::builder() .registry(Registry::new(exprs)) .workflow_json(&json!({"id": "w", "tasks": [ {"id": "t", "function": {"name": "map", "input": { "mappings": [{"path": "data.x", "logic": {"answer": []}}]}}} ]})) .build() .unwrap(); let mut message = Message::from_json(&json!({})); engine.run(&mut message).await; assert_eq!(message.to_json()["context"]["data"]["x"], json!(42)); }); }
Each Registry (and the Engine built from it) is bound to a single
ExprEngine, meaning all workflows compiled by that registry share the same
operators and secret store. To change operators or rotate secrets, build a new
ExprEngine and Registry.
Limiting Evaluation Cost (ops_budget)
When the budget Cargo feature is enabled, ExprEngineBuilder::ops_budget(n)
caps the number of operations a single expression evaluation can perform.
(Enabling the feature flag alone sets no limit until you call ops_budget.) If
an evaluation exceeds the budget, it fails with a Budget error (error code
BUDGET_EXCEEDED). You can inspect how many operations an evaluation used via
Evaluated::ops (which returns Some(0) for constant-folded expressions).
let exprs = ExprEngine::builder().ops_budget(2_000).build()?;
let fold: Expr<u64> = exprs.compile(&json!({"reduce": [
{"var": "data.xs"}, {"+": [{"var": "current"}, {"var": "accumulator"}]}, 0
]}))?;
// Evaluating over 20,000 elements exceeds the 2,000-op budget:
let err = exprs.eval(&fold, &big_context).unwrap_err();
assert_eq!(err.code(), "BUDGET_EXCEEDED");
The operation budget applies to every evaluation in the engine:
- If a workflow, group, or task
conditionexceeds the budget, it evaluates tofalse(and workflow selection recordsConditionFailed). - If a built-in function (
map,filter,validation, etc.) exceeds the budget, the task fails withBUDGET_EXCEEDEDand follows itson_failurepolicy. - If a custom function calls
ctx.evaland exceeds the budget,ctx.evalreturns theBudgeterror to the function.
Because the budget is configured on ExprEngine, it is preserved across
workflow reloads via Engine::compiler() and Engine::with_program().
Best Practices
- Use
vardefaults: Provide fallback values for optional fields using{"var": ["data.field", "default"]}. - Check truthiness with
!!: Verify that optional fields or arrays are present and non-empty before operating on them. - Move complex logic into custom functions: Keep JSONLogic expressions focused on routing, validation, and data mapping; implement heavy business logic or external I/O in Rust host functions.
- Verify enabled operator families: Check
ExprEngine::operators()to confirm which extended families are enabled, since disabled operators pass through as literal objects instead of raising errors. - Test in the Playground: Use the interactive Playground or Debugger UI to test and step through complex expressions before deploying.
Control Flow
A workflow's tasks array is a sequence of steps. Each step is either:
- a task: an object with a
functionkey that executes a built-in or custom function - a group: an object with a
taskskey that holds a nested list of steps
Both tasks and groups accept condition and terminal. Tasks also support two
outcome policiesβon_failure and on_rejectionβthat control what happens
after the task finishes. Together, these fields give you familiar structured
control-flow patterns:
| Step Configuration | Equivalent Control Flow |
|---|---|
Group without terminal | if (β¦) { β¦ } |
Task with terminal: true | Early return |
Group with terminal: true | if (β¦) { β¦; return; } |
Task with on_rejection: "halt" | if (rejected) return; |
Task with on_failure: "halt" | if (failed) return; |
Task with on_failure: "continue" | try { β¦ } catch { log } |
All control-flow fields have sensible defaults: terminal defaults to false,
on_failure defaults to "fail", and on_rejection defaults to "continue".
Why Structured Control Flow Matters
Without early exits (terminal), every task following a conditional branch has
to repeat the negation of that branch's condition, causing conditions to grow
more complex with each step:
{
"tasks": [
{ "id": "load_user",
"function": { "name": "user_lookup", "input": {} } },
{ "id": "respond_404",
"condition": {"!": [{"var": "temp_data.user"}]},
"function": { "name": "map", "input": { "mappings": [
{ "path": "data.status", "logic": 404 } ] } } },
{ "id": "check_pw",
"condition": {"!!": [{"var": "temp_data.user"}]},
"function": { "name": "map", "input": { "mappings": [] } } },
{ "id": "issue_tokens",
"condition": {"and": [{"!!": [{"var": "temp_data.user"}]},
{"var": "temp_data.pw.ok"}]},
"function": { "name": "map", "input": { "mappings": [] } } }
]
}
With terminal: true, guard clauses exit the workflow as soon as they run, so
subsequent steps don't need to re-check earlier conditions:
{
"tasks": [
{ "id": "load_user",
"function": { "name": "user_lookup", "input": {} } },
{ "id": "respond_404", "terminal": true,
"condition": {"!": [{"var": "temp_data.user"}]},
"function": { "name": "map", "input": { "mappings": [
{ "path": "data.status", "logic": 404 } ] } } },
{ "id": "check_pw",
"function": { "name": "map", "input": { "mappings": [] } } },
{ "id": "issue_tokens",
"function": { "name": "map", "input": { "mappings": [] } } }
]
}
Early Exit with terminal
Setting terminal: true stops the current workflow after the task or group
executes.
terminal is based on whether the step ran, not whether it succeeded or
rejected:
| Scenario | Halts the Workflow? |
|---|---|
| The task ran and succeeded | Yes |
The task's condition evaluated to false | No (the task did not run) |
The task returned Outcome::Skip | No |
The task returned a 4xx status with on_rejection: "continue" | Yes |
The task failed with on_failure: "continue" | Yes (and the failure is still recorded) |
The task failed with on_failure: "fail" | The failure propagates as usual |
Halting a workflow stops only the current workflow. Any subsequent workflows
in the engine still process the message, just like a filter task with
on_reject: "halt". Inside a workflow with a loop, terminal
exits the entire loop rather than just the current iteration.
When a terminal task halts a workflow, its audit entry records the task's
actual status code (such as 200 or 404) rather than the 299 status
used by filter halts or Outcome::Halt.
#![allow(unused)] fn main() { tokio::runtime::Builder::new_current_thread().build().unwrap().block_on(async { use dataflow_rs::run::WorkflowExit; use dataflow_rs::{Engine, Message}; use serde_json::json; let engine = Engine::builder() .workflow_json(&json!({"id": "login", "tasks": [ {"id": "respond_404", "terminal": true, "condition": {"!": [{"var": "data.user"}]}, "function": {"name": "map", "input": {"mappings": [ {"path": "data.status", "logic": 404}]}}}, {"id": "issue_tokens", "function": {"name": "map", "input": {"mappings": [ {"path": "data.tokens", "logic": true}]}}} ]})) .build() .unwrap(); let mut message = Message::from_json(&json!({})); let run = engine.run(&mut message).await; // The 404 branch ran and ended the workflow; `issue_tokens` never ran. let login = run.workflows().next().unwrap(); assert_eq!(login.exit, Some(WorkflowExit::Halted)); assert_eq!(message.to_json()["context"]["data"], json!({"status": 404})); // The audit entry preserves the task's own status (200), not 299. assert_eq!(message.audit().len(), 1); assert_eq!(message.audit()[0].status, 200); }); }
If you want to halt a workflow based on the task's outcome (for example, halt
only when a task fails or is rejected), use on_failure and on_rejection
instead.
Outcome Policies (on_failure and on_rejection)
Every task execution finishes in one of four ways, governed by two policies:
| Task Outcome | Meaning | Governed By |
|---|---|---|
Ok, or status < 400 | Success | Continues to the next task |
Status 400..=499 | Rejection (input did not pass a check) | on_rejection |
Status >= 500, or Err | Failure (task could not complete its work) | on_failure |
Skip | No-op | Continues to the next task (no audit entry or progress update) |
on_rejectionaccepts:"continue"(default): Records the4xxstatus and continues to the next task."halt": Stops the current workflow.
on_failureaccepts:"fail"(default): Propagates the failure to the workflow level."halt": Stops the current workflow and allows the engine run to continue to the next workflow."continue": Records the failure and continues to the next task.
When a task failure propagates to the workflow level, it is handled by the
workflow's own on_failure policy:
"fail"(default): Stops the entire engine run with statusStoppedand returns the error."continue": Records the failure and moves on to the next workflow (or the next iteration of a loop).
Regardless of the policy, the engine always records the task's outcome first: a
5xx status adds a TASK_STATUS_ERROR record to message.errors(), an Err
adds a record with the error's code, and the audit entry logs the status (500
for an Err). terminal is evaluated after outcome policies and only turns
"continue to the next task" into a workflow haltβso a terminal task that fails
under on_failure: "fail" still fails the workflow.
Halting on Validation Rejections
When a validation rule fails, the task returns status 400 (a rejection).
Because on_rejection defaults to "continue", workflows can run multiple
validation tasks to collect all errors before deciding what to do. If you want a
failed validation task to stop the workflow immediately, set
on_rejection: "halt":
{
"tasks": [
{ "id": "check_state", "on_rejection": "halt",
"function": { "name": "validation", "input": { "rules": [
{ "logic": {"==": [{"var": "data.state"}, {"var": "data.cookie"}]},
"message": "state mismatch" } ] } } },
{ "id": "exchange",
"function": { "name": "map", "input": { "mappings": [
{ "path": "data.exchanged", "logic": true } ] } } }
]
}
#![allow(unused)] fn main() { tokio::runtime::Builder::new_current_thread().build().unwrap().block_on(async { use dataflow_rs::datavalue::OwnedDataValue; use dataflow_rs::run::WorkflowExit; use dataflow_rs::{Engine, Message}; use serde_json::json; let engine = Engine::builder() .workflow_json(&json!({"id": "oauth", "tasks": [ {"id": "check_state", "on_rejection": "halt", "function": {"name": "validation", "input": {"rules": [ {"logic": {"==": [{"var": "data.state"}, {"var": "data.cookie"}]}, "message": "state mismatch"}]}}}, {"id": "exchange", "function": {"name": "map", "input": {"mappings": [ {"path": "data.exchanged", "logic": true}]}}} ]})) .build() .unwrap(); let message_with = |data: serde_json::Value| Message::from_parts( OwnedDataValue::Null, OwnedDataValue::from(&data), OwnedDataValue::Null); let mut mismatch = message_with(json!({"state": "a", "cookie": "b"})); let run = engine.run(&mut mismatch).await; assert_eq!(run.workflows().next().unwrap().exit, Some(WorkflowExit::Halted)); assert_eq!(mismatch.audit()[0].status, 400); assert!(mismatch.to_json()["context"]["data"].get("exchanged").is_none()); let mut matching = message_with(json!({"state": "a", "cookie": "a"})); let run = engine.run(&mut matching).await; assert_eq!(run.workflows().next().unwrap().exit, Some(WorkflowExit::Completed)); assert_eq!(matching.to_json()["context"]["data"]["exchanged"], json!(true)); }); }
If a validation task leaves on_rejection at "continue" and nothing later
in the workflow checks for errors, Compiler::check reports an
UNGUARDED_VALIDATION advisory pointing at the task's on_rejection field.
Note: Halting a workflow is not a global security gate across multiple
workflows. Because subsequent workflows in the engine still process the message,
either return an Err from a host function with on_failure: "fail" to stop
the entire run, or check the error-context path
in downstream workflows.
Task Groups
A group applies a single condition to a contiguous sequence of steps:
{
"id": "have_videos",
"condition": {"!!": [{"var": "temp_data.videos"}]},
"tasks": [
{ "id": "rank", "function": { "name": "map", "input": { "mappings": [] } } },
{ "id": "trim", "function": { "name": "map", "input": { "mappings": [] } } }
]
}
| Field | Required | Default | Description |
|---|---|---|---|
id | Yes | None | Unique step ID within the workflow (shares the same ID namespace as tasks). |
tasks | Yes | None | Non-empty array of nested tasks or groups. |
condition | No | true | JSONLogic condition that gates the entire group. |
terminal | No | false | If true, halts the workflow once the group finishes executing. |
name, description, ext | No | None | Metadata for traces and UI tools (ext is preserved verbatim). |
Because a group does not execute a function of its own, it does not accept
on_failure, on_rejection, or for_each (setting for_each on a group is
rejected with INVALID_FOR_EACH). Place those fields on the individual tasks
inside the group.
A group's condition is evaluated once when entering the group. If the
condition is false, the entire group is skipped without evaluating the
conditions of its inner steps. If the condition is true, the engine enters the
group and runs its steps to completionβeven if an earlier task inside the group
modifies the field that the group's condition checked:
{
"id": "drain",
"condition": {"!!": [{"var": "temp_data.queue"}]},
"tasks": [
{ "id": "take",
"function": { "name": "map", "input": { "mappings": [
{ "path": "temp_data.taken", "logic": {"var": "temp_data.queue"} },
{ "path": "temp_data.queue", "logic": [] } ] } } },
{ "id": "process",
"function": { "name": "map", "input": { "mappings": [] } } }
]
}
Here, take empties temp_data.queue, and process still runs because the
drain group was already entered. (If you had duplicated the condition on both
tasks instead of using a group, process would have been skipped.)
Groups can be nested up to def::MAX_GROUP_DEPTH (8) levels deep. Execution
traces remain task-granular: when a group is skipped, the trace records a
skipped step for each task inside the group.
Combining Groups and terminal
Use terminal to avoid repeating negated conditions after an early-exit
branch, and use groups to avoid repeating a positive condition across
multiple tasks in the same branch. You can combine both to create a multi-step
guard clause:
{
"id": "reject_unverified",
"condition": {"!": [{"var": "temp_data.user.verified"}]},
"terminal": true,
"tasks": [
{ "id": "audit",
"function": { "name": "log", "input": { "message": "unverified" } } },
{ "id": "respond_403",
"function": { "name": "map", "input": { "mappings": [
{ "path": "data.status", "logic": 403 } ] } } }
]
}
terminal vs. filter
A filter task with on_reject: "halt" can
also stop a workflow. Use filter when you want a standalone gate that decides
whether the rest of the workflow (or a single task via "skip") should proceed.
Use terminal: true when a branch performs work (such as setting a 404
response) and then needs to exit. Without terminal, you would need two
separate tasks with opposite conditions that must be kept in sync manually:
{ "id": "respond_404",
"condition": {"!": [{"var": "temp_data.user"}]},
"function": { "name": "map", "input": { "mappings": [] } } },
{ "id": "gate",
"function": { "name": "filter", "input": {
"condition": {"!!": [{"var": "temp_data.user"}]},
"on_reject": "halt" } } }
Setting "terminal": true on respond_404 expresses the same intent in a
single step.
Walking the Authored Step Tree
def::parse parses a workflow definition into a WorkflowDef whose tasks
preserve the nested structure of StepDef::Task and StepDef::Group nodes.
Tools that validate, lint, or visualize workflows can traverse this tree with
WorkflowDef::walk(), which yields each step along with its authored JSON path
and StepKind (visiting loop.setup steps first, and each group before its
children):
#![allow(unused)] fn main() { use dataflow_rs::def::{MAX_GROUP_DEPTH, ParseOptions, parse}; use dataflow_rs::program::StepKind; use serde_json::json; let parsed = parse( &json!({"id": "w", "tasks": [ {"id": "load", "function": {"name": "map", "input": {"mappings": []}}}, {"id": "have_user", "condition": true, "tasks": [ {"id": "greet", "function": {"name": "map", "input": {"mappings": []}}} ]} ]}), ParseOptions::default(), ); let def = parsed.def.expect("the definition parses"); let steps: Vec<(String, StepKind, &str)> = def .walk() .map(|(path, kind, step)| (path.to_string(), kind, step.id())) .collect(); assert_eq!(steps, [ ("tasks[0]".to_owned(), StepKind::Task, "load"), ("tasks[1]".to_owned(), StepKind::Group, "have_user"), ("tasks[1].tasks[0]".to_owned(), StepKind::Task, "greet"), ]); assert_eq!(MAX_GROUP_DEPTH, 8); }
Filtering walk() to StepKind::Task (and StepKind::Setup for loop setup
steps) gives the exact sequence of tasks the engine executes. See
Authoring-Time Validation for more details on
structural validation.
Migrating from 3.x
In 3.x, failure and rejection behavior was configured using continue_on_error
(a boolean on tasks and workflows) and halt_on ("never" or "failure" on
tasks). By default, def::parse enables the 3.x compatibility reader, which
automatically translates these fields into 4.0 policies and emits a
V3_SPELLING advisory at the 3.x key:
| Scope | 3.x Configuration | 4.0 Equivalent |
|---|---|---|
| Workflow | continue_on_error: true | on_failure: "continue" |
| Workflow | continue_on_error: false | on_failure: "fail" |
| Task | continue_on_error: true | on_failure: "continue", on_rejection: "continue" |
| Task | halt_on: "failure" | on_failure: "fail", on_rejection: "halt" |
| Task | Both continue_on_error: true and halt_on: "failure" | on_failure: "halt", on_rejection: "halt" |
| Task | Neither, or halt_on: "never" | Default policies (on_failure: "fail", on_rejection: "continue") |
- Combining a 3.x flag (
continue_on_errororhalt_on) with a 4.0 policy (on_failureoron_rejection) on the same node is rejected withMIXED_SPELLING. - Setting
continue_on_erroron a group (which 3.x ignored) moves the field into the group'sextmap and emits aGROUP_CONTINUE_ON_ERRORadvisory. - Setting
halt_onon a group is rejected withINVALID_HALT_ON. - When the compatibility reader is disabled (
ParseOptions::default().compat(false)), 3.x keys are treated as unknown keys and rejected.
To permanently upgrade stored workflow JSON to the 4.0 format, use
compat::migrate_v3. It rewrites 3.x fields into 4.0 policies (omitting keys
that match their default values) so the workflow can be parsed with
.compat(false):
#![allow(unused)] fn main() { use dataflow_rs::compat::migrate_v3; use serde_json::json; let migrated = migrate_v3(&json!({ "id": "w", "tasks": [{"id": "t", "halt_on": "failure", "function": {"name": "validation", "input": {"rules": []}}}] })) .unwrap(); let task = &migrated.json["tasks"][0]; assert_eq!(task["on_rejection"], json!("halt")); assert!(task.get("on_failure").is_none()); assert!(task.get("halt_on").is_none()); assert_eq!(migrated.rewrites[0].path.to_string(), "tasks[0].halt_on"); assert_eq!( migrated.rewrites[0].to_string(), "`halt_on: failure` is a v3 spelling, read as `on_failure: fail, on_rejection: halt`" ); }
See Migrating from 3.x for the complete migration guide.
Authoring-Time Validation
Services that accept workflow definitions from an API or store them in a database need to validate definitions as soon as they are submittedβrather than waiting until the engine is next rebuilt. Dataflow-rs provides two validation stages, both of which report every issue at the exact JSON path where it was written:
def::parseparses the JSON into aWorkflowDefand checks all structural rules, unknown keys, and 3.x compatibility spellings.Compilercompiles a parsedWorkflowDefagainst aRegistry, checking that every task references a registered function, every function input compiles cleanly, and every JSONLogic expression passes static checks.
Engine::builder().build() runs both stages automatically and fails if any
definition is rejected. That works well when your application loads a fixed set
of workflows at startup. If your application loads workflows dynamically from a
database and needs to quarantine invalid definitions while running the valid
ones, call def::parse and Compiler directly.
Parsing a Definition (def::parse)
def::parse reads a single workflow JSON value into a WorkflowDef and lints
its structure, unknown keys, and 3.x spellings. It returns a Parsed struct
containing all discovered issues, and sets def to Some(WorkflowDef) when
no issue has Rejected severity.
If you already own the serde_json::Value, use def::parse_owned(value, options)
(or EngineBuilder::workflow_json_owned(value)), which rewrites 3.x
compatibility spellings in place without cloning the document.
#![allow(unused)] fn main() { use dataflow_rs::def::{ParseOptions, parse}; use dataflow_rs::program::IssueCode; use serde_json::json; let submitted = json!({ "id": "", "tasks": [ {"id": "charge", "function": {"name": "map", "input": {"mappings": []}}}, {"id": "charge", "function": {"name": "map", "input": {"mappings": []}}} ] }); let parsed = parse(&submitted, ParseOptions::default()); assert!(parsed.def.is_none()); // Reports all problems in a single pass, not just the first one encountered. let codes: Vec<IssueCode> = parsed.issues.iter().map(|i| i.code).collect(); assert_eq!(codes, [IssueCode::EmptyWorkflowId, IssueCode::DuplicateStepId]); }
Each Issue includes the exact authored path in the submitted JSON and the step
where the problem occurred, making it easy to return detailed 400 Bad Request
responses:
#![allow(unused)] fn main() { use dataflow_rs::def::{ParseOptions, parse}; use dataflow_rs::program::{IssueCode, StepKind}; use serde_json::json; let nested = json!({ "id": "w", "tasks": [ {"id": "first", "function": {"name": "map", "input": {"mappings": []}}}, {"id": "guard", "condition": true, "tasks": [ {"id": "first", "function": {"name": "map", "input": {"mappings": []}}} ]} ] }); let parsed = parse(&nested, ParseOptions::default()); let issue = &parsed.issues[0]; assert_eq!(issue.code, IssueCode::DuplicateStepId); assert_eq!(issue.path.to_string(), "tasks[1].tasks[0].id"); assert_eq!(issue.step_kind, Some(StepKind::Task)); assert_eq!(issue.step_id.as_deref(), Some("first")); }
Notice that issue.path is tasks[1].tasks[0].idβmatching the nested structure
in the submitted JSON (WorkflowDef::walk uses the same coordinates) rather
than a flattened task index. Missing or mistyped fields are reported as
PARSE_FAILED at their exact path with a message explaining what was expected.
Issue Fields
| Field | Description |
|---|---|
code | The IssueCode (as_str() returns its stable SCREAMING_SNAKE_CASE identifier). |
severity | Rejected, Defect, or Advisory. |
path | The AuthoredPath in the submitted JSON (e.g., tasks[1].tasks[0].id). |
step_kind, step_id | The enclosing step (task, group, or setup under loop.setup). |
field | For function input issues, the top-level field name inside function.input. |
message | Human-readable explanation of the issue. |
Issue serializes to a JSON object with these seven fields, and its Display
implementation formats as
tasks[0].id: every step needs a non-empty id [MISSING_STEP_ID].
Issue Severity
Not every issue prevents a workflow from loading. Severity categorizes issues
into three levels, and IssueCode::severity() returns the default severity for
each code:
#![allow(unused)] fn main() { use dataflow_rs::program::{IssueCode, Severity}; // Advisory: reported for review, and the workflow loads and runs normally. assert_eq!(IssueCode::UnguardedValidation.severity(), Severity::Advisory); // Defect: loads, but will likely fail or misbehave when processing messages. assert_eq!(IssueCode::LikelyTypo.severity(), Severity::Defect); // Rejected: invalid definition; cannot be loaded or compiled. assert_eq!(IssueCode::DuplicateStepId.severity(), Severity::Rejected); assert_eq!(IssueCode::DuplicateStepId.as_str(), "DUPLICATE_STEP_ID"); assert_eq!(Severity::Defect.as_str(), "DEFECT"); }
Rejected: The workflow definition is invalid and cannot run (parsereturnsdef: None, orCompiler::compile_onereturnsErr).Defect: The workflow compiles and loads, but contains a bug that will cause it to misbehave at runtimeβsuch as a misspelled field name (LIKELY_TYPO), avarreading frompayload(PAYLOAD_VAR), or a missing external connector (UNRESOLVED_REFERENCE). Checking only whether a workflow builds will miss defects; always check forSeverity::Defectwhen validating submitted workflows.Advisory: The workflow loads and runs, but has a pattern worth bringing to the author's attention (such as an unguardedvalidationtask).
Severity::Advisory is the only level that can be safely ignored when gating
workflow activation. Both IssueCode and Severity are marked
#[non_exhaustive], so always include a wildcard arm when matching on them.
Unknown Keys and the Compatibility Reader
ParseOptions::default() enables the 3.x compatibility reader. It
translates 3.x policy flags (continue_on_error, halt_on) and validate into
their 4.0 equivalents and emits a V3_SPELLING advisory (see
Control Flow).
When the compatibility reader is enabled, unknown keys on workflow, task, or group objects are handled as follows:
| Unknown Key Type | Issue Code | Severity | Behavior |
|---|---|---|---|
Close to a known key at that level (e.g., conditon, on_failrue) | LIKELY_TYPO | Defect | Workflow still loads; the key is moved into the node's ext object. |
| Any other unknown key | UNKNOWN_KEY | Advisory | Workflow still loads; the key is moved into the node's ext object. |
Unknown keys inside loop, for_each, rollout, or function are preserved
in ext under loop.<key>, for_each.<key>, etc., and unknown keys inside a
built-in function's input are preserved under function.input.<key>.
#![allow(unused)] fn main() { use dataflow_rs::def::{ParseOptions, parse}; use dataflow_rs::program::{IssueCode, Severity}; use serde_json::json; let json = json!({"id": "w", "tasks": [ {"id": "t", "conditon": true, "colour": "teal", "function": {"name": "map", "input": {"mappings": []}}} ]}); // With the compatibility reader enabled (default), both keys load and are reported in order. let parsed = parse(&json, ParseOptions::default()); let rows: Vec<(IssueCode, Severity, String)> = parsed .issues .iter() .map(|i| (i.code, i.severity, i.path.to_string())) .collect(); assert_eq!(rows, [ (IssueCode::LikelyTypo, Severity::Defect, "tasks[0].conditon".to_owned()), (IssueCode::UnknownKey, Severity::Advisory, "tasks[0].colour".to_owned()), ]); let def = parsed.def.unwrap(); let dataflow_rs::def::StepDef::Task(task) = &def.tasks()[0] else { unreachable!() }; assert_eq!(task.header().ext()["colour"], json!("teal")); // With `.compat(false)`, all unknown keys are rejected. let strict = parse(&json, ParseOptions::default().compat(false)); assert!(strict.def.is_none()); assert!(strict.issues.iter().all(|i| i.severity == Severity::Rejected)); }
Inside a custom host function's input, validation is always strict:
#[derive(FunctionConfig)] rejects any unknown key (LIKELY_TYPO or
UNKNOWN_KEY with Rejected severity) regardless of the compat setting.
Issue Code Reference
Workflow and Step Structure
| Code | Severity | Description |
|---|---|---|
EMPTY_WORKFLOW_ID | Rejected | Workflow id is missing or empty. |
NO_TASKS | Rejected | Workflow tasks is missing, not an array, or empty. |
MISSING_STEP_ID | Rejected | A task or group is missing its id. |
DUPLICATE_STEP_ID | Rejected | Two steps share the same id (tasks, groups, and loop.setup steps all share one ID namespace). |
EMPTY_GROUP | Rejected | A group's tasks field is not a non-empty array. |
GROUP_TOO_DEEP | Rejected | Groups are nested deeper than MAX_GROUP_DEPTH (8). |
MISSING_FUNCTION | Rejected | A task is missing its function field. |
INVALID_FUNCTION_NAME | Rejected | function is not an object with a non-empty name. |
INVALID_TERMINAL | Rejected | terminal is present but is not a boolean. |
PARSE_FAILED | Rejected | A required key is missing or has the wrong JSON type. |
NULL_CONDITION | Advisory | condition is literally null (which is falsy, so the step never runs; omit condition for an unconditional step). |
INVALID_ROLLOUT | Defect | A workflow's rollout range can never match (bucket_end < bucket_start or bucket_end > 100). |
MIXED_SPELLING | Rejected | A 3.x policy key (continue_on_error, halt_on) is combined with a 4.0 policy key on the same node. |
EXT_COLLISION | Rejected | An unknown key moved into ext collides with a key already present in ext. |
Loops and Fan-Out
| Code | Severity | Description |
|---|---|---|
LOOP_INCREMENT_TOO_SMALL | Rejected | loop.increment < 1 (the counter would never reach max). |
LOOP_BOUND_EMPTY | Rejected | loop.max <= loop.init (zero iterations could run). |
LOOP_COUNTER_INVALID | Rejected | loop.counter is not a valid non-empty dotted path. |
LOOP_SLOT_INVALID | Rejected | loop.as or loop.scratch is not a valid non-empty dotted path. |
LOOP_ITEM_WITHOUT_OVER | Rejected | loop.as is set without loop.over. |
LOOP_SLOT_COLLISION | Rejected | Two of counter, as, and scratch point to the same or overlapping paths. |
LOOP_OVER_INVALID | Rejected | loop.over is a scalar literal that can never evaluate to an array, or loop.init < 0 is used alongside loop.over (see Loops). |
INVALID_FOR_EACH | Rejected | A task's for_each configuration is invalid, or for_each is placed on a built-in function or group (see Fan-Out). |
Functions and Inputs
| Code | Severity | Description |
|---|---|---|
UNKNOWN_FUNCTION | Rejected | No built-in or registered host function matches function.name (includes a "did you mean" suggestion when close to a registered name). |
INPUT_PARSE | Rejected | Function configuration failed validation (missing field, wrong type, custom check failure, or Function::prepare error). |
TEMPLATE_COMPILE | Rejected | A JSONLogic expression failed to compile. |
INVALID_MAPPING | Rejected | A map item violates mapping constraints (see Map). |
NULL_MAPPING | Advisory | A map item's logic is a constant null, which is a no-op by default (use "unset": true to remove a field). |
UNGUARDED_VALIDATION | Advisory | A validation task's rejection is neither halted on nor checked later in the workflow. |
INVALID_PATH | Rejected | A literal context path or Interpolated placeholder is not rooted at data, metadata, or temp_data. |
PAYLOAD_VAR | Defect | A literal var expression reads from payload (which is not part of the evaluation context). |
UNRESOLVED_REFERENCE | Defect | A field marked as a Reference in FunctionSpec contains a literal name that the ResourceOracle could not resolve. |
Secrets, Template Keys, and Interpolated Documents
| Code | Severity | Description |
|---|---|---|
UNKNOWN_SECRET | Rejected | An expression references a literal secret name not present in the engine's Secrets store (see Secrets). |
SECRET_IN_MESSAGE_WRITE | Rejected | An expression whose output is recorded by the engine reads a secret (see Secrets). |
DUPLICATE_TEMPLATE_KEY | Rejected | Two keys in the same template object resolve to the same name after stripping a leading $. |
ESCAPED_TEMPLATE_KEY | Advisory | A template key uses a leading $ escape prefix. |
OPERATOR_IN_DOCUMENT | Advisory | An Interpolated document contains an operator-shaped object (which is kept as literal data rather than evaluated). |
SECRET_IN_DOCUMENT | Rejected | An Interpolated document contains a {"secret": β¦} node. |
Unknown Keys and 3.x Spellings
| Code | Severity | Description |
|---|---|---|
LIKELY_TYPO | Defect | An unknown key is within a small edit distance of a valid key at that level. |
UNKNOWN_KEY | Advisory | An unknown key does not resemble any valid key at that level (moved into ext). |
V3_SPELLING | Advisory | A 3.x policy key or validate function name was rewritten to its 4.0 equivalent. |
GROUP_CONTINUE_ON_ERROR | Advisory | A group has continue_on_error (moved into ext). |
INVALID_HALT_ON | Rejected | halt_on has a value other than "never" or "failure", or is placed on a group. |
Compiling Against a Registry (Compiler)
Structural parsing (def::parse) checks the shape of the workflow JSON. The
second stageβcompiling against a Registryβverifies that every task names a
registered function, every function config compiles, and all expressions pass
secret and path checks.
Create a Compiler from a Registry (or call engine.compiler() on a running
engine) to compile or check workflows individually:
#![allow(unused)] fn main() { use dataflow_rs::def::{ParseOptions, parse}; use dataflow_rs::program::{CompileOptions, IssueCode}; use dataflow_rs::Registry; use serde_json::json; let compiler = Registry::default().compiler(CompileOptions::default()); let def = parse( &json!({"id": "w", "tasks": [{"id": "t", "function": {"name": "fetch"}}]}), ParseOptions::default(), ) .def .unwrap(); // `compile_one` returns `Err(issues)` when any issue has `Rejected` severity, // or `Ok(Compiled)` containing the compiled workflow plus any Defects and Advisories. let issues = compiler.compile_one(&def).unwrap_err(); assert_eq!(issues[0].code, IssueCode::UnknownFunction); assert_eq!(issues[0].path.to_string(), "tasks[0].function.name"); assert_eq!(issues[0].step_id.as_deref(), Some("t")); // `check` returns all issues regardless of whether compilation succeeded or failed. assert_eq!(compiler.check(&def), issues); }
Issues inside a function's configuration are reported under
tasks[i].function.input.<field>, with issue.field set to the top-level input
field name.
Validating Step Fragments (check_steps)
If you are building a workflow editor that validates a single branch or group as
the user edits it, use Compiler::check_steps. It parses and compiles a JSON
array of steps relative to a given root path (such as "tasks[2].tasks" or
"loop.setup"), reporting all issue paths under that prefix:
#![allow(unused)] fn main() { use dataflow_rs::program::{CompileOptions, IssueCode}; use dataflow_rs::Registry; use serde_json::json; let compiler = Registry::default().compiler(CompileOptions::default()); let steps = json!([ {"id": "t", "function": {"name": "fetch", "input": {}}} ]); let issues = compiler.check_steps(&steps, "tasks[2].tasks"); assert_eq!(issues[0].code, IssueCode::UnknownFunction); assert_eq!(issues[0].path.to_string(), "tasks[2].tasks[0].function.name"); }
Validating External Resource References (ResourceOracle)
A custom function's FunctionSpec can declare that certain config fields
reference external resources (such as database connectors) using
Reference::new(field, kind). When you supply a ResourceOracle via
CompileOptions::resources, the compiler validates every literal resource name
against your oracle and reports missing resources as UNRESOLVED_REFERENCE
(Severity::Defect).
#![allow(unused)] fn main() { use dataflow_rs::def::{ParseOptions, parse}; use dataflow_rs::function::{FunctionConfig, FunctionSpec, Reference}; use dataflow_rs::program::{CompileOptions, IssueCode, ResourceOracle, Severity}; use dataflow_rs::{Ctx, Function, Outcome, Registry, Result}; use serde_json::json; use std::sync::Arc; #[derive(FunctionConfig)] struct QueryConfig { connector: String, sql: String, } struct Query; impl Function for Query { type Config = QueryConfig; fn spec(&self) -> FunctionSpec { FunctionSpec::default().with_references(vec![Reference::new("connector", "connector")]) } async fn call(&self, _ctx: &mut Ctx<'_>, _cfg: &QueryConfig) -> Result<Outcome> { Ok(Outcome::Ok) } } /// Checks whether a connector name exists in the current deployment. struct Connectors; impl ResourceOracle for Connectors { fn resolve(&self, _kind: &str, name: &str) -> std::result::Result<(), String> { if name == "orders" { Ok(()) } else { Err(String::new()) } } } let mut registry = Registry::default(); registry.register("query", Query); let compiler = registry.compiler(CompileOptions::default().resources(Arc::new(Connectors))); let def = parse( &json!({"id": "w", "tasks": [{"id": "t", "function": {"name": "query", "input": {"connector": "sessions", "sql": "select 1"}}}]}), ParseOptions::default(), ) .def .unwrap(); let compiled = compiler.compile_one(&def).unwrap(); assert_eq!(compiled.issues[0].code, IssueCode::UnresolvedReference); assert_eq!(compiled.issues[0].severity, Severity::Defect); assert_eq!(compiled.issues[0].path.to_string(), "tasks[0].function.input.connector"); }
If ResourceOracle::resolve returns Err(reason) with a non-empty reason
string, that reason is included in the issue message; returning Err(String::new())
produces the default message (no connector named `sessions` resolves).
Quarantining Invalid Workflows
Engine::builder().build() fails if any workflow definition is rejected. When
loading many workflow rows from a database, you can compile each workflow
individually, quarantine any rows that fail, and link the valid workflows into a
running Engine with compiler.link(accepted):
#![allow(unused)] fn main() { use dataflow_rs::def::{ParseOptions, parse}; use dataflow_rs::program::CompileOptions; use dataflow_rs::run::EngineConfig; use dataflow_rs::{Engine, Issue, Registry}; use serde_json::{Value, json}; let submitted: Vec<Value> = vec![ json!({"id": "good", "tasks": [{"id": "t", "function": {"name": "map", "input": {"mappings": [{"path": "data.ok", "logic": true}]}}}]}), json!({"id": "bad", "tasks": [{"id": "t", "function": {"name": "fetch"}}]}), ]; let compiler = Registry::default().compiler(CompileOptions::default()); let mut accepted = Vec::new(); let mut quarantined: Vec<(usize, Vec<Issue>)> = Vec::new(); for (row, raw) in submitted.iter().enumerate() { let parsed = parse(raw, ParseOptions::default()); let Some(def) = parsed.def else { quarantined.push((row, parsed.issues)); continue; }; match compiler.compile_one(&def) { Ok(compiled) => accepted.push(compiled.workflow), Err(issues) => quarantined.push((row, issues)), } } let engine = Engine::new(compiler.link(accepted), EngineConfig::default()); assert_eq!(engine.program().workflows().len(), 1); assert_eq!(quarantined.len(), 1); assert_eq!(quarantined[0].0, 1); }
compiler.link(accepted) sorts the compiled workflows by priority and builds
an immutable Program. To hot-reload workflows on a running engine, run the
same loop using engine.compiler() and call engine.with_program(program): the
new Engine instance runs the updated program while any in-flight runs on the
previous instance finish undisturbed.
Recommended Validation Flow for APIs
When validating a workflow submitted to an API endpoint:
- Run
def::parse(&json, ParseOptions::default())to validate structure and field paths. Ifparsed.defisNone(or if any issue hasDefectseverity), reject the request with400 Bad Requestand returnparsed.issues. - Run
compiler.compile_one(&def)against your targetRegistry. If it returnsErr(issues)(or ifcompiled.issuescontains anyDefect), reject the request with400 Bad Requestand return the issues. - Store and activate the workflow, keeping any
Advisoryissues to display as warnings in your UI.
Secrets
Workflows often need sensitive valuesβsuch as signing keys, partner API tokens,
and webhook secretsβthat must never appear in serialized messages, audit
entries, or execution traces. Store these values in the engine's Secrets store
and read them using the {"secret": "name"} operator.
Why Secrets Belong Outside the Message Context
A message's context ({data, metadata, temp_data}) is both the input to
JSONLogic expressions and the recorded output of a run: Message serializes its
context directly, execution traces capture the initial context and every change
made to it, and audit entries record the values written by tasks. Any secret
placed in data, metadata, or temp_data will inevitably appear in
serialized messages and traces.
While TraceOptions::redact can mask specific paths in a trace, keeping secrets
out of the message context altogether prevents accidental leaks across every
output channel.
Configuring the Secrets Store
Store secrets in a Secrets instance on ExprEngineBuilder. Workflows can then
access them through the reserved {"secret": "name"} operator:
#![allow(unused)] fn main() { tokio::runtime::Builder::new_current_thread().build().unwrap().block_on(async { use dataflow_rs::datavalue::OwnedDataValue; use dataflow_rs::expr::{ExprEngine, Secrets}; use dataflow_rs::{Engine, Message, Registry}; use serde_json::json; // Resolve secret values from your environment, vault, or config file first. // Here, the webhook accepts both the current token and the next token during rotation. let store = Secrets::new(OwnedDataValue::from(&json!({ "webhook_tokens": ["tok-7f2a", "tok-next-91c0"], "partner": {"hmac": "hmac-3d4e"} // Nested objects can be accessed via dotted paths: {"secret": "partner.hmac"} }))) .unwrap(); let exprs = ExprEngine::builder().secrets(store).build().unwrap(); let engine = Engine::builder() .registry(Registry::new(exprs)) .workflow_json(&json!({ "id": "verify", "condition": { "in": [{"var": "metadata.headers.x-token"}, {"secret": "webhook_tokens"}] }, "tasks": [ {"id": "accept", "function": {"name": "map", "input": { "mappings": [{"path": "data.accepted", "logic": true}]}}} ] })) .build() .unwrap(); let mut message = Message::from_parts( OwnedDataValue::Null, OwnedDataValue::Null, OwnedDataValue::from(&json!({"headers": {"x-token": "tok-7f2a"}})), ); engine.run(&mut message).await; assert_eq!(message.to_json()["context"]["data"]["accepted"], json!(true)); // Even though the condition read the full token list, none of the store's secrets // appear in the serialized message. let wire = serde_json::to_string(&message).unwrap(); assert!(!wire.contains("tok-next-91c0") && !wire.contains("hmac-3d4e")); assert!(!wire.contains("secret")); }); }
Secrets::new accepts an object of already-resolved secret values (passing a
non-object returns a Config error). You can nest objects to organize secrets
by namespace and access them with dotted paths like {"secret": "partner.hmac"}.
Because the Registry compiles workflows against this ExprEngine, all
workflows in the engine share the same secret store.
You can use {"secret": "name"} in workflow, group, and task conditions,
validation rules, filter conditions, and custom host function inputs. If a
host function takes a secret name as a string parameter rather than a
JSONLogic expression, it can look up the secret directly via ctx.secret:
#![allow(unused)] fn main() { use dataflow_rs::Ctx; fn _demo(ctx: &Ctx<'_>, key_name: &str) { let key = ctx.secret(key_name); // Option<&OwnedDataValue> let _ = key; } }
Security Guarantees
Because the Secrets store is never part of a Message, secrets cannot leak
through Serialize for Message, trace initial snapshots, trace change logs,
or audit entries.
In addition:
- Zero overhead when unused: Expressions that do not call
secretincur no runtime cost. - Redacted
Debugoutput: FormattingSecretswithDebugprints secret names with their values masked, andSecretsintentionally does not implementSerializeorClone.Secrets::names()lists only top-level key names. - Automatic redaction for
CallLayers: When a logging or dry-run layer callsctx.input(), any field whose expression reads a secret is replaced withnullwithout being evaluated. Inside the host function itself,ctx.resolve(&cfg)evaluates all fields including secrets.
Preventing Secret Leaks
Keeping secrets in a separate store wouldn't help if a workflow simply copied a secret into the message context:
{ "path": "data.sig", "logic": { "secret": "partner_key" } }
Because JSONLogic operators like cat, substr, and if can copy or transform
values in arbitrary ways, dataflow-rs enforces a strict compile-time rule:
any expression whose result is recorded by the engine is forbidden from
reading a secret. This applies to both literal secret names and dynamic names
({"secret": {"var": "β¦"}}).
This rule covers destination paths as well as values, since paths are recorded
in Change.path and on the audit trail:
| Parameter | Why Secret Reads Are Blocked |
|---|---|
map: logic | The result is written directly into the message context. |
map: path | The destination path is recorded in Change.path and the audit trail. |
validation: message | Recorded in Message::errors (a rule may test a secret in logic, but may not output one in message). |
log: message, fields.* | Emitted to application logs. |
parse_* / publish_*: source, target | Determine paths read from and written to the message context. |
publish_xml: root_element | Written into the serialized XML stored at data.{target}. |
Any Output field of a host function | Recorded as the destination path in every Change emitted through it. |
Any #[config(records)] field of a host function | Explicitly marked by the function author as recorded in the message or logs. |
All other expressionsβincluding conditions (which only produce booleans) and
regular Expr<T> fields on custom host functionsβare allowed to read secrets.
Note that Interpolated documents cannot contain {"secret": β¦} nodes at all
(SECRET_IN_DOCUMENT), because only var placeholders are substituted in
interpolated documents.
The compiler checks secret usage at both authoring time and build time:
| Issue Code | When It Is Reported |
|---|---|
SECRET_IN_MESSAGE_WRITE | Any recorded expression listed above reads from {"secret": β¦}. |
UNKNOWN_SECRET | An expression references a literal secret name that is not present in the Secrets store. |
Both issues have Rejected severity: Engine::builder().build() refuses to
build the workflow, and Compiler::check reports the exact authored path:
#![allow(unused)] fn main() { use dataflow_rs::datavalue::OwnedDataValue; use dataflow_rs::def::{ParseOptions, parse}; use dataflow_rs::expr::{ExprEngine, Secrets}; use dataflow_rs::program::{CompileOptions, IssueCode}; use dataflow_rs::Registry; use serde_json::json; let store = Secrets::new(OwnedDataValue::from(&json!({"k": "v"}))).unwrap(); let exprs = ExprEngine::builder().secrets(store).build().unwrap(); let compiler = Registry::new(exprs).compiler(CompileOptions::default()); let leaky = parse( &json!({"id": "w", "tasks": [{"id": "sign", "function": {"name": "map", "input": { "mappings": [{"path": "data.sig", "logic": {"secret": "k"}}]}}}]}), ParseOptions::default(), ) .def .unwrap(); let issues = compiler.check(&leaky); assert_eq!(issues[0].code, IssueCode::SecretInMessageWrite); assert_eq!(issues[0].path.to_string(), "tasks[0].function.input.mappings[0].logic"); assert!(compiler.compile_one(&leaky).is_err()); let undeclared = parse( &json!({"id": "w", "condition": {"==": [{"var": "data.t"}, {"secret": "nope"}]}, "tasks": [{"id": "t", "function": {"name": "map", "input": {"mappings": []}}}]}), ParseOptions::default(), ) .def .unwrap(); let issues = compiler.check(&undeclared); assert_eq!(issues[0].code, IssueCode::UnknownSecret); assert_eq!(issues[0].path.to_string(), "condition"); }
When you need to compute a value derived from a secret (such as an HMAC
signature or a signed URL) and write it to the message, implement that operation
in a custom host function. The host function can evaluate the secret via an
Expr field and write only the non-sensitive result to the message:
#![allow(unused)] fn main() { tokio::runtime::Builder::new_current_thread().build().unwrap().block_on(async { use dataflow_rs::datavalue::OwnedDataValue; use dataflow_rs::expr::{ExprEngine, Output, Secrets}; use dataflow_rs::function::{FunctionConfig, testing}; use dataflow_rs::{Ctx, Expr, Function, Message, Outcome, Result}; use serde_json::json; #[derive(FunctionConfig)] struct SignConfig { key: Expr<String>, // Configured as {"secret": "partner.hmac"} in the workflow body: Expr<String>, // Configured as {"var": "data.body"} #[output(default = "data.signature")] output: Output, } struct Sign; impl Function for Sign { type Config = SignConfig; async fn call(&self, ctx: &mut Ctx<'_>, cfg: &SignConfig) -> Result<Outcome> { let key = ctx.eval(&cfg.key)?; let body = ctx.eval(&cfg.body)?; // Layers calling `ctx.input()` see `null` for the secret-reading field: assert_eq!(ctx.input()?.get("key"), Some(&OwnedDataValue::Null)); let signature = hmac_hex(&key, &body); ctx.emit(&cfg.output, OwnedDataValue::from(&json!(signature)))?; // Writes the signature, never the key Ok(Outcome::Ok) } } fn hmac_hex(key: &str, body: &str) -> String { format!("{}:{}", body.len(), key.len()) } let store = Secrets::new(OwnedDataValue::from(&json!({"partner": {"hmac": "hmac-91c0"}}))).unwrap(); let exprs = ExprEngine::builder().secrets(store).build().unwrap(); let data = OwnedDataValue::from(&json!({"body": "hello"})); let called = testing::call( Sign, json!({"key": {"secret": "partner.hmac"}, "body": {"var": "data.body"}}), ) .exprs(exprs) .message(Message::from_parts(OwnedDataValue::Null, data, OwnedDataValue::Null)) .await; assert_eq!(called.outcome.unwrap(), Outcome::Ok); assert_eq!(called.message.to_json()["context"]["data"]["signature"], json!("5:9")); }); }
Handling Unknown Secret Names
- Literal secret names that do not exist in the
Secretsstore are rejected at compile time withUNKNOWN_SECRET, catching typos before any messages run. - Dynamic secret names (such as
{"secret": {"var": "data.key_name"}}) that fail to resolve at runtime produce an evaluation error: conditions evaluate tofalse,validationrules record an error, andctx.evalin a host function returnsErr. Missing secrets never silently resolve tonull.
Error messages only ever include the secret's key name, never its value.
Boundaries and Limitations
- Host function responsibility: The compiler prevents workflows from writing secrets directly into recorded fields, but it cannot inspect Rust code inside custom host functions. Ensure your host functions never write raw secrets into the message context or error messages.
- Operator error formatting: Some extended
datalogic-rsoperators (such asdatetime) include invalid operand values in their error messages when parsing fails. Avoid passing secrets into operators that echo their inputs on failure. - In-process memory: Resolved secrets are copied into the evaluation arena during expression evaluation and are not zeroized afterward.
- Secret rotation: Every workflow compiled by a
Registryshares itsExprEngineandSecretsstore (including across reloads viaengine.compiler()). To rotate secrets, construct a newExprEngineandRegistryand recompile your workflows. - Reserved operator name:
secretis a reserved operator name on everyExprEngine. Calling{"secret": "x"}on an engine without a configuredSecretsstore returns an evaluation error rather than passing through as a literal JSON object.
Audit Trails and Execution Traces
Dataflow-rs provides two ways to inspect what a run did to a message:
- The audit trail: one
AuditEntryper executed task, stored directly on theMessage. - An execution trace (optional): a record of the initial context and every step-by-step change, allowing you to reconstruct the exact message context at any point in the run.
This guide covers both mechanisms and how to configure their memory footprint.
The Audit Trail
Every task that executes (and does not return Outcome::Skip) appends an
AuditEntry to message.audit():
| Field | Description |
|---|---|
workflow_id, task_id | Identifies the workflow and task that produced the entry. |
timestamp | The run's timestamp (all entries in a single run share the same clock reading). |
status | The task's status code: 200 for Outcome::Ok, n for Status(n), 299 for Halt, or 500 for Err. |
changes | The list of context writes made by the task (when change capture is enabled). |
loop_counter | The current iteration counter when inside a workflow loop; omitted otherwise. |
element_index | The current array index when inside a task for_each; omitted otherwise. |
Each Change in entry.changes represents a single write or key removal:
| Field | Description |
|---|---|
path | The dotted context path that was modified (e.g., data.full_name). |
old_value | The previous value at path, or null if the key did not exist. |
new_value | The new value written at path, or null if the key was removed. |
removed | true if the key was removed rather than set to null (omitted in JSON when false). |
Because new_value is null both when a field is set to null and when a
field is removed (via map's "unset": true, on_null: "unset", or
ctx.remove in a host function), check change.removed to distinguish a
removal from a null assignment. (Change also has a replaced field used
exclusively by execution traces; on audit entries it is always false and
omitted in JSON.)
If a custom host function returns Err, the engine discards its buffered
context writes and still records an AuditEntry with status: 500 and an empty
changes list, while appending the error details to message.errors().
#![allow(unused)] fn main() { tokio::runtime::Builder::new_current_thread().build().unwrap().block_on(async { use dataflow_rs::datavalue::OwnedDataValue; use dataflow_rs::{Engine, Message}; use serde_json::json; let engine = Engine::builder() .workflow_json(&json!({"id": "names", "tasks": [ {"id": "full_name", "function": {"name": "map", "input": {"mappings": [ {"path": "data.full_name", "logic": {"cat": [{"var": "data.first"}, " ", {"var": "data.last"}]}}, {"path": "data.first", "unset": true} ]}}} ]})) .build() .unwrap(); let data = OwnedDataValue::from(&json!({"first": "Ada", "last": "Lovelace"})); let mut message = Message::from_parts(OwnedDataValue::Null, data, OwnedDataValue::Null); engine.run(&mut message).await; for entry in message.audit() { println!("{}/{} -> {}", entry.workflow_id, entry.task_id, entry.status); for change in &entry.changes { println!(" {}: {} -> {}", change.path, change.old_value, change.new_value); } } let entry = &message.audit()[0]; assert_eq!((&*entry.task_id, entry.status), ("full_name", 200)); assert_eq!(entry.changes.len(), 2); assert_eq!(&*entry.changes[0].path, "data.full_name"); assert_eq!(entry.changes[0].new_value, OwnedDataValue::from(&json!("Ada Lovelace"))); assert!(entry.changes[1].removed); assert_eq!(entry.changes[1].old_value, OwnedDataValue::from(&json!("Ada"))); }); }
JSON Format
When a Message is serialized to JSON, the audit trail is written under the
audit_trail key:
{
"audit_trail": [
{
"workflow_id": "names",
"task_id": "full_name",
"timestamp": "2026-01-01T12:00:00Z",
"changes": [
{"path": "data.full_name", "old_value": null, "new_value": "Ada Lovelace"},
{"path": "data.first", "old_value": "Ada", "new_value": null, "removed": true}
],
"status": 200
}
]
}
What Gets Recorded in changes
map: Records oneChangeper mapping that modified the context (a write, a removal, orβformode: "append"and"extend"βoneChangeper appended element atpath.<index>).- Custom host functions: Records one
Changeperctx.set,ctx.emit, orctx.removecall in execution order once the call returnsOk. validation: Modifies no context fields, so itschangeslist is empty. Itsstatusis200when all rules pass and400when any rule fails (failed rules are recorded inmessage.errors()).- Engine bookkeeping writes: Internal context updatesβsuch as
metadata.progress, loop counters/elements/scratch paths, andfor_eachresult arraysβare not added to task audit entries, though they are captured in execution traces.
Only Outcome::Skip suppresses the AuditEntry altogether.
Bounding Audit Trail Memory
Two options on RunOptions control how much audit data a run retains on the
message:
capture_changes(bool)(defaulttrue): Controls whether each write's old and new values are cloned intoentry.changes. Cloning values on every write is the main allocation cost in mapping-heavy pipelines and long loops. Settingcapture_changes(false)leavesentry.changesempty while preserving the audit entries, status codes, and error records.audit(AuditMode)(defaultAuditMode::Full): Controls how many audit entries are kept:AuditMode::Full: Keeps all audit entries.AuditMode::Last(n): Keeps only the most recentnentries in a ring buffer.AuditMode::Off: Keeps no audit entries (and automatically disables change capture, just likeLast(0)).
Control flow, error records, and metadata.progress behave identically across
all audit modes.
#![allow(unused)] fn main() { tokio::runtime::Builder::new_current_thread().build().unwrap().block_on(async { use dataflow_rs::run::AuditMode; use dataflow_rs::{Engine, Message, RunOptions}; use serde_json::json; let engine = Engine::builder() .workflow_json(&json!({"id": "w", "tasks": [ {"id": "a", "function": {"name": "map", "input": {"mappings": [{"path": "data.a", "logic": 1}]}}}, {"id": "b", "function": {"name": "map", "input": {"mappings": [{"path": "data.b", "logic": 2}]}}}, {"id": "c", "function": {"name": "map", "input": {"mappings": [{"path": "data.c", "logic": 3}]}}} ]})) .build() .unwrap(); // Keep all audit entries, but omit the old/new value snapshots: let mut message = Message::from_json(&json!({})); engine.run_with(&mut message, &RunOptions::default().capture_changes(false)).await; assert_eq!(message.audit().len(), 3); assert!(message.audit().iter().all(|e| e.changes.is_empty())); // Keep only the last 2 audit entries: let mut message = Message::from_json(&json!({})); engine.run_with(&mut message, &RunOptions::default().audit(AuditMode::Last(2))).await; let kept: Vec<&str> = message.audit().iter().map(|e| &*e.task_id).collect(); assert_eq!(kept, ["b", "c"]); // Disable the audit trail completely; context writes still take effect: let mut message = Message::from_json(&json!({})); engine.run_with(&mut message, &RunOptions::default().audit(AuditMode::Off)).await; assert!(message.audit().is_empty()); assert_eq!(message.to_json()["context"]["data"], json!({"a": 1, "b": 2, "c": 3})); }); }
Execution Traces
To record a full execution trace for a run, pass
RunOptions::trace(TraceOptions) to engine.run_with and retrieve the trace
from the returned Run via run.trace() (or run.take_trace()). A Trace
contains:
initial: The message context after initial run metadata stamps (metadata.processed_at,metadata.engine_version, andmetadata.channel) have been written, before any workflow executes.steps: A sequence ofTraceSteprecords covering every executed task, skipped task, skipped or failed workflow, and loop iteration setup write. Each step records itsresult(executed,skipped, orfailed),status, orderedchanges(including engine bookkeeping writes such asmetadata.progressand error-context writes), addederrors,loop_counter,element_index, and optional execution duration (whenTraceOptions::timings(true)is enabled).truncated: Indicates whether the trace exceededTraceOptions::max_bytes, after which subsequent steps record their metadata without change payloads.
Rather than snapshotting the entire context after every step, Trace stores
only the incremental changes. Calling trace.contexts() replays those changes
from initial to yield the exact context after each stepβand for map tasks,
trace.mapping_contexts(step) replays changes mapping by mapping. For
interactive step-by-step navigation over large traces, observe::Replay
maintains a single working context and seeks on demand (context_at,
mapping_context_at). If your application accepts serialized traces from
external callers, call trace.validate() first to verify its paths, root
writes, and mapping boundaries.
Note three differences between trace changes and audit trail changes:
- Independent of
capture_changes: Enabling tracing captures step changes in theTraceeven whenRunOptions::capture_changes(false)is set for the message's audit trail. - Root replacement tracking: A trace
Changeat a context root setsreplaced: truewhen a host function replaces the root object as a whole, distinguishing it from amaproot mapping that merges keys into the root. - Path redaction:
TraceOptions::redactmasks values at specified paths in bothinitialand stepchangesas they are recorded, while leaving the real values intact on theMessage.
#![allow(unused)] fn main() { tokio::runtime::Builder::new_current_thread().build().unwrap().block_on(async { use dataflow_rs::observe::{StepResult, TraceOptions}; use dataflow_rs::{Engine, Message, RunOptions}; use serde_json::json; let engine = Engine::builder() .workflow_json(&json!({"id": "w", "tasks": [ {"id": "first", "function": {"name": "map", "input": {"mappings": [{"path": "data.a", "logic": 1}]}}}, {"id": "skipped", "condition": false, "function": {"name": "map", "input": {"mappings": [{"path": "data.never", "logic": 1}]}}}, {"id": "second", "function": {"name": "map", "input": {"mappings": [{"path": "data.b", "logic": 2}]}}} ]})) .build() .unwrap(); let mut message = Message::from_json(&json!({})); let run = engine .run_with(&mut message, &RunOptions::default().trace(TraceOptions::default())) .await; let trace = run.trace().unwrap(); let steps: Vec<(&str, StepResult)> = trace .steps .iter() .map(|s| (s.task_id.as_deref().unwrap(), s.result)) .collect(); assert_eq!(steps, [ ("first", StepResult::Executed), ("skipped", StepResult::Skipped), ("second", StepResult::Executed), ]); // Replaying changes from `initial` reproduces the final message context: let contexts: Vec<_> = trace.contexts().collect(); assert_eq!(serde_json::to_value(contexts[0].data()).unwrap(), json!({"a": 1})); assert_eq!(contexts.last().unwrap().as_value(), message.context().as_value()); }); }
A Trace serializes cleanly to {initial, steps, truncated} and deserializes
back, so you can store traces and replay them later in another service or load
them into the UI debugger.
Lightweight Metrics with Observer
When you want to collect metrics, counters, histograms, or tracing spans during
execution without the overhead of building a full Trace, implement Observer.
The engine invokes your observer inline for each event it subscribes to
(RunStarted, WorkflowStarted, TaskFinished, WorkflowFinished,
RunFinished).
Attach an observer globally via EngineConfig::observer or for a single run via
RunOptions::observer. Override Observer::interest() to select which events
you want to receive; the engine only reads the system clock for task timings if
your observer requests Interest::TIMINGS.
#![allow(unused)] fn main() { tokio::runtime::Builder::new_current_thread().build().unwrap().block_on(async { use dataflow_rs::observe::{Event, Observer}; use dataflow_rs::run::EngineConfig; use dataflow_rs::{Engine, Message}; use serde_json::json; use std::sync::Arc; use std::sync::atomic::{AtomicU64, Ordering}; #[derive(Default)] struct Calls(AtomicU64); impl Observer for Calls { fn on(&self, event: &Event<'_>) { if let Event::TaskFinished { .. } = event { self.0.fetch_add(1, Ordering::Relaxed); } } } let calls = Arc::new(Calls::default()); let engine = Engine::builder() .config(EngineConfig::default().observer(calls.clone())) .workflow_json(&json!({"id": "w", "tasks": [ {"id": "t", "function": {"name": "map", "input": { "mappings": [{"path": "data.a", "logic": 1}]}}} ]})) .build() .unwrap(); engine.run(&mut Message::from_json(&json!({}))).await; assert_eq!(calls.0.load(Ordering::Relaxed), 1); }); }
Common Use Cases
Debugging Where a Field Changed
#![allow(unused)] fn main() { fn _demo(message: dataflow_rs::Message) { for entry in message.audit() { for change in &entry.changes { if &*change.path == "data.total" { println!("data.total set by {}/{}", entry.workflow_id, entry.task_id); println!("changed from {} to {}", change.old_value, change.new_value); } } } } }
Detecting Whether a Field Was Modified
#![allow(unused)] fn main() { use dataflow_rs::Message; fn was_field_modified(message: &Message, field: &str) -> bool { message .audit() .iter() .flat_map(|e| e.changes.iter()) .any(|c| &*c.path == field) } let _ = was_field_modified; }
Compliance Logging
Each AuditEntry serializes and deserializes directly with serde_json, making
it straightforward to persist audit records to an external log or database.
Best Practices
- Perform all writes through
Ctx: Host functions write viactx.set,ctx.emit, andctx.remove, ensuring every context modification is captured consistently. - Disable
capture_changesin high-throughput pipelines: If downstream code only needs task status codes and not old/new value snapshots, run withRunOptions::default().capture_changes(false). See Memory in Long Loops. - Bound or disable the audit trail when unused: Use
AuditMode::Last(n)orAuditMode::Offwhen you don't inspectmessage.audit(). - Use
Tracefor step-by-step state inspection: The audit trail records what each task wrote; aTracelets you reconstruct the complete message context after any step (including skipped steps and engine bookkeeping).
Loops
By default, a workflow executes its task list once. Adding a loop configuration
repeats the workflow's tasks as a bounded loop: you can iterate over an array,
run a fixed number of times, step through a counter range, or repeat until the
workflow's condition evaluates to false.
{
"id": "per_item",
"condition": {"<": [{"var": "temp_data.i"}, {"var": "temp_data.n"}]},
"loop": { "counter": "i", "init": 0, "increment": 1, "max": 10000 },
"tasks": [ ]
}
Configuration Fields
| Field | Required | Default | Description |
|---|---|---|---|
max | Yes | None | Upper bound on the loop counter. Iterations run while counter < max (half-open range). |
counter | No | None | temp_data path where the engine writes the current counter value (e.g., "i" writes to temp_data.i). Supports nested dot-paths. |
init | No | 0 | Initial counter value for the first iteration. |
increment | No | 1 | Amount added to the counter after each iteration. Must be >= 1. |
setup | No | [] | Steps executed once before the first iteration, using the standard task/group grammar. See Iterating Over an Array. |
over | No | None | JSONLogic expression evaluated once after setup. Must produce an array; the loop stops when it reaches the end of the array. |
as | No | None | temp_data path where the engine writes the current element of over on each iteration. Requires over. |
scratch | No | None | temp_data path reset to {} at the start of every iteration for per-iteration temporary state. |
max is required so that every loop has a guaranteed upper bound. Setting
init: 0, max: n produces counter values 0..n-1, matching zero-based array
indices. The default values for init and increment are exposed as constants
at def::defaults::INIT and def::defaults::INCREMENT.
Execution Order per Iteration
Each pass through the loop is called a sweep (or iteration). On every sweep, the engine performs the following steps in order:
1. Write the counter to `temp_data.<counter>` (if `counter` is configured)
2. Check `counter < max` -> exit loop if false
3. Check the run's deadline and cancel token -> stop the run if expired/cancelled
4. Re-evaluate the workflow `condition` -> exit loop if false
5. Execute the workflow's `tasks` in sequence
6. Add `increment` to the counter
Because the counter is written to temp_data before the workflow condition is
evaluated, the condition can safely reference or index by the counter on the very
first iteration.
A loop finishes when:
- The counter reaches
max - An
overarray has no more elements - The workflow
conditionevaluates tofalse - A task halts the workflow (
terminal: true,Outcome::Halt,filterhalt, or a"halt"outcome policy) - An unhandled task failure stops the workflow
Reaching max is treated as normal completion (WorkflowExit::Completed), not
an error.
The engine manages the counter. The counter value is tracked internally by
the engine and written into temp_data at the start of each iteration. If a
task overwrites temp_data.<counter> during an iteration, the engine replaces
it with the true next counter value on the following iteration. When the loop
finishes, temp_data.<counter> holds the first out-of-bounds counter value.
#![allow(unused)] fn main() { tokio::runtime::Builder::new_current_thread().build().unwrap().block_on(async { use dataflow_rs::run::WorkflowExit; use dataflow_rs::{Engine, Message}; use serde_json::json; let engine = Engine::builder() .workflow_json(&json!({"id": "w", "loop": {"counter": "i", "max": 3}, "tasks": [ {"id": "seen", "function": {"name": "map", "input": {"mappings": [ {"path": "data.seen", "logic": {"var": "temp_data.i"}, "mode": "append"}]}}}, {"id": "clobber", "function": {"name": "map", "input": {"mappings": [ {"path": "temp_data.i", "logic": 100}]}}} ]})) .build() .unwrap(); let mut message = Message::from_json(&json!({})); let run = engine.run(&mut message).await; let w = run.workflows().next().unwrap(); assert_eq!((w.sweeps, w.exit), (3, Some(WorkflowExit::Completed))); let context = message.to_json()["context"].clone(); assert_eq!(context["data"]["seen"], json!([0, 1, 2])); assert_eq!(context["temp_data"]["i"], json!(3)); // Each iteration's audit entries are stamped with the loop counter: let counters: Vec<Option<i64>> = message.audit().iter().map(|e| e.loop_counter).collect(); assert_eq!(counters, [Some(0), Some(0), Some(1), Some(1), Some(2), Some(2)]); }); }
Iterating Over an Array
A common workflow pattern is processing a batch of items: run one-time setup steps, then execute a sequence of tasks (including custom host functions) for each item in an array.
Use over to specify the array expression, as to bind the current element in
temp_data, setup for steps that run once before the loop starts, and
scratch to give each iteration a clean {} object for temporary per-item
variables:
{
"id": "batch",
"loop": {
"setup": [
{ "id": "read",
"function": { "name": "map", "input": { "mappings": [
{ "path": "temp_data.batch", "logic": { "var": "data.claim.items" } } ] } } }
],
"over": { "var": "temp_data.batch" },
"as": "item",
"counter": "i",
"max": 64,
"scratch": "it"
},
"tasks": [
{ "id": "flag", "condition": { ">": [{ "var": "temp_data.item.qty" }, 10] },
"function": { "name": "map", "input": { "mappings": [
{ "path": "temp_data.it.large", "logic": true } ] } } },
{ "id": "collect",
"function": { "name": "map", "input": { "mappings": [
{ "path": "data.processed", "mode": "append",
"logic": { "id": { "var": "temp_data.item.id" },
"large": { "!!": [{ "var": "temp_data.it.large" }] } } } ] } } }
]
}
When over is configured, the engine executes the workflow in this order:
1. Evaluate the workflow `condition` once -> if false, skip both `setup` and the loop
2. Run `setup` steps once -> if `terminal` or halted here, exit the workflow
3. Evaluate `over` once -> must produce an array; otherwise the workflow fails
Then, on each iteration:
4. Write the counter to `temp_data.<counter>`
5. Exit the loop if `counter >= max` or `counter >= over.len()`
6. Check the run's deadline and cancellation token
7. Write `over[counter]` to `temp_data.<as>`
8. Reset `temp_data.<scratch>` to `{}`
9. Re-evaluate the workflow `condition` -> exit loop if false
10. Execute `tasks` in sequence
11. Add `increment` to the counter
With over and scratch, you don't need manual array-length checks, index
lookups, or cleanup mappings between iterations: temp_data.it is reset to {}
before every item, so temp_data.it.large never leaks from one item to the
next.
#![allow(unused)] fn main() { tokio::runtime::Builder::new_current_thread().build().unwrap().block_on(async { use dataflow_rs::datavalue::OwnedDataValue; use dataflow_rs::{Engine, Message}; use serde_json::json; let engine = Engine::builder() .workflow_json(&json!({"id": "batch", "loop": { "setup": [{"id": "read", "function": {"name": "map", "input": {"mappings": [ {"path": "temp_data.batch", "logic": {"var": "data.claim.items"}}]}}}], "over": {"var": "temp_data.batch"}, "as": "item", "counter": "i", "max": 64, "scratch": "it"}, "tasks": [ {"id": "flag", "condition": {">": [{"var": "temp_data.item.qty"}, 10]}, "function": {"name": "map", "input": {"mappings": [{"path": "temp_data.it.large", "logic": true}]}}}, {"id": "collect", "function": {"name": "map", "input": {"mappings": [ {"path": "data.processed", "mode": "append", "logic": {"id": {"var": "temp_data.item.id"}, "large": {"!!": [{"var": "temp_data.it.large"}]}}}]}}} ]})) .build() .unwrap(); let data = OwnedDataValue::from(&json!({"claim": {"items": [ {"id": "b", "qty": 20}, {"id": "a", "qty": 5} ]}})); let mut message = Message::from_parts(OwnedDataValue::Null, data, OwnedDataValue::Null); let run = engine.run(&mut message).await; assert_eq!(run.workflows().next().unwrap().sweeps, 2); assert_eq!( message.to_json()["context"]["data"]["processed"], json!([{"id": "b", "large": true}, {"id": "a", "large": false}]) ); // Setup steps execute outside the loop and have `loop_counter: None`. assert_eq!(message.audit()[0].loop_counter, None); assert_eq!(&*message.audit()[0].task_id, "read"); }); }
Important Details for Array Loops
- The counter is the array index:
initsets the starting index (must be>= 0whenoveris used) andincrementsets the step size. With the defaults (init: 0,increment: 1), the loop visits each element in order up tomax. overmust evaluate to an array: Ifoverevaluates tonullor any non-array value, the workflow fails before the first iteration and records aLOGIC_ERRORunder the workflow's ID. (An empty array[]is valid and runs zero iterations.) How the failure is handled depends on the workflow'son_failurepolicy; see Error Handling in Loops.setupruns outside the loop: Audit entries and trace steps fromsetuphaveloop_counter: None,CallInfo::loop_counterisNone, andWorkflowRecord::sweepscounts only loop iterations.setupshares the step ID namespace withtasksand supports nested task groups.- A failure in
setupaborts the workflow: If a setup step fails, the loop never starts. Under workflowon_failure: "continue", the failure is recorded and the engine moves on to the next workflow; under"fail"(the default), the run stops. scratchis reset before the workflow condition is re-evaluated: Each iteration starts withtemp_data.<scratch>set to{}. After the loop terminates,counter,as, andscratchretain their final values intemp_data.setupandscratchwork withoutover: Whileasrequiresover, you can usesetupandscratchon any counter or condition loop.- Engine loop bindings are not recorded as task audit changes: Writes to
counter,as, andscratchhappen between iterations outside any task. Execution traces record them as dedicated engine steps at the start of each iteration.
Indexing an Array by Counter Manually
If you prefer not to use over, you can index into an array manually using the
loop counter and the val operator:
[
{
"id": "setup", "priority": 0,
"tasks": [{
"id": "count",
"function": { "name": "map", "input": { "mappings": [
{ "path": "temp_data.n",
"logic": {"reduce": [{"var": "data.items"},
{"+": [{"var": "accumulator"}, 1]}, 0]} },
{ "path": "data.processed", "logic": [] }
]}}
}]
},
{
"id": "per_item", "priority": 1,
"condition": {"<": [{"var": "temp_data.i"}, {"var": "temp_data.n"}]},
"loop": { "counter": "i", "max": 10000 },
"tasks": [
{
"id": "pick",
"function": { "name": "map", "input": { "mappings": [
{ "path": "temp_data.item",
"logic": {"val": [["data", "items", {"var": "temp_data.i"}]]} }
]}}
},
{
"id": "collect",
"function": { "name": "map", "input": { "mappings": [
{ "path": "data.processed", "mode": "append",
"logic": {"var": "temp_data.item.id"} }
]}}
}
]
}
]
Keep in mind that temp_data persists across iterations unless you use
scratch or clear fields explicitly. If a task writes to a temp_data field
conditionally (for example, {"if": [cond, value, null]}, where null is
skipped by map), the previous iteration's value will remain in that field on
iterations where the condition is false. Either place per-iteration fields under
scratch, add "on_null": "unset" to the mapping, or unset the field at the
end of the iteration with {"path": "temp_data.slot", "unset": true}.
Running a Fixed Number of Times
To run a workflow's tasks a fixed number of times, omit condition and set
max:
{ "id": "three_times",
"loop": { "max": 3 },
"tasks": [ ] }
counter is optionalβthe engine still tracks the iteration count internally and
stamps loop_counter on every audit entry.
Repeating Until a Condition Becomes False
You can drive a loop with the workflow's condition (such as polling or
following pagination cursors) while using max as a safety limit:
{ "id": "paginate",
"condition": {"!!": [{"var": "temp_data.next_cursor"}]},
"loop": { "max": 1000 },
"tasks": [ ] }
If evaluating the workflow condition produces an error, it is treated as
false and ends the loop.
Breaking Out of a Loop Early
The workflow condition is checked only between iterations. To exit a loop
partway through an iteration, use any workflow-halting mechanism:
- A task or group with
terminal: true - A
filtertask withon_reject: "halt" - A task with
on_rejection: "halt"oron_failure: "halt" - A custom host function returning
Ok(Outcome::Halt)
Each of these exits the entire loop (not just the current iteration).
{
"id": "stop_on_error",
"function": { "name": "filter", "input": {
"condition": {"!": [{"var": "temp_data.item.invalid"}]},
"on_reject": "halt"
}}
}
To skip only a single task while continuing the current iteration, use filter
with on_reject: "skip" or a task condition.
Error Handling in Loops
When a task failure propagates to the workflow level (because the task returned
a 5xx status or Err under its default on_failure: "fail" policy), the
workflow's own on_failure policy decides what happens next:
on_failure: "continue": The engine records the error and advances to the next iteration of the loop rather than aborting the remaining items. This is ideal for batch processing where a failure on item 7 should not prevent item 8 from being processed.on_failure: "fail"(default): The failure stops the loop and the engine run immediately.
#![allow(unused)] fn main() { tokio::runtime::Builder::new_current_thread().build().unwrap().block_on(async { use dataflow_rs::run::{RunStatus, WorkflowExit}; use dataflow_rs::{Engine, Message}; use serde_json::json; // `data.batch` is missing, so `over` evaluates to `null` instead of an array. let engine = Engine::builder() .workflow_json(&json!({"id": "batch", "priority": 0, "on_failure": "continue", "loop": {"over": {"var": "data.batch"}, "as": "item", "max": 10}, "tasks": [{"id": "t", "function": {"name": "log", "input": {"message": "item"}}}]})) .workflow_json(&json!({"id": "after", "priority": 1, "tasks": [{"id": "mark", "function": {"name": "map", "input": {"mappings": [ {"path": "data.after", "logic": true}]}}}]})) .build() .unwrap(); let mut message = Message::from_json(&json!({})); let run = engine.run(&mut message).await; assert!(matches!(run.status(), RunStatus::CompletedWithErrors)); let exits: Vec<_> = run.workflows().map(|w| w.exit).collect(); assert_eq!(exits, [Some(WorkflowExit::Failed), Some(WorkflowExit::Completed)]); let error = &message.errors()[0]; assert_eq!(error.code, "LOGIC_ERROR"); assert_eq!(error.workflow_id.as_deref(), Some("batch")); assert_eq!(error.task_id, None); assert_eq!(message.to_json()["context"]["data"]["after"], json!(true)); }); }
Audit Trail in Loops
Every task executed inside a loop records an AuditEntry stamped with
loop_counter, indicating which iteration produced it:
{
"workflow_id": "per_item",
"task_id": "call",
"status": 200,
"loop_counter": 7,
"changes": []
}
loop_counter is recorded even when loop.counter is omitted in the workflow
definition. Workflows without a loop omit loop_counter from their audit
entries. Trace steps and TaskFinished observer events also include
loop_counter.
Memory in Long Loops
Each iteration appends one AuditEntry per executed taskβso a 1,000-iteration
loop with 3 tasks produces 3,000 audit entries. When capture_changes is
enabled (the default), every entry also stores clones of the old and new values
for every context write. In long loops or loops that modify large fields, those
change snapshots can consume significant memory.
If you do not need AuditEntry::changes, disable change capture on
RunOptions:
#![allow(unused)] fn main() { use dataflow_rs::RunOptions; let opts = RunOptions::default().capture_changes(false); assert!(!opts.capture_changes); }
(Execution traces capture their own changes independently of capture_changes,
so disabling capture_changes does not affect traced runs.)
If you also want to cap the number of AuditEntry records retained on the
message during a long loop, configure AuditMode::Last(n) or AuditMode::Off:
#![allow(unused)] fn main() { use dataflow_rs::run::AuditMode; use dataflow_rs::RunOptions; let opts = RunOptions::default() .capture_changes(false) .audit(AuditMode::Last(64)); assert_eq!(opts.audit, AuditMode::Last(64)); }
See Bounding Audit Trail Memory for more details.
Performance
Workflows without a loop incur zero overhead from loop support.
During a loop run, the engine maintains a single arena-backed view of the message context and splices each write directly into that view, so each iteration only pays for the fields it reads and writes rather than the total size of the message context. See Performance for benchmark details.
Compile-Time Validation
def::parse validates loop configurations at authoring and build time,
reporting any invalid configuration with Rejected severity:
max <= init: The loop range is empty (LOOP_BOUND_EMPTY).increment < 1: The counter would never advance (LOOP_INCREMENT_TOO_SMALL).- Empty or malformed
counter,as, orscratchpath (LOOP_COUNTER_INVALID,LOOP_SLOT_INVALID). asconfigured withoutover(LOOP_ITEM_WITHOUT_OVER).overset to a scalar literal (string, number, boolean, ornull) that can never evaluate to an array, orinit < 0combined withover(LOOP_OVER_INVALID).- Overlapping paths among
counter,as, andscratchβfor example,"it"and"it.item"(LOOP_SLOT_COLLISION). - Invalid steps inside
setup, or step IDs insetupthat duplicate step IDs intasks(DUPLICATE_STEP_ID).
See Authoring-Time Validation for the full list of issue codes.
Fan-Out (for_each)
Adding for_each to a task invokes its host function once per element of an
array (for example, running one model inference per participant or sending one
HTTP request per recipient) as a single workflow step.
{
"id": "infer",
"for_each": {
"over": { "var": "data.participants" },
"as": "p",
"max_concurrency": 8,
"collect": "temp_data.move",
"into": "temp_data.moves"
},
"on_failure": "continue",
"function": { "name": "model_infer", "input": {
"model": { "var": "temp_data.p.model" },
"input": { "var": "temp_data.p.view" },
"output": "temp_data.move" } }
}
In this example, each invocation sees its element at temp_data.p and its zero-based
index at temp_data.p_index, writes its output to temp_data.move, and has its
output collected by the engine into the temp_data.moves array in element
order.
When to Use for_each vs. loop
- A workflow
looprepeats the entire workflow task list sequentially for each iteration, and a workflow can have only oneloop. - A task
for_eachrepeats a single host-function task across an array of elements (optionally concurrently viamax_concurrency), and can be used inside a workflow that already has aloop.
Configuration Fields
| Field | Required | Default | Description |
|---|---|---|---|
over | Yes | None | JSONLogic expression evaluated once before fan-out. Must produce an array. |
as | Yes | None | temp_data key bound to the current element in each call. The element's index is bound alongside it at temp_data.<as>_index. |
max_concurrency | No | 1 | Maximum number of element calls allowed to run concurrently (1 runs them sequentially). |
collect | With into | None | Context path where each element call writes its result. |
into | With collect | None | Context path where the engine writes the collected results array, in element order. |
Only custom host functions support for_each. Built-in functions (map,
validation, etc.) run inline and already have JSONLogic array operators (map,
filter, reduce) for per-element transformations; placing for_each on a
built-in function or a task group is rejected at compile time.
Execution Lifecycle
When a task with for_each runs, the engine performs these steps:
1. Evaluate the task's `condition` once -> if false, skip the entire task
2. Evaluate `over` once -> if not an array, fail the task
3. Execute one function call per element (up to `max_concurrency` in flight at once),
each reading from the shared context with its element bound in an isolated overlay
4. Commit results in element order (0, 1, 2, ...), stopping at the first element
that fails the task or halts:
- append any error records from the call
- apply the call's context writes
- copy the value at `collect` into `into[i]`
- append the element's audit entry
5. Apply the task's `terminal` and halting policies to the fan-out as a whole
Call Isolation and Determinism
Every element call sees the message context as it was before the for_each
task started, with its own temp_data.<as> and temp_data.<as>_index
bindings layered underneath its own buffered writes.
- No cross-element visibility: Element 3 never sees writes made by element
2, even when
max_concurrencyis1. - Zero context cloning: Element bindings live only in each call's read view; the message context itself is not cloned per element.
- Deterministic output regardless of concurrency: Because writes are buffered
per call and committed in strict element order (
0..n) after all calls finish,max_concurrency: 1andmax_concurrency: 8produce the exact same context state, collected array, and audit trail order.
For custom function authors, this means:
- Write through
Ctx: All writes made withctx.set,ctx.emit, andctx.removeare replayed in element order during the commit phase. - Do not rely on state from earlier elements: Each element call starts from the pre-task context snapshot.
- Element bindings are temporary:
temp_data.<as>andtemp_data.<as>_indexexist only in each call's view and are not written to the message after the task completes (unless your function explicitly callsctx.seton those paths). You can also read the current element index in Rust viactx.info().element_index.
Collecting Results (collect and into)
When collect and into are configured, each element call writes its output to
the collect path, and the engine gathers those values into an array at into.
Element i's result is always placed at into[i].
into[i] is set to null if element i:
- Failed (returned
Error a status>= 400) - Did not write a value to
collect - Never ran (or was not committed) because an earlier element failed the task or halted
Because each call's writes to collect are also replayed onto the message in
element order, collect holds the last committed element's value after the task
completes. If over evaluates to an empty array [], no calls run and into
is set to [].
collect and into are optional: if your function performs side effects (such
as publishing a notification per recipient) or writes to distinct paths per
element, you can omit both fields.
Error Handling and Halting in for_each
During fan-out execution, individual element failures and rejections are governed
by the task's on_failure and on_rejection policies, while workflow-halting
actions (terminal: true, on_rejection: "halt", and on_failure: "halt") are
applied once after all elements have been committed:
- With
on_failure: "continue"or"halt": If an element fails, its error is recorded, its slot ininto[i]is set tonull, and the remaining elements continue normally. If the policy is"halt", the workflow stops after all elements finish and commit. - With
on_failure: "fail"(default): If an element fails, the task fails. No new element calls are started, any in-flight calls are allowed to finish, and the commit step stops at the first failing element index (subsequent elements do not commit their context writes, though external side effects such as network requests already in flight may have occurred). - When an element returns
Outcome::Halt: No new element calls are started, the commit step stops after committing the halting element (discarding context writes from higher-indexed elements), and the workflow halts. - Non-array
over: Ifoverevaluates tonullor a non-array value, the task fails immediately before invoking any element calls. - Deadlines and cancellation: If the run's deadline expires or its
cancellation token is triggered during a fan-out, uncommitted element writes
are discarded, recorded errors are kept, and the run ends with
TimedOutorCancelled.
Audit Trail and Observability
A for_each task records one AuditEntry per committed element, stamped
with element_index:
{ "workflow_id": "turn", "task_id": "infer", "status": 200,
"element_index": 3, "changes": [ ] }
The same element_index field is included on trace steps and error records
produced by that element. Inside a looping workflow, entries carry both
loop_counter and element_index.
When capture_changes is enabled, each element's audit entry stores the changes
made by that element. For large fan-outs inside long loops, consider disabling
capture_changes; see Memory in Long Loops.
metadata.progress is updated after each element's entry. If over is empty or
fails to evaluate to an array, the task records a single audit entry without
element_index. Observers receive one TaskFinished event per element call,
each timed individually.
Complete Example
#![allow(unused)] fn main() { tokio::runtime::Builder::new_current_thread().build().unwrap().block_on(async { use dataflow_rs::datavalue::OwnedDataValue; use dataflow_rs::function::FunctionConfig; use dataflow_rs::{Ctx, Engine, Expr, Function, Message, Outcome, Result}; use serde_json::json; #[derive(FunctionConfig)] struct GreetConfig { /// Who to greet: configured as `{"var": "temp_data.p.name"}` in the workflow. name: Expr<String>, } /// Greets the person bound at `temp_data.p`. struct Greet; impl Function for Greet { type Config = GreetConfig; async fn call(&self, ctx: &mut Ctx<'_>, cfg: &GreetConfig) -> Result<Outcome> { let name = ctx.eval(&cfg.name)?; let greeting = OwnedDataValue::from(&json!(format!("hello, {name}"))); ctx.set(&"temp_data.greeting".parse()?, greeting)?; Ok(Outcome::Ok) } } let engine = Engine::builder() .register("greet", Greet) .workflow_json(&json!({ "id": "greet_all", "tasks": [{ "id": "greet", "for_each": { "over": {"var": "data.people"}, "as": "p", "max_concurrency": 4, "collect": "temp_data.greeting", "into": "data.greetings" }, "function": {"name": "greet", "input": {"name": {"var": "temp_data.p.name"}}} }] })) .build() .unwrap(); let data = OwnedDataValue::from(&json!({"people": [{"name": "ada"}, {"name": "grace"}]})); let mut message = Message::from_parts(OwnedDataValue::Null, data, OwnedDataValue::Null); engine.run(&mut message).await; let context = message.to_json()["context"].clone(); assert_eq!(context["data"]["greetings"], json!(["hello, ada", "hello, grace"])); // Element bindings are scoped to each call's view and not left in `temp_data`: assert!(context["temp_data"].get("p").is_none()); // One audit entry per element, in element order: let elements: Vec<Option<usize>> = message.audit().iter().map(|e| e.element_index).collect(); assert_eq!(elements, [Some(0), Some(1)]); }); }
Performance
Because for_each binds each element in a lightweight overlay over the shared
context view rather than cloning the message context per element, fanning out
over 100 elements on a multi-megabyte context allocates only the per-element
writes. See Performance for benchmark results.
Validation
def::parse validates for_each configurations at compile time and reports any
violation as INVALID_FOR_EACH (Severity::Rejected) at the offending key:
overis a scalar literal that can never evaluate to an arrayasis missing or not a valid non-empty pathmax_concurrencyis0collectis specified withoutinto, orintowithoutcollectcollectorintois not a valid sub-path underdata,metadata, ortemp_data(or points directly to a root)collectoverlaps withtemp_data.<as>ortemp_data.<as>_indexintooverlaps withcollect,temp_data.<as>, ortemp_data.<as>_indexfor_eachis placed on a built-in function or on a task group
See Authoring-Time Validation for more details.
Performance
Dataflow-rs minimizes per-message latency and allocations by moving parsing, type-checking, and expression compilation to build time, and by avoiding unnecessary copies of the message context at runtime.
Architectural Design
1. Ahead-of-Time Compilation
When you build a workflow with Compiler (or Engine::builder().build()), the
engine compiles the workflow definition into a flat sequence of execution
operations:
- Every JSONLogic expressionβincluding conditions,
maptransformations, and function input fieldsβis compiled once at build time. - Expressions that do not read from the message context or secrets are constant-folded at build time and stored as ready-made constants, so literal parameters have zero per-message evaluation cost.
- Every function's
inputis compiled into its typedFunctionConfigstruct at build time, eliminating JSON parsing, key lookups, and type validation while processing messages.
#![allow(unused)] fn main() { tokio::runtime::Builder::new_current_thread().build().unwrap().block_on(async { use dataflow_rs::{Engine, Message}; use serde_json::json; // Compiles all expressions and function configs once at build time: let engine = Engine::builder() .workflow_json(&json!({"id": "w", "tasks": [ {"id": "t", "function": {"name": "map", "input": {"mappings": [ {"path": "data.total", "logic": {"*": [{"var": "data.price"}, {"var": "data.qty"}]}} ]}}} ]})) .build() .unwrap(); // Executes pre-compiled operations with no runtime JSON parsing or compilation: let mut message = Message::from_json(&json!({})); let run = engine.run(&mut message).await; assert_eq!(run.matched(), 1); }); }
2. Single Shared Context View per Run
The JSONLogic evaluator operates over an arena-allocated view of the message
context that borrows string and key data directly from the Message and only
allocates lightweight index spines for arrays and objects.
The engine constructs this view once at the start of a run and maintains it across all workflows, tasks, and loop iterations:
- Writes from
maptasks and custom host functions are spliced directly into the active view at the modified path. - Periodic arena checkpoints reclaim memory from overwritten subtrees during long runs.
- Because the context is never re-converted between tasks or loop iterations, runtime cost scales with the data read and written rather than the total size of the message.
3. Zero-Copy Host Calls and Overlays
When invoking a custom host function:
Ctxreads directly from the run's shared context view without building a new view per call.- Writes made during the call are buffered in a lightweight per-call overlay that
subsequent reads in the same call see immediately, and are committed to the
message only if the call returns
Ok. - In a
for_eachfan-out, each element call binds its element and index in its own overlay on top of the shared context viewβso fanning out across 100 elements on a 2 MiB context performs zero full-context copies. Interpolateddocuments render by borrowing placeholder values in place and serializing the surrounding template structure directly without allocating an intermediate JSON tree.
4. Pay-Only-for-What-You-Use Observability
During normal execution, the engine only records what is enabled:
- No system clock readings are taken per task unless an
ObserverrequestsInterest::TIMINGSorTraceOptions::timings(true)is enabled. - No
TraceSteprecords are built unless tracing is requested onRunOptions. - No
Changesnapshots (cloning old and new values) are allocated when both change capture (RunOptions::capture_changes(false)) and tracing are off. - Runs without an observer, trace, or
ErrorContextuse a streamlined fast-path recorder.
Running Benchmarks
The repository includes thirteen benchmark examples covering macro workloads and
targeted micro-benchmarks. Always run benchmarks with --release:
cargo run --example realistic_benchmark --release # ISO 20022 -> SwiftMT-style pipeline
cargo run --example benchmark --release # Multi-threaded throughput and latency percentiles
cargo run --example micro_cond_bench --release # Condition evaluation and constant folding
cargo run --example micro_aggregate_bench --release # Aggregate-heavy (reduce/map) mappings
cargo run --example micro_subtree_write_bench --release # k map writes into a depth-2 subtree
cargo run --example micro_multiworkflow_bench --release # N chained workflows over a single message
cargo run --example async_handler_benchmark --release # Marginal cost of a custom host function call
cargo run --example map_performance_test --release # 10 chained mappings across all 3 context roots
cargo run --example loop_scaling --release # 1,000-iteration append loop over a large context
cargo run --example large_context_handler --release # Evaluating 5 expression fields over a 2 MiB context
cargo run --example fan_out --release # for_each over 100 elements on a 2 MiB context
cargo run --example orion_connector --release # Connector workload: interpolated docs, outputs, and layers
cargo run --example observed_run --release # Realistic workload with a counting observer attached
Each benchmark verifies its output before timing begins. The macro benchmarks
run on a multi-threaded Tokio runtime, while the micro_* benchmarks run a
tight loop on a current_thread runtime to isolate sub-microsecond engine
changes from thread-scheduling noise.
Comparing Changes with bench/gate.py
Running all "before" benchmarks followed by all "after" benchmarks can easily
confound code changes with CPU thermal throttling. Instead, interleave runs
round-robin using bench/gate.py:
git switch --detach main && python3 bench/gate.py build --out /tmp/gate/base
git switch my-branch && python3 bench/gate.py build --out /tmp/gate/candidate
python3 bench/gate.py compare --a /tmp/gate/base --b /tmp/gate/candidate --rounds 5
buildcompiles all thirteen benchmark binaries in--releasemode and copies them to the output directory.compareruns the base (A) and candidate (B) binaries round-robin (with a warmup round discarded first) and compares the median per-round ratio (B / A), where values below1.0meanBis faster.
4.0 vs. 3.15 Benchmark Highlights
Because 4.0 maintains a single context view and splices writes in place rather
than re-converting the entire context for each synchronous task stretch or
for_each element, workloads with large contexts or loops see dramatic speedups
compared to 3.15:
| Benchmark | Metric | 4.0 / 3.15 Ratio |
|---|---|---|
realistic_benchmark | mean latency | 0.76 (24% faster) |
realistic_benchmark | p99 latency | 0.27 (3.7x faster) |
benchmark | mean latency | 0.51 (2x faster) |
micro_multiworkflow_bench | grouped.ns_per_msg | 0.86 (14% faster) |
loop_scaling | 250 and 1,000 iterations | 0.01β0.03 (30xβ100x faster) |
large_context_handler | per_field.mean | 0.001 (~1,000x faster) |
fan_out | per_element.mean | 0.002 (~500x faster) |
orion_connector | connector.mean | 0.45 (2.2x faster) |
Optimization Tips
1. Prefer Literal Parameters When Values Are Static
Any config field or expression that does not read from the message context is
constant-folded at compile time and never evaluated per message. Prefer plain
literals like "timeout_ms": 5000 over dynamic expressions whenever a value is
fixed.
2. Gate Expensive Steps with Conditions
Use workflow, group, and task condition fields to skip unnecessary work early.
A literal true condition compiles to a no-op with zero runtime cost.
{
"id": "expensive_task",
"condition": {"==": [{"var": "metadata.needs_processing"}, true]},
"function": { "name": "map", "input": { "mappings": [] } }
}
3. Stage Shared Intermediate Values in temp_data
While the expression evaluator automatically deduplicates repeated pure
subexpressions within a single mapping, staging an expensive calculation in
temp_data avoids recomputing it across multiple mappings or tasks:
{
"mappings": [
{"path": "temp_data.computed", "logic": {"var": "data.source.expensive"}},
{"path": "data.result1", "logic": {"var": "temp_data.computed"}},
{"path": "data.result2", "logic": {"var": "temp_data.computed"}}
]
}
4. Disable capture_changes When Old/New Snapshots Aren't Needed
When capture_changes is enabled (the default), every context write clones both
the old and new value into AuditEntry::changes. In mapping-heavy pipelines and
loops, those deep clones are often the largest part of per-message cost.
If your application does not inspect AuditEntry::changes, disable change
capture (and optionally disable the audit trail altogether if you don't read
message.audit()):
#![allow(unused)] fn main() { tokio::runtime::Builder::new_current_thread().build().unwrap().block_on(async { use dataflow_rs::run::AuditMode; use dataflow_rs::{Engine, Message, RunOptions}; use serde_json::json; let engine = Engine::builder() .workflow_json(&json!({"id": "w", "tasks": [ {"id": "t", "function": {"name": "map", "input": {"mappings": [{"path": "data.a", "logic": 1}]}}} ]})) .build() .unwrap(); let lean = RunOptions::default() .capture_changes(false) .audit(AuditMode::Off); let mut message = Message::from_json(&json!({})); engine.run_with(&mut message, &lean).await; assert!(message.audit().is_empty()); }); }
See Audit Trails and Memory in Long Loops for details.
5. Use Observer for Production Metrics Instead of Full Traces
An execution Trace clones the initial context and records every step's changes
for offline replay, whereas an Observer runs inline with minimal overhead. Use
Observer for production counters, histograms, and timings, and reserve
TraceOptions for debugging and diagnostics.
6. Leave Diagnostic log Tasks in Workflows
log tasks check tracing::enabled! for the dataflow::log target before
evaluating their message or fields expressions. When RUST_LOG=dataflow::log=warn
is set in production, debug and info log tasks short-circuit immediately at
near-zero cost.
7. Only Set Deadlines When Needed
Configuring a deadline or cancel token on RunOptions adds periodic check
points and wraps host function calls in a timer race. Omit them on runs that do
not require per-run timeouts or cancellation.
Concurrent Message Processing
Engine is Clone, Send, and Sync. Cloning an Engine simply clones an
Arc to the compiled Program, so you can process messages concurrently across
Tokio tasks with zero lock contention:
#![allow(unused)] fn main() { tokio::runtime::Builder::new_multi_thread().enable_all().build().unwrap().block_on(async { use dataflow_rs::{Engine, Message}; use serde_json::json; let engine = Engine::builder() .workflow_json(&json!({"id": "w", "tasks": [ {"id": "t", "function": {"name": "map", "input": {"mappings": [ {"path": "data.seen", "logic": true}]}}} ]})) .build() .unwrap(); let handles: Vec<_> = (0..8) .map(|i| { let engine = engine.clone(); tokio::spawn(async move { let mut message = Message::from_json(&json!({"i": i})); engine.run(&mut message).await.matched() }) }) .collect(); for handle in handles { assert_eq!(handle.await.unwrap(), 1); } }); }
Each engine.run call manages its own message and evaluation arena. When hot-reloading
workflows via engine.with_program(new_program), the new Engine serves new
runs while any in-flight runs on clones of the previous Engine complete
undisturbed.
Production Checklist
- Always compile with
--release. - Build
Engineonce and reuse it: Useengine.compiler()andengine.with_program()for hot reloads rather than rebuilding from scratch per message. - Disable
capture_changes(orAuditMode) on high-throughput pipelines that don't read audit change diffs. - Use
Observerfor telemetry, enablingInterest::TIMINGSonly when you record task durations. - Benchmark changes with interleaved runs via
bench/gate.py.
API Reference
Dataflow-rs 4.0 is organized into seven public modules with sixteen core types re-exported at the crate root. This page provides an overview of each module, the purpose of every public type, and common usage examples. Full API documentation is available on docs.rs.
At a high level:
- Workflow JSON is parsed and linted by
def::parse. - Parsed definitions are compiled against a
Registrybyprogram::Compiler. - Messages are executed by
run::Engineover arun::Message. - Execution is observed via
observe::Observerorobserve::Trace. - Custom functions implement
function::Function.
Crate Root Re-exports
The crate root re-exports the sixteen most commonly used types and traits:
| Name | Module | Description |
|---|---|---|
Engine | run | Holds a compiled Program and EngineConfig; executes messages. |
Message | run | Carries the payload, three-root context (data, metadata, temp_data), audit trail, and error records. |
Run | run | Execution report for a single run: overall status, per-workflow records, and optional trace. |
RunOptions | run | Per-run configuration: channel, routing bucket, audit mode, deadline, cancellation token, layers, observer, and trace options. |
Error, ErrorKind | root | Error type and classification for runs, compilation, and host functions (Error::service(code, message) constructs custom service errors). |
Result<T> | root | Type alias for std::result::Result<T, Error>. |
Issue | root | A single validation finding on a workflow definition: code, severity, authored path, and message. |
WorkflowDef | def | A parsed workflow definition with uncompiled JSONLogic expressions. |
Expr<T> | expr | A compiled JSONLogic expression that evaluates to T. |
Path | expr | A pre-parsed context path (data.*, metadata.*, or temp_data.*). |
Function | function | Trait implemented by custom host functions. |
Ctx | function | Execution context passed to a host function call for reading, writing, and evaluating expressions. |
Outcome | function | Return value of a host function call: Ok, Status(u16), Skip, or Halt. |
Registry | function | Holds registered custom functions, call layers, and the ExprEngine used to compile workflows. |
Observer | observe | Trait for receiving inline execution events during a run. |
In addition, VERSION provides the crate version string (written to
metadata.engine_version on every run), and datalogic_rs and datavalue are
re-exported so downstream crates don't need to manage separate dependency
versions.
def β Workflow Definitions
Contains the types representing authored workflow definitions. Definitions are
parsed and linted via def::parse (or def::parse_owned) and serialize back to
canonical 4.0 JSON.
(docs.rs)
| Name | Description |
|---|---|
parse, parse_owned, ParseOptions, Parsed | Parses and lints a workflow JSON value (parse_owned rewrites 3.x spellings in place). Returns all Issues, with Parsed::def set to Some when no issue has Rejected severity. ParseOptions::compat (default true) enables 3.x compatibility rewrites. |
WorkflowDef | Parsed workflow definition: ID, header, priority, condition, steps, on_failure, channel, status, version, rollout, loop config, tags, and timestamps. walk() traverses all steps with their authored JSON paths. |
StepDef | An element in a tasks list: either Task(TaskDef) or Group(GroupDef). |
TaskDef | A single task step, including its FunctionRef, on_failure, on_rejection, terminal, and for_each. |
GroupDef | A group of nested steps gated by a single condition evaluated once on entry. |
NodeHeader | Common metadata shared by workflows, tasks, and groups (name, description, and ext). |
FunctionRef | The target function name and raw JSON input. |
Logic | Uncompiled JSONLogic expression as authored in the definition. |
LoopDef, ForEachDef | Configuration for a workflow loop and a task for_each fan-out. |
FailurePolicy, RejectionPolicy, WorkflowFailurePolicy | Task failure policy (fail, halt, continue), task rejection policy (continue, halt), and workflow failure policy (fail, continue). |
Status | Workflow lifecycle status: active, paused, or archived (only active workflows execute). |
Rollout, RolloutError | Percentage-based traffic routing configuration for a workflow. |
defaults | Default constants used when optional fields are omitted (PRIORITY, VERSION, CHANNEL, INIT, INCREMENT, MAX_CONCURRENCY). |
MAX_GROUP_DEPTH, BUILTIN_FUNCTIONS, is_reserved_function | Maximum group nesting depth (8), the eight built-in function names, and helper to check if a function name is reserved. |
definition_keys, DefinitionKeys | Lists the recognized JSON keys at each level of a workflow definition. |
expr β Expressions, Paths, and Secrets
Provides the JSONLogic compilation and evaluation engine, context path handling, secret store, and configuration field types. (docs.rs)
| Name | Description |
|---|---|
ExprEngine, ExprEngineBuilder | Compiles and evaluates JSONLogic expressions; builder configures operator families, custom operators, Secrets, and optional ops_budget. |
Expr<T>, FromValue, Evaluated, ResolveExpr | Compiled JSONLogic expression typed to return T (OwnedDataValue, String, u64, i64, f64, bool, or Path); constant-folds pure expressions at compile time. |
Path, Root, MAX_ARRAY_INDEX | Pre-parsed context path rooted at data, metadata, or temp_data; supports get, set, and remove with numeric array indices up to 1,048,575. |
Output | A destination path field (Expr<Path>) used by custom functions to emit results. |
Interpolated, Rendered | A literal JSON document containing {"var": β¦} placeholders, and its rendered view for a specific call. |
Secrets | Secure store for sensitive values accessed via {"secret": "name"} and never recorded in messages or traces. |
Operator, OperatorFamily | Metadata for an active operator and the operator family it belongs to. |
ConfigField, Resolver, CompileCx, Visit, ExprVisitor, FieldSpec, FieldKind, ValueKind | Traits and types backing #[derive(FunctionConfig)] compilation, resolution, and static analysis. |
EvalRoot | Trait implemented by types that can serve as an expression evaluation root (&OwnedDataValue and &serde_json::Value). |
escaped_keys, secret_refs | Helpers that walk raw JSON to find all $-escaped template keys or {"secret": β¦} references. |
function β Custom Host Functions and Layers
Defines the Function trait, configuration derive macro, call context,
middleware layers, and function testing utilities.
(docs.rs)
| Name | Description |
|---|---|
Function | Trait for custom host functions: defines type Config, spec(), prepare(), and async fn call(). |
FunctionConfig (trait & derive) | Compiles a task's input JSON into a typed Rust struct once at build time. Supports #[config(...)] and #[output(...)] attributes. |
FunctionSpec, RetrySafety, Reference | Declares function metadata for catalogues and compilers: description, category, idempotency, external resource references, and runtime fields. |
Ctx, CallInfo | Per-call execution context (get, set, remove, eval, render, emit, input, resolve, record_error, deadline, cancel) and call metadata (info()). |
Outcome | Execution outcome: Ok (200), Status(u16), Skip (no audit entry), or Halt (299). |
Prepare | Compile-time context passed to Function::prepare (registered name, workflow ID, task ID, and FunctionSpec). |
CallLayer, Next, BoxFuture | Middleware trait for wrapping host function calls at registry or per-run scope. |
layers::Answer | Built-in middleware layer that intercepts matching calls using a closure (ideal for dry runs and mocks). |
Registry, FunctionInfo | Stores registered host functions, layers, and the ExprEngine; catalogue() lists all registered functions and their specs. |
Templated, Rendered | Dynamic configuration type for manifest-driven plugins whose fields are declared at runtime via FunctionSpec::runtime_fields. |
testing | Unit-testing harness: testing::call(f, input) runs a single function call through compilation, layers, and context commit; testing::config tests config compilation standalone. |
program β Compilation and Validation
Compiles parsed WorkflowDefs against a Registry into an immutable Program.
(docs.rs)
| Name | Description |
|---|---|
Compiler, CompileOptions | check(&def) returns all issues; check_steps(&steps, root) validates a step fragment; compile_one(&def) returns a Compiled workflow or Err(issues); link(workflows) builds a Program. |
Compiled, CompiledWorkflow | A successfully compiled workflow along with any non-rejected Issues (Defect and Advisory) and its fingerprint(). |
Program, WorkflowInfo, TaskInfo | Immutable linked collection of compiled workflows sorted by priority, with metadata accessors. |
ProgramId, WorkflowIx, TaskIx | Unique identifier for a compiled program and dense indices for its workflows and tasks. |
IssueCode, Severity | Validation issue code (as_str() returns SCREAMING_SNAKE_CASE) and severity (Rejected, Defect, or Advisory). |
AuthoredPath, PathSeg, StepKind | Path in the authored JSON (tasks[1].tasks[0].id) and the enclosing step kind (Task, Group, or Setup). |
ResourceOracle | Trait for validating external resource names (such as connectors) referenced by FunctionSpec::references at compile time. |
Fingerprint | Hash of the operators and context paths used by a workflow's expressions (Display and FromStr). |
ParseIssueCodeError, ParsePathError, ParseFingerprintError | Error types for FromStr parsing. |
run β Executing Messages
Executes compiled programs over messages. (docs.rs)
| Name | Description |
|---|---|
Engine, EngineBuilder, EngineConfig | Engine::builder() builds an engine directly from definitions; Engine::new(program, config) builds from a pre-linked Program; compiler() and with_program() support hot reloading. |
ErrorContext | Configures mirroring of failure records into a context path (e.g., metadata.errors) up to a maximum count. |
RunOptions | Per-run settings (channel, routing bucket, audit mode, change capture, deadline, cancellation token, layers, observer, and trace options). |
Run, RunStatus | Execution report: status(), workflows(), matched(), partial(), retryable(), trace(), take_trace(), into_result(). Statuses: Completed, CompletedWithErrors, Stopped(Error), TimedOut, Cancelled. |
WorkflowRecord, Selection, WorkflowExit | Per-workflow execution summary: selection outcome, loop sweep count, and exit reason. |
Message | new, from_parts, from_json; accessors payload(), context(), context_mut(), audit(), errors(); serializes as {id, payload, context, audit_trail, errors}. |
Context | Holds the three context roots (data, metadata, temp_data) with get, set, and remove by Path. |
AuditEntry, Change, AuditMode | Task audit record, field change record, and retention mode (Full, Last(n), Off). |
ErrorRecord | Structured error record stored in message.errors(). |
CancelToken | Cooperative cancellation token (child() creates a linked child token). |
Instant | Cross-platform monotonic clock type (std::time::Instant on native targets, browser clock on wasm32). |
CHECK_EVERY | Operation interval (64) between deadline and cancellation checks. |
observe β Observers and Traces
Provides inline event observation and step-by-step execution traces. (docs.rs)
| Name | Description |
|---|---|
Observer, Interest | interest() declares which events (WORKFLOWS, TASKS, TIMINGS) to receive; on(&Event) handles events synchronously inline. |
Event | RunStarted, WorkflowStarted, TaskFinished, WorkflowFinished, and RunFinished (all #[non_exhaustive]). |
TraceOptions | Configures trace recording: timings, changes, redact, and max_bytes. |
Trace, TraceStep, StepResult | Execution trace containing initial context and incremental steps; contexts() and mapping_contexts() replay state step by step; validate() checks deserialized traces. |
Replay | Interactive trace cursor that maintains a single working context and seeks on demand (context_at, mapping_context_at). |
compat β 3.x Migration Utilities
Provides tools for upgrading 3.x workflow definitions to canonical 4.0 JSON. (docs.rs)
| Name | Description |
|---|---|
migrate_v3 | Parses a 3.x or 4.0 workflow JSON value and rewrites it into canonical 4.0 JSON (or returns Err(issues) if any issue is Rejected). |
Migrated, Rewrite, RewriteKind | Contains the migrated WorkflowDef, canonical json, ordered list of rewrites, and any non-rejected issues. |
See Migrating from 3.x for details.
Common Usage Patterns
Building an Engine
#![allow(unused)] fn main() { use dataflow_rs::Engine; use dataflow_rs::run::EngineBuilder; use serde_json::json; let builder: EngineBuilder = Engine::builder(); let engine: Engine = builder .workflow_json(&json!({"id": "w", "tasks": [ {"id": "t", "function": {"name": "map", "input": { "mappings": [{"path": "data.greeting", "logic": "hello"}]}}} ]})) .build() .expect("Err contains all issues from rejected definitions"); assert_eq!(engine.program().workflows().len(), 1); }
EngineBuilder supports .register(name, function), .layer(l),
.registry(r), .options(CompileOptions), .config(EngineConfig),
.workflow(WorkflowDef), .workflow_json(&Value), and
.workflow_json_owned(Value), followed by .build() -> Result<Engine, Vec<Issue>>.
If your application needs to quarantine invalid workflows and run the valid
ones, compile workflows individually with Compiler:
#![allow(unused)] fn main() { use dataflow_rs::program::{CompileOptions, Compiler, Program}; use dataflow_rs::run::EngineConfig; use dataflow_rs::{Engine, Registry}; use dataflow_rs::def::{ParseOptions, parse}; use serde_json::json; let def = parse(&json!({"id": "w", "tasks": [ {"id": "t", "function": {"name": "log", "input": {"message": "hi"}}} ]}), ParseOptions::default()).def.unwrap(); let compiler: Compiler = Registry::default().compiler(CompileOptions::default()); let compiled = compiler.compile_one(&def).expect("Err contains all issues"); let program: Program = compiler.link([compiled.workflow]); let engine = Engine::new(program, EngineConfig::default()); assert_eq!(engine.program().workflows()[0].id, "w"); }
To reload workflows on a running engine without disrupting in-flight runs, call
engine.compiler(), link the new workflows, and call
engine.with_program(program).
Running a Message
#![allow(unused)] fn main() { tokio::runtime::Builder::new_current_thread().build().unwrap().block_on(async { use dataflow_rs::{Engine, Message, Run, RunOptions}; use serde_json::json; let engine = Engine::builder() .workflow_json(&json!({"id": "w", "tasks": [ {"id": "t", "function": {"name": "map", "input": { "mappings": [{"path": "data.greeting", "logic": "hello"}]}}} ]})) .build().unwrap(); let mut message = Message::from_json(&json!({"name": "Ada"})); // Run with default options: let run: Run = engine.run(&mut message).await; // Run with custom per-run options (channel, routing bucket, deadline, trace, etc.): let opts = RunOptions::default().channel("default"); let run: Run = engine.run_with(&mut message, &opts).await; assert_eq!(run.matched(), 1); }); }
Inspecting the Run Report
#![allow(unused)] fn main() { tokio::runtime::Builder::new_current_thread().build().unwrap().block_on(async { use dataflow_rs::run::RunStatus; use dataflow_rs::{Engine, Message}; use serde_json::json; let engine = Engine::builder() .workflow_json(&json!({"id": "w", "tasks": [ {"id": "t", "function": {"name": "map", "input": { "mappings": [{"path": "data.greeting", "logic": "hello"}]}}} ]})) .build().unwrap(); let mut message = Message::from_json(&json!({})); let run = engine.run(&mut message).await; match run.status() { RunStatus::Completed => {} RunStatus::CompletedWithErrors => { /* inspect message.errors() */ } RunStatus::Stopped(error) => eprintln!("{}: {}", error.code(), error.message()), RunStatus::TimedOut | RunStatus::Cancelled => {} _ => {} } for workflow in run.workflows() { println!("{} {:?} {:?}", workflow.id, workflow.selection, workflow.exit); } assert!(matches!(run.status(), RunStatus::Completed)); }); }
If you prefer a Result<(), Error> that returns Err when a run stops, times
out, or is cancelled, call run.into_result() (extracting run.take_trace()
first if you need the trace).
Constructing a Message
#![allow(unused)] fn main() { use dataflow_rs::Message; use dataflow_rs::datavalue::OwnedDataValue; use serde_json::json; use std::sync::Arc; // From a serde_json::Value payload: let m = Message::from_json(&json!({"name": "Ada"})); // From an OwnedDataValue or a shared Arc<OwnedDataValue>: let payload = Arc::new(OwnedDataValue::from(&json!({"name": "Ada"}))); let m = Message::new(Arc::clone(&payload)); // With pre-populated `data` and `metadata` objects: let m = Message::from_parts( payload, OwnedDataValue::from(&json!({"stage": "intake"})), OwnedDataValue::Null, ); assert_eq!(m.context().data(), &OwnedDataValue::from(&json!({"stage": "intake"}))); }
Implementing a Custom Host Function
#![allow(unused)] fn main() { use dataflow_rs::expr::Output; use dataflow_rs::function::FunctionConfig; use dataflow_rs::datavalue::OwnedDataValue; use dataflow_rs::{Ctx, Expr, Function, Outcome, Result}; use serde_json::json; #[derive(FunctionConfig)] struct FetchConfig { url: Expr<String>, #[output(default = "data.response")] output: Output, } struct Fetch; impl Function for Fetch { type Config = FetchConfig; async fn call(&self, ctx: &mut Ctx<'_>, cfg: &FetchConfig) -> Result<Outcome> { let url = ctx.eval(&cfg.url)?; let response = OwnedDataValue::from(&json!({"url": &*url, "status": 200})); ctx.emit(&cfg.output, response)?; Ok(Outcome::Ok) } } let _ = Fetch; }
Validating a Workflow Definition
#![allow(unused)] fn main() { use dataflow_rs::def::{ParseOptions, Parsed, parse}; use dataflow_rs::program::{CompileOptions, Severity}; use dataflow_rs::{Issue, Registry}; use serde_json::json; let json = json!({"id": "w", "tasks": [ {"id": "t", "function": {"name": "map", "input": { "mappings": [{"path": "data.x", "logic": {"var": "payload.x"}}]}}} ]}); // 1. Validate structure (`def` is `Some` when no issue is `Rejected`): let parsed: Parsed = parse(&json, ParseOptions::default()); let def = parsed.def.expect("the structure is fine"); // 2. Validate functions, inputs, expressions, paths, and secrets: let compiler = Registry::default().compiler(CompileOptions::default()); let issues: Vec<Issue> = compiler.check(&def); let rejected = issues.iter().filter(|i| i.severity == Severity::Rejected).count(); assert_eq!(rejected, 0); assert_eq!(issues[0].code.as_str(), "PAYLOAD_VAR"); assert_eq!(issues[0].path.to_string(), "tasks[0].function.input.mappings[0].logic"); }
Error Codes
Error::code() returns the error code string written to ErrorRecords on
message.errors() and reported on RunStatus::Stopped(error):
ErrorKind | Error Code | When It Occurs |
|---|---|---|
Eval | LOGIC_ERROR | A JSONLogic expression failed to evaluate. |
Budget | BUDGET_EXCEEDED | An expression exceeded the configured ops_budget (budget feature). |
TimedOut | TIMEOUT_ERROR | The run's deadline expired (marked retryable by default). |
Cancelled | CANCELLED | The run's cancellation token was triggered. |
Config | CONFIG_ERROR | Invalid runtime or engine configuration. |
Compile | COMPILATION_ERROR | An expression or definition failed to compile. |
Service { code } | code (or TASK_ERROR if empty) | Custom error returned by a host function via Error::service(code, message). |
Service { code } | VALIDATION_ERROR | parse_xml, publish_json, or publish_xml encountered missing or malformed input data. |
In addition, the engine records the following codes directly on
message.errors() without producing a standalone Error:
| Recorded Code | When It Is Recorded |
|---|---|
TASK_STATUS_ERROR | A task returned Outcome::Status(5xx) (if the failure stops the run, the stop error code is TASK_ERROR). |
EVALUATION_ERROR | A validation rule or filter condition failed to evaluate. |
MAPPING_ERROR | A map item failed to evaluate or write (recorded with path set to the mapping destination, followed by TASK_STATUS_ERROR). |
VALIDATION_ERROR | A validation rule evaluated to a non-true value (status 400). |
Cargo Features
No Cargo features are enabled by default.
| Feature | Description |
|---|---|
datetime | Compiles in OperatorFamily::Datetime (enabled at runtime on ExprEngineBuilder) and enables ISO-8601 date/duration parsing in ==, <, <=, >, and >=. |
tensor | Compiles in Tensor values and OperatorFamily::Tensor (must be enabled explicitly on ExprEngineBuilder; excluded from all_operators()). |
budget | Enables per-evaluation operation limits via ExprEngineBuilder::ops_budget. |
wasm-web | Uses browser clock and randomness APIs on wasm32-unknown-unknown. |
ts | Derives ts_rs::TS on wire types to generate bindings/dataflow.d.ts (internal tooling; not covered by semver). |
[dependencies]
dataflow-rs = { version = "4.0", features = ["datetime", "budget"] }
Migrating from 3.x
Dataflow-rs 4.0 maintains backward compatibility for stored 3.x workflow definitions and serialized messages while introducing a cleaner, compile-time validated Rust API.
- Workflow authors: Read Backward Compatibility for 3.x Workflows and Behavioral Changes for Workflow Authors.
- Rust application developers: Read those sections first, then follow Rust API Migration Guide and the Pre-Migration Checklist.
Backward Compatibility for 3.x Workflows
By default, def::parse enables the 3.x compatibility reader
(ParseOptions::compat(true)). It automatically translates 3.x workflow
spellings into their 4.0 equivalents in memory and reports each translation as a
V3_SPELLING advisory at the authored JSON path. Your existing 3.x workflow
definitions load and run without requiring immediate database migrations.
Automatic Rewrites
| 3.x Spelling | 4.0 Equivalent | Issue Reported |
|---|---|---|
continue_on_error: true / false on a workflow | on_failure: "continue" / "fail" | V3_SPELLING (Advisory) |
continue_on_error and halt_on on a task | on_failure and on_rejection (see table below) | V3_SPELLING (Advisory) |
"name": "validate" on a task | "name": "validation" | V3_SPELLING (Advisory) |
Unknown key on a workflow, task, or group (e.g., UI "path") | Moved into that node's ext object (ext.path) | UNKNOWN_KEY (Advisory) |
Unknown key close to a valid key name (e.g., "conditon") | Moved into ext | LIKELY_TYPO (Defect) |
Unknown key inside loop, for_each, or rollout | Moved into the owning node's ext as loop.<key>, etc. | UNKNOWN_KEY or LIKELY_TYPO |
Unknown key inside a built-in's input, map mapping, or validation rule | Moved into the task's ext under its relative path (e.g., function.input.mappings[0].note) | UNKNOWN_KEY or LIKELY_TYPO |
continue_on_error on a task group | Moved into the group's ext | GROUP_CONTINUE_ON_ERROR (Advisory) |
Timestamp formatted as 2024-01-01 00:00:00 UTC | Parsed cleanly and serialized as RFC 3339 | None |
An unknown key is flagged as a LIKELY_TYPO (Severity::Defect) when it is
within an edit distance of 1 (for keys up to 5 characters) or 2 (for longer
keys) of a known key at that level.
Task Policy Mapping
In 3.x, tasks used two flags (continue_on_error and halt_on). In 4.0, tasks
use two explicit policies (on_failure for 5xx statuses and Err, and
on_rejection for 4xx statuses):
| 3.x Task Flags | 4.0 Task Policies | Behavior |
|---|---|---|
| Neither | on_failure: "fail", on_rejection: "continue" | 5xx or Err propagates to the workflow's on_failure policy; 4xx is recorded and execution continues to the next task. |
continue_on_error: true | on_failure: "continue", on_rejection: "continue" | Both rejections and failures are recorded, and execution continues to the next task. |
halt_on: "failure" | on_failure: "fail", on_rejection: "halt" | 4xx halts the current workflow; 5xx or Err propagates to the workflow's on_failure policy. |
Both continue_on_error: true and halt_on: "failure" | on_failure: "halt", on_rejection: "halt" | Any 4xx, 5xx, or Err halts the current workflow while allowing subsequent workflows to run. |
terminal: true | terminal: true | Unchanged (applied after outcome policies, so failures under on_failure: "fail" still propagate). |
Workflow continue_on_error: false / true | Workflow on_failure: "fail" / "continue" | "continue" ends the current workflow (or advances to the next loop iteration) and moves on to the next workflow. |
Default 3.x values (halt_on: "never" and continue_on_error: false) map to
the 4.0 defaults and are omitted when rewritten by migrate_v3, while still
reporting V3_SPELLING.
Do not mix 3.x and 4.0 policy keys on the same node. Combining a 3.x flag
(continue_on_error or halt_on) with a 4.0 policy (on_failure or
on_rejection) on the same workflow or task is rejected with MIXED_SPELLING.
Upgrading Stored Definitions to 4.0 JSON (compat::migrate_v3)
Use compat::migrate_v3 to permanently convert stored 3.x workflow JSON into
canonical 4.0 JSON (which parses cleanly with .compat(false)). It returns the
rewritten JSON, an ordered list of rewrites, and any non-rejected issues:
#![allow(unused)] fn main() { use dataflow_rs::compat::migrate_v3; use dataflow_rs::program::IssueCode; use serde_json::json; let stored = json!({ "id": "checkout", "continue_on_error": true, "tasks": [ {"id": "guard", "halt_on": "failure", "path": "guards/1", "function": {"name": "validate", "input": {"rules": [ {"logic": {"!!": [{"var": "data.order"}]}, "message": "no order"} ]}}} ] }); let migrated = migrate_v3(&stored).unwrap(); assert_eq!(migrated.json, json!({ "id": "checkout", "on_failure": "continue", "tasks": [ {"id": "guard", "on_rejection": "halt", "ext": {"path": "guards/1"}, "function": {"name": "validation", "input": {"rules": [ {"logic": {"!!": [{"var": "data.order"}]}, "message": "no order"} ]}}} ] })); let rewrites: Vec<(String, String)> = migrated.rewrites.iter() .map(|r| (r.path.to_string(), r.to_string())) .collect(); // Reported in document key order: assert_eq!(rewrites, [ ( "continue_on_error".to_string(), "`continue_on_error: true` is a v3 spelling, read as `on_failure: continue`".to_string(), ), ( "tasks[0].halt_on".to_string(), "`halt_on: failure` is a v3 spelling, read as `on_failure: fail, on_rejection: halt`".to_string(), ), ( "tasks[0].function.name".to_string(), "`validate` is a v3 spelling, read as `validation`".to_string(), ), ]); let codes: Vec<IssueCode> = migrated.issues.iter().map(|i| i.code).collect(); assert_eq!(codes, [ IssueCode::V3Spelling, IssueCode::V3Spelling, IssueCode::V3Spelling, IssueCode::UnknownKey, ]); assert_eq!(migrated.issues[3].path.to_string(), "tasks[0].path"); // Verify that the migrated JSON parses with `.compat(false)` to the same definition: use dataflow_rs::def::{ParseOptions, parse}; let off = parse(&migrated.json, ParseOptions::default().compat(false)); assert_eq!(off.def, parse(&stored, ParseOptions::default()).def); assert!(off.issues.is_empty()); }
Behavioral Changes for Workflow Authors
Core execution semantics remain unchanged: a failed validation task still
returns status 400 and only halts the workflow when on_rejection: "halt" is
set, null results in map are skipped by default, terminal and halt
policies apply after status classification, loops and for_each behave the same
way, group conditions evaluate once on entry, and metadata.progress is written
after every task.
The following edge cases and validations have been tightened in 4.0:
mappaths must start withdata,metadata, ortemp_data: A literalpathoutside the three context roots is rejected at compile time withINVALID_PATH(3.x created a fourth top-level key on the context). A dynamic path that resolves outside the three roots fails the mapping at runtime with status500.- Literal
payloadreads invarare flagged as aDefect: Writing{"var": "payload.x"}is reported asPAYLOAD_VARbecausepayloadis not in the evaluation context (it always resolved tonullin 3.x as well). Parsepayloadintodatafirst usingparse_jsonorparse_xml. - No wrapper
WORKFLOW_ERRORrecords: Each task'sErrorRecordalready carriesworkflow_idandtask_id. Workflow-level errors (such as aloop.overexpression that does not evaluate to an array) record a singleLOGIC_ERRORwithworkflow_idset andtask_id: None. nameis optional on workflows, tasks, and groups.- Only
activeworkflows execute: Workflows withstatus: "paused"or"archived"are skipped regardless of whether a channel is specified, and do not appear inRun::workflows(). - Unregistered functions are reported per task: Calling an unknown function
reports
UNKNOWN_FUNCTIONattasks[i].function.nameand rejects only the affected workflow rather than failing the entire registry build on the first missing function. - Integration config types (
http_call,enrich,publish_kafka) were removed from the core crate: These are host functions that your application registers like any otherFunction. If your stored workflows still use 3.x field names (path_logic,body_logic,key_logic,value_logic), rename them in your loader or support them on yourFunctionConfigstruct with#[config(rename = ...)]. - Unknown keys in host function inputs are strictly rejected: While unknown
keys on workflow/task/group nodes are moved into
extwhencompatis on, unknown keys inside a custom host function'sinputare always rejected withLIKELY_TYPOorUNKNOWN_KEY. - Array index paths are capped at
1,048,575(MAX_ARRAY_INDEX): Numeric path segments above1,048,575are treated as object keys rather than growing an array without bound. - Context roots must always be objects: Neither
mapnor a custom host function can replacedata,metadata, ortemp_datawith a scalar or array.
Rust API Migration Guide
Type and Method Mapping
| 3.x API | 4.0 Equivalent |
|---|---|
Workflow, Task, TaskGroup, LoopConfig, ForEach | def::WorkflowDef, TaskDef, GroupDef, LoopDef, ForEachDef (parsed via def::parse, accessed via methods like def.id(), task.on_failure()) |
Workflow::validate_authored, check_workflow | def::parse + Compiler::check |
walk_authored_steps, is_group, MAX_GROUP_DEPTH | WorkflowDef::walk, StepDef::Group, def::MAX_GROUP_DEPTH |
Engine::new, new_with_operators, builder, with_new_workflows | Registry + Compiler + Engine::new (or Engine::builder()); reload via engine.compiler(), link, and engine.with_program() |
process_message, process_message_in_channel, process_message_tracing | Engine::run(&mut msg) and Engine::run_with(&mut msg, &opts) |
AsyncFunctionHandler, type Input, execute | Function, #[derive(FunctionConfig)] type Config, async fn call |
TaskContext | Ctx (unit-tested with function::testing::call) |
Template, PathTemplate, TemplateCompiler | Expr<T>, Output, Interpolated |
FunctionConfig enum (HttpCallConfig, EnrichConfig, PublishKafkaConfig) | Custom Function implementations registered on Registry |
DataflowError, ErrorInfo, ServiceErrorBuilder | Error and ErrorKind; ErrorRecord on Message::errors() |
ExecutionObserver (5 trait methods) | Observer (interest() + on(&Event)) |
ExecutionTrace (full message snapshots per step) | Trace (initial context + incremental changes; replay via trace.contexts()) |
with_error_context_path, with_error_context_limit | EngineConfig::error_context(ErrorContext::new(path, limit)?) |
MessageBuilder (capture_changes, audit_mode, routing_bucket) | Configured per run on RunOptions |
get_nested_value, set_nested_value, remove_nested_value | Path::get, Path::set, Path::remove and Context::get, set, remove |
declared_secrets, operator_names, template_key_escape, datalogic | ExprEngine::secrets().names(), operators(), escape(), ExprEngine |
Rollout::partition, validate_set | Unchanged, in dataflow_rs::def |
RetryPolicy, retry_with_policy, retry_with_attempts | Removed (check Run::retryable() or Error::is_retryable() in your application's retry loop) |
Rule, Action, RulesEngine type aliases | Removed |
1. Building and Reloading the Engine
In 3.x, you parsed each Workflow separately and passed it to Engine::builder():
// 3.x
let workflow = Workflow::from_json(json_text)?;
let engine = Engine::builder()
.with_workflow(workflow)
.register("fetch", Fetch)
.build()?;
In 4.0, if you want the build to fail when any workflow is invalid, pass JSON
directly to Engine::builder():
#![allow(unused)] fn main() { use dataflow_rs::Engine; use dataflow_rs::{Ctx, Function, Outcome, Result}; use serde_json::json; struct Fetch; impl Function for Fetch { type Config = (); async fn call(&self, _ctx: &mut Ctx<'_>, _cfg: &()) -> Result<Outcome> { Ok(Outcome::Ok) } } let json = json!({"id": "w", "tasks": [{"id": "t", "function": {"name": "fetch"}}]}); let engine = Engine::builder() .register("fetch", Fetch) .workflow_json(&json) .build() .map_err(|issues| issues.len())?; Ok::<(), usize>(()) }
If you load workflows from a database and want to quarantine invalid workflows
while running the valid ones, use def::parse and Compiler:
#![allow(unused)] fn main() { use dataflow_rs::def::{ParseOptions, parse}; use dataflow_rs::program::CompileOptions; use dataflow_rs::run::EngineConfig; use dataflow_rs::{Engine, Registry}; use serde_json::json; let stored = vec![ json!({"id": "a", "tasks": [{"id": "t", "function": {"name": "log", "input": {"message": "a"}}}]}), json!({"id": "b", "tasks": [{"id": "t", "function": {"name": "nowhere"}}]}), ]; let compiler = Registry::default().compiler(CompileOptions::default()); let mut workflows = Vec::new(); for json in &stored { let Some(def) = parse(json, ParseOptions::default()).def else { continue }; match compiler.compile_one(&def) { Ok(compiled) => workflows.push(compiled.workflow), Err(issues) => eprintln!("{}: {} issues, quarantined", def.id(), issues.len()), } } let engine = Engine::new(compiler.link(workflows), EngineConfig::default()); assert_eq!(engine.program().workflows().len(), 1); // Hot-reload workflows while preserving the registry and engine configuration: let reloaded = engine.with_program(engine.compiler().link([])); assert_eq!(reloaded.program().workflows().len(), 0); }
2. Custom Host Functions
In 3.x, custom functions implemented AsyncFunctionHandler with #[async_trait],
a serde::Deserialize input struct, and TaskContext:
// 3.x
#[derive(Deserialize)]
struct FetchInput { url: Template, target: String }
#[async_trait]
impl AsyncFunctionHandler for Fetch {
type Input = FetchInput;
async fn execute(&self, ctx: &mut TaskContext<'_>, input: &FetchInput)
-> Result<TaskOutcome> {
let url = input.url.resolve_str(ctx)?;
ctx.set(&input.target, response);
Ok(TaskOutcome::Success)
}
}
In 4.0, custom functions implement Function with a #[derive(FunctionConfig)]
struct:
#![allow(unused)] fn main() { use dataflow_rs::expr::Output; use dataflow_rs::function::FunctionConfig; use dataflow_rs::datavalue::OwnedDataValue; use dataflow_rs::{Ctx, Expr, Function, Outcome, Result}; use serde_json::json; #[derive(FunctionConfig)] struct FetchConfig { url: Expr<String>, #[config(default = 5000)] timeout_ms: Expr<u64>, #[output(default = "data.response")] output: Output, } struct Fetch; impl Function for Fetch { type Config = FetchConfig; async fn call(&self, ctx: &mut Ctx<'_>, cfg: &FetchConfig) -> Result<Outcome> { let url = ctx.eval(&cfg.url)?; let timeout_ms = ctx.eval(&cfg.timeout_ms)?; let response = OwnedDataValue::from(&json!({"url": &*url, "timeout_ms": timeout_ms})); ctx.emit(&cfg.output, response)?; Ok(Outcome::Ok) } } let _ = Fetch; }
TaskContext methods map to Ctx as follows:
3.x TaskContext | 4.0 Ctx |
|---|---|
ctx.get("data.x") | ctx.get(&path) (where path is a pre-parsed Path) |
ctx.set("data.x", value) | ctx.set(&path, value)? |
ctx.eval(&logic) | ctx.eval(&expr) (typed by Expr<T>) |
Writing to input.target | ctx.emit(&cfg.output, value)? via an Output field |
ctx.add_error(info) | ctx.record_error(record) |
ctx.workflow_id(), task_id(), loop_counter(), element_index() | ctx.info().workflow_id, task_id, loop_counter, element_index |
ctx.secret(name) | ctx.secret(name) (unchanged) |
ctx.message_mut() | Removed (perform all context writes through set, remove, and emit) |
TaskOutcome::Success | Outcome::Ok (Status, Skip, and Halt keep the same names) |
Remember that context writes in Ctx are buffered in an overlay and committed
only when call returns Ok; if call returns Err, all context writes from
that call are discarded. Use function::testing::call to unit-test functions
directly without building a full Engine.
3. Running Messages and Checking Results
In 3.x, process_message returned Result<(), DataflowError>:
// 3.x
match engine.process_message(&mut message).await {
Ok(()) => {}
Err(e) => eprintln!("stopped: {e}"),
}
In 4.0, engine.run and engine.run_with always return a Run report, and
you inspect run.status() (or call run.into_result()):
#![allow(unused)] fn main() { tokio::runtime::Builder::new_current_thread().build().unwrap().block_on(async { use dataflow_rs::run::RunStatus; use dataflow_rs::{Engine, Message, RunOptions}; use serde_json::json; let engine = Engine::builder() .workflow_json(&json!({"id": "w", "tasks": [ {"id": "t", "function": {"name": "validation", "input": {"rules": [ {"logic": false, "message": "always"}]}}} ]})) .build().unwrap(); let mut message = Message::from_json(&json!({})); let run = engine.run_with(&mut message, &RunOptions::default().channel("default")).await; match run.status() { RunStatus::Completed => {} RunStatus::CompletedWithErrors => eprintln!("{} records", message.errors().len()), RunStatus::Stopped(e) => eprintln!("stopped: {}", e.code()), RunStatus::TimedOut | RunStatus::Cancelled => eprintln!("cut short"), _ => {} } assert!(matches!(run.status(), RunStatus::CompletedWithErrors)); assert_eq!(message.errors()[0].code, "VALIDATION_ERROR"); }); }
4. Observers, Traces, and Error Context
Observers
In 3.x, ExecutionObserver had five separate callback methods. In 4.0,
Observer has a single on(&Event) method and an interest() method that
controls which events and timings are collected:
#![allow(unused)] fn main() { tokio::runtime::Builder::new_current_thread().build().unwrap().block_on(async { use dataflow_rs::observe::{Event, Interest, Observer}; use dataflow_rs::run::{EngineConfig, RunStatus}; use dataflow_rs::{Engine, Message}; use std::sync::Arc; use std::sync::atomic::{AtomicUsize, Ordering}; use serde_json::json; #[derive(Default)] struct Metrics { tasks: AtomicUsize, stopped: AtomicUsize } impl Observer for Metrics { fn interest(&self) -> Interest { Interest::TASKS | Interest::TIMINGS } fn on(&self, event: &Event<'_>) { match event { Event::TaskFinished { info, status, elapsed, .. } => { let _ = (info.function, status, elapsed.expect("TIMINGS was asked for")); self.tasks.fetch_add(1, Ordering::Relaxed); } Event::RunFinished { status, .. } => { if !matches!(status, RunStatus::Completed | RunStatus::CompletedWithErrors) { self.stopped.fetch_add(1, Ordering::Relaxed); } } _ => {} } } } let metrics = Arc::new(Metrics::default()); let engine = Engine::builder() .config(EngineConfig::default().observer(metrics.clone())) .workflow_json(&json!({"id": "w", "tasks": [ {"id": "t", "function": {"name": "map", "input": { "mappings": [{"path": "data.a", "logic": 1}]}}} ]})) .build() .unwrap(); engine.run(&mut Message::from_json(&json!({}))).await; assert_eq!(metrics.tasks.load(Ordering::Relaxed), 1); assert_eq!(metrics.stopped.load(Ordering::Relaxed), 0); }); }
Execution Traces
Instead of calling a separate process_message_tracing method that stores full
message snapshots per step, pass TraceOptions on RunOptions and retrieve the
Trace from Run:
#![allow(unused)] fn main() { tokio::runtime::Builder::new_current_thread().build().unwrap().block_on(async { use dataflow_rs::observe::{StepResult, TraceOptions}; use dataflow_rs::{Engine, Message, RunOptions}; use serde_json::json; let engine = Engine::builder() .workflow_json(&json!({"id": "w", "tasks": [ {"id": "t", "function": {"name": "map", "input": { "mappings": [{"path": "data.a", "logic": 1}, {"path": "data.b", "logic": 2}]}}} ]})) .build().unwrap(); let mut message = Message::from_json(&json!({})); let opts = RunOptions::default().trace(TraceOptions::default().timings(true)); let mut run = engine.run_with(&mut message, &opts).await; let trace = run.take_trace().expect("a trace was asked for"); for (step, context) in trace.steps.iter().zip(trace.contexts()) { // `context` is the reconstructed message context after this step: let _ = (step.result, step.status, &step.changes, &step.errors, &step.mapping_ends, context); } assert_eq!(trace.steps.len(), 1); assert_eq!(trace.steps[0].result, StepResult::Executed); assert_eq!(trace.steps[0].mapping_ends, [1, 2]); assert!(trace.steps[0].elapsed.is_some()); let last = trace.contexts().last().unwrap(); assert_eq!(last.data(), message.context().data()); }); }
Error Context
Configure ErrorContext on EngineConfig rather than directly on
EngineBuilder:
#![allow(unused)] fn main() { use dataflow_rs::run::{EngineConfig, ErrorContext}; use dataflow_rs::Engine; let config = EngineConfig::default() .error_context(ErrorContext::new("metadata.errors".parse()?, 50)?); let builder = Engine::builder().config(config); let _ = builder; Ok::<(), dataflow_rs::Error>(()) }
5. Constructing and Inspecting Errors
Replace DataflowError::service(...).retryable(true).build() with
Error::service(code, message).with_retryable(true):
#![allow(unused)] fn main() { use dataflow_rs::{Error, ErrorKind}; let e = Error::service("HTTP_ERROR", "upstream 502").with_retryable(true); assert_eq!(e.code(), "HTTP_ERROR"); assert!(matches!(e.kind(), ErrorKind::Service { .. })); assert!(e.is_retryable()); }
See Error Codes for the full list of error codes in 4.0.
6. Run Options and Context Paths
capture_changes, audit_mode, and routing_bucket have moved from
MessageBuilder to RunOptions:
#![allow(unused)] fn main() { use dataflow_rs::run::AuditMode; use dataflow_rs::RunOptions; let opts = RunOptions::default() .capture_changes(false) .audit(AuditMode::Last(10)) .routing_bucket(7); assert_eq!(opts.routing_bucket, Some(7)); }
Replace string-based get_nested_value / set_nested_value /
remove_nested_value calls with pre-parsed Path operations on
message.context() / message.context_mut():
#![allow(unused)] fn main() { use dataflow_rs::datavalue::OwnedDataValue; use dataflow_rs::{Message, Path}; use serde_json::json; let mut message = Message::from_json(&json!({})); let name: Path = "data.user.name".parse()?; message.context_mut().set(&name, OwnedDataValue::from(&json!("Ada")))?; assert_eq!(message.context().get(&name), Some(&OwnedDataValue::from(&json!("Ada")))); let removed = message.context_mut().remove(&name)?; assert!(removed.is_some()); // Every Path must start with `data`, `metadata`, or `temp_data`: assert!("payload.x".parse::<Path>().is_err()); Ok::<(), dataflow_rs::Error>(()) }
Wire Format for Messages
The serialized JSON format of Message ({id, payload, context, audit_trail, errors})
is unchanged, and every valid 3.x message deserializes cleanly in 4.0. Note that
4.0's Message deserializer strictly enforces that context contains only the
three object roots (data, metadata, and temp_data) and rejects duplicate
JSON keys:
#![allow(unused)] fn main() { use dataflow_rs::Message; let text = r#"{"id":"m","payload":{"a":1},"context":{"data":{},"metadata":{},"temp_data":{}},"audit_trail":[],"errors":[]}"#; let message: Message = serde_json::from_str(text)?; assert_eq!(serde_json::to_string(&message)?, text); let fourth = text.replace(r#""temp_data":{}"#, r#""temp_data":{},"secrets":{}"#); let refused = serde_json::from_str::<Message>(&fourth).unwrap_err().to_string(); assert!(refused.starts_with("a context holds only `data`, `metadata` and `temp_data`, not `secrets`")); Ok::<(), serde_json::Error>(()) }
WASM and UI Packages
@goplasmatic/dataflow-wasm4.0 provides a singleWasmEngineclass constructed withnew WasmEngine(workflows, { secrets })and executed viaawait engine.run(payload, options)(returning{ message, run, trace? }). See WASM Package.@goplasmatic/dataflow-ui4.0 uses the generated TypeScript bindings from the WASM package and replays step contexts from incremental traces. See UI Package.
Pre-Migration Checklist
Before deploying 4.0 in production, run this validation pass over all stored workflow definitions:
- Parse and check every stored workflow with
def::parseandCompiler::checkagainst your productionRegistry. - Fix all
Rejectedissues andLIKELY_TYPOdefects. Any workflow with aRejectedissue will not load, and anyLIKELY_TYPOpoints to a misspelled field that was previously ignored. - Review
Advisoryissues (V3_SPELLINGshows what was rewritten;UNKNOWN_KEYshows what moved intoext). - Persist the output of
compat::migrate_v3so your stored definitions use canonical 4.0 spellings and can be parsed with.compat(false). - Record each workflow's
Fingerprint(compiled.workflow.fingerprint()) to track which operators and context paths each workflow depends on. - Check stored messages (if applicable) to ensure no historical serialized
Messagehas a fourth key incontextor a non-object context root.
#![allow(unused)] fn main() { use dataflow_rs::compat::migrate_v3; use dataflow_rs::def::{ParseOptions, parse}; use dataflow_rs::program::{CompileOptions, IssueCode, Severity}; use dataflow_rs::Registry; use serde_json::json; let stored = vec![ json!({"id": "a", "continue_on_error": true, "tasks": [ {"id": "t", "function": {"name": "log", "input": {"message": "a"}}}]}), json!({"id": "b", "tasks": [{"id": "t", "conditon": true, "function": {"name": "nowhere"}}]}), ]; let compiler = Registry::default().compiler(CompileOptions::default()); let mut ready = Vec::new(); for json in &stored { let parsed = parse(json, ParseOptions::default()); let mut issues = parsed.issues; if let Some(def) = &parsed.def { issues.extend(compiler.check(def)); } let blocking = issues.iter().any(|i| { i.severity == Severity::Rejected || i.code == IssueCode::LikelyTypo }); if blocking { eprintln!("{json:#}: {issues:#?}"); continue; } let def = parsed.def.expect("nothing was Rejected"); let migrated = migrate_v3(json).expect("nothing was Rejected"); let fingerprint = compiler.compile_one(&def).expect("checked").workflow.fingerprint(); ready.push((migrated.json, fingerprint.to_string())); } assert_eq!(ready.len(), 1); assert_eq!(ready[0].0["on_failure"], "continue"); assert!(ready[0].1.starts_with("v1:")); }