Introduction
rust-camel is a Rust-native integration framework. It moves messages between timers, HTTP APIs, files, message brokers, databases, and LLMs. You compose each route from Enterprise Integration Patterns.
The pattern vocabulary comes from Apache Camel. The implementation does not.
Every processor and producer is a Tower Service<Exchange>. A route is a
middleware chain, so backpressure, timeouts, and cancellation are built in.
The compiler checks the route before it runs. See ADR-0001 for the data-plane
design.
Two interfaces build the same RouteDefinition. A fluent Rust API gives
developers compile-time safety. A YAML DSL gives operators declarative
authoring. Neither is a wrapper around the other.
You can build:
- HTTP APIs that fan out to Kafka and a database.
- File pipelines that watch a directory, transform, and publish.
- Message routers that split, filter, and resequence broker streams.
- Scheduled jobs that call an LLM and write the reply to a sink.
rust-camel is not a Camel port. It promises no Camel compatibility. It promises Camel familiarity. A Camel user recognizes Filter, Content-Based Router, and Splitter on first read. See ADR-0046 for the consultation protocol that governs when Camel behavior informs this design.
Start with the Getting started guide.
Getting started
Build and run your first rust-camel route. By the end, you will have a route that moves messages between two endpoints.
Prerequisites
You need the Rust toolchain (1.89 or newer). For the YAML path, also install
the CLI with cargo install camel-cli. See Installation.
Choose a path
The Rust builder API gives type-safe compiled routes for application code. The YAML DSL gives declarative routes for ops-authored configuration.
- Installation
- First route in Rust or First route in YAML
- CLI usage: run, scaffold, and inspect routes from the terminal.
Next, see Core concepts for the Exchange, Message, and CamelContext model.
Installation
Install rust-camel to build integration routes in Rust. Two paths: embed the crates in a Rust application, or run YAML routes with the CLI.
Prerequisites
Install the Rust toolchain with rustup. rust-camel requires Rust 1.89 or newer. The workspace pins edition 2024, and the Tokio async runtime needs a current stable release.
Confirm your toolchain:
rustc --version
Path 1: Embed in a Rust project
Add the core crates to an existing Cargo project:
cargo add camel-core camel-builder camel-api
Then add only the components your routes reference. A timer-to-log route needs two endpoint crates:
cargo add camel-component-timer camel-component-log
These commands resolve the latest published release from crates.io. The
resulting [dependencies] block looks like:
[dependencies]
camel-api = "0.26"
camel-core = "0.26"
camel-builder = "0.26"
camel-component-timer = "0.26"
camel-component-log = "0.26"
tokio = { version = "1", features = ["full"] }
tracing-subscriber = "0.3"
camel-core provides CamelContext, the runtime that starts and stops routes.
camel-builder provides the fluent RouteBuilder API. camel-api provides
the Exchange, Message, and Value types. Each component crate is optional.
Add only what a route references.
Write your first route next: First route in Rust.
Path 2: Run YAML routes with the CLI
The camel CLI parses YAML route files and starts them without compiling Rust.
Install the binary from crates.io:
cargo install camel-cli
This places camel on your PATH. Confirm the install:
camel --version
Scaffold a project and run it:
camel new my-integration
cd my-integration
camel run
camel new creates a Camel.toml config file and a routes/ directory with a
starter route. camel run starts the Camel context from that config.
Write your first YAML route next: First route in YAML.
Run the examples from source
The repository ships compiled examples. Clone it and run one to verify your toolchain:
git clone https://github.com/kennycallado/rust-camel.git
cd rust-camel
cargo run -p hello-world
The hello-world example fires a timer five times and logs each tick. You
should see five log lines, then the process waits for Ctrl+C.
Troubleshooting
| Symptom | Fix |
|---|---|
rustc 1.xx is unsupported | Run rustup update stable. rust-camel needs 1.89 or newer. |
camel: command not found | cargo install puts binaries in ~/.cargo/bin. Add it to your PATH. |
error[E0658] on edition 2024 features | Your rustc is too old. See the first row. |
Next steps
- First route in Rust or First route in YAML
- CLI usage for every CLI command
- Core concepts for the Exchange and CamelContext model
Reference: CLI crate
First route in Rust
Build a route that produces five log messages from a timer. The route stamps a header onto each message and prints it through the log component.
The code comes from the compiled
hello-world
example.
The complete route
#[tokio::main]
async fn main() -> Result<(), CamelError> {
tracing_subscriber::fmt()
.with_target(false) // Cleaner output
.init();
let mut ctx = CamelContext::builder().build().await.unwrap(); // allow-unwrap
ctx.register_component(TimerComponent::new());
ctx.register_component(LogComponent::new());
let route = RouteBuilder::from("timer:tick?period=1000&repeatCount=5")
.route_id("hello-world") // Named routes
.set_header("source", Value::String("timer".into()))
.to("log:info?showHeaders=true&showCorrelationId=true") // Correlation ID
.build()?;
ctx.add_route_definition(route).await?;
ctx.start().await?;
println!("Hello World example running. Press Ctrl+C to stop.");
tokio::signal::ctrl_c().await.ok();
ctx.stop().await?;
Ok(())
}
YAML equivalent
routes:
- id: "hello-world"
from: "timer:tick?period=1000&repeatCount=5"
steps:
- set_header:
key: "source"
value: "timer"
- to: "log:info?showHeaders=true&showCorrelationId=true"
Dependencies
Add these crates to your Cargo.toml:
[dependencies]
camel-api.workspace = true
camel-core.workspace = true
camel-builder.workspace = true
camel-component-timer.workspace = true
camel-component-log.workspace = true
tokio.workspace = true
tracing-subscriber.workspace = true
Each crate has one job. camel-builder gives you the fluent RouteBuilder
API. camel-core gives you CamelContext, the runtime that owns routes and
components. camel-component-timer and camel-component-log provide the two
endpoints the route connects. camel-api provides the shared types Value
and CamelError. tokio runs the async runtime. tracing-subscriber
formats the log output.
How it works
Build the context
CamelContext::builder().build().await constructs the runtime. The context
is the composition root for the whole process. It holds the component,
language, function, and service registries. It also controls route
lifecycle: start, stop, suspend, and resume. You create one context per
process.
The route references two endpoint schemes, timer and log. The context
resolves a scheme to a component only after you register that component.
ctx.register_component(TimerComponent::new()) registers the timer
scheme. ctx.register_component(LogComponent::new()) registers the log
scheme. Without registration, RouteBuilder::from("timer:...") fails at
build time with an unknown scheme.
Author the route
RouteBuilder::from("timer:tick?period=1000&repeatCount=5") opens a route
and attaches a timer consumer. The endpoint URI has three parts. timer is
the component scheme. tick is the endpoint name inside the component. The
query string configures the schedule: period=1000 fires once per second,
and repeatCount=5 stops the timer after five ticks.
.route_id("hello-world") names the route. Named routes are easier to
inspect and to stop individually at runtime.
.set_header("source", Value::String("timer".into())) stamps a header onto
every exchange. The timer consumer creates one exchange per tick. The
header travels with the exchange so downstream steps can read it.
.to("log:info?showHeaders=true&showCorrelationId=true") sends each
exchange to a log producer. The log component formats the exchange body
and writes it through tracing. The query parameters tell the component to
include the headers and the correlation ID in each output line.
.build() consumes the builder and returns a RouteDefinition. The
builder is a single-shot object. You cannot clone or reuse it after build.
Register and start the route
ctx.add_route_definition(route).await hands the route to the context. The
context stores the route but does not start it.
ctx.start().await starts every registered route. The timer consumer
begins to fire. Each tick produces an exchange, the route stamps the
header, and the log component writes a line.
tokio::signal::ctrl_c().await blocks the main task until you press
Ctrl+C. ctx.stop().await then shuts the context down cleanly.
Run it
cargo run -p hello-world
The timer fires once per second. After five ticks it stops producing. The program keeps running until you press Ctrl+C.
The output shows five log lines. Each line carries the source header and
a correlation ID that traces the exchange through the pipeline.
Next steps
- First route in YAML: the same route in declarative form.
- CLI usage: run, scaffold, and inspect routes from the terminal.
- Core concepts: the Exchange, Message, and CamelContext data model.
Reference: camel-builder, camel-core
First route in YAML
Declare a route in YAML that produces log messages from a timer. You write no Rust code and compile nothing. The CLI parses the YAML file and starts the route.
The YAML route and the Rust builder route lower to the same
RouteDefinition. The runtime cannot tell which authoring form produced
it. Pick YAML when routes live with configuration, change without a
rebuild, or belong to an ops team. See ADR-0026 for the canonical
authoring decision.
The route file comes from the
config-basic
example.
The complete route
routes:
- id: "hello-timer"
from: "timer:tick?period=2000&repeatCount=3"
steps:
- log: "Hello from config-loaded route!"
- to: "log:info"
Project layout
A YAML route project needs two files: a route file and a Camel.toml
config file.
my-integration/
├── Camel.toml # Config: route discovery, log level
└── routes/
└── hello.yaml # Route definitions
Run camel new my-integration to scaffold this layout. The config file
tells the CLI where to find route files. A minimal config declares the
route glob and a log level:
[default]
routes = ["routes/**/*.yaml"]
log_level = "INFO"
The routes glob selects which files the CLI loads. The log_level
sets the tracing threshold for the whole context. See
CLI usage for the full config reference.
How it works
Top-level structure
routes is the top-level list. Each entry is one route definition. A
file can hold many routes. Each route has three required fields: id,
from, and steps.
Route identity
id is a unique name for the route. The CLI prints this id in log
output and startup messages. Use it to inspect or stop one route among
many at runtime.
Consumer endpoint
from is the consumer endpoint URI.
timer:tick?period=2000&repeatCount=3 creates a timer that fires every
two seconds, three times.
The URI has three parts. timer is the component scheme. tick is the
endpoint name inside the component. The query string sets the schedule.
period=2000 fires every 2000 milliseconds. repeatCount=3 stops the
timer after three ticks.
Processing steps
steps is the ordered list of processing steps. The route runs them top
to bottom for each exchange.
The log step writes a fixed message through tracing. The to step
sends the exchange to a producer endpoint. Here to: "log:info" writes
the exchange body at info level. Both step verbs map to the same
processor types the Rust builder exposes.
Run it
From the project directory, run:
camel run
The CLI reads Camel.toml from the current directory. It loads every
file that matches the routes glob, parses each YAML route into a
RouteDefinition, and starts the context.
What you see
The timer fires three times. Each tick produces one exchange. The log
step writes its message, then the to: "log:info" producer writes the
exchange body. You see log output every two seconds for six seconds.
After three ticks the timer consumer stops producing. The process keeps running until you press Ctrl+C.
Next steps
- CLI usage: run, scaffold, and inspect routes from the terminal.
- YAML DSL: every step verb and route option.
- Core concepts: the Exchange, Message, and CamelContext model.
Reference: DSL crate, Config crate
CLI usage
The camel CLI runs, scaffolds, and inspects integration routes from the terminal. Install it with cargo install camel-cli.
Quick reference
| Command | Purpose | Example |
|---|---|---|
run | Start routes from a config file | camel run |
new | Scaffold a new project | camel new my-integration |
journal inspect | Read events from a journal file | camel journal inspect runtime.db |
plugin new | Scaffold a WASM plugin | camel plugin new my-plugin |
plugin build | Compile and install a WASM plugin | camel plugin build |
openapi generate | Emit an OpenAPI document from REST routes | camel openapi generate routes.yaml |
camel run
Start a Camel context from YAML route files.
Trust model.
camel runexecutes route scripts, WASM modules, and beans that the current working directory supplies. Run it only from a trusted directory.
The CLI reads Camel.toml from the current directory. The file defines route file patterns, log levels, component settings, and supervision policies.
camel run
Config file
[default]
routes = ["routes/*.yaml"]
log_level = "INFO"
watch = false
The [default] profile sets routes = ["routes/*.yaml"] to discover route files. The [development] and [production] profiles override log level and watch mode.
Route file
routes:
- id: "hello"
from: "timer:tick?period=2000"
steps:
# log: messages are evaluated as Simple Language — ${...} expressions are interpolated
- log: "Hello from camel-cli! Exchange #${header.CamelTimerCounter}"
Flags
| Flag | Description |
|---|---|
--routes <GLOB> | Override the route file pattern from Camel.toml |
--config <FILE> | Path to Camel.toml (default: Camel.toml) |
--watch | Enable file-watcher hot-reload |
--no-watch | Disable file-watcher hot-reload |
--otel | Enable OpenTelemetry export |
--otel-endpoint <URL> | OTLP endpoint URL (implies --otel) |
--service-name <NAME> | OTel service name (implies --otel) |
--health-port <PORT> | Start a standalone health server on this port |
Flag definitions live in crates/camel-cli/src/main.rs.
Expected output
The CLI starts the context, discovers routes that match the glob, and runs them. For hello.yaml above, the route logs one message every two seconds. The message text repeats with an increasing counter:
Hello from camel-cli! Exchange #1
Hello from camel-cli! Exchange #2
Press Ctrl+C (or send SIGTERM) to stop.
Profiles
Set the active profile with the CAMEL_PROFILE environment variable:
CAMEL_PROFILE=development camel run
The development profile sets log_level = "DEBUG" and watch = true. The production profile sets log_level = "WARN" and watch = false.
Hot-reload
With --watch, the CLI monitors route files for changes. The watcher groups rapid edits behind a 300 ms debounce window. Set watch_debounce_ms in Camel.toml to change it. Edits take effect without a restart.
camel run --watch
See crates/camel-config/CONTEXT.md for the debounce default.
Minimal config
A route can start without exec components or complex setup:
[default]
routes = ["routes/*.yaml"]
log_level = "INFO"
routes:
- id: "hello"
from: "timer:tick?period=2000"
steps:
- log: "Hello without exec! Exchange #${header.CamelTimerCounter}"
camel new
Scaffold a new Camel project with a Camel.toml and a routes/ directory.
camel new my-integration
cd my-integration
camel run
| Flag | Description |
|---|---|
<name> (positional) | Project name (letters, digits, hyphens, underscores) |
--template <NAME> | Template to use (default: basic) |
--profile-layout <LAYOUT> | simple or env (default: env) |
--force | Overwrite files if the directory already exists |
Layout simple writes only a [default] profile. Layout env adds [development] and [production]. Flag definitions live in crates/camel-cli/src/commands/new.rs.
Expected output
Created camel project: my-integration
Next steps:
cd my-integration
camel run
camel run --watch
camel journal inspect
Read events from a redb runtime journal file. Use this command for offline debugging of a previous session.
camel journal inspect runtime.db
| Flag | Description |
|---|---|
<path> (positional) | Path to the .db journal file |
--limit <N> | Show only the last N events (default: 100) |
--route <ID> | Filter to a specific route id |
--format <FMT> | table (default) or json |
Flag definitions live in crates/camel-cli/src/commands/journal.rs.
Expected output
The default table format prints one row per event:
SEQ TIMESTAMP EVENT ROUTE_ID
--------------------------------------------------------------------------------
00000001 2026-08-08T12:00:00.000Z RouteRegistered hello
00000002 2026-08-08T12:00:00.100Z RouteStartRequested hello
00000003 2026-08-08T12:00:00.250Z RouteStarted hello
Pass --format json to pipe events into another tool.
camel plugin
Scaffold and build WASM plugins. Plugins extend the runtime with custom processors, beans, or authorization policies. The CLI ships two subcommands: new and build.
camel plugin new
Create a plugin project from a template.
camel plugin new my-plugin
| Flag | Description |
|---|---|
<name> (positional) | Plugin name (letters, digits, hyphens, underscores) |
--type <TYPE> | processor (default), bean, or authorization-policy |
--force | Overwrite files if the directory already exists |
Flag definitions live in crates/camel-cli/src/commands/plugin.rs.
Expected output
Created camel processor plugin 'my-plugin'
Next steps:
cd my-plugin
camel plugin build
camel plugin build
Compile a plugin to the wasm32-wasip2 target and install the artifact into the project plugins directory.
camel plugin build
Run this from inside the plugin directory, or pass a path:
camel plugin build ./my-plugin
| Flag | Description |
|---|---|
<path> (positional, optional) | Plugin directory (default: current directory) |
--debug | Build without --release |
The CLI copies the compiled .wasm into the plugins directory. It reads the directory from [default.components.wasm].plugins_dir in Camel.toml. The default is plugins. See crates/camel-cli/src/commands/plugin.rs for the resolution rules.
Expected output
Built and installed plugin 'my-plugin'
source: /path/to/my-plugin/target/wasm32-wasip2/release/my_plugin.wasm
installed: /path/to/camel-root/plugins/my-plugin.wasm
camel openapi
Emit an OpenAPI 3.0.3 document from the rest: blocks in a YAML or JSON route file. The CLI ships one subcommand: generate.
camel openapi generate routes.yaml
| Flag | Description |
|---|---|
<file> (positional) | Path to the route file (.yaml, .yml, or .json) |
--title <TITLE> | API title for the info section (default: Generated API) |
--version <VER> | API version for the info section (default: 1.0.0) |
Flag definitions live in crates/camel-cli/src/commands/openapi.rs.
Expected output
The command prints a pretty JSON document to stdout. The top-level openapi field is 3.0.3. Each rest: block becomes a path entry. Each operation becomes a verb under that path.
{
"openapi": "3.0.3",
"info": {
"title": "Generated API",
"version": "1.0.0"
},
"paths": {
"/api/users": {
"get": { "operationId": "listUsers" }
}
}
}
If a file has no rest: blocks, the command exits with an error. Validation warnings print to stderr.
Next steps
- See First route in YAML for a complete walkthrough.
- See YAML DSL for the full YAML reference.
- See Operations for health checks and monitoring.
Reference: CLI crate
OpenAPI and plugin subcommands
The CLI ships two helper families for route work outside the running context. One reads rest: blocks and emits an OpenAPI 3.0.3 document. The other scaffolds and compiles WASM plugins for the runtime.
Quick reference
| Command | Purpose | Example |
|---|---|---|
openapi generate | Emit an OpenAPI 3.0.3 document from rest: blocks | camel openapi generate routes.yaml |
plugin new | Scaffold a WASM plugin project from a template | camel plugin new my-plugin |
plugin build | Compile a WASM plugin and install the artifact | camel plugin build |
camel openapi generate
Emit an OpenAPI 3.0.3 document from the rest: blocks of a YAML or JSON route file. The CLI reads the file, lowers the blocks, and prints a pretty JSON document to stdout.
camel openapi generate routes.yaml
Input file
A route file with one or more rest: blocks. Each block maps to a path entry. Each operation maps to a verb.
rest:
- host: 0.0.0.0
port: 9090
path: /api/users
operations:
- method: GET
operation_id: listUsers
to: direct:listUsers
- method: POST
operation_id: createUser
consumes: application/json
produces: application/json
success_status: 201
to: direct:createUser
request_schema:
type: object
properties:
name:
type: string
required: [name]
Flags
| Flag | Description |
|---|---|
<file> (positional) | Path to the route file (.yaml, .yml, or .json) |
--title <TITLE> | API title for the info section (default: Generated API) |
--version <VER> | API version for the info section (default: 1.0.0) |
Flag definitions live in crates/camel-cli/src/commands/openapi.rs.
Expected output
The command prints a pretty JSON document. The top-level openapi field is 3.0.3. Each rest: block becomes a path entry. Each operation becomes a verb under that path. The success response carries a description and a content schema under produces. Body verbs carry a requestBody with a content schema under consumes.
{
"openapi": "3.0.3",
"info": {
"title": "Generated API",
"version": "1.0.0"
},
"paths": {
"/api/users": {
"get": {
"operationId": "listUsers",
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": { "schema": { "type": "object" } }
}
}
}
},
"post": {
"operationId": "createUser",
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": { "name": { "type": "string" } },
"required": ["name"]
}
}
}
},
"responses": {
"201": {
"description": "Created",
"content": {
"application/json": { "schema": { "type": "object" } }
}
}
}
}
}
}
}
How generation works
The CLI selects a parser by file extension. .yaml and .yml files go through extract_rest_blocks. .json files go through serde_json. Unknown extensions fall back to YAML parsing.
Generation fails fast on three conditions. A file with no rest: blocks exits with an error. Lowering errors fail before generation runs. Duplicate route ids fail before generation runs. Validation warnings print to stderr and do not abort the command.
A body verb with no request_schema produces a warning and a weak stub. A non-204 verb with no response schema does the same. Regenerate the document whenever the rest: blocks change. The document mirrors the runtime contract.
camel plugin new
Create a WASM plugin project from a template. The CLI writes a Cargo workspace member with the right target and the right dependencies. It also writes a sample lib.rs for the chosen plugin type.
camel plugin new my-plugin
| Flag | Description |
|---|---|
<name> (positional) | Plugin name (letters, digits, hyphens, underscores) |
--type <TYPE> | processor (default), bean, or authorization-policy |
--force | Overwrite files if the directory already exists |
Flag definitions live in crates/camel-cli/src/commands/plugin.rs.
Plugin types
| Type | Purpose |
|---|---|
processor | Custom pipeline step. The plugin runs inside a Route as a Service<Exchange>. |
bean | Named function called by name from a route. |
authorization-policy | Security policy decision source. Routes reference it from a security_policy block. |
The default is processor. Pick the type that matches the plugin role. The template files differ by type. A processor template exports a function that takes an exchange. A bean template exports a named function. An authorization-policy template exports a decision function that returns allow or deny.
Expected output
Created camel processor plugin 'my-plugin'
Next steps:
cd my-plugin
camel plugin build
camel plugin build
Compile a plugin to the wasm32-wasip2 target. The CLI copies the compiled .wasm into the project plugins directory.
camel plugin build
Run this from inside the plugin directory, or pass a path:
camel plugin build ./my-plugin
| Flag | Description |
|---|---|
<path> (positional, optional) | Plugin directory (default: current directory) |
--debug | Build without --release |
The build calls cargo build --target wasm32-wasip2. Install the target with rustup target add wasm32-wasip2 before the first build.
Plugins directory resolution
The CLI resolves the destination in two steps. First, it walks up from the plugin directory to find the project root. The root holds a Camel.toml or a workspace Cargo.toml.
Second, it reads [default.components.wasm].plugins_dir from Camel.toml. The default is plugins. The value must be a relative path with no .. segments. The CLI rejects absolute paths and paths that resolve outside the project root. These checks catch symlink escapes.
Expected output
Built and installed plugin 'my-plugin'
source: /path/to/my-plugin/target/wasm32-wasip2/release/my_plugin.wasm
installed: /path/to/camel-root/plugins/my-plugin.wasm
The CLI converts hyphens in the plugin name to underscores for the wasm artifact name. The my-super-plugin package compiles to my_super_plugin.wasm.
See also
- CLI usage for the rest of the CLI surface.
- YAML DSL for the full
rest:block reference. - WASM component for runtime plugin loading.
Reference: CLI crate | DSL crate | OpenAPI generator source | Plugin command source
Core concepts
rust-camel builds every route from three abstractions. An Exchange carries the data. A Route chains the processors that transform it. A Component owns a URI scheme and connects the route to the outside world. This section defines that model. Every pattern in the guide assumes it.
Read the pages in this order:
- Exchange and Message - the envelope that carries the body, headers, properties, and error state. Every pattern mutates it.
- Routes and pipelines - how a source and an ordered step list form a Tower service chain. This is where you compose patterns.
- Components and endpoints - how a Component turns a URI into the consumers and producers that do real I/O.
- Error handling - how faults propagate through a route, and how handlers control recovery.
- Data plane vs control plane - why message flow and route lifecycle run on separate trait hierarchies.
- Glossary - the canonical name for every term. Open it when a word needs a precise definition.
When the model is clear, the EIP patterns show how to compose these concepts into routing, transformation, and messaging solutions.
Exchange and Message
An Exchange is the unit of work that flows through a pipeline. It carries an input Message, an optional output Message, properties, an error state, and an exchange pattern. Each processor reads the Exchange, mutates it, and returns it. The next processor picks up the modified Exchange.
let on_complete = RouteBuilder::from("direct:on-complete")
.route_id("on-complete")
.process(|mut exchange| async move {
let body = exchange
.input
.body
.as_text()
.unwrap_or("<empty>")
.to_string();
exchange.input.body = Body::Text(format!("Exchange completed: {body}"));
Ok(exchange)
})
.to("log:uow-complete?showBody=true&showCorrelationId=true")
.build()?;
let on_failure = RouteBuilder::from("direct:on-failure")
.route_id("on-failure")
.process(|mut exchange| async move {
exchange.input.body = Body::Text("Exchange failed".to_string());
Ok(exchange)
})
.to("log:uow-failed?showBody=true&showCorrelationId=true")
.build()?;
let success_route = RouteBuilder::from("timer:uow-success?delay=0&period=1200&repeatCount=4")
.route_id("uow-success")
.process(|mut exchange| async move {
exchange.input.body = Body::Text("order-123".to_string());
sleep(Duration::from_millis(450)).await;
Ok(exchange)
})
.to("log:uow-main-success?showBody=true")
.build()?
.with_unit_of_work(UnitOfWorkConfig {
on_complete: Some("direct:on-complete".to_string()),
on_failure: None,
});
let failure_route = RouteBuilder::from("timer:uow-failure?delay=0&period=1800&repeatCount=2")
.route_id("uow-failure")
.process(|mut exchange| async move {
exchange.input.body = Body::Text("will-fail".to_string());
sleep(Duration::from_millis(300)).await;
exchange.set_error(CamelError::ProcessorError("simulated failure".to_string()));
Ok(exchange)
})
.build()?
.with_unit_of_work(UnitOfWorkConfig {
on_complete: None,
on_failure: Some("direct:on-failure".to_string()),
});
YAML equivalent
routes:
- id: "on-complete"
from: "direct:on-complete"
steps:
# The body-formatting Rust closure maps to a registered bean in YAML.
- bean:
name: "format-completed-body"
method: "process"
- to: "log:uow-complete?showBody=true&showCorrelationId=true"
- id: "on-failure"
from: "direct:on-failure"
steps:
- bean:
name: "format-failed-body"
method: "process"
- to: "log:uow-failed?showBody=true&showCorrelationId=true"
- id: "uow-success"
from: "timer:uow-success?delay=0&period=1200&repeatCount=4"
on_complete: "direct:on-complete"
steps:
- bean:
name: "set-order-body"
method: "process"
- to: "log:uow-main-success?showBody=true"
- id: "uow-failure"
from: "timer:uow-failure?delay=0&period=1800&repeatCount=2"
on_failure: "direct:on-failure"
steps:
# The Rust closure sets the body then calls set_error. A YAML route
# needs a bean or function step to produce the failure.
- bean:
name: "set-body-and-fail"
method: "process"
The include shows two routes. The uow-success route sets the body to "order-123", then forwards the Exchange to a log sink. The uow-failure route sets the body, then calls exchange.set_error(...) to mark the Exchange as failed. Both routes attach a UnitOfWorkConfig that fires a hook route when the Exchange exits the pipeline.
Exchange fields
The Exchange struct lives in crates/camel-api/src/exchange.rs. Its fields:
| Field | Type | Purpose |
|---|---|---|
input | Message | Incoming message. Always present. |
output | Option<Message> | Response message. Set for InOut exchanges. |
properties | HashMap<String, Value> | Exchange-scoped key-value map. Cross-step scratch space. |
extensions | HashMap<String, Arc<dyn Any + Send + Sync>> | Non-serializable values like channel senders. |
error | Option<CamelError> | Failure state. Controls pipeline resolution. |
pattern | ExchangePattern | InOnly (fire-and-forget) or InOut (request-reply). |
correlation_id | String | UUID v4 for tracing across steps. |
otel_context | opentelemetry::Context | Active span for distributed tracing. |
The pattern field defaults to InOnly. Call Exchange::new_in_out(...) to build an InOut exchange. The Consumer checks the pattern to decide whether to wait for a reply.
Message: body and headers
A Message holds the payload plus its metadata. Two fields:
body: Body— the payload.headers: HashMap<String, Value>— metadata about the body. Keys are strings. Values areserde_json::Value.
Components construct the initial input Message when a Consumer fires. Processors read and write it through exchange.input and exchange.output.
Body variants
The body is a typed enum, not raw bytes. Each variant tags the payload with its format:
| Variant | Holds |
|---|---|
Empty | No content. Default. |
Bytes(Bytes) | Raw bytes. |
Text(String) | UTF-8 string. |
Json(serde_json::Value) | Parsed JSON. |
Xml(String) | XML string. |
Stream(StreamBody) | Lazy byte stream. Single-consumption. |
The Body enum is #[non_exhaustive] (ADR-0049). New variants may appear in a future release without a breaking-change semver bump.
Every variant can materialize to bytes. Call body.materialize() to collect the full payload as Bytes, or body.into_bytes(limit) to cap the read. Producers use this to serialize the body before I/O.
Typed body access
Processors read the body in two ways. For a quick string peek, call body.as_text(), which returns Option<&str>. For a typed conversion, call exchange.body_as::<T>(). This method uses the FromBody trait.
FromBody has built-in implementations for String, Vec<u8>, bytes::Bytes, and serde_json::Value. Each implementation converts from compatible body variants and rejects the rest with CamelError::TypeConversionFailed. For custom serde types, the impl_from_body_via_serde! macro generates an implementation from any type that implements DeserializeOwned.
The example shows both access styles. The on-complete hook reads the body with as_text(). It then rewrites the body with a direct field assignment: exchange.input.body = Body::Text(...).
Headers versus properties
Headers and properties are both string-keyed maps of Value. They differ in scope.
Headers live on a Message. They describe that specific message: content type, encoding, custom fields set by a component. Read a header with exchange.input.header("content-type"). Set one with exchange.input.set_header("source", "timer"). Headers belong to the message they were set on. When the pipeline swaps input for output, headers do not carry over.
Properties live on the Exchange itself. They survive body and message changes. A processor sets a property with exchange.set_property("attempt", 1). The next step reads it with exchange.property("attempt"). Use properties for cross-step state that is not part of the payload.
The error machinery uses properties. When set_error() fires, it auto-populates three property keys: CamelExceptionMessage, CamelExceptionKind, and CamelExceptionCaught. All languages read error context through these keys. Call handle_error() to set CamelExceptionHandled to true and clear the error.
Exchange through the pipeline
The Runtime wraps each Exchange in a UnitOfWorkConfig layer and passes it to the Route pipeline. Each processor receives the Exchange, transforms it, and returns it. The next processor picks up the result. This chain continues until the last step completes.
When a processor fails, it sets the error state with exchange.set_error(...). The pipeline executor detects the error and resolves the outcome. The UnitOfWorkConfig fires its on_failure hook instead of on_complete.
The pipeline resolves the Exchange to a PipelineOutcome: Completed, Stopped, or Failed (ADR-0024). See routes and pipelines for how the executor produces each outcome and how structural EIPs interact with it.
Reference: API contracts · Runtime
Routes and pipelines
A Route is a source endpoint followed by an ordered list of steps. Each step is a Processor that receives an Exchange, transforms it, and returns it. The final step forwards the result to a sink.
Route
A Route is a named message-processing pipeline. It pairs a source endpoint that emits Exchanges with a sequence of steps that transform or route them. The Runtime owns the definition.
#[tokio::main]
async fn main() -> Result<(), CamelError> {
tracing_subscriber::fmt()
.with_target(false) // Cleaner output
.init();
let mut ctx = CamelContext::builder().build().await.unwrap(); // allow-unwrap
ctx.register_component(TimerComponent::new());
ctx.register_component(LogComponent::new());
let route = RouteBuilder::from("timer:tick?period=1000&repeatCount=5")
.route_id("hello-world") // Named routes
.set_header("source", Value::String("timer".into()))
.to("log:info?showHeaders=true&showCorrelationId=true") // Correlation ID
.build()?;
ctx.add_route_definition(route).await?;
ctx.start().await?;
println!("Hello World example running. Press Ctrl+C to stop.");
tokio::signal::ctrl_c().await.ok();
ctx.stop().await?;
Ok(())
}
YAML equivalent
routes:
- id: "hello-world"
from: "timer:tick?period=1000&repeatCount=5"
steps:
- set_header:
key: "source"
value: "timer"
- to: "log:info?showHeaders=true&showCorrelationId=true"
The include shows both ends of a Route. RouteBuilder::from("timer:tick?...") is the source endpoint. .to("log:info?...") is the sink. The .set_header(...) and .to(...) calls between them are the ordered steps. The source fires a new Exchange per timer tick. Each step runs in order. The sink receives the final state.
Pipeline
A Pipeline is the compiled assembly of Processors that processes an Exchange through a Route. Each Processor is a single processing unit. It can be an EIP pattern (filter, choice, split, setBody) or a custom step that receives and returns an Exchange.
The data plane runs on Tower (ADR-0001). Every processor and producer is a Service<Exchange>. The Tower Layer trait composes these services into a chain at build time. This is the architectural advantage over Apache Camel. Middleware, backpressure, timeout, and cancellation compose through one uniform trait instead of ad-hoc hooks.
Pipeline outcome and flow control
The pipeline executor produces a PipelineOutcome (ADR-0024) with three variants:
| Variant | Means | Reply channel sees |
|---|---|---|
Completed(Exchange) | The pipeline ran to the end | Ok(ex) |
Stopped(Exchange) | A step ended the route early with Stop. Successful control flow, not an error | Ok(ex) |
Failed(CamelError) | A step returned an error and no handler absorbed it | Err(err) |
PipelineOutcome sits one layer above Tower. Tower Service<Exchange> responses stay Result<Exchange, CamelError>. A single adapter inside SequentialPipeline::call translates PipelineOutcome to Result. Completed and Stopped both become Ok. The consumer reply channel cannot distinguish them. It builds the response from the Exchange state in both cases.
Stop is successful control flow, not a failure (ADR-0024). The Exchange carries every mutation made before the Stop step. The error handler never runs for a Stop.
Structural EIPs
Steps come in two shapes. A leaf EIP (setBody, log, marshal) is one Processor that maps an Exchange to an Exchange. A structural EIP (Filter, Choice, Loop, Throttle, doTry, Split, Multicast, LoadBalance) contains child steps that form a sub-pipeline.
let route = RouteBuilder::from("timer:tick?period=1000&repeatCount=6")
.route_id("content-based-router-demo")
.process(move |mut exchange: camel_api::Exchange| {
let c = Arc::clone(&counter_clone);
Box::pin(async move {
let n = c.fetch_add(1, Ordering::SeqCst);
let priority = match n % 3 {
0 => "high",
1 => "medium",
_ => "low",
};
exchange.input.body = Body::Text(priority.into());
Ok(exchange)
})
})
.choice()
.when(|ex| ex.input.body.as_text() == Some("high"))
.to("log:high-priority?showBody=true&showCorrelationId=true")
.end_when()
.when(|ex| ex.input.body.as_text() == Some("medium"))
.to("log:medium-priority?showBody=true&showCorrelationId=true")
.end_when()
.otherwise()
.to("log:low-priority?showBody=true&showCorrelationId=true")
.end_otherwise()
.end_choice()
.error_handler(
ErrorHandlerConfig::log_only()
.on_exception(|_| true)
.retry(1)
.build(),
)
.build()?;
YAML equivalent
routes:
- id: "content-based-router-demo"
from: "timer:tick?period=1000&repeatCount=6"
error_handler:
on_exceptions:
- retry:
max_attempts: 1
steps:
# The Rust closure that rotates the body becomes a registered bean.
- bean:
name: "set-rotating-priority"
method: "process"
- choice:
when:
- simple: "${body} == 'high'"
steps:
- to: "log:high-priority?showBody=true&showCorrelationId=true"
- simple: "${body} == 'medium'"
steps:
- to: "log:medium-priority?showBody=true&showCorrelationId=true"
otherwise:
- to: "log:low-priority?showBody=true&showCorrelationId=true"
The include shows a Choice segment with three branches. Each .when(...) arm compiles to a sub-pipeline. The Choice evaluates predicates in order. It runs the first matching branch and skips the rest.
Structural EIPs return PipelineOutcome directly through the OutcomePipeline trait (ADR-0025). Each wraps its body as an OutcomeSegment, stored in a CompiledStep::Segment variant. This lets Stopped(ex) propagate out of the sub-pipeline with Exchange state intact. A .stop() inside any branch halts the entire route, not just the branch.
For the full processor catalog, see the Processor crate.
Reference: Runtime · Processor crate
Components and endpoints
A Component owns a URI scheme and builds the Endpoints that connect a Route to an external system. The Endpoint then creates the Consumer that pulls data in, or the Producer that sends data out.
#[tokio::main]
async fn main() -> Result<(), CamelError> {
tracing_subscriber::fmt()
.with_target(false) // Cleaner output
.init();
let mut ctx = CamelContext::builder().build().await.unwrap(); // allow-unwrap
ctx.register_component(TimerComponent::new());
ctx.register_component(LogComponent::new());
let route = RouteBuilder::from("timer:tick?period=1000&repeatCount=5")
.route_id("hello-world") // Named routes
.set_header("source", Value::String("timer".into()))
.to("log:info?showHeaders=true&showCorrelationId=true") // Correlation ID
.build()?;
ctx.add_route_definition(route).await?;
ctx.start().await?;
println!("Hello World example running. Press Ctrl+C to stop.");
tokio::signal::ctrl_c().await.ok();
ctx.stop().await?;
Ok(())
}
YAML equivalent
routes:
- id: "hello-world"
from: "timer:tick?period=1000&repeatCount=5"
steps:
- set_header:
key: "source"
value: "timer"
- to: "log:info?showHeaders=true&showCorrelationId=true"
Two registrations sit before the Route. TimerComponent owns the timer: scheme. LogComponent owns the log: scheme. The Route then refers to those schemes by URI: timer:tick?period=1000&repeatCount=5 as its source, and log:info?showHeaders=true&showCorrelationId=true as its sink.
Component
A Component is a factory. It is identified by a URI scheme. Each scheme (timer, log, http, kafka) has exactly one Component. Components register into CamelContext by scheme at startup. The Runtime resolves every Route URI through this registry. The Component trait and the startup and shutdown contracts live in the Component SPI.
Endpoint
An Endpoint is an instantiated communication point. The Component creates it from a specific URI. The same Component can build many Endpoints from different URIs. timer:tick?period=1000 and timer:once?delay=2000 are two Endpoints from one TimerComponent. An Endpoint creates a Consumer for inbound traffic, or a Producer for outbound traffic.
Consumer
A Consumer is the source side of a Route. The Runtime starts it for the from: Endpoint. It is event-driven. It receives data from an external system and submits Exchanges to the Pipeline. The Consumer runs for the lifetime of the Route.
Some Components also expose a pull-based PollingConsumer. A PollingConsumer does not start a Route. The pollEnrich verb and the WASM camel_poll host function use it to read a resource on demand (ADR-0015).
Producer
A Producer is the sink side. The Runtime creates it for each to: Endpoint. It sends an Exchange to an external system. Producers are strictly write and send. Every Producer is a Tower Service<Exchange>. To read a resource mid-route, use a PollingConsumer. Do not use a producer mode for reads.
URI scheme resolution
When the Runtime builds a Route, it resolves each URI the same way:
- Extract the scheme. This is the part before the first
:. Fortimer:tick?period=1000, the scheme istimer. - Look up the registered Component for that scheme.
- Call
Component::create_endpoint(uri)to build the Endpoint. - The Endpoint creates the Consumer for a
from:URI, or the Producer for ato:URI.
The path (tick) and the query (period=1000&repeatCount=5) belong to the Endpoint. The Component interprets them. A missing scheme or an unregistered scheme fails at startup, before the Route runs.
For the component catalog, see the Components section.
Reference: Component SPI · Components bounded context
Data plane vs control plane
rust-camel splits its runtime into two planes (ADR-0001). The data plane processes every Exchange through a Route pipeline. The control plane manages the lifecycle of Components, Endpoints, Consumers, and Routes. The split keeps the hot path fast and the cold path safe.
#[tokio::main]
async fn main() -> Result<(), CamelError> {
tracing_subscriber::fmt()
.with_target(false) // Cleaner output
.init();
let mut ctx = CamelContext::builder().build().await.unwrap(); // allow-unwrap
ctx.register_component(TimerComponent::new());
ctx.register_component(LogComponent::new());
let route = RouteBuilder::from("timer:tick?period=1000&repeatCount=5")
.route_id("hello-world") // Named routes
.set_header("source", Value::String("timer".into()))
.to("log:info?showHeaders=true&showCorrelationId=true") // Correlation ID
.build()?;
ctx.add_route_definition(route).await?;
ctx.start().await?;
println!("Hello World example running. Press Ctrl+C to stop.");
tokio::signal::ctrl_c().await.ok();
ctx.stop().await?;
Ok(())
}
YAML equivalent
routes:
- id: "hello-world"
from: "timer:tick?period=1000&repeatCount=5"
steps:
- set_header:
key: "source"
value: "timer"
- to: "log:info?showHeaders=true&showCorrelationId=true"
The example shows both planes at work. The RouteBuilder::from(...) chain builds a Tower service pipeline. That pipeline is the data plane. The ctx.add_route_definition(...) and ctx.start() calls route through the control plane. Each plane has its own contracts, its own performance budget, and its own trait hierarchy.
Data plane: the hot path
The data plane processes every Exchange. Each step in the pipeline is a Tower Service<Exchange>. A Filter wraps a BoxProcessor. A Choice routes to one of several BoxProcessor arms. A WireTap forks to a secondary BoxProcessor. EIP composition maps cleanly to Tower's Service plus Layer pair (ADR-0001).
This is the hot path. Every microsecond matters. Tower's poll_ready and call protocol gives backpressure from the first step back to the Consumer. Services are cheap to clone and compose. The data plane must stay free of locks, allocations, and blocking operations. A step that blocks starves every Exchange behind it.
Control plane: the cold path
The control plane manages lifecycle. Components, Endpoints, and Consumers use their own trait hierarchy with start, stop, suspend, resume, and health operations. These operations do not fit Tower's request/response model (ADR-0001).
Lifecycle commands flow through the RuntimeBus. The bus records intent, projects route status, and starts or stops the Consumer. The control plane uses synchronous-projection CQRS with optimistic versioning and optional journal persistence (ADR-0002). Safety wins over speed here. The control plane can afford heavier abstractions because it runs on the cold path.
Why the separation exists
The split serves two goals.
Performance. The data plane must not block on control-plane locks. Every Exchange pays for the hot path. The control plane can take its time. It can persist commands, project state, and run health checks without charging that cost to throughput.
Safety. The data plane cannot mutate route state. It cannot start or stop a Consumer. It cannot change a Component registration. This isolation prevents runtime data from corrupting configuration. camel-core enforces the boundary at the module level. Each bounded context is a vertical slice with its own ports and adapters (ADR-0045). The data plane is not CQRS. The control plane is.
Trust boundary
Exchange data is untrusted (ADR-0032). Operator configuration is trusted. Headers, body, properties, and correlation keys inside an Exchange are adversary-controlled. This is the trust boundary.
No untrusted exchange datum may drive a control-plane action. It may not drive an unbounded numeric or resource decision. It may not reach an executable or interpretable sink. Every such crossing requires validation, bounding, or a capability check.
This rule prevents injection attacks. A hostile header cannot start or stop a Route. A crafted body cannot inflate a throttle limit or a loop cap. The pre-1.0 audit found eight violations of this principle. Each one became a Batch 1 security fix.
Cancellation
The control plane can stop a Route while Exchanges are in flight. A per-start tokio::task_local! cancel token carries the cancellation signal into the pipeline (ADR-0043). The step loop checks the token between steps, before each call. If the token is set, the pipeline returns Failed(ConsumerStopping).
Graceful stop drains in-flight Exchanges to completion first. The cancel check is a backstop. It fires only after the drain timeout expires, so stragglers exit at the next step boundary instead of hanging. The token is task-local, not compiled into the pipeline. A compiled-in token would survive a restart as a cancelled child and fail every new Exchange. The task-local resets on each start from a fresh child token.
Reference: camel-core crate
Error handling
Errors in rust-camel split into two mechanisms. Disposition decides what
happens to an exchange after a catch matches it. The route-level error
handler is the safety net that retries failed steps and routes exhausted
exchanges to a dead-letter endpoint. The two compose. They do not overlap.
Block-level do_try adds a third, local scope for a small group of steps.
Error model
Every failure in the data plane is a CamelError. The enum lives in
crates/camel-api/src/error.rs.
| Variant | Cause |
|---|---|
ProcessorError(String) | A pipeline step failed. |
ProcessorErrorWithSource(String, ..) | A step failed and the source error chain is kept. |
Io(String) | An I/O operation failed. |
RouteError(String) | A route lifecycle error. |
CircuitOpen(String) | A circuit breaker rejected the exchange. |
HttpOperationFailed { .. } | An HTTP call returned an error status. |
ValidationError(String) | Exchange validation failed. |
Unauthorized(String) | Authorization denied the exchange. |
ConfigValidation(ConfigValidationError) | Route or context configuration is invalid. |
ConsumerStopping | The consumer is shutting down. This is a control signal, not a processing failure. |
CamelError is #[non_exhaustive]. New variants can arrive in any release
without a breaking change. Match with a _ arm in downstream code. See
crates/camel-api/CONTEXT.md.
Stop is not a CamelError. A stop step ends the route as successful
control flow. The pipeline reports it as PipelineOutcome::Stopped, never as
Err (ADR-0024).
How errors propagate
A pipeline runs its compiled steps in sequence. See
routes and pipelines. Each step returns
Result<Exchange, CamelError> to the executor. On Err, the executor calls
the route error handler before the loop continues.
The handler returns a disposition. The executor translates that disposition
into a PipelineOutcome. The full outcome algebra, and the adapter that maps
it back to Result at the Tower boundary, live on the
routes and pipelines page. This page covers only the
error path.
Error disposition
Disposition is the per-catch decision. After a catch clause matches an error,
the handler picks one of three dispositions. The ExceptionDisposition enum
in crates/camel-api/src/error_handler.rs defines them.
| Disposition | Effect on the exchange | Effect on the pipeline |
|---|---|---|
Propagate | Keep the error. | Abort. The outcome is Failed. |
Handled | Clear the error. | Stop. The outcome is Completed. |
Continued | Clear the error. | Advance to the next step. |
Disposition runs inside the pipeline loop. It is not the retry policy and it is not the dead-letter channel. Those belong to the route-level error handler, which runs first. When the handler exhausts its retries and (optionally) sends the exchange to the dead-letter channel, it then applies the disposition. See ADR-0019.
The default disposition for a route-level onException clause is Propagate.
The default for a do_try catch clause is Handled.
Route-level error handler
The route-level error_handler wraps every step in a route. It is the safety
net. It does three jobs, in this order:
- Retry the failed step with a redelivery policy.
- Route the exchange to a dead-letter endpoint or a custom
handled_byendpoint. - Apply the disposition from the matched
onExceptionclause.
Configure it with ErrorHandlerConfig. A global handler on CamelContext
covers routes that have no per-route handler.
Dead Letter Channel
let route1 = RouteBuilder::from("timer:route1?period=2000&repeatCount=1")
.route_id("basic-dlc")
.set_header("example", Value::String("basic-dlc".into()))
.process_fn(always_fail("route1: permanent failure"))
.error_handler(ErrorHandlerConfig::dead_letter_channel(
"log:route1-dlc?showHeaders=true&showBody=true&showCorrelationId=true",
))
.build()?;
YAML equivalent
routes:
- id: "basic-dlc"
from: "timer:route1?period=2000&repeatCount=1"
error_handler:
dead_letter_channel: "log:route1-dlc?showHeaders=true&showBody=true&showCorrelationId=true"
steps:
- set_header:
key: "example"
value: "basic-dlc"
# The always_fail Rust closure maps to a registered bean in YAML.
- bean:
name: "always-fail"
method: "process"
When a step fails and retries are absent or exhausted, the handler sends the exchange to the DLC endpoint. The exchange keeps its error state and its original message. The DLC is a fallback sink. It does not by itself stop the route. The disposition still decides whether the pipeline aborts, stops, or advances.
Retry with backoff
let route2 = RouteBuilder::from("timer:route2?period=2000&repeatCount=1")
.route_id("retry-backoff")
.set_header("example", Value::String("retry-backoff".into()))
.process_fn(fail_n_times(2))
.error_handler(
ErrorHandlerConfig::dead_letter_channel(
"log:route2-dlc?showHeaders=true&showBody=true&showCorrelationId=true",
)
.on_exception(|_| true) // match all errors
.retry(3)
.with_backoff(Duration::from_millis(50), 2.0, Duration::from_secs(1))
.build(),
)
.build()?;
YAML equivalent
routes:
- id: "retry-backoff"
from: "timer:route2?period=2000&repeatCount=1"
error_handler:
dead_letter_channel: "log:route2-dlc?showHeaders=true&showBody=true&showCorrelationId=true"
on_exceptions:
- retry:
max_attempts: 3
initial_delay_ms: 50
multiplier: 2.0
max_delay_ms: 1000
steps:
- set_header:
key: "example"
value: "retry-backoff"
# The fail_n_times Rust closure maps to a registered bean in YAML.
- bean:
name: "fail-twice-then-succeed"
method: "process"
The RedeliveryPolicy drives the retry loop:
initial_delaysets the wait before the first retry.multiplierscales the wait after each attempt.max_delaycaps the wait.jitter_factorrandomizes the wait to avoid thundering-herd retries.
Defaults are a 100 ms initial delay, a 2x multiplier, a 10 s cap, and no
jitter. See crates/camel-api/src/error_handler.rs. A retry that recovers the
exchange clears the error and the pipeline advances. A retry that exhausts
falls through to the DLC and then to the disposition.
OnException clauses
onException matches errors by variant or predicate. The first matching
clause wins. Each clause can carry its own retry, its own handled_by
endpoint, and its own disposition. Put broad clauses before specific clauses.
A broad clause first shadows the rest.
Global error handler
Set a default handler on CamelContext. Routes without a per-route handler
use it.
ctx.set_error_handler(ErrorHandlerConfig::dead_letter_channel(
"log:global-dlc",
))
.await;
This is Rust API only. YAML routes compile to the same RouteDefinition but cannot express registration logic. A global handler has no YAML field. Set it on CamelContext in Rust.
Continued disposition in practice
The continued example shows the two mechanisms composing. The onException
clause sets the disposition. The error handler clears the error and the
pipeline advances to the next step. The DLC still receives the exchange for
auditing.
let eh_config = ErrorHandlerConfig::dead_letter_channel(
"log:route10-dlc?showHeaders=true&showBody=true&showCorrelationId=true",
)
.on_exception(|e| matches!(e, CamelError::ProcessorError(_)))
.continued(true) // ← clear error, pipeline continues to next step
.retry(1)
.build();
let route10 = RouteBuilder::from("timer:route10?period=2000&repeatCount=1")
.route_id("continued-disposition")
.set_header("example", Value::String("continued-disposition".into()))
.process_fn(always_fail("route10: permanent failure (continued=true)"))
.error_handler(eh_config)
.to("log:route10-continued?showHeaders=true&showBody=true&showCorrelationId=true")
.build()?;
YAML equivalent
routes:
- id: "continued-disposition"
from: "timer:route10?period=2000&repeatCount=1"
error_handler:
dead_letter_channel: "log:route10-dlc?showHeaders=true&showBody=true&showCorrelationId=true"
on_exceptions:
- kind: "ProcessorError"
continued: true
retry:
max_attempts: 1
steps:
- set_header:
key: "example"
value: "continued-disposition"
# The always_fail Rust closure maps to a registered bean in YAML.
- bean:
name: "always-fail"
method: "process"
- to: "log:route10-continued?showHeaders=true&showBody=true&showCorrelationId=true"
Use Continued when a step is non-critical and the route should keep moving
after it fails.
doTry blocks
do_try is a local error-handling scope. It wraps a group of steps in a try
block with catch clauses and an optional finally clause. A handled catch does
not trigger the route-level error handler. The block stays a local island.
Unhandled errors bubble up to the route.
let route1 = RouteBuilder::from("direct:catch-by-variant")
.route_id("catch-by-variant")
.do_try()
.process(always_fail("boom-from-route-1"))
.do_catch_exception(&["ProcessorError"])
.handled()
.process(log_marker("log:caught-by-variant"))
.end_do_catch()
.end_do_try()
.build()?;
YAML equivalent
routes:
- id: "catch-by-variant"
from: "direct:catch-by-variant"
steps:
- do_try:
steps:
# The always_fail Rust closure maps to a registered bean in YAML.
- bean:
name: "always-fail"
method: "process"
catch:
- exception:
- "ProcessorError"
disposition: "handled"
steps:
- bean:
name: "log-marker"
method: "process"
See the Do Try pattern page for catch-by-variant,
catch-by-predicate, and finally clauses. The processor contract is in
crates/camel-processor/CONTEXT.md.
doTry vs route-level error_handler
| Situation | Use |
|---|---|
| One step may fail and you want to repair it locally. | do_try with a catch clause. |
| All steps in the route share one error policy. | Route-level error_handler. |
| You need cleanup that runs on success and failure. | do_try with a finally clause. |
| You want to retry a failed step. | Route-level error_handler with retry. |
| You want to advance after a non-critical step fails. | Route-level error_handler with Continued. |
Step lifecycle and drain
Stateful steps (aggregators, idempotent repositories, resequencers) own
background work that outlives a single process() call. They implement the
StepLifecycle trait.
When a route stops, the runtime drains stateful steps in this order:
- Cancel consumer intake.
- Force-complete aggregator buckets.
- Cancel the pipeline token.
- Join the consumer and pipeline tasks.
- Call
shutdown(RouteStop)on each stateful step. - Reset the cancellation tokens.
Shutdown errors are best-effort. A failing step does not block the rest from draining. See ADR-0022.
Consumer failure supervision
A consumer that cannot continue returns an error from its task. The RuntimeBus records the route as failed. An optional supervision policy then restarts the whole route with backoff. Consumers retry transient external failures inside their normal receive loop. They do not restart their own task after a task-level failure. That decision belongs to the route control plane. See ADR-0007.
The SupervisingRouteController watches crashed routes and restarts them.
Configure it with SupervisionConfig:
let ctx = CamelContext::builder()
.supervision(SupervisionConfig {
max_attempts: None,
initial_delay: Duration::from_millis(500),
backoff_multiplier: 2.0,
max_delay: Duration::from_secs(4),
})
.build()
.await?;
This is Rust API only. YAML routes compile to the same RouteDefinition but cannot express registration logic. Supervision is a CamelContext concern, not a route field.
See examples/auto-restart/ for a complete example.
Examples
examples/error-handling/covers the dead-letter channel, retry,onException, the continued disposition, the global handler, and the shorthand builder API.examples/do-try/covers catch by variant, catch by predicate, and finally cleanup.examples/auto-restart/covers theSupervisingRouteControllerwith exponential backoff.
Glossary
Glossary of names used across this guide. Bold entries are cross-cutting
domain terms registered in CONTEXT-MAP.md. Plain-text entries are
foundational primitives defined in their owning crate's CONTEXT.md. The
bold list is alphabetical. Each entry links its canonical guide page and
the decision or crate that defines it.
Cross-cutting terms
- ArcSwap<TlsAcceptor> — atomic-swap holder for the gRPC TLS acceptor.
Each accept loop loads a cert snapshot. HTTP and WS swap certs through
RustlsConfig. Hot reload, ADR-0004. - Bounded context — behavioral area of camel-core that owns its own domain vocabulary. The CQRS flavor is a per-context decision, never a crate-wide one. Architecture, ADR-0045.
- Bridged error — consumer failure that a
bridge_*path converts into a synthetic error-bearing Exchange throughsend_and_wait. The route error handler owns the operational signal. Error handling, ADR-0012. - CanonicalRouteSpec — versioned minimal route contract that runtime commands, config tooling, and hot-reload consume. v2 adds lifecycle metadata and rejects unsupported fields. Route structure, ADR-0011, ADR-0016.
- CircuitBreaker — DSL-declared fault tolerance pattern. It compiles into error-handling middleware, not a Pipeline Step. Circuit breaker, ADR-0019.
- ConsumerStopping —
CamelErrorvariant for producerpoll_readyshutdown. It signals that the producer channel is closing. Distinct from Stop EIP. Error handling, ADR-0024. - Credential redaction boundary — types that hold passwords, tokens,
keys, or credential bytes must not expose those values through
Debugor general-purposeSerialize. Use manual redaction or a tested wrapper. Auth, ADR-0051. - Degraded — health state meaning the component can still process
Exchanges: HTTP 200 on
/readyz, pod Ready.Unhealthyreturns HTTP 503 and marks the pod NotReady. Health. - EnrichmentStrategy — strategy that merges the original Exchange with
the polled or enriched Exchange in the
enrichandpollEnrichverbs. Distinct from the EIP-22AggregateStrategyDeffamily. Content enricher, ADR-0015. - ErrorHandler — DSL declaration (
ErrorHandler,OnException) that compiles intoErrorHandlerConfigandExceptionPolicyat runtime. Error handling, ADR-0019. - Exchange-data trust boundary — operator config is trusted. Exchange data (headers, body, properties, correlation keys) is untrusted and adversary-controlled. Such data must not reach a control-plane action, an unbounded decision, or an executable sink without validation. Planes, ADR-0032.
- ExceptionDisposition — enum (
Propagate | Handled | Continued) that replaceshandled: bool.Propagatereturns the error upstream.Handledends the route normally.Continuedclears the error and advances. Error handling, ADR-0019. - ForcedHealthFailure — when a Consumer crashes,
HealthCheckRegistrypins the route's health entry toUnhealthythroughforce_unhealthy_for_route()until aConsumerRestartreplaces it with a live probe. Health. - Handler-contract boundary — conceptual line between an error emitter
and the route element that owns the failure's operational signal. Emitters
inside the boundary log at
warn!or below. Error handling, ADR-0012. - LlmProvider — trait abstraction over LLM backends (OpenAI, Ollama, Mock). Camel-shaped, not siumai-shaped. All siumai imports stay in the adapter. Components, ADR-0020.
- Message — body and headers container inside an Exchange.
exchange.inputis the incoming Message.exchange.outputis the optional reply Message. Exchange & Message. - Module-discipline ceiling — camel-core 1.0 policy. Clean Architecture rings are enforced by module paths and boundary tests, not by crate isolation. A crate split stays a post-1.0 option. Architecture, ADR-0045.
- OpenAPI code-first generation —
rest:AST compiled into an OpenAPI 3.0.3 document viacamel openapi generateorcamel_dsl::openapi::generate_openapi(). YAML DSL. - OutcomePipeline — internal trait one layer above Tower for structural
EIP sub-pipelines. It returns
PipelineOutcomedirectly soStopped(ex)keeps Exchange state intact. Error handling, ADR-0025. - OutcomeSegment — wrapper struct over
Box<dyn OutcomePipeline>with tracing and metrics hooks. It is the payload ofCompiledStep::Segment. Error handling, ADR-0025. - PipelineOutcome — enum (
Completed(Exchange) | Stopped(Exchange) | Failed(CamelError)) produced by the pipeline executor one layer above Tower. Stop EIP is successful control flow, not an error. Error handling, ADR-0024. - PollingConsumer — pull-based adapter created on demand from an
Endpoint. It delivers one Exchange per call. Used by
pollEnrichand the WASMcamel_pollhost function. Poll enrich, ADR-0015. - ProviderMap —
HashMap<String, Arc<dyn LlmProvider>>owned byLlmComponentand resolved by name from config. Not a global registry. Components, ADR-0020. - REST DSL — declarative
rest:YAML/JSON blocks that lower tohttp:consumer routes with JSON binding, path templates, and optional schema validation. YAML DSL. - RetryableStep — object-safe trait that unifies
BoxProcessorandOutcomeSegmentforRouteErrorHandler::retry_step. One retry path serves both Tower processors and outcome-aware segments. Error handling, ADR-0019. - Route lifecycle compensation — control-plane recovery rule. If a
lifecycle side effect fails after durable intent changed, the Runtime marks
the Route
Failed. It reconciles the projection and publishes failure events instead of rolling history back. Routes & pipelines, ADR-0018. - RouteChannelService — service that chains Security, CircuitBreaker
(
before_call), Pipeline (run_steps), and CircuitBreaker (after_result). Built only when anerrorHandleris configured. Error handling, ADR-0019. - RouteErrorHandler — trait injected into the pipeline with four async
methods (
match_policy,retry_step,handle_step,handle_boundary). The returned disposition drives the loop. Error handling, ADR-0019. - SecurityPolicy — route-level authorization contract applied before
normal Route Steps run. Denials return
Unauthorizedinto route error handling. Auth, ADR-0010. - Security defaults & fail-closed startup validation — five-disposition policy (Intent-Violation, Intent-Declaration, Require-Explicit-Choice, Safety-Primitive, Untrusted-Data-Validation) enforced by one fail-closed startup phase. Each hardened default has its own per-item flag. Auth, ADR-0033.
- ServerTlsSource — shared cert-file source struct (
cert_path,key_path,client_ca_path) used by the gRPC, HTTP, and WS server components for initial TLS setup and reload. Hot reload. - Side-effect failure — consumer failure that occurs after a successful
send_and_wait, for example SQLonConsumepost-processing. No route-level handler runs for it. The emitter owns the signal. Error handling, ADR-0012. - Starting Route — externally observable Route lifecycle state between
accepted start intent and confirmed Consumer or Pipeline side effect.
Operators can see
StartinginRouteStatusProjection. Routes & pipelines, ADR-0018. - StopSegment — outcome-aware analog of
CompiledStep::Stopfor structural EIP sub-pipelines. It always returnsPipelineOutcome::Stopped(ex). Stop, ADR-0024. - Supervision — route-level crash recovery. A Consumer task failure sends
a
CrashNotification. The RuntimeBus records the route asFailed. An optional restart policy recreates the whole Route with backoff. Error handling, ADR-0007. - Synchronous-projection CQRS — CQRS variant where the read-side projection updates inside the same optimistic-versioned UnitOfWork as the command. This gives strong read-model freshness with no projection lag. Planes, ADR-0002.
- System-broken error — failure that indicates corruption, a panic
equivalent, or a contract violation. Always logged at
error!, never downgraded. Error handling, ADR-0012. - Template rendering language — language SPI implementation that renders templates (HTML, JSON, prompts) against Exchange data. Phase 1 covers inline templates. Phase 2 adds external file loading and hot-reload. MiniJinja, ADR-0047.
- TLS cert hot-reload — platform-wide inbound TLS certificate rotation
via
RuntimeCommand::ReloadTlsCerts { scheme, host, port }. The reload is idempotent and skips the journal. Hot reload, ADR-0004. - TlsReloadHandler — trait that each TLS-terminating component implements
(
matches(scheme, host, port)plusasync reload()). Components register it lazily inTlsReloadRegistry::global(). Hot reload. - Vertical slice — unit of decomposition for camel-core. Each bounded
context is a self-contained slice with its own
domain/application/ports/adapterslayout, not a shared technical layer. Architecture, ADR-0045. - WASM sandbox capability posture — per-world grant model across Camel host functions and WASI interfaces. Camel calls use explicit scheme allowlists. WASI uses selective registration, not full-linker registration with runtime denial. Extending, ADR-0050.
Foundational primitives
Crate-local building blocks. The owning crate's CONTEXT.md is the
canonical definition. These terms are intentionally not bold, so they stay
out of the lint-glossary vocabulary check.
- Component — factory for Endpoints, identified by a URI scheme, registered
into
CamelContext. Components & endpoints, Components bounded context. - Consumer — inbound adapter the Runtime starts for a Route's
from:Endpoint. Components & endpoints, Components bounded context. - EIP — Enterprise Integration Pattern. A Processor implemented as Tower
middleware in
camel-processor. EIP patterns, Processor crate. - Endpoint — communication point a Component creates from a URI. Components & endpoints, Components bounded context.
- Exchange — data envelope carrying an input Message, an optional output Message, properties, error state, and an exchange pattern. Exchange & Message, API contracts.
- Producer — outbound adapter created for a
to:Endpoint. It returns aBoxProcessorthat sends an Exchange. Components & endpoints, Components bounded context. - Processor — one processing unit in a Pipeline. The universal step contract over a Tower service. EIP patterns, API contracts.
- Route — named pipeline of a source Endpoint and an ordered step sequence. Routes & pipelines, Runtime.
Coming from Apache Camel
rust-camel shares Apache Camel's pattern vocabulary because the vocabulary is proven. The implementation is independent. The runtime, the type system, and the execution model are all different. A Camel user recognizes Filter, Content-Based Router, and Splitter. rust-camel is not a drop-in replacement and never claims Camel compatibility. See ADR-0046 for the design stance.
Core vocabulary
| Apache Camel | rust-camel | Notes |
|---|---|---|
| CamelContext | CamelContext | Same name. Built with CamelContext::builder(). No Spring or CDI. |
| Route | RouteDefinition | Built with RouteBuilder (Rust) or parsed from YAML. |
| RouteBuilder (Java DSL) | RouteBuilder (Rust) | Same fluent style: .from().to().build(). |
| XML DSL | YAML DSL | No XML DSL. Declarative routes use YAML. |
| Processor | Service<Exchange> | Every processor is a Tower Service. No Java interface to implement. |
| Exchange | Exchange | Same concept. Carries input Message, optional output Message, headers, properties. |
| Message | Message | Body + headers container inside Exchange. |
| Body | Body | Enum: Text, Json, Bytes, Stream, Empty. Not Object. |
| Endpoint | Endpoint | Resolved from a URI scheme (e.g. timer:tick, log:info). |
| Component | Component | Registered on CamelContext. Same scheme names as Camel (timer, log, file, http, kafka). |
| Channel | (no equivalent) | No first-class Channel type. The from:/to: URI pair fills this role. |
| BeanRegistry | BeanRegistry | Named instances, resolved at route start time. |
EIP names
Most EIP step names match between Apache Camel and rust-camel. The YAML DSL uses snake_case (wire_tap, load_balance) where Apache Camel's XML uses camelCase (wireTap, loadBalance).
| Apache Camel (Java/XML) | rust-camel YAML | rust-camel Rust |
|---|---|---|
choice | choice | .choice() |
when | when (under choice) | .when(predicate) |
otherwise | otherwise (under choice) | .otherwise() |
filter | filter | .filter(predicate) |
split | split | .split(config) |
aggregate | aggregate | .aggregate(config) |
multicast | multicast | .multicast() |
wireTap | wire_tap | .wire_tap(uri) |
loadBalance | load_balance | .load_balance() |
recipientList | recipient_list | .recipient_list(expr) |
routingSlip | routing_slip | .routing_slip(expr) |
throttle | throttle | .throttle(n, duration) |
delay | delay | .delay(duration) |
loop | loop | .loop_count(n) |
marshal | marshal | .marshal(format) |
unmarshal | unmarshal | .unmarshal(format) |
enrich | enrich | .enrich(uri) |
pollEnrich | poll_enrich | .poll_enrich(uri, timeout) |
validate | validate | .validate(predicate) |
doTry | do_try | .do_try() |
doCatch | catch (under do_try) | .do_catch_exception(&[...]) |
doFinally | finally (under do_try) | .do_finally() |
circuitBreaker | circuit_breaker (route-level) | .circuit_breaker(config) |
transform | transform | .transform(body) (alias for set_body) |
setBody | set_body | .set_body(value) |
setHeader | set_header | .set_header(key, value) |
script | script | .script(language, source) |
Execution model
Apache Camel runs processors in a pipeline backed by a Java service architecture. rust-camel runs processors as Tower Service<Exchange> steps in a Tower middleware chain. This means:
- Every step is
Clone + Send + Sync + 'static. - Backpressure is explicit through Tower's
poll_ready. - The pipeline outcome is
Completed,Stopped, orFailed(see ADR-0024).Stoppedis not an error.
Error handling
| Apache Camel | rust-camel | Notes |
|---|---|---|
onException(Exception.class) | on_exceptions: [{ exception: [...] }] | Match by variant name. |
errorHandler(deadLetterChannel) | error_handler: { dead_letter_channel: ... } | Route-level config. |
handled(true) | disposition: Handled | Absorbs the error. Route terminates normally. |
continued(true) | disposition: Continued | Clears the error. Advances to the next step. |
maximumRedeliveries | retry(max).with_backoff(...) | Retry on the builder. |
See ADR-0019 for the exception disposition contract.
What rust-camel does not have
| Apache Camel feature | Status | Alternative |
|---|---|---|
| Spring XML DSL | Not planned | YAML DSL |
| CDI / Spring DI | Not planned | Rust trait system, manual registration |
| JMX | Not planned | OpenTelemetry, metrics endpoints |
| Bean annotation scanning | Not planned | Explicit bean registration |
| Normalizer EIP | Not implemented | Compose from Convert Body + Content-Based Router |
| Content Filter EIP | Not implemented | Use Script or process closure to strip fields |
| Detour EIP | Not implemented | Compose from filter + to |
| Transaction Client | Not implemented | Future work |
Body types
Apache Camel uses Object as the body type and relies on type converters. rust-camel uses a Body enum with five variants:
| Variant | Holds |
|---|---|
Body::Text | UTF-8 string |
Body::Json | Parsed JSON value |
Body::Bytes | Raw byte buffer |
Body::Stream | Async stream (materialized by Stream Cache) |
Body::Empty | No payload |
The Exchange and Message page covers typed body access. The Convert Body step changes between variants. The Marshal and Unmarshal step converts between wire formats (JSON, CSV, XML, ZIP) and these variants.
Getting started
If you are new to rust-camel, start with these pages:
| Page | Covers |
|---|---|
| Getting started | Install and run your first route |
| Core concepts | Exchange, routes, components |
| EIP patterns | The pattern catalogue |
| YAML DSL route structure | Declarative route syntax |
EIP patterns
rust-camel implements the Enterprise Integration Patterns catalog (Hohpe and Woolf) as Tower middleware. Every pattern is a Service<Exchange>. You add it to a route builder next to a source and a sink.
The patterns group into four families:
- Routing: decide where an exchange goes next
- Transformation: change the content, format, or type of the exchange body
- Messaging: split, aggregate, reorder, and sample exchanges
- Resilience and control: protect a route from failure, limit throughput, and scope error handling
The vocabulary is shared with Apache Camel for familiarity, not compatibility (ADR-0046).
For route steps that are not EIPs (Stream Cache, Bean, Stop), see Processing steps. For the route structure that hosts these patterns, see Routes and pipelines.
Most patterns take a predicate or expression. For the available languages, see Expression languages.
Routing
Routing patterns decide where an exchange goes next. They pick a destination, branch on content, fan out to several endpoints, or distribute load. They correspond to the Message Routing category in Hohpe and Woolf.
- Message Filter — pass or drop an exchange by predicate
- Content-Based Router — route an exchange to one of several destinations by predicate
- Dynamic Router — compute the destination at runtime from exchange content
- Recipient List — broadcast an exchange to a list of endpoints computed at runtime
- Routing Slip — attach a sequence of endpoints and route through each in order
- Scatter-Gather — broadcast to a fixed list of endpoints and collect the responses
- Wire Tap — send a copy of the exchange to a side endpoint without blocking the main flow
- Multicast — send the exchange to several destinations in parallel
- Load Balancer — distribute exchanges across destination endpoints by strategy
For the route structure that hosts these patterns, see Routes and pipelines.
Message Filter
The Message Filter is a Message Router from Hohpe and Woolf. It drops exchanges that fail a predicate. A filter step evaluates a predicate on each exchange and runs its inner steps only when the predicate holds.
let route = RouteBuilder::from("timer:tick?period=1000&repeatCount=10")
.route_id("content-based-routing-demo")
.process(move |mut exchange: camel_api::Exchange| {
let c = Arc::clone(&counter_clone);
Box::pin(async move {
let n = c.fetch_add(1, Ordering::SeqCst);
if n.is_multiple_of(2) {
exchange.input.body = Body::Text("important".into());
} else {
exchange.input.body = Body::Text("routine".into());
}
Ok(exchange)
})
})
.filter(|ex| ex.input.body.as_text() == Some("important"))
.to("log:filtered?showBody=true&showCorrelationId=true")
.end_filter()
.error_handler(
ErrorHandlerConfig::log_only()
.on_exception(|_| true)
.retry(1)
.build(),
)
.build()?;
YAML equivalent
- id: content-based-routing-demo
from: timer:tick?period=1000&repeatCount=10
error_handler:
retry:
max_attempts: 1
steps:
- filter:
simple: "${body} == 'important'"
steps:
- to: log:filtered?showBody=true&showCorrelationId=true
The .filter(predicate) call takes a closure of type Fn(&Exchange) -> bool. In the included route the predicate reads ex.input.body.as_text() and keeps only exchanges whose body is the string important. The .to(...) call inside the filter scope is the inner step. It runs for exchanges the predicate accepts. The .end_filter() call closes the scope. The error_handler that follows then attaches to the route, not to the filter.
When the predicate returns false, the filter does not raise an error. It returns PipelineOutcome::Completed with the original exchange, and the inner step is skipped. The exchange then continues to any step after .end_filter(). In this route the filter is the last step before the error handler, so a filtered exchange simply ends with no log line written.
This is the rule that separates filter from choice. A filter has one branch that runs or skips. A Content-Based Router selects one branch from several. Use filter to gate a single sub-route on a yes-or-no condition. Use Content-Based Router when you must dispatch to one of many destinations.
Per ADR-0025, the filter compiles into a FilterSegment that operates on PipelineOutcome directly. The processor contract for the filter is documented in camel-processor/CONTEXT.md.
The example source is at examples/content-based-routing.
Content-Based Router
The Content-Based Router is a Message Router from Hohpe and Woolf. It inspects the exchange and routes it to one destination from a fixed set of branches.
let route = RouteBuilder::from("timer:tick?period=1000&repeatCount=6")
.route_id("content-based-router-demo")
.process(move |mut exchange: camel_api::Exchange| {
let c = Arc::clone(&counter_clone);
Box::pin(async move {
let n = c.fetch_add(1, Ordering::SeqCst);
let priority = match n % 3 {
0 => "high",
1 => "medium",
_ => "low",
};
exchange.input.body = Body::Text(priority.into());
Ok(exchange)
})
})
.choice()
.when(|ex| ex.input.body.as_text() == Some("high"))
.to("log:high-priority?showBody=true&showCorrelationId=true")
.end_when()
.when(|ex| ex.input.body.as_text() == Some("medium"))
.to("log:medium-priority?showBody=true&showCorrelationId=true")
.end_when()
.otherwise()
.to("log:low-priority?showBody=true&showCorrelationId=true")
.end_otherwise()
.end_choice()
.error_handler(
ErrorHandlerConfig::log_only()
.on_exception(|_| true)
.retry(1)
.build(),
)
.build()?;
YAML equivalent
- id: content-based-router-demo
from: timer:tick?period=1000&repeatCount=6
error_handler:
retry:
max_attempts: 1
steps:
- choice:
when:
- simple: "${body} == 'high'"
steps:
- to: log:high-priority?showBody=true&showCorrelationId=true
- simple: "${body} == 'medium'"
steps:
- to: log:medium-priority?showBody=true&showCorrelationId=true
otherwise:
- to: log:low-priority?showBody=true&showCorrelationId=true
The included route fires a timer that assigns one of three priority strings to the body. The .choice() call opens a routing block. Each .when(predicate).to(endpoint).end_when() chain defines one branch. The .otherwise().to(endpoint).end_otherwise() chain defines the fallback. The .end_choice() call closes the block so the error_handler attaches to the route, not to the choice.
The router evaluates the when predicates in order and runs the first branch that matches. It short-circuits on the first match, so it skips the remaining branches. The order of your when clauses matters when predicates overlap. Put the most specific predicate first. Each predicate is a closure of type Fn(&Exchange) -> bool. It can read the body, headers, and properties. The example reads ex.input.body.as_text() to dispatch on the string the previous process step produced.
If no predicate matches and you omit otherwise, the exchange passes through unchanged. It does not stop, and it raises no error. The route continues to the next step. Add an otherwise branch when an unmatched exchange must not proceed silently. The choice block composes with other steps. A process step inside a branch mutates the body, and the to endpoint receives that mutated body. An error handler attached after end_choice() catches errors raised inside the chosen branch.
Use the Content-Based Router when the set of destinations is fixed at build time. Use the Dynamic Router when the destination is itself data. A header value, a registry lookup, or a computation decides where the exchange goes next. CBR chooses among branches you wrote. The Dynamic Router computes an endpoint at runtime.
Per ADR-0025, the choice compiles into a ChoiceSegment that returns PipelineOutcome directly, so Stop propagates with exchange state intact. Per ADR-0001, that segment runs as a Service<Exchange> in the Tower middleware pipeline. The processor contract is documented in camel-processor/CONTEXT.md.
For how a filter feeds a choice inside the same route, see the Message Filter page.
The example source is at examples/content-based-router.
Dynamic Router
The Dynamic Router is a Message Router from Hohpe and Woolf. It computes the destination endpoint at runtime from exchange data, instead of selecting from a fixed set of branches.
let route = RouteBuilder::from("timer:tick?period=1000&repeatCount=10")
.route_id("dynamic-router-demo")
// Set a rotating destination header
.process(move |mut exchange: camel_api::Exchange| {
let dests = dest_clone.clone();
Box::pin(async move {
let mut d = dests.lock().unwrap(); // allow-unwrap
let dest = d.next().unwrap(); // allow-unwrap
exchange
.input
.set_header("destination", Value::String(dest.to_string()));
exchange.input.body = Body::Text(format!("routed to {}", dest));
Ok(exchange)
})
})
// Dynamic router: read header and route to correct endpoint
.dynamic_router(Arc::new(|exchange: &camel_api::Exchange| {
let dest = exchange
.input
.header("destination")
.and_then(|v| v.as_str())
.unwrap_or("a");
Some(format!(
"log:routed-{}?showBody=true&showHeaders=true",
dest
))
}))
.build()?;
YAML equivalent
- id: dynamic-router-demo
from: timer:tick?period=1000&repeatCount=10
steps:
- set_header:
key: destination
value: a
- dynamic_router:
simple: "log:routed-${header.destination}?showBody=true&showHeaders=true"
The Rust example rotates the destination header through a, b, c. YAML set_header sets one fixed value, so the YAML form routes every exchange to log:routed-a. The Simple expression log:routed-${header.destination} mirrors the Rust closure.
The .dynamic_router(Arc::new(|exchange| ...)) step takes a closure of type Fn(&Exchange) -> Option<String>. The router calls the closure and forwards the exchange to the endpoint it returns. Then it calls the closure again on the result. The loop ends when the closure returns None. Each hop receives the exchange as the previous endpoint left it. That endpoint can mutate a header or the body. The next closure call then reads the changed value and either returns a new destination or None to stop.
The example reads the destination header that an upstream process step sets. It returns the matching log:routed-{dest} endpoint. A single Some value may also carry several endpoints separated by the uri_delimiter (default ,). The router visits all of them within one iteration before it calls the closure again.
Two safeguards stop a runaway loop. The closure must not return the same endpoint on consecutive iterations. A hop that leaves the routing data unchanged trips this guard. The router then raises an error instead of spinning. An iteration cap (max_iterations, default 1000) and an optional timeout bound the loop. To end routing after a single hop, have that hop clear the value the closure reads, or return None.
The Dynamic Router differs from the Recipient List. The Dynamic Router re-evaluates its expression after every hop, so each endpoint can steer the exchange to the next. The Recipient List evaluates its expression once and sends the exchange to every endpoint on that list. Use the Dynamic Router when each hop can change where the exchange goes next. Use the Recipient List for one-shot fan-out to a known set.
The closure runs synchronously on the route channel. Any I/O the closure performs blocks the next step until the closure returns. If you need async work, resolve the destination in an upstream process step and store it in a header. Let the dynamic router read that header. This keeps the router a small, fast step.
Per ADR-0001, the dynamic router compiles into a DynamicRouterService that runs as a Service<Exchange> in the Tower middleware pipeline. The processor contract is documented in camel-processor/CONTEXT.md.
The example source is at examples/dynamic-router.
Recipient List
The Recipient List is a Message Router from Hohpe and Woolf. It evaluates an expression once to resolve a list of endpoints, then sends a copy of the exchange to each one.
let route = RouteBuilder::from("timer:tick?period=2000&repeatCount=3")
.route_id("recipientlist-demo")
.set_header(
"destinations",
Value::String("log:channel-a?showBody=true,log:channel-b?showBody=true,log:channel-c?showBody=true".into()),
)
.recipient_list_with_config(
RecipientListConfig::new(expression)
.parallel(true),
)
.to("log:summary?showBody=true")
.build()?;
YAML equivalent
- id: recipientlist-demo
from: timer:tick?period=2000&repeatCount=3
steps:
- set_header:
key: destinations
value: "log:channel-a?showBody=true,log:channel-b?showBody=true,log:channel-c?showBody=true"
- recipient_list:
simple: "${header.destinations}"
parallel: true
- to: log:summary?showBody=true
The route stores three log endpoint URIs in the destinations header. The .recipient_list_with_config(...) call takes a closure of type RecipientListExpression. That closure reads the header and returns the comma-separated string. The processor splits the string, resolves each URI, and dispatches a clone of the exchange to each endpoint. The .parallel(true) flag runs the three dispatches concurrently instead of one after the other.
The exchange that reaches the next step depends on the aggregation strategy. The default LastWins strategy forwards one branch's result to the step that follows. Set MulticastStrategy::Original to pass the input exchange through unchanged and discard every branch output. Set CollectAll to gather each branch body into a JSON array. The example keeps the default, so log:summary receives the result of one resolved branch.
Use the Recipient List when the destinations are data. A header, a registry lookup, or a computation decides the targets at runtime. Use Multicast when the targets are fixed in the route. A Content-Based Router picks one branch from a set. The Recipient List fans out to all of them. A guard caps the resolved list at max_recipients (default 1000) before any endpoint resolves. An expression that returns millions of URIs cannot exhaust memory.
Per ADR-0025, the recipient list is an outcome-aware structural EIP. A branch that returns PipelineOutcome::Stopped records a partial failure. The aggregation skips the stopped branch, and the route error handler sees the partial outcome through the same boundary that step errors flow through.
Per ADR-0001, the recipient list compiles into a Service<Exchange> step in the Tower middleware pipeline. The processor contract is documented in camel-processor/CONTEXT.md.
The example source is at examples/recipientlist.
Routing Slip
The Routing Slip is a Message Router from Hohpe and Woolf. It reads a header that holds a comma-separated list of endpoints. It routes the exchange through each one in sequence.
let route = RouteBuilder::from("timer:tick?period=1000&repeatCount=10")
.route_id("routing-slip-demo")
.process(move |mut exchange: camel_api::Exchange| {
let c = counter_clone.clone();
Box::pin(async move {
let n = c.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
let slip = if n.is_multiple_of(2) {
"log:step-a?showBody=true,log:step-b?showBody=true"
} else {
"log:step-c?showBody=true,log:step-d?showBody=true"
};
exchange
.input
.set_header("slip", Value::String(slip.to_string()));
exchange.input.body = Body::Text(format!("message #{}", n));
Ok(exchange)
})
})
.routing_slip(Arc::new(|exchange: &camel_api::Exchange| {
exchange
.input
.header("slip")
.and_then(|v| v.as_str().map(|s| s.to_string()))
}))
.build()?;
YAML equivalent
- id: routing-slip-demo
from: timer:tick?period=1000&repeatCount=10
steps:
- set_header:
key: slip
value: "log:step-a?showBody=true,log:step-b?showBody=true"
- routing_slip:
simple: "${header.slip}"
The Rust example uses a process closure to alternate the slip header between two endpoint lists. YAML set_header sets one fixed value, so the YAML form routes every exchange through log:step-a then log:step-b.
The route sets a slip header that holds one of two endpoint lists, then calls .routing_slip(...). The closure takes a &Exchange and returns an Option<String>. It reads the slip header and returns the string. The processor splits the string on the uri_delimiter (default ,), resolves each URI, and calls each endpoint in turn. The exchange that one endpoint returns feeds into the next. If the closure returns None, the exchange passes through unchanged.
Each step sees the mutations the previous step made. A log endpoint prints the body. An endpoint can append a header or transform the body. Any of these changes what the next endpoint receives. In the example the body carries the identifier message #N, and that identifier propagates from the first step to the last.
The slip is computed once. The closure runs on the initial exchange, and the resulting list drives the whole sequence. Each endpoint mutates the exchange, but the list of endpoints itself is fixed. To change the path between exchanges, vary the header value upstream of the slip, as the example does with its counter.
The Routing Slip is sequential. The Recipient List also evaluates its expression once. It sends a copy to every endpoint and aggregates the results, instead of threading one exchange through a chain. Use the Routing Slip when the exchange must visit a sequence of endpoints in order, each one building on the last. Use the Recipient List for one-shot fan-out to an independent set. The Dynamic Router re-evaluates its destination after every hop, so it suits a path that each endpoint steers.
Per ADR-0001, the routing slip compiles into a RoutingSlipService that runs as a Service<Exchange> in the Tower middleware pipeline. The processor contract is documented in camel-processor/CONTEXT.md.
The example source is at examples/routing-slip.
Scatter-Gather
The Scatter-Gather is a Message Router from Hohpe and Woolf. It broadcasts a copy of the exchange to a fixed list of endpoints in parallel and gathers the responses into one aggregate exchange.
- id: scatter-gather-demo
from: timer:tick?period=1000&repeatCount=3
steps:
- scatter_gather:
endpoints:
- direct:pricing
- direct:inventory
- direct:reviews
aggregation: collect_all
- to: log:aggregated?showBody=true
The scatter_gather step sends the same exchange to every endpoint listed under endpoints. All dispatches run in parallel. The aggregation field picks the strategy that merges the responses. The default is last_wins, which keeps the body of the last branch to complete. Set aggregation: collect_all to assemble every branch body into a JSON array. The step after scatter_gather receives the merged result.
Scatter-Gather is DSL sugar over Multicast. The YAML parser lowers the step to a multicast block with parallel: true and the chosen aggregation. No new processor or Rust builder method exists for it. Rust code calls .multicast() directly. Use Scatter-Gather when the broadcast-and-collect shape is the point. Use a raw multicast block when you need the extra knobs it exposes: parallel_limit, stop_on_exception, or timeout_ms.
The endpoints are fixed in the route definition. A route that must compute destinations at runtime uses a Recipient List. The gather is also stateless. No correlation key or completion condition exists. The step collects all parallel responses in one pass and moves on. Stateful accumulation across many exchanges is the Aggregator EIP.
Per ADR-0025, the lowered multicast compiles into a MulticastSegment that operates on PipelineOutcome. Per ADR-0001, the segment runs as a Service<Exchange> step in the Tower pipeline. The processor contract is documented in camel-processor/CONTEXT.md.
Wire Tap
The Wire Tap is a Message Router from Hohpe and Woolf. It sends a copy of the exchange to a tap endpoint for inspection or monitoring while the original exchange continues down the route unchanged.
let route = RouteBuilder::from("timer:tick?period=1000&repeatCount=5")
.route_id("wiretap-demo")
// Tap: send a clone to monitoring (fire-and-forget)
.wire_tap("log:monitor?showBody=true&showCorrelationId=true")
// Main pipeline continues normally
.to("log:main?showBody=true&showCorrelationId=true")
.build()?;
YAML equivalent
- id: wiretap-demo
from: timer:tick?period=1000&repeatCount=5
steps:
- wire_tap: log:monitor?showBody=true&showCorrelationId=true
- to: log:main?showBody=true&showCorrelationId=true
The included route fires a timer and calls .wire_tap("log:monitor?..."). The tap clones the exchange and dispatches the clone to log:monitor, which logs the body and correlation id. The main pipeline then runs the original exchange through .to("log:main?..."), which logs the same body. The tap runs as fire-and-forget. An error on the tap endpoint does not stop the main flow, and the main flow does not wait for the tap to finish.
The Wire Tap differs from Multicast in scope. A wire tap is one side channel. It does not return a value into the main pipeline. The exchange that continues down the route is the same exchange the tap saw before the clone. Multicast is the main flow: it fans out to every branch and aggregates the results back into the pipeline. Use a wire tap to observe an exchange without changing it. Put a consumer on the main pipeline when it must affect the result.
The clone duplicates the body. A wire tap on an exchange with a multi-megabyte body copies that body for the tap endpoint, which costs memory and CPU. For large bodies, pair the route with a Claim Check step that stashes the body and passes a reference id. The tap then reads the reference id without copying the body.
Per ADR-0001, the wire tap compiles into a Service<Exchange> step in the Tower middleware pipeline. The processor contract is documented in camel-processor/CONTEXT.md.
The example source is at examples/wiretap.
Multicast
The Multicast is a Message Router from Hohpe and Woolf. It sends a copy of the exchange to every endpoint in a fixed list and merges the responses back into one exchange.
let route = RouteBuilder::from("timer:tick?period=2000&repeatCount=3")
.route_id("multicast-demo")
// Give the exchange a meaningful body before multicasting
.process(|mut exchange: camel_api::Exchange| {
Box::pin(async move {
exchange.input.body = Body::Text("hello from multicast".to_string());
// Add broadcast-id header with UUID
exchange
.input
.set_header("broadcast-id", Value::String(Uuid::new_v4().to_string()));
Ok(exchange)
})
})
// Multicast: same message goes to all three log endpoints in parallel.
// CollectAll aggregation gathers each response body into a JSON array.
.multicast()
.parallel(true)
.aggregation(MulticastStrategy::CollectAll)
.to("log:channel-a?showBody=true&showCorrelationId=true")
.to("log:channel-b?showBody=true&showCorrelationId=true")
.to("log:channel-c?showBody=true&showCorrelationId=true")
.end_multicast()
// After multicast: the body is a JSON array of all three responses
.to("log:summary?showBody=true&showCorrelationId=true")
.error_handler(
ErrorHandlerConfig::log_only()
.on_exception(|_| true)
.retry(1)
.build(),
)
.build()?;
YAML equivalent
- id: multicast-demo
from: timer:tick?period=2000&repeatCount=3
error_handler:
retry:
max_attempts: 1
steps:
- set_body:
value: hello from multicast
- multicast:
parallel: true
aggregation: collect_all
steps:
- to: log:channel-a?showBody=true&showCorrelationId=true
- to: log:channel-b?showBody=true&showCorrelationId=true
- to: log:channel-c?showBody=true&showCorrelationId=true
- to: log:summary?showBody=true&showCorrelationId=true
The example sets a text body and a broadcast-id header, then opens a multicast block over three log endpoints. The parallel: true flag dispatches all three branches at once instead of one after another. Each branch receives its own clone of the exchange, so a branch that mutates the body or headers does not affect its siblings. The clone also carries a CamelMulticastIndex property and a CamelMulticastComplete flag. A branch can read these to learn its position in the fan-out. The collect_all strategy gathers each branch response body into a JSON array, and the step that follows the block, log:summary, receives that array as its body.
The aggregation strategy decides what the post-multicast exchange carries. LastWins (the default) keeps the body of the last branch to complete and discards the rest. CollectAll assembles every branch body into a JSON array. Original passes the input exchange through unchanged and drops all branch output. A Custom variant takes a function of type MulticastAggregationFn for merge logic the built-ins do not cover. Three other knobs live on the block. parallel_limit caps how many branches run at once when parallel is on. stop_on_exception fails the whole block on the first branch error. timeout bounds how long the block waits for slow branches.
Pick Multicast when the destinations are fixed in the route and you want every one to run. Pick the Recipient List when a header or expression must compute the destinations at runtime. Scatter-Gather is YAML sugar over Multicast with parallel dispatch and collect_all. Use it when the broadcast-and-collect shape is the whole point. A Wire Tap sends one copy to a side channel and never merges a result back.
Per ADR-0025, multicast is an outcome-aware structural EIP. A branch that returns PipelineOutcome::Stopped propagates that stop out of the block. Aggregation does not run on stopped output. The route error handler sees any partial outcome through the same boundary that step errors use. Per ADR-0001, the block compiles into a Service<Exchange> step in the Tower pipeline, with each branch a child step on the route channel. The processor contract is documented in camel-processor/CONTEXT.md.
The example source is at examples/multicast.
Load Balancer
The Load Balancer is a Message Router from Hohpe and Woolf. For each exchange it picks one endpoint from a fixed list and sends the exchange there.
let route = RouteBuilder::from("timer:tick?period=1000&repeatCount=10")
.route_id("load-balancer-demo")
// Give the exchange a meaningful body before load balancing
.process(|mut exchange: camel_api::Exchange| {
Box::pin(async move {
exchange.input.body = Body::Text("hello from load balancer".to_string());
Ok(exchange)
})
})
// Load Balance: RoundRobin distributes messages across endpoints
.load_balance()
.to("log:server-a?showBody=true")
.to("log:server-b?showBody=true")
.to("log:server-c?showBody=true")
.end_load_balance()
.build()?;
YAML equivalent
- id: load-balancer-demo
from: timer:tick?period=1000&repeatCount=10
steps:
- set_body:
value: hello from load balancer
- load_balance:
strategy: round_robin
steps:
- to: log:server-a?showBody=true
- to: log:server-b?showBody=true
- to: log:server-c?showBody=true
The example sets a text body, then opens a load-balance block over three log endpoints with the default round_robin strategy. For each exchange, the strategy picks one endpoint and dispatches the exchange there. The other two endpoints see nothing. With ten timer ticks over three endpoints, round-robin hands ticks out in order. The first goes to log:server-a, the second to log:server-b, the third to log:server-c. The fourth cycles back to server-a.
The strategy decides which endpoint a given exchange hits. RoundRobin (the default) cycles the endpoints in order with a shared counter. Random picks an index at random on each call. Weighted takes a list of (name, weight) pairs and draws an endpoint in proportion to its weight. A heavier endpoint gets more traffic. Failover tries the endpoints in order. On error it moves to the next, and keeps going until one succeeds or the list is exhausted. Pick round-robin for uniform endpoints of equal capacity. Pick weighted when endpoints differ in throughput. Pick failover when one primary endpoint should serve and the rest stand by as backups.
The Load Balancer sends each exchange to one endpoint. Multicast sends a copy to all of them. A Content-Based Router picks a branch by predicate. The Load Balancer picks by strategy, not by message content. The selection is stateless across exchanges except for the round-robin counter. Under Random or Weighted, two consecutive exchanges may land on the same endpoint.
Per ADR-0025, the load balancer is an outcome-aware structural EIP. A selected branch that returns PipelineOutcome::Stopped propagates that stop out of the block. Under Failover, a branch that returns Failed moves the dispatch to the next endpoint instead of propagating the failure. Per ADR-0001, the block compiles into a Service<Exchange> step in the Tower pipeline, with each to arm a child step on the route channel. The processor contract is documented in camel-processor/CONTEXT.md.
The example source is at examples/load-balancer.
Transformation
Transformation patterns change the content, format, or type of the exchange body. They convert between wire formats, enrich the body with external data, or set the body from an expression. They correspond to the Message Transformation category in Hohpe and Woolf.
- Convert Body — convert the exchange body to a new type in the pipeline
- Marshal and Unmarshal — serialize and deserialize the body to or from a named format
- Transform — set the body from a Simple expression or a literal value
- Script — run an inline script that can modify the exchange
- Poll Enrich — poll a resource and replace the body with the result
- Content Enricher — call a resource and replace the body with the enriched result
For the data types these steps operate on, see Exchange and Message.
Convert Body
The Convert Body step is a Message Translator (Hohpe & Woolf). It converts the exchange body to a target BodyType variant so downstream steps read the same data under a different type.
let route_text_to_json = RouteBuilder::from("timer:text-to-json?period=2000&repeatCount=3")
.route_id("text-to-json")
.set_body(r#"{"message": "hello from text", "count": 42}"#)
.log(
"Route 1: Starting with Text body containing JSON string",
LogLevel::Info,
)
.convert_body_to(BodyType::Json)
.log(
"Route 1: Converted Text → Json successfully!",
LogLevel::Info,
)
.to("log:info?showBody=true")
.error_handler(ErrorHandlerConfig::log_only())
.build()?;
YAML equivalent
- id: text-to-json
from: timer:text-to-json?period=2000&repeatCount=3
error_handler:
retry:
max_attempts: 0
steps:
- set_body:
value: '{"message": "hello from text", "count": 42}'
- convert_body_to: json
- to: log:info?showBody=true
The .convert_body_to(BodyType::Json) call picks a target variant from the BodyType enum: Json, Bytes, or Text. The step reads the current body, re-encodes it to that variant, and stores the result on the exchange. Steps after the conversion then read the body under its new type.
Use Convert Body when the data is correct but the type is wrong. A source that emits bytes and a sink that expects JSON sit at a type boundary. The step changes the type and keeps the content. Transform does a different job. It replaces the body from an expression or a literal value. Convert Body re-types the existing data. Transform overwrites it.
Convert Body works only within the built-in BodyType variants. Marshal and Unmarshal handle named wire formats such as CSV or Protobuf. Those steps translate between a structured type and a serialized representation, not between body types.
Per ADR-0001, the step compiles into a Service<Exchange> in the Tower middleware pipeline. The processor contract and BodyType definition are documented in camel-processor/CONTEXT.md.
The example source is at examples/convert-body-to.
Marshal and Unmarshal
Marshal and Unmarshal are the Message Translator pair (Hohpe & Woolf). Marshal serializes the body to a named wire format. Unmarshal deserializes a wire-format body back into a structured type. Together they cross the boundary between the pipeline and external systems.
let route_csv_marshal = RouteBuilder::from("timer:csv-marshal?period=2000&repeatCount=3")
.route_id("csv-marshal")
.set_body(r#"[{"name":"Carol","age":28},{"name":"Dave","age":35}]"#)
.unmarshal("json")?
.log("Route 2: starting CSV marshal from Json", LogLevel::Info)
.marshal("csv")?
.log("Route 2: marshalled Json -> CSV Text", LogLevel::Info)
.to("log:info?showBody=true")
.error_handler(ErrorHandlerConfig::log_only())
.build()?;
let route_json_roundtrip = RouteBuilder::from("timer:json-roundtrip?period=2000&repeatCount=3")
.route_id("json-roundtrip")
.set_body(r#"{"message": "hello", "count": 42}"#)
.log(
"Route 1: Starting with Text body containing JSON string",
LogLevel::Info,
)
.unmarshal("json")?
.log("Route 1: Unmarshalled Text -> Json", LogLevel::Info)
.marshal("json")?
.log(
"Route 1: Marshalled Json -> Text (round-trip complete!)",
LogLevel::Info,
)
.to("log:info?showBody=true")
.error_handler(ErrorHandlerConfig::log_only())
.build()?;
YAML equivalent
- id: json-roundtrip
from: timer:json-roundtrip?period=2000&repeatCount=3
error_handler:
retry:
max_attempts: 0
steps:
- set_body:
value: '{"message": "hello", "count": 42}'
- unmarshal: json
- marshal: json
- to: log:info?showBody=true
- id: csv-marshal
from: timer:csv-marshal?period=2000&repeatCount=3
error_handler:
retry:
max_attempts: 0
steps:
- set_body:
value: '[{"name":"Carol","age":28},{"name":"Dave","age":35}]'
- unmarshal: json
- marshal: csv
- to: log:info?showBody=true
The .marshal("csv") call names the wire format as a string. The processor looks up that name in the data format registry and applies the format to the current body. Marshal stores the serialized result on the exchange. Unmarshal reverses the flow. It parses the body through the named format and stores the parsed structure on the exchange.
The string parameter keeps the route declaration format-agnostic. Common formats include json, csv, xml, and protobuf. Each format owns its body-type mapping and its configuration. See Data Formats for the format catalog, the DataFormat trait, and per-format options. This page covers only the route-level step.
A route that crosses a system boundary pairs the two steps. Marshal prepares the body for the wire on the outgoing side. Unmarshal restores a structured type on the incoming side. Each step in between then reads the body shape it expects.
Per ADR-0001, both steps compile into Service<Exchange> services in the Tower middleware pipeline. The data format registry and the marshal/unmarshal hooks are documented in camel-processor/CONTEXT.md.
The example sources are at examples/marshal-csv and examples/marshal-unmarshal.
Transform
The Transform step is a Message Translator from Hohpe and Woolf. It sets the exchange body from a literal value, a Simple expression, or a Rhai expression. In the Rust builder API, transform is an alias for set_body. Both compile to the same SetBody processor.
let route = RouteBuilder::from("timer:transform?period=1000&repeatCount=3")
.route_id("transform-pipeline")
.process(|mut exchange| async move {
exchange.input.body = Body::Text("hello world".to_string());
Ok(exchange)
})
// .transform() is an alias for .set_body() — sets body to a static value
.transform(Value::String("hello world (via transform)".into()))
.map_body(|body: Body| {
if let Some(text) = body.as_text() {
Body::Text(text.to_uppercase())
} else {
body
}
})
.set_header("transformed", Value::Bool(true))
.to("log:info?showHeaders=true&showBody=true&showCorrelationId=true")
.error_handler(
ErrorHandlerConfig::log_only()
.on_exception(|_| true)
.retry(1)
.build(),
)
.build()?;
YAML equivalent
- id: transform-demo
from: timer:tick?period=1000
steps:
- set_header:
key: prefix
value: hello
- transform:
simple: "${header.prefix} world"
- to: log:transformed?showBody=true
The Rust .set_body(value) call accepts any type that implements Into<Body>. Strings, JSON values, and byte vectors all qualify. The step discards the old body and stores the new value on the exchange. When the new body depends on the current exchange, call .set_body_fn(closure) instead. The closure receives an &Exchange and returns a Body, so it can read headers, properties, and the inbound body to compute the replacement. The .transform(value) method forwards to set_body and exists for parity with the Apache Camel route DSL.
The YAML transform: step accepts three shapes that map to the SetBodyConfig fields in the DSL layer. A literal (transform: "hello") stores the value as-is. A Simple expression (transform: { simple: "${body.upper()}" }) evaluates the expression and stores the result. A Rhai expression (transform: { rhai: "body + '_processed'" }) does the same through the Rhai engine. The Rust API splits the static and dynamic cases across set_body and set_body_fn. The YAML form unifies them under one step.
Transform overwrites the body. Convert Body re-types the existing body without changing its content. A route that needs new content uses Transform. A route that needs the same content under a different type (Text to Json) uses Convert Body.
Transform evaluates one expression and writes one result. Script runs a full Rhai block that can mutate headers, properties, and body in the same step. A route that needs a single replacement uses Transform. A route that needs branching logic or several mutations uses Script.
Per ADR-0001, the step compiles into a Service<Exchange> in the Tower middleware pipeline. The processor contract is documented in camel-processor/CONTEXT.md.
The example source is at examples/transform-pipeline.
Script
The Script step is a Message Translator from Hohpe and Woolf. It runs an inline Rhai script against the exchange so the route can mutate headers, properties, and body in one step. rust-camel registers Rhai as the scripting language.
// Step 4: .script() — mutating Rhai expression tags the order and
// appends a status suffix. Changes propagate back to the Exchange.
.script(
"rhai",
r#"
headers["processed"] = true;
let status = if headers["priority"] == "high" { "PRIORITY" } else { "STANDARD" };
body = body + " [" + status + "]";
"#,
)
// Step 5: log every message (after enrichment)
.to("log:all-orders?showBody=true&showHeaders=true")
YAML equivalent
- id: script-demo
from: timer:tick?period=1000
steps:
- script:
language: rhai
source: |
headers["tenant"] = "acme"
body = body + "_processed"
- to: log:scripted?showBody=true&showHeaders=true
The .script("rhai", source) call hands the script text to the Rhai engine. The engine exposes three variables on the exchange: headers and properties as mutable maps, and body as the current body string. The script reads and writes all three. Assignments to headers["key"] and body propagate to the next step in the pipeline.
Script handles logic that one expression cannot express. A Rhai block runs conditionals, loops, and several mutations in a single step. Transform evaluates one expression and writes one result. A route that needs a single body replacement uses Transform. A route that needs branching logic or several field changes uses Script.
Script interprets Rhai at runtime, so it runs slower than a native .process(|ex| ...) closure. The closure compiles to Rust and reads the full Exchange type. A route that needs maximum throughput uses process. A route that needs logic it can change without a rebuild uses Script.
Per ADR-0001, the step compiles into a Service<Exchange> in the Tower middleware pipeline. The Rhai integration is documented in camel-language-api/CONTEXT.md.
The example source is at examples/language-rhai.
Poll Enrich
The Poll Enrich is a Content Enricher variant from Hohpe and Woolf. It polls a passive resource and feeds the result into the exchange.
let route = RouteBuilder::from("timer:tick?period=1000&repeatCount=3")
.route_id("pollenrich-demo")
.poll_enrich(
format!("file:{config_dir_str}?fileName=config.json&noop=true"),
5000,
)
.stream_cache_default()
.to("log:enriched?showBody=true&showHeaders=true&showCorrelationId=true")
.build()?;
YAML equivalent
- id: pollenrich-demo
from: timer:tick?period=1000&repeatCount=3
steps:
- poll_enrich:
uri: "file:/tmp/rust-camel-pollenrich?fileName=config.json&noop=true"
timeout: 5000
- stream_cache: true
- to: log:enriched?showBody=true&showHeaders=true&showCorrelationId=true
The .poll_enrich(uri, timeout) step reads from a polling consumer. It waits up to timeout milliseconds for a message. A file or database is a passive resource. It stores data but does not push it. Poll Enrich pulls that data on demand. The example reads a config file each time the timer fires.
The EnrichmentStrategy trait decides how the polled exchange combines with the original. The default UseEnrichedBody strategy replaces the original body with the polled body. It keeps the original headers and properties. It discards the headers from the polled exchange. Write a custom strategy when you need to merge both bodies or preserve the polled headers.
When the poll returns no message within the timeout, the step calls on_no_poll. The default behavior passes the original exchange through unchanged. ThrowOnNoPoll wraps a base strategy and errors the exchange instead. Use it when missing data is a failure, not a normal condition.
Choose Poll Enrich when the source is a passive consumer. Choose the Content Enricher when the source is an active endpoint that receives a request and returns a response. Poll Enrich reads. Content Enricher calls.
Per ADR-0001, the step compiles into a Service<Exchange> in the Tower pipeline. The strategy contract and the PollEnrichService implementation are documented in camel-processor/CONTEXT.md.
Reference: Processor crate · Example source
Content Enricher
The Content Enricher is a Message Translator from Hohpe and Woolf. It calls a producer endpoint and feeds the response into the exchange.
let route = RouteBuilder::from("timer:tick?period=1000&repeatCount=3")
.route_id("content-enricher-demo")
.enrich("direct:enrich-data")
.to("log:enriched?showBody=true&showCorrelationId=true")
.build()?;
YAML equivalent
- id: content-enricher-demo
from: timer:tick?period=1000&repeatCount=3
steps:
- enrich: "direct:enrich-data"
- to: log:enriched?showBody=true&showCorrelationId=true
- id: enrichment-source
from: direct:enrich-data
steps:
- set_body:
value: "enriched-value"
The .enrich(uri) step sends the exchange to a producer endpoint and waits for a response. The example routes the call to direct:enrich-data. A second route consumes from that endpoint and supplies the enrichment payload. The two-route pattern keeps the enrichment source out of the main route. Multiple consumers can share the same source.
The EnrichmentStrategy trait decides how the response combines with the original exchange. The default UseEnrichedBody strategy replaces the original body with the response body. It keeps the original headers and properties. It discards the response headers. Write a custom strategy when you need to merge both bodies or preserve the response headers.
The difference from Poll Enrich is the data source. Content Enricher calls an active endpoint that receives a request and returns a response. Poll Enrich reads from a polling consumer that holds passive data. Use Content Enricher to pull data from a service. Use Poll Enrich to read from a file or database.
Per ADR-0001, the step compiles into a Service<Exchange> in the Tower pipeline. The EnrichService calls the producer inside the step and merges the response before the step returns. The processor contract is documented in camel-processor/CONTEXT.md.
Reference: Processor crate · Example source
Messaging
Messaging patterns change the cardinality or order of exchanges. They split one message into many, gather many into one, reorder a sequence, or sample one in every N. Several compose into split-process-aggregate flows.
- Aggregator — collect exchanges by correlation key and emit a batch
- Splitter — break a composite message into one exchange per fragment
- Streaming Splitter — split a byte-stream body into fragment exchanges with backpressure
- Zip Splitter — split a ZIP archive into one exchange per entry
- Resequencer — reorder exchanges by sequence number in batches or streams
- Sort — sort an array body by a comparator expression
- Sampling — pass through one out of every N exchanges
- Claim Check — stash a large payload and replace it with a claim ticket
- Cache — cache a computed body by key with TTL and stale-read fallback
For the processor contract that implements these patterns, see camel-processor/CONTEXT.md.
Aggregator
The Aggregator is a Message Routing pattern from Hohpe and Woolf. It collects related exchanges into a bucket and emits one combined exchange when a completion condition holds.
let route = RouteBuilder::from("timer:orders?period=200&repeatCount=12")
.route_id("aggregator-demo")
.process(move |mut ex: camel_api::Exchange| {
let c = Arc::clone(&counter_clone);
Box::pin(async move {
let n = c.fetch_add(1, Ordering::SeqCst);
let order_id = ["A", "B", "C"][(n % 3) as usize];
ex.input
.headers
.insert("orderId".to_string(), serde_json::json!(order_id));
ex.input.body = Body::Text(format!("order-item-{n}"));
println!("[timer] #{n} orderId={order_id}");
Ok(ex)
})
})
.aggregate(
AggregatorConfig::correlate_by("orderId")
.complete_when_size(3)
.max_buckets(100)
.bucket_ttl(std::time::Duration::from_secs(60))
.build()
.unwrap(), // allow-unwrap
)
// Pending exchanges (Body::Empty, CamelAggregatorPending=true) still flow
// through the pipeline — log only completed batches.
.process(|ex: camel_api::Exchange| {
Box::pin(async move {
if ex.property("CamelAggregatorPending").is_some() {
return Ok(ex);
}
let key = ex
.property("CamelAggregatedKey")
.map(|v: &serde_json::Value| v.to_string())
.unwrap_or_default();
let size = ex
.property("CamelAggregatedSize")
.map(|v: &serde_json::Value| v.to_string())
.unwrap_or_default();
println!(
"[batch] orderId={} size={} body={:?}",
key, size, ex.input.body
);
Ok(ex)
})
})
.to("log:batch?showBody=true&showCorrelationId=true")
.error_handler(
ErrorHandlerConfig::log_only()
.on_exception(|_| true)
.retry(1)
.build(),
)
.build()?;
YAML equivalent
- id: aggregator-demo
from: timer:orders?period=200&repeatCount=12
error_handler:
retry:
max_attempts: 1
steps:
- aggregate:
header: "orderId"
completion_size: 3
max_buckets: 100
bucket_ttl_ms: 60000
- to: log:batch?showBody=true&showCorrelationId=true
Each incoming exchange carries a correlation key in a header. The correlate_by("orderId") call names that header. Exchanges that share the key land in the same bucket. An AggregationFn folds each new exchange into the bucket seed to build the emitted batch body. In the included route, the process step rotates the orderId header through "A", "B", and "C", so three buckets fill in parallel.
A bucket completes when it reaches its size limit or when its inactivity timeout fires. complete_when_size(3) flushes the bucket after three exchanges arrive for that key. complete_when_timeout(Duration) flushes it after a period with no new exchange. The bucket_ttl setting caps how long an incomplete bucket can live before the background sweep evicts it. The config validator rejects any setup with no memory bound, so set max_buckets, a timeout, or bucket_ttl.
Exchanges that arrive before a bucket completes still pass through the pipeline. They carry the CamelAggregatorPending property and an empty body. The process step after the aggregator checks that property and skips them. Only completed batches carry the CamelAggregatedKey and CamelAggregatedSize properties.
Use the Aggregator when many correlated messages arrive over time and you want one combined output. Multicast does the opposite job. It sends one message to many endpoints at once. The Aggregator collects. Multicast fans out.
Per ADR-0001, the aggregator compiles into a Service<Exchange> step in the Tower middleware pipeline. The processor contract and the divergences from Apache Camel are documented in camel-processor/CONTEXT.md.
The example source is at examples/aggregator.
Splitter
The Splitter is a Message Routing pattern from Hohpe and Woolf. It takes one composite message and produces one exchange per fragment.
let route = RouteBuilder::from("timer:batch?period=2000&repeatCount=3")
.route_id("splitter-demo")
// Simulate incoming CSV data
.process(|mut exchange: camel_api::Exchange| {
Box::pin(async move {
exchange.input.body = Body::Text("alice,100\nbob,200\ncharlie,300".to_string());
Ok(exchange)
})
})
// Split by lines, aggregate all fragment bodies into a JSON array
.split(SplitterConfig::new(split_body_lines()).aggregation(AggregationStrategy::CollectAll))
// Transform each CSV line into a JSON object
.map_body(|body: Body| {
let text = body.as_text().unwrap_or("");
let parts: Vec<&str> = text.splitn(2, ',').collect();
let (name, amount) = match parts.as_slice() {
[n, a] => (*n, a.parse::<u64>().unwrap_or(0)),
_ => (text, 0),
};
Body::Json(serde_json::json!({
"name": name,
"amount": amount,
}))
})
.to("log:fragment?showBody=true&showCorrelationId=true")
.end_split()
// After split: aggregated JSON array of all fragments
.to("log:aggregated?showBody=true&showCorrelationId=true")
.error_handler(
ErrorHandlerConfig::log_only()
.on_exception(|_| true)
.retry(1)
.build(),
)
.build()?;
YAML equivalent
- id: splitter-demo
from: timer:batch?period=2000&repeatCount=3
error_handler:
retry:
max_attempts: 1
steps:
- set_body:
value: "alice,100\nbob,200\ncharlie,300"
- split:
expression: body_lines
aggregation: collect_all
steps:
- to: log:fragment?showBody=true&showCorrelationId=true
- to: log:aggregated?showBody=true&showCorrelationId=true
A split expression decides how to divide the body. The included route uses split_body_lines(), which returns one fragment per line. Each fragment becomes its own exchange that flows through the sub-pipeline. A fragment inherits the parent headers, properties, message pattern, and OpenTelemetry context, so its span is a child of the parent span. The CamelSplitIndex, CamelSplitSize, and CamelSplitComplete properties mark where each fragment sits in the batch. The .map_body(...) step inside the split scope turns each line into a JSON object, and .end_split() closes the scope.
When the split scope closes, an aggregation strategy combines the fragment outputs. CollectAll gathers every fragment body into a JSON array. That array then flows to the step after .end_split(). Per the aggregation contract, failed fragments reach the strategy as Err(e) entries in the vector, not as exchanges with an attached exception.
The Splitter differs from Multicast in how it produces children. Multicast sends the same exchange to several endpoints. The Splitter derives one exchange per fragment from a single input. It also pairs with the Aggregator in a split-process-aggregate flow. The Splitter divides one message into many. The Aggregator collects many correlated messages back into one.
Per ADR-0001, the splitter compiles into a Service<Exchange> step in the Tower middleware pipeline. The per-fragment sub-pipeline compiles into child steps on the same route channel. The processor contract is documented in camel-processor/CONTEXT.md.
Per ADR-0025, the split block is an outcome-aware structural EIP. A Stopped outcome from the sub-pipeline returns Stopped(fragment_ex) and skips aggregation. The parent pipeline never sees a partial batch. The per-fragment boundary keeps the fragment mutations made before the Stop visible to the outer pipeline.
The example source is at examples/splitter.
Streaming Splitter
The Streaming Splitter is a Message Routing pattern from Hohpe and Woolf. It splits a streaming body into individual exchanges one fragment at a time, without first buffering the full body in memory.
let mut splitter = StreamingSplitterService::new(
expression,
sub_pipeline,
AggregationStrategy::CollectAll,
true,
);
let ndjson = vec![
r#"{"user":"alice","action":"login"}"#,
r#"{"user":"bob","action":"purchase"}"#,
r#"{"user":"charlie","action":"logout"}"#,
];
let exchange = Exchange::new(Message::new(make_ndjson_stream(ndjson)));
println!("Streaming Split example");
println!("Input: Body::Stream with 3 NDJSON lines");
println!();
let _ = splitter.poll_ready(&mut std::task::Context::from_waker(
&futures::task::noop_waker(),
));
let result = splitter.call(exchange).await?;
println!();
println!("Aggregated result body:");
println!(" {:?}", result.input.body);
YAML equivalent
- id: streaming-split-demo
from: file:data.ndjson
steps:
- split:
streaming: true
stream:
format: ndjson
aggregation: collect_all
steps:
- to: log:fragment?showBody=true&showCorrelationId=true
- to: log:aggregated?showBody=true&showCorrelationId=true
The included example builds a Body::Stream that holds three NDJSON chunks. A StreamingSplitterService reads the stream through a StreamSplitCodec, which resolves the format from the content type. For application/x-ndjson, the codec parses each line into a separate fragment exchange. The sub-pipeline logs each fragment. When the split scope closes, the aggregation strategy combines the fragment outputs into the result body.
The streaming variant is the memory-efficient alternative to the Splitter. The Splitter materializes every fragment before it processes the first one. The Streaming Splitter pulls one fragment from the source, runs the sub-pipeline, then pulls the next. A multi-gigabyte NDJSON file or a long-running log stream fits in constant memory. The codec reads bytes lazily, so the source produces data only as fast as the sub-pipeline accepts it.
Backpressure flows through the segment boundary. When the sub-pipeline pauses, the segment stops pulling from the stream, and the source stops producing. A Stopped outcome drops the underlying stream and returns the fragment exchange to the outer pipeline. The outer pipeline sees the same outcome shape it would from the eager Splitter.
Per ADR-0025, streaming split is an outcome-aware structural EIP. Stop and Failed outcomes flow through the CompiledStep::Segment boundary with the fragment exchange intact. The processor contract is documented in camel-processor/CONTEXT.md.
The example source is at examples/streaming-split.
ZIP Splitter
The ZIP Splitter is a specialized Splitter from Hohpe and Woolf. It decomposes a multi-entry ZIP archive into one exchange per entry. Each entry flows through the route as its own message.
let route = camel_builder::RouteBuilder::from("timer:zip-marshal?period=3000&repeatCount=2")
.route_id("zip-marshal-route")
.set_body("payload for ZIP compression")
.log("Route 3: Original body", LogLevel::Info)
.marshal("zip")?
.log("Route 3: After marshal to ZIP", LogLevel::Info)
.unmarshal("zip")?
.log(
"Route 3: After unmarshal from ZIP (round-trip!)",
LogLevel::Info,
)
.to("log:zip-result?showBody=true")
.error_handler(ErrorHandlerConfig::log_only())
.build()?;
YAML equivalent
- id: zip-marshal-route
from: timer:zip-marshal?period=3000&repeatCount=2
error_handler: {}
steps:
- set_body: "payload for ZIP compression"
- log: "Route 3: Original body"
- marshal: zip
- log: "Route 3: After marshal to ZIP"
- unmarshal: zip
- log: "Route 3: After unmarshal from ZIP (round-trip!)"
- to: log:zip-result?showBody=true
The included route runs a timer that fires twice. The .marshal("zip") step compresses the body into a single-entry archive. The .unmarshal("zip") step decompresses it back. The route logs the byte sizes at each step. A reader can watch the body shrink during compression and grow during decompression.
The actual split happens through the zip_splitter expression and the StreamingSplitterService. The expression reads the ZIP central directory and emits one exchange per entry. Each exchange carries the entry body plus headers: CAMEL_ZIP_ENTRY_NAME, CAMEL_ZIP_ENTRY_PATH, CAMEL_ZIP_ENTRY_SIZE, and CAMEL_ZIP_ENTRY_INDEX. The ZipSplitConfig struct caps the entry count, the per-entry size, and the total decompressed size.
The ZIP Splitter is format-specific. It knows how to parse the ZIP archive structure. The Streaming Splitter is the generic alternative. It splits any byte stream through a codec that the content type selects. Use the ZIP Splitter when the input is a ZIP archive and you need per-entry headers. Use the Streaming Splitter for NDJSON, log lines, or raw byte chunks.
Per ADR-0001, the splitter compiles into a Service<Exchange> step in the Tower middleware pipeline. The per-entry sub-pipeline compiles into child steps on the same route channel. The processor contract is documented in camel-processor/CONTEXT.md.
The example source is at examples/zip-splitter.
Sort
The Sort is a Message Translator from Hohpe and Woolf. It orders the elements of a body array by a sort key expression. The route downstream sees the array in the chosen order.
- set_body:
value: [3, 1, 4, 1, 5, 9, 2, 6]
- sort:
expression: "${body}"
- to: "log:sorted?showBody=true&showCorrelationId=true"
The included route fires a timer every second for three ticks. The set_body step sets the body to a JSON array of eight numbers. The sort step orders the array by the ${body} expression, which uses each element itself as the sort key. The to step logs the sorted array. The sort defaults to ascending order. A reverse: true flag produces descending order.
The Sort step reads the body as a JSON array. A non-array body fails with a processor error. The upstream set_body step guarantees that contract for this example. The sort key expression extracts a comparable value from each element. The expression ${body} uses the element itself. The expression ${body.field} extracts a nested field for sorting objects by a property.
The Sort differs from the Splitter. The Sort keeps the body as a single array and reorders it in place. The Splitter decomposes the body into many exchanges. A route that needs both patterns sorts first and then splits per element. The Sort also pairs with the Aggregator. A split-sort-aggregate flow splits a collection, sorts the fragments, then aggregates the results back into one message.
Per ADR-0001, the sort step compiles into a Service<Exchange> step in the Tower middleware pipeline. The sort runs inside the step and the ordered body flows out before the next step runs. The processor contract is documented in camel-processor/CONTEXT.md.
The example source is at examples/sort.
Sampling
The Sampling is a Message Router from Hohpe and Woolf. It passes one exchange out of every N and drops the rest. The route downstream sees a subsample of the upstream traffic.
- sampling: 3
- to: "log:passed?showBody=true&showCorrelationId=true"
The sample period is an integer. sampling: 3 accepts one exchange out of every three and drops the other two. The counter starts at zero. The step increments it before the modulo check. Exchange number three is the first one that passes, then six, then nine. A period of one passes every exchange. The step rejects a period of zero at build time.
The step sets the CamelStop header on a dropped exchange. The surrounding pipeline translates that header into PipelineOutcome::Stopped. The steps after the sampling step do not run for that exchange. The included route fires a timer nine times. The log step runs three times.
Use the Sampling when the downstream rate is more than the route needs. Load shedding drops traffic before it reaches expensive steps. A monitoring or debugging route can sample a feed so the logs stay readable under high volume. A period-N sampler reduces the rate by an integer factor without the state of a rate limiter.
The Sampling differs from the Throttler. The Sampling is stateless and uses a counter. The Throttler paces the rate over a sliding time window and queues excess exchanges. A route that needs both patterns samples first, then throttles.
Per ADR-0001, the sampling step compiles into a Service<Exchange> step in the Tower middleware pipeline. The processor contract is documented in camel-processor/CONTEXT.md.
The example source is at examples/sampling.
Resequencer
The Resequencer is a Message Router from Hohpe and Woolf. It reorders a stream of exchanges into a defined sequence. The route downstream sees the exchanges in order even when the upstream side delivers them out of order.
- set_header:
key: "seq"
simple: "${header.CamelTimerCounter}"
- set_header:
key: "region"
value: "default"
- resequence:
batch:
correlation: "${header.region}"
sort: "${header.seq}"
completion:
size_or_timeout: [3, 2000]
- to: "log:sorted?showBody=true&showCorrelationId=true"
The batch policy buffers exchanges per correlation key. Each exchange enters the bucket that matches its correlation header. The bucket completes when it reaches its size limit or when the timeout window elapses. At completion, the policy sorts the buffered exchanges by the sort expression. It emits the sorted exchanges as an ordered burst. The included route copies the timer counter into a seq header and tags each exchange with a region correlation key. The resequence.batch step releases three sorted exchanges at a time.
The sort expression extracts a comparable value from each exchange. The route uses ${header.seq}, so the release order matches the sequence numbers. Exchanges that arrive out of order wait in the buffer until the window completes. The policy then sorts them into the correct sequence before release.
The Resequencer differs from the Sort. The Sort orders the elements of one body array in place. The Resequencer buffers separate out-of-order exchanges across many messages, then releases them in sequence. Use Sort when one exchange carries a collection. Use Resequencer when many exchanges arrive out of sequence. The Resequencer also differs from the Aggregator. The Aggregator fuses many exchanges into one. The Resequencer keeps one exchange per output and only changes the order. A route that needs both patterns places a Resequencer before an Aggregator.
Per ADR-0029, the resequencer is a continuation boundary. The main pipeline ends at the resequencer. A post-driver task runs the steps after the resequencer on each sorted emission. Per ADR-0025, the batch and stream policies propagate PipelineOutcome::Stopped through the post-continuation. Per ADR-0001, the pre-steps compile into the main Service<Exchange> pipeline and the post-steps compile into a continuation processor. The processor contract is documented in camel-processor/CONTEXT.md.
The example source is at examples/resequencer.
Claim Check
The Claim Check is a Message Translator from Hohpe and Woolf. It stores the full message payload in a repository and replaces the body with a lightweight claim key. The route carries only the key until a later step retrieves the original body.
- claim_check:
repository: vault
operation: set
key: "${header.claimKey}"
- log: "After SET body is the claim key: ${body}"
- claim_check:
repository: vault
operation: get
key: "${header.claimKey}"
- log: "After GET body restored: ${body}"
The claim_check step delegates to a ClaimCheckRepository registered on the context. The set operation stashes the current body under the given key and replaces the body with that key. The get operation reads the key, retrieves the stashed body from the repository, and restores it as the body. The included route sets a placeholder payload, pins the claimKey header, then calls set to stash the body. The log step after set shows the claim key as the new body. The second claim_check step calls get with the same key, and the final log step shows the restored body.
Use the Claim Check when the payload is large and the route does not need the full body at every step. Store-and-forward flows stash the payload at the ingress. The route carries only the lightweight key through the pipeline. A later step retrieves the payload at the egress before delivery. This keeps the in-memory footprint low across long routes. The repository also supports get_and_remove, push, and pop for stack and queue access patterns.
The Claim Check differs from the Idempotent Consumer. Both patterns use a repository trait for their state. The Idempotent Consumer stores only the deduplication key. The Claim Check stores the full payload. A route that needs to deduplicate and stash uses both patterns. Claim Check alone keeps a heavy payload out of the pipeline.
Per ADR-0001, the claim check step compiles into a Service<Exchange> step in the Tower middleware pipeline. The repository trait is defined in ADR-0028. The processor contract is documented in camel-processor/CONTEXT.md.
The example source is at examples/claim-check.
Cache
The Cache pattern stores the result of an expensive computation and serves it on subsequent requests with the same key. When the key matches, the route returns the cached body and skips the computation. When the key does not match, the route runs the on_miss sub-pipeline, stores the result, and returns it.
- cache:
key: "${header.cacheKey}"
ttl: "5s"
on_miss:
- set_body: "computed-fresh-data"
- log: "Cache MISS — body computed and stored"
- log: "After cache body is: ${body}"
The cache step evaluates a key expression against each exchange. If the repository holds a live entry for that key, the step replaces the body with the cached bytes and returns Completed. If the key is absent or expired, the step runs the on_miss sub-pipeline, writes the resulting body to the repository with the given ttl, and returns the body to the pipeline. A key expression that evaluates to None bypasses the cache. The route runs the on_miss sub-pipeline without a lookup or a write-back.
The cache_invalidate step removes a single key from the repository. Use it when an upstream event makes a cached entry stale. The cache_peek_stale step reads a cached entry and ignores its in-band expiry. This serves a post-expiry entry as a fallback when the source is unavailable.
Use the Cache pattern when a route computes the same result more than once. API responses, database lookups, and transform-heavy pipelines benefit from caching. Pair cache_peek_stale with a Circuit Breaker to serve stale data when the downstream service is open.
The default repository is "memory" (moka-backed, size-eviction only). A persistent "persistent" repository (redb-backed) is available when [default.cache_repo] backend = "redb" is set. The redb backend survives process restarts. Its sweep task reclaims entries whose expires_at + stale_retention has passed. The memory backend does not run a sweep. Expired entries stay in memory until size pressure evicts them.
The Cache differs from the Claim Check and the Idempotent Consumer. All three use a repository trait. The Cache stores the full computed body with a TTL. The Claim Check stores the original payload without a TTL. The Idempotent Consumer stores only the deduplication key. A route that needs all three can chain them.
Per ADR-0056, the CacheRepository trait lives in camel-api, with memory and redb backends in camel-core. The trait stores CacheEntry { bytes, content_type, expires_at } with in-band expiry. Both backends do size-eviction only. The expires_at field drives get() misses and peek_stale() reads. Per ADR-0001, each cache step compiles into a Service<Exchange> step in the Tower middleware pipeline. The processor contract is documented in camel-processor/CONTEXT.md.
The example source is at examples/cache-example.
Resilience and control
Resilience and control patterns protect a route from failure, limit throughput, and scope error handling. They map to the System Management family in Hohpe and Woolf, adapted to the Tower middleware pipeline.
- Circuit Breaker — trip after repeated failures and recover after a cool-down
- Do Try — scoped catch and finally blocks around a group of steps
- Throttler — cap the rate of exchanges through a route
- Idempotent Consumer — reject duplicates by correlation key
- Delayer — hold an exchange for a fixed duration
- Loop — repeat a sub-route a fixed number of times
- Validator — check exchange content against a schema
For the error-handling contract that governs catch and finally behavior, see error handling.
Circuit breaker
The Circuit Breaker is a System Management pattern from Hohpe and Woolf. It trips after repeated failures against a downstream service, then short-circuits further calls for a cool-down so the dependency can recover.
let main_route = RouteBuilder::from("timer:cb-test?period=1000&repeatCount=15")
.route_id("circuit-breaker-demo")
.process(|mut exchange| async move {
let n = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
exchange.input.body = Body::Text(format!("request-{}", n));
Ok(exchange)
})
.to("direct:failing-service")
.circuit_breaker(
CircuitBreakerConfig::new()
.failure_threshold(3)
.open_duration(Duration::from_secs(3)),
)
.to("log:cb-success?showBody=true&showCorrelationId=true")
.error_handler(
ErrorHandlerConfig::dead_letter_channel(
"log:cb-fallback?showBody=true&showHeaders=true&showCorrelationId=true",
)
.on_exception(|_| true)
.build(),
)
.build()?;
YAML equivalent
- id: circuit-breaker-demo
from: timer:cb-test?period=1000&repeatCount=15
circuit_breaker:
failure_threshold: 3
open_duration_ms: 3000
error_handler:
dead_letter_channel: log:cb-fallback?showBody=true&showHeaders=true&showCorrelationId=true
steps:
- to: direct:failing-service
- to: log:cb-success?showBody=true&showCorrelationId=true
The breaker cycles through three states. In Closed, traffic flows. Each failed call increments a consecutive-failure counter. A successful call resets that counter to zero. When the counter reaches failure_threshold, the breaker trips into Open. In Open, the breaker rejects every call with CamelError::CircuitOpen. The route never touches the downstream service. The breaker holds Open for open_duration, then enters HalfOpen.
HalfOpen admits a single probe call. The breaker rejects concurrent callers in that window so the probe runs in isolation. A probe that succeeds closes the breaker and resets the counter. A probe that fails reopens the breaker for another full cool-down. This single-probe design stops a backlog of traffic from stampeding the service. The dependency gets one probe, not a flood, at the first sign of recovery.
Use the circuit breaker to protect a downstream service. Do not use it to repair a transient blip. Retry sends more traffic at a failing call. The circuit breaker sends none. Pair them when a flaky dependency needs a retry for transient errors but a hard stop during a sustained outage. Per ADR-0019, the breaker compiles into a gate on RouteChannelService rather than a pipeline step. Boundary rejections flow through RouteErrorHandler::handle_boundary. The route's error_handler can then route CircuitOpen to a dead-letter sink, as the example does with log:cb-fallback.
The example source is at examples/circuit-breaker.
Do Try
The Do Try is an Error Handling pattern from Hohpe and Woolf. It wraps a group of steps in a local scope with one or more catch clauses and an optional finally clause. A route can repair or clean up after a failing step without triggering the route-level error handler.
let route1 = RouteBuilder::from("direct:catch-by-variant")
.route_id("catch-by-variant")
.do_try()
.process(always_fail("boom-from-route-1"))
.do_catch_exception(&["ProcessorError"])
.handled()
.process(log_marker("log:caught-by-variant"))
.end_do_catch()
.end_do_try()
.build()?;
YAML equivalent
- id: catch-by-variant
from: direct:catch-by-variant
steps:
- do_try:
steps:
- to: direct:failing-op
catch:
- exception:
- ProcessorError
steps:
- to: log:caught-by-variant
A do_try() block opens the scope. Steps inside the try body run in sequence. When a step returns Err, the block walks its catch clauses in order and runs the body of the first match. do_catch_exception(&["ProcessorError"]) matches by CamelError variant name. do_catch_when(predicate) matches by a FilterPredicate over the exchange when the variant alone is not specific enough. The example shows both shapes. List specific clauses before broad ones. The first match wins.
do_finally() adds a body that runs exactly once whether the try body succeeded, threw, or was caught. Use it for cleanup that must always run. The example's third route pairs a .propagate() catch with a do_finally counter. The catch logs the failure and lets the error escape. The finally block still runs and bumps the counter.
Each catch clause ends with a disposition. The disposition decides what happens to the exchange after the catch body runs. Handled clears the error and stops the block. propagate() keeps the error live so it escapes after the catch body finishes. The full disposition model, with all values and their effects on the pipeline, lives in error handling. The block is a local error-handling island. A Handled catch never reaches the route's error_handler. Only unhandled errors and errors from a propagate() catch escape to the route level.
Use do_try when the repair is scoped to one step or a small group of steps. Use the route-level error_handler when every step in the route shares one retry, dead-letter, or disposition policy. The two compose. A Handled catch repairs a step locally. Unhandled failures still fall through to the route-level safety net.
Per ADR-0001, the do-try compiles into a Service<Exchange> step in the Tower middleware pipeline. The try body and each catch branch compile as child steps on the same route channel. The processor contract is documented in camel-processor/CONTEXT.md.
The example source is at examples/do-try.
Throttler
The Throttler is a System Management pattern from Hohpe and Woolf. It caps how many exchanges a route processes in a fixed time window. Downstream services never receive traffic faster than they can handle.
let route = RouteBuilder::from("timer:tick?period=100&repeatCount=20")
.route_id("throttler-demo")
// Throttle: limit to 2 requests per second
// Default strategy is Delay - queues messages until capacity available
.throttle(2, Duration::from_secs(1))
.to("log:throttled?showBody=true")
.end_throttle()
.build()?;
YAML equivalent
- id: throttler-demo
from: timer:tick?period=100&repeatCount=20
steps:
- throttle:
max_requests: 2
period_secs: 1
strategy: delay
steps:
- to: log:throttled?showBody=true
The .throttle(2, Duration::from_secs(1)) call sets the rate limit. At most two exchanges pass per second. The included route fires a timer 20 times at 100-millisecond intervals, which yields ten exchanges per second. The throttler holds the excess in an internal queue and releases those exchanges as the one-second window refills. Each released exchange flows into the .to("log:throttled?showBody=true") step inside the throttle scope. The .end_throttle() call closes the scope.
The default Delay strategy queues excess exchanges instead of dropping them. The throttler never discards an exchange on its own. This protects a downstream service from bursts without losing messages. A route that must reject instead of queue composes a filter on a backpressure signal.
Use the Throttler when a downstream service or external API imposes a rate limit. Database writers, third-party HTTP endpoints, and metered SaaS APIs reject or fail when traffic exceeds their quota. The Throttler smooths the source rate to fit that contract.
Per ADR-0001, the throttler compiles into a Service<Exchange> step in the Tower middleware pipeline. The rate-limit window lives inside the service. The processor contract is documented in camel-processor/CONTEXT.md.
The example source is at examples/throttler.
Idempotent Consumer
The Idempotent Consumer is a System Management pattern from Hohpe and Woolf. It detects duplicate exchanges by a message key and skips them. The route processes only the first delivery of each key.
- idempotent_consumer:
repository: dedupe
expression: "${header.messageId}"
steps:
- log: "Processing unique message ${header.messageId}"
- to: "log:processed?showBody=true&showHeaders=true"
The idempotent_consumer step computes a key from each exchange with a MessageIdExpression. In the included route, the expression reads ${header.messageId}. The step then asks its repository whether it has seen the key before. A new key runs the child steps, and the step records that key in the repository. A repeated key skips the child steps entirely. The route pins messageId to a fixed value across five timer ticks. Only the first tick runs the log and to steps. The other four are duplicates.
A duplicate does not raise an error. The segment returns Completed and the parent pipeline continues. This matters when a source retries delivery on failure. Without deduplication, each retry re-runs the child steps and produces duplicate side effects. With the Idempotent Consumer, the second and later deliveries of the same key return Completed and leave the recorded result intact.
Use the Idempotent Consumer when a route must tolerate redelivery without repeating work. Payment processing, order creation, and any at-least-once message source benefit from a deduplication gate. The example registers a memory-backed repository. A memory-backed repository loses its keys when the process exits, so choose a durable backing store when a route must survive a restart.
The Idempotent Consumer differs from the Claim Check. Both use a repository trait. The Idempotent Consumer stores only the key. The Claim Check stores the full payload. Per ADR-0025, the consumer is an outcome-aware segment. Per ADR-0001, it compiles into a Service<Exchange> step in the Tower middleware pipeline. The processor contract is documented in camel-processor/CONTEXT.md.
The example source is at examples/idempotent-consumer.
Delayer
The Delayer is a System Management pattern from Hohpe and Woolf. It holds an exchange in the pipeline for a fixed or dynamic duration. The exchange then moves to the next step.
let fixed_route = RouteBuilder::from("timer:tick?period=2000&repeatCount=5")
.route_id("delayer-fixed")
.delay(Duration::from_millis(500))
.to("log:delayed?showBody=true")
.build()?;
YAML equivalent
- id: delayer-fixed
from: timer:tick?period=2000&repeatCount=5
steps:
- delay:
delay_ms: 500
- to: log:delayed?showBody=true
The included route fires a timer every two seconds. The .delay(Duration::from_millis(500)) step suspends the exchange for a fixed half-second. The log:delayed step receives the exchange only after the timer elapses. In the YAML DSL the same fixed pause is the delay_ms field. Use a fixed delay to space messages apart by a known interval. Common cases are rate-limited APIs and scheduled batches.
For a per-message pause, set the dynamic_header option to a header name. Write the delay in milliseconds to that header on each exchange. The service reads the header value and clamps it to max_delay_ms (default 3,600,000 ms, one hour). A missing or non-numeric header falls back to delay_ms. The builder equivalent is delay_with_header. The example registers it on a second route that sets CamelDelayMs to 1000. A dynamic delay suits routes where the producer supplies a retry-after or backoff value.
The Delayer differs from the Throttler. The Delayer pauses every exchange by the configured amount. The Throttler paces the rate of accepted exchanges over a window. A route that needs both can place a Delayer after a Throttler.
Per ADR-0001, the delayer compiles into a Service<Exchange> step. Its call method awaits a tokio::time::sleep and returns the exchange when the timer elapses. The processor contract is documented in camel-processor/CONTEXT.md.
The example source is at examples/delayer.
Loop
The Loop is a System Management pattern from Hohpe and Woolf. It repeats a block of steps a fixed number of times for each incoming exchange. The body or state can change one iteration at a time.
let loop_route = RouteBuilder::from("timer:tick?period=3000&repeatCount=3")
.route_id("loop-count-demo")
.set_body("hello")
.loop_count(3)
.process(|mut ex: camel_api::Exchange| async move {
let body = ex.input.body.as_text().unwrap_or("").to_string();
ex.input.body = camel_api::body::Body::Text(format!("{body}!"));
Ok(ex)
})
.end_loop()
.to("log:loop-result?level=info&showBody=true")
.build()?;
YAML equivalent
- id: loop-count-demo
from: timer:tick?period=3000&repeatCount=3
steps:
- set_body:
value: hello
- loop:
count: 3
steps:
- to: log:loop-iteration
- to: log:loop-result?level=info&showBody=true
The included route fires a timer three times. The .set_body("hello") step sets the body to a short string. The .loop_count(3) step opens the loop and .end_loop() closes it. The .process(...) step inside the loop appends one ! to the body on each pass. After three iterations the body becomes hello!!!. The log:loop-result step after the loop logs the final body. In the YAML DSL the loop count is the count field under loop. The count is fixed before the route starts.
Each iteration runs the wrapped sub-pipeline against the same exchange. A mutating step builds on the result of the previous pass. The loop acts as a fold over a fixed range. Use a loop for retry-with-backoff sequences, batch enrichment, or pagination that fetches one page per iteration. The Loop differs from the Splitter. The Loop produces a single output exchange with accumulated state. The Splitter produces many output exchanges, one per fragment.
Per ADR-0025, the loop is an outcome-aware structural EIP. A Stopped outcome from the inner sub-pipeline returns Stopped(ex) and skips the remaining iterations. The parent pipeline never sees a half-applied loop body. Per ADR-0001, the loop compiles into a Service<Exchange> step. The per-iteration sub-pipeline compiles into child steps on the same route channel. The processor contract is documented in camel-processor/CONTEXT.md.
The example source is at examples/loop.
Validator
The Validator is a Message Transformation pattern from Hohpe and Woolf. It checks an exchange body against a schema or predicate. It rejects the exchange when the check fails, so the rest of the route only sees valid input.
let route_xsd = RouteBuilder::from("timer:xsd-valid?period=3000&repeatCount=2")
.route_id("xsd-valid")
.set_body("<order><id>A1</id><amount>5</amount></order>")
.log("Route 1: Validating XML order against XSD", LogLevel::Info)
.validate(&xsd)
.log("Route 1: XML is valid!", LogLevel::Info)
.to("log:info?showBody=true")
.build()?;
YAML equivalent
- id: xsd-valid
from: timer:xsd-valid?period=3000&repeatCount=2
steps:
- set_body:
value: "<order><id>A1</id><amount>5</amount></order>"
- validate: "${body.contains('<order>')}"
- to: log:info?showBody=true
The included route fires a timer twice. The .set_body(...) step sets the body to an XML order. The .validate(&xsd) step loads the XSD schema from the configured path and checks the body against it. The log step after the validator runs only when the body is valid. A second route in the example sends an invalid order. It pairs the validator with an error_handler, so the validation error is logged instead of crashing the process.
On a mismatch the validator returns an error. The error flows back through the same RouteErrorHandler boundary as any other step error. A route-level error_handler or a do-try catch block can recover it. Wrap the validator in a do-try block to keep the exchange in the pipeline after a failure. Use the bare validator to fail fast on bad input. The YAML validate step also accepts a predicate expression like ${body.contains('<order>')} for inline checks without a schema file.
The Validator differs from the Filter. The Validator stops the route on a failed check and surfaces the error. The Filter silently drops the exchange. Use the Validator for input validation and contract enforcement at a trust boundary. Use the Filter when an exchange is well-formed but not relevant.
Per ADR-0001, the validator compiles into a Service<Exchange> step. The schema engine runs inside the step. The processor contract is documented in camel-processor/CONTEXT.md.
The example source is at examples/validator.
Processing steps
Route steps that are not Enterprise Integration Patterns. They handle body materialization, method invocation, and flow control. These are route-building utilities, not named patterns from Hohpe and Woolf.
- Stream Cache — materialize a stream body into bytes so later steps can re-read it
- Bean — call a registered Rust function as a route step
- Stop — halt the pipeline without raising an error
For the route structure that hosts these steps, see Routes and pipelines.
Stream Cache
The Stream Cache step converts a Body::Stream into Body::Bytes up to a
threshold. A stream drains on the first read. Steps that must read the body
more than once cannot do so against a stream. Stream Cache reads the stream
once, stores the bytes, and replaces the body. Every later step sees the same
bytes.
RouteBuilder::from("timer:tick?period=1000")
.route_id("stream-cache-demo")
.stream_cache_default()
.to("log:cached?showBody=true")
.build()?;
YAML equivalent
- id: stream-cache-demo
from: timer:tick?period=1000
steps:
- stream_cache: true
- to: log:cached?showBody=true
The .stream_cache(threshold) call sets the maximum byte count the step stores
in memory. Bodies smaller than the threshold become Body::Bytes. Bodies larger
than the threshold stay as Body::Stream. The default threshold is 128 KB
(DEFAULT_STREAM_CACHE_THRESHOLD). Call .stream_cache_default() to use this
default.
In YAML, stream_cache: true enables caching with the default threshold. The
form stream_cache: { threshold: N } sets a custom threshold in bytes.
Stream Cache is not an EIP. It is a pipeline utility that makes stream bodies safe for multi-read steps. Place a Stream Cache step before a Splitter that reads the body line by line. Place it before a Content Enricher that sends the original body and then inspects the response.
Per ADR-0001, the
step compiles into a Service<Exchange> in the Tower pipeline. The stream body
type is documented in camel-api/CONTEXT.md.
Bean
The Bean step calls a method on a registered bean. A bean is a named Rust object that lives in the bean registry. The step looks up the bean by name, calls the named method, and passes exchange data to it.
The #[bean_impl] and #[handler] macros turn a Rust impl block into a bean.
Each #[handler] method becomes a callable entry point. The handler signature
controls how the framework extracts data from the exchange. A parameter of type
Order receives the deserialized body. The macro generates the binding code at
compile time, so the binding is type-checked before the route runs.
RouteBuilder::from("timer:tick?period=1000")
.route_id("bean-demo")
.bean("orderProcessor", "handle")
.to("log:processed?showBody=true")
.build()?;
YAML equivalent
- id: bean-demo
from: timer:tick?period=1000
steps:
- bean:
name: orderProcessor
method: handle
- to: log:processed?showBody=true
The .bean("name", "method") call takes the bean name and method name as
strings. The registry resolves the name to an instance. The method receives the
exchange data and returns its result into the exchange body.
The Bean step is not an EIP. It is a Message Endpoint utility from the Hohpe and
Woolf vocabulary. In Rust, the .process() closure serves the same role with a
direct function reference. The Bean step exists primarily for the YAML DSL,
where a closure is not available. A route that needs custom logic in YAML
defines a bean, registers it, and calls it by name.
The Bean differs from Script. Script runs inline code declared in the route. Bean calls a pre-registered instance. A change to bean behavior needs a rebuild and re-registration. A change to script behavior needs only a route file edit.
Per ADR-0001, the
step compiles into a Service<Exchange> in the Tower pipeline. The bean
registry contract is documented in
camel-bean/CONTEXT.md.
Stop
The Stop step halts the pipeline. No further steps run after a Stop. The
exchange does not raise an error. The pipeline reports the outcome as
PipelineOutcome::Stopped. This is a successful termination, not a failure.
RouteBuilder::from("timer:tick?period=1000")
.route_id("stop-demo")
.filter(|ex| ex.input.body.as_text().map(|t| t.contains("urgent")).unwrap_or(false))
.to("log:urgent?showBody=true")
.end_filter()
.stop()
.build()?;
YAML equivalent
- id: stop-demo
from: timer:tick?period=1000
steps:
- filter:
simple: "${body} contains 'urgent'"
steps:
- to: log:urgent?showBody=true
- stop: true
The .stop() call adds a terminal step. When the exchange reaches this step,
the pipeline returns PipelineOutcome::Stopped and the route stops processing
that exchange. The exchange state is preserved as-is. The filter in the example
shows the common pattern. Process exchanges that match a condition, then stop.
Exchanges that fail the filter skip the filter block, reach the Stop step, and
halt.
The Stop step is not an EIP. It is a flow control utility. It differs from a Message Filter. The Filter drops exchanges that fail the predicate. Stop halts every exchange that reaches it. A route that needs to stop conditionally places a filter or choice block before the Stop.
Stop is modeled as PipelineOutcome::Stopped, not as CamelError. This
distinction matters for error handling. A stopped exchange does not trigger the
route error handler. Place any log or retry step before the Stop step, not in an
error handler.
Per ADR-0024,
Stop is a CompiledStep::Stop variant. The executor converts it into
PipelineOutcome::Stopped without invoking a Tower service. The outcome layer
sits above Tower so the runtime can distinguish a stop from a failure.
Components
Components connect routes to external systems. Each Component owns a URI scheme and creates Endpoints that produce Consumers, Producers, or both. The vocabulary for Component, Endpoint, Consumer, and Producer lives in crates/components/CONTEXT.md.
Catalog
| Scheme | Direction | Authority |
|---|---|---|
timer | consumer | parent |
log | producer | camel-log |
direct | both | camel-direct |
seda | both | camel-component-seda |
controlbus | producer | camel-controlbus |
mock | both | parent |
file | both | camel-file |
http, http-static | both | camel-http |
ws, wss | both | camel-ws |
grpc, grpcs | both | camel-component-grpc |
cron | consumer | camel-cron |
kafka | both | camel-kafka |
jms | both | camel-jms |
mqtt | both | camel-mqtt |
redis, rediss | both | camel-redis |
sql | both | camel-sql |
surrealdb | both | camel-component-surrealdb |
opensearch, opensearchs | producer | camel-opensearch |
master | consumer | parent |
container | both | camel-container |
llm | producer | camel-component-llm |
exec | producer | camel-component-exec |
validator | producer | camel-validator |
xslt | producer | camel-xslt |
xj | producer | camel-xj |
cxf | both | camel-cxf |
keycloak | consumer | camel-component-keycloak |
wasm | both | camel-component-wasm |
template | producer | parent |
The table covers every crate under crates/components/. The contract crate camel-component-api defines the Component SPI and the Consumer, Producer, and Endpoint traits. It registers no URI scheme.
Direction
consumer marks an inbound Component. It starts a Consumer that submits Exchanges into the Route. producer marks an outbound Component. It creates a Producer that sends Exchanges to an external system. both means the Component supports either direction, one per Endpoint.
master wraps a delegate Consumer in a leadership gate. The bridge exposes inbound traffic only while this node holds the leadership lock (ADR-0035).
Narrative pages
- Timer and log. The smallest working route.
- File. Directory poller and disk writer.
- HTTP. Server Consumer and response handling.
- gRPC. Service consumer and producer.
- WebSocket and SOAP. Bidirectional WebSocket traffic and SOAP calls through the Java bridge.
- Kafka. Broker producer and consumer.
- JMS. Java bridge consumer and producer.
- MQTT. MQTT 3.1.1 broker producer and consumer.
- Redis. Datastore and pub/sub.
- Database. SQL access.
- SurrealDB. Multi-model database.
- OpenSearch. Search and indexing.
- LLM. Chat completions and embeddings.
- WASM. Sandboxed plugins with capability model.
- Cron. Scheduled message generation.
- Direct. Synchronous in-process routing.
- SEDA. Asynchronous staging between routes.
- ControlBus. Runtime control messages.
- Master. Leader-only route execution.
- Template. External template rendering.
- Validator. Schema validation.
- Exec. External process execution.
- Keycloak. OIDC auth and JWKS validation.
- XML transform. XSLT and JSON-XML conversion.
- Mock. Testing assertions.
Timer and log
The timer and log components form the smallest working route. Timer is a pure source. It fires Exchanges on a schedule and reads from no external system. Log is a pure sink. It formats Exchange state and writes the result through tracing. Together they exercise the source-to-sink path with no network and no extra dependencies.
The hello-world example wires both components and produces one Exchange per tick:
#[tokio::main]
async fn main() -> Result<(), CamelError> {
tracing_subscriber::fmt()
.with_target(false) // Cleaner output
.init();
let mut ctx = CamelContext::builder().build().await.unwrap(); // allow-unwrap
ctx.register_component(TimerComponent::new());
ctx.register_component(LogComponent::new());
let route = RouteBuilder::from("timer:tick?period=1000&repeatCount=5")
.route_id("hello-world") // Named routes
.set_header("source", Value::String("timer".into()))
.to("log:info?showHeaders=true&showCorrelationId=true") // Correlation ID
.build()?;
ctx.add_route_definition(route).await?;
ctx.start().await?;
println!("Hello World example running. Press Ctrl+C to stop.");
tokio::signal::ctrl_c().await.ok();
ctx.stop().await?;
Ok(())
}
YAML equivalent
routes:
- id: hello-world
from: "timer:tick?period=1000&repeatCount=5"
steps:
- set_header:
key: source
value: "timer"
- to: "log:info?showHeaders=true&showCorrelationId=true"
Timer
timer:tick?period=1000&repeatCount=5 fires an Exchange every period milliseconds. The first tick fires immediately. The Consumer stops after repeatCount fires. Omit repeatCount to fire until the Route stops. Set repeatCount=0 and the timer fires never.
| Parameter | Default | Description |
|---|---|---|
period | 1000 | Interval between ticks in milliseconds |
delay | 0 | Wait before the first tick in milliseconds |
repeatCount | omitted | Tick limit. Omit for infinite. 0 fires never |
fixedRate | false | true skips missed ticks. false fires all missed ticks at once |
includeMetadata | true | Attach CamelTimer* headers to each Exchange |
Timer is a consumer-only Component. Its Consumer submits one Exchange per tick. The Exchange body holds a short label. When includeMetadata=true, the Consumer sets four Exchange headers. They carry the timer name, the tick counter, the ISO-8601 fire time, and the epoch timestamp. Timer does not poll a directory and does not implement PollingConsumer. It is event-driven and runs inside the Route lifecycle.
Log
log:info?showHeaders=true&showCorrelationId=true writes the Exchange to the configured tracing level. The query parameters select what the Producer prints. showHeaders=true adds the Exchange header map. showCorrelationId=true prefixes the line with the correlation id, so several Routes can share one log output.
Log is a producer-only Component. Its Producer formats the Exchange body, headers, and correlation id, then returns the Exchange unchanged. The Producer composes with every other pipeline step (ADR-0001). Calling log: from a from: position returns an error at Endpoint creation.
The Producer exposes five levels: trace, debug, info, warn, error. Each level selects a tracing macro. Routes that handle sensitive data should set logMask=true. The option replaces the body and matching header values with a redaction marker.
Putting it together
The example registers both components before the Route starts. The Route flows from: timer:tick to to: log:info. Each Exchange passes through one Timer Consumer, one .set_header step, and one Log Producer. The result is one log line per tick for five ticks.
This pair is the recommended starting template. Once a route reads a header, transforms the body, or routes to more than one sink, replace log with a real sink and timer with a real source. The registration shape and the Endpoint URIs stay the same.
The timer URI grammar lives in the parent Components authority. The log contract surface lives in the camel-log CONTEXT. The example source is at examples/hello-world.
File
The file component is a directory poller for source routes and a disk writer for sink routes. The same crate covers both directions. The Consumer watches a directory for new or changed files. The Producer writes the Exchange body to disk under a chosen file name.
The file-pipeline example shows both directions with an upper-case transform and a dead-letter channel:
let route = RouteBuilder::from(&format!(
"file:{}?delete=true&initialDelay=0&delay=500&readTimeout=5000",
input_path
))
.route_id("file-pipeline-demo")
.process(|mut exchange: camel_api::Exchange| {
Box::pin(async move {
if let Body::Text(text) = &exchange.input.body {
let original_len = text.len();
exchange.input.body = Body::Text(text.to_uppercase());
exchange
.input
.set_header("original-length", Value::Number(original_len.into()));
}
Ok(exchange)
})
})
.to(format!(
"file:{}?fileExist=Override&writeTimeout=5000",
output_path
))
.to("log:pipeline?showHeaders=true&showBody=true&showCorrelationId=true")
.error_handler(ErrorHandlerConfig::dead_letter_channel(
"log:dead-letter?showBody=true&showHeaders=true&showCorrelationId=true",
))
.build()?;
YAML equivalent
routes:
- id: file-pipeline-demo
from: "file:/tmp/rust-camel-pipeline/input?delete=true&initialDelay=0&delay=500&readTimeout=5000"
error_handler:
dead_letter_channel: "log:dead-letter?showBody=true&showHeaders=true&showCorrelationId=true"
steps:
- bean:
name: uppercase-transform
method: apply
- to: "file:/tmp/rust-camel-pipeline/output?fileExist=Override&writeTimeout=5000"
- to: "log:pipeline?showHeaders=true&showBody=true&showCorrelationId=true"
The .process() closure has no YAML step. Register the upper-case transform as a bean and call it with a bean: step. The Rust example writes to temp directories. Substitute real paths in the from and to URIs.
Source
file:{input}?delete=true&initialDelay=0&delay=500&readTimeout=5000 polls the input directory every delay milliseconds after an initialDelay wait. The Consumer submits one Exchange per detected file. The file body becomes the Exchange body. readTimeout=5000 bounds how long a poll waits for new content before it yields an empty result.
delete=true removes each file after a successful read. The deletion happens after the Route processes the Exchange. A route failure leaves the file in place, so the next poll retries it. Omit delete=true to keep every file after consumption. This fits audit pipelines and idempotent replay.
The Consumer is event-driven. It polls the directory on a schedule and pushes each file as an Exchange. It does not implement the on-demand PollingConsumer SPI. The consumer task starts with the Route and runs until the Route stops.
Sink
file:{output}?fileExist=Override&writeTimeout=5000 writes the Exchange body to the output directory. The fileExist parameter controls what happens when the target file already exists:
| Value | Behavior |
|---|---|
Override | Replace the target file. Default |
Append | Add the body to the end of the target file |
Fail | Reject the write if the target exists |
Ignore | Skip the write if the target exists |
TryRename | Write through a temp file, then rename. Requires tempPrefix |
The Override and TryRename strategies route through one private atomic-write helper. The helper writes a temp file, then renames it over the target. The Fail strategy uses the OS-level atomic create_new(true) directly. The TryRename strategy requires an explicit tempPrefix. The Producer rejects path-traversal segments in fileName before any file operation. It also rejects a cross-filesystem rename (EXDEV) rather than falling back to a non-atomic copy.
fileName resolves from the CamelFileName Exchange header. A route can set this header in a process step to control the output name. The Producer creates missing parent directories on the path. Validation runs at Endpoint creation and at the Producer boundary. It fails closed (ADR-0033).
Set durable=true for crash safety. The Producer then fsyncs the temp file, performs the rename, then fsyncs the parent directory, in that order. This is crash-safe but slower. Leave durable=false for speed when crash safety is not required.
Pipeline shape
The example chains the source to a process step, then a sink, then log, then a dead-letter error handler. The from: Endpoint and the to: Endpoint share one CamelContext and resolve at Route start.
Use the file component when a directory on disk is the source or the sink. Use a different source when the data lives on a remote system that exposes a pull interface, such as SFTP. The file component polls a local path only. Use a different sink when the destination must be transactional, replicated, or ordered. File writes succeed per file with no cross-file consistency.
The atomic-write contract and the accepted fileExist values live in the camel-file CONTEXT. The example source is at examples/file-pipeline.
HTTP
The HTTP component covers both directions in one crate. The server side binds a TCP listener and consumes inbound requests. The client side produces outbound requests and forwards the response into the Route. The same http: scheme serves both. The direction follows the Endpoint position: from: for a server, to: for a client.
The http-server example shows the server direction with a process step that returns a JSON body:
RouteBuilder::from("http://0.0.0.0:8080/health")
.route_id("health-check")
.to("log:health?showHeaders=true&showBody=true&showCorrelationId=true")
.process(move |mut exchange| {
let storage = Arc::clone(&storage);
let rc = Arc::clone(&request_count);
async move {
let uptime = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
exchange.input.body = Body::Json(serde_json::json!({
"status": "UP",
"service": "rust-camel-api",
"version": "1.0.0",
"uptime_seconds": uptime,
"requests_processed": rc.load(Ordering::Relaxed),
"users_count": storage.count(),
"timestamp": current_timestamp(),
"correlation_id": exchange.correlation_id,
}));
Ok(exchange)
}
})
.build()
}
YAML equivalent
routes:
- id: health-check
from: "http://0.0.0.0:8080/health"
steps:
- to: "log:health?showHeaders=true&showBody=true&showCorrelationId=true"
- bean:
name: health-handler
method: build_status
The .process() closure reads runtime state. It reads the system clock, a request counter, and an in-memory store. Register that logic as a bean and call it with a bean: step.
The http-client example shows the client direction. A timer drives a GET request to a remote service, and a log step records the response:
let route = RouteBuilder::from("timer:http-poll?period=5000&repeatCount=3")
.route_id("http-client")
.process(|mut exchange| {
Box::pin(async move {
exchange.input.body = Body::Empty;
exchange
.input
.set_header("X-Request-Id", Value::String(uuid_header()));
Ok(exchange)
})
})
.to("https://httpbin.org/get?source=rust-camel&allowInternal=false")
.process(|exchange| {
Box::pin(async move {
let body_str = match &exchange.input.body {
Body::Text(s) => Some(s.as_str()),
Body::Bytes(b) => std::str::from_utf8(b).ok(),
_ => None,
};
if let Some(text) = body_str
&& let Ok(json) = serde_json::from_str::<serde_json::Value>(text)
{
println!(
"Response status: {:?}",
exchange.input.header("CamelHttpResponseCode")
);
if let Some(url) = json.get("url") {
println!("Response from: {}", url);
}
}
Ok(exchange)
})
})
.to("log:http-response?showHeaders=true&showBody=true&showCorrelationId=true")
.error_handler(
ErrorHandlerConfig::dead_letter_channel("log:http-dlc?showBody=true")
.on_exception(|_| true)
.retry(2)
.build(),
)
.build()?;
YAML equivalent
routes:
- id: http-client
from: "timer:http-poll?period=5000&repeatCount=3"
error_handler:
dead_letter_channel: "log:http-dlc?showBody=true"
retry:
max_attempts: 2
steps:
- bean:
name: request-id-setter
method: apply
- to: "https://httpbin.org/get?source=rust-camel&allowInternal=false"
- bean:
name: response-printer
method: apply
- to: "log:http-response?showHeaders=true&showBody=true&showCorrelationId=true"
Both .process() closures have no YAML step. The first sets a UUID request header. The second parses and prints the response. Register each as a bean and call them with bean: steps. The Rust on_exception(|_| true) matches every exception. The YAML retry.max_attempts mirrors the Rust .retry(2).
Server
http://0.0.0.0:8080/health is a Consumer Endpoint. The Runtime binds a TCP listener on the address and port from the URI, then dispatches each inbound request to the Route. The Consumer submits one Exchange per request. The Exchange body carries the request payload. The Exchange headers carry the request headers, the path, the query string, and the request metadata.
The example binds 0.0.0.0 to listen on every interface. Use 127.0.0.1 when the server must stay on the loopback. The component rejects a partial server TLS configuration that supplies only a certificate or only a key. Operators who terminate TLS at a separate proxy need no server TLS in the URI.
The Consumer bounds resource use. It enforces a 2 MiB default request-body limit, a read timeout, and an in-flight request semaphore. The reply finaliser maps the Exchange body shape to a response. A Body::Json value serializes to JSON. A Body::Stream value streams the response. The CamelHttpResponseCode header sets the HTTP status. A missing header produces 200 OK.
Client
to: http://example.org/api/data is a Producer Endpoint. The Producer builds an outbound request, sends it through the configured transport, and returns the response as the new Exchange body. The Producer follows redirects, applies the configured NetworkRetryPolicy, and surfaces transport failures as CamelError.
The component validates each outbound URL and each redirect hop. allow_internal=false rejects internal addresses by default. The Producer pins DNS resolution to validated addresses to prevent DNS rebinding. Cross-origin redirects drop the Authorization and Cookie headers. The Producer has a 10 MiB default response-body limit.
TlsConfig verifies peer certificates by default. An operator can opt out with insecure=true or verify_peer=false. The opt-out emits a warning. The component forbids cleartext HTTP to public addresses even with allow_internal=true.
Direction choice
A route that serves an API uses HTTP as a source. A route that calls another service uses HTTP as a sink. A route that does both needs two Endpoints, one in each position. The second Endpoint belongs to a second Route. The component supports both directions because the same crate owns the connection plumbing. Each Endpoint still picks one direction at creation time.
The inbound and outbound contract surface lives in the camel-http CONTEXT. The diagnostic endpoints (/healthz, /readyz, /metrics) live in the Operations authority. The example sources are at examples/http-server and examples/http-client.
gRPC
The gRPC component produces and consumes gRPC with runtime proto resolution. No compile-time code generation is required. The component resolves .proto files at runtime through camel-proto-compiler and prost-reflect. It supports unary, server-streaming, client-streaming, and bidirectional streaming. The mode is auto-detected from the proto method descriptor.
The grpc-example wires a consumer on port 50051 and a timer-driven producer:
let consumer_route = RouteBuilder::from(&format!(
"grpc://0.0.0.0:50051/helloworld.Greeter/SayHello?protoFile={}",
proto_path
))
.set_body(Body::Json(
serde_json::json!({"message": "Hello from consumer!"}),
))
.to("log:grpc-consumer?showBody=true")
.build()?;
YAML equivalent
routes:
- id: grpc-consumer
from: "grpc://0.0.0.0:50051/helloworld.Greeter/SayHello?protoFile=helloworld.proto&transport=plaintext"
steps:
- set_body:
value:
message: Hello from consumer!
- to: "log:grpc-consumer?showBody=true"
The Rust example builds protoFile from CARGO_MANIFEST_DIR. Substitute the real path to your .proto file. The transport=plaintext parameter is required (ADR-0033).
let producer_route = RouteBuilder::from("timer:grpc-tick?period=3000&repeatCount=3")
.set_body(Body::Json(serde_json::json!({"name": "World"})))
.to(format!(
"grpc://127.0.0.1:50051/helloworld.Greeter/SayHello?protoFile={}",
proto_path
))
.to("log:grpc-response?showBody=true")
.build()?;
YAML equivalent
routes:
- id: grpc-producer
from: "timer:grpc-tick?period=3000&repeatCount=3"
steps:
- set_body:
value:
name: World
- to: "grpc://127.0.0.1:50051/helloworld.Greeter/SayHello?protoFile=helloworld.proto&transport=plaintext"
- to: "log:grpc-response?showBody=true"
The Rust example builds protoFile from CARGO_MANIFEST_DIR. Substitute the real path to your .proto file.
URI
grpc://<host>:<port>/<package>.<Service>/<Method>?protoFile=<path>&transport=<mode>
| Parameter | Required | Default | Description |
|---|---|---|---|
protoFile | yes | — | Path to the .proto file for runtime descriptor resolution |
transport | yes | — | plaintext or tls (ADR-0033) |
serverCertPath | consumer (tls) | — | Path to the server TLS certificate |
serverKeyPath | consumer (tls) | — | Path to the server TLS key |
clientCaPath | consumer (mtls) | — | Path to the client CA certificate for mTLS |
clientCertPath | producer (mtls) | — | Path to the client TLS certificate |
clientKeyPath | producer (mtls) | — | Path to the client TLS key |
Consumer
grpc://0.0.0.0:50051/helloworld.Greeter/SayHello?protoFile=helloworld.proto&transport=plaintext binds an HTTP/2 listener. The Consumer dispatches each inbound gRPC request to the Route. The Exchange body carries the decoded protobuf message as JSON. The Exchange headers carry gRPC metadata.
Multiple GrpcConsumers on the same (host, port) share one HTTP/2 server. Each consumer registers dispatch by URI path. The shared-server registry refuses to mix TLS and plaintext on one listener.
The Consumer supports four RPC modes. It auto-detects the mode from the proto method descriptor. The same Consumer handles unary, server-streaming, client-streaming, and bidirectional calls without configuration changes.
Producer
grpc://127.0.0.1:50051/helloworld.Greeter/SayHello?protoFile=helloworld.proto&transport=plaintext sends the Exchange body as a gRPC call. The Producer holds a lazy pool of connections. It reports endpoint health through RuntimeObservability.
The Producer requires transport=plaintext or transport=tls in the URI (ADR-0033). The legacy tls=true and tls=false keys are rejected. Under tls, the endpoint URL is rewritten to https://. The insecure_skip_verify=true option hard-errors. The component fails closed on an incomplete mTLS identity.
Security
The component enforces two security layers (ADR-0010, ADR-0032):
- Authentication. The server handler parses
Authorization: Bearer <token>and callsTokenAuthenticator::authenticate_bearer. Missing or invalid credentials returnStatus::unauthenticated. - Authorization. The Consumer calls
SecurityPolicy::evaluatebefore pipeline dispatch in all four RPC modes.DeniedreturnsStatus::permission_denied. Unknown future decisions fail closed.
Transport setup also fails closed (ADR-0033). Every Endpoint declares transport=plaintext or transport=tls. The component rejects insecure_skip_verify=true, an incomplete mTLS identity, and a TLS/plaintext mismatch on a shared listener.
Streaming
Routes that send streaming responses use GrpcStreamObserver. The observer exposes three methods: on_next, on_error, and on_completed. The route calls these methods to push response messages onto the gRPC stream.
Reference: gRPC crate CONTEXT. Example source: examples/grpc-example.
WebSocket and SOAP (CXF)
The WebSocket component (ws, wss) serves and connects to real-time bidirectional endpoints. The CXF component (cxf) calls and hosts SOAP services through a supervised Java bridge. Both speak plain URI syntax. No code generation. No SOAP engine in the Rust data plane.
The ws-server example wires a chat broadcast and a per-connection echo against the same port:
use camel_api::{CamelError, Value};
use camel_builder::{RouteBuilder, StepAccumulator};
use camel_component_ws::WsComponent;
use camel_core::context::CamelContext;
#[tokio::main]
async fn main() -> Result<(), CamelError> {
let mut ctx = CamelContext::builder().build().await.unwrap();
ctx.register_component(WsComponent::new());
// Echo: CamelWsConnectionKey arrives on the inbound message; the producer
// targets the same key on the same path, so the reply reaches the sender.
let echo_route = RouteBuilder::from("ws://0.0.0.0:9000/echo")
.route_id("ws-echo")
.to("ws://0.0.0.0:9000/echo")
.build()?;
// Chat: CamelWsSendToAll=true tells the producer to fan out to every
// local client connected to /chat.
let chat_route = RouteBuilder::from("ws://0.0.0.0:9000/chat")
.route_id("ws-chat")
.set_header("CamelWsSendToAll", Value::Bool(true))
.to("ws://0.0.0.0:9000/chat")
.build()?;
ctx.add_route_definition(echo_route).await?;
ctx.add_route_definition(chat_route).await?;
ctx.start().await?;
Ok(())
}
YAML equivalent
routes:
- id: ws-echo
from: "ws://0.0.0.0:9000/echo"
steps:
- to: "ws://0.0.0.0:9000/echo"
- id: ws-chat
from: "ws://0.0.0.0:9000/chat"
steps:
- set_header:
name: "CamelWsSendToAll"
value: "true"
- to: "ws://0.0.0.0:9000/chat"
The echo route replies to the sender. The chat route broadcasts to every local client on /chat. The two routes share one port because ServerRegistry keys servers by port and paths register independently.
WebSocket URI
ws://host:port/path[?options]
wss://host:port/path[?options]
| Parameter | Default | Description |
|---|---|---|
maxConnections | 100 | Maximum concurrent connections for the path |
maxMessageSize | 65536 | Inbound message size limit in bytes |
heartbeatIntervalMs | 0 | Ping interval in ms; 0 disables heartbeat |
idleTimeoutMs | 0 | Idle close timeout in ms; 0 disables it |
connectTimeoutMs | 10000 | Client connect timeout in ms |
responseTimeoutMs | 30000 | Client response timeout in ms |
allowOrigin | * | Allowed Origin header for upgrade requests |
tlsCert | required for wss | Path to TLS certificate |
tlsKey | required for wss | Path to TLS private key |
TLS uses rustls. No OpenSSL dependency.
WebSocket consumer
ws://0.0.0.0:9000/chat accepts inbound upgrade requests and submits one Exchange per received frame. The Consumer sets three headers on every inbound message:
| Header | Value |
|---|---|
CamelWsConnectionKey | UUID that identifies the connection for targeted replies |
CamelWsPath | URL path that received the frame |
CamelWsRemoteAddress | Peer socket address as a string |
A ServerRegistry keys servers by port. The first Consumer to register on a port fixes the host and TLS mode. A later registration with the other TLS mode fails (Server on port N already running with different TLS mode). Multiple Consumers on the same port share one server and register independent paths. Consumer shutdown removes the path, the security policy, and the connection registry. The server stays alive until the process exits.
The upgrade handler (dispatch_handler) checks Origin against allowOrigin before any auth step. When a SecurityContext is attached, the handler fails closed on missing credentials, denied policy decisions, and future AuthorizationDecision variants. Query-token values are redacted before logging (ADR-0051). TLS certificate and key paths in WsEndpointConfig::fmt are redacted as well. The crate applies a stricter diagnostic policy than ADR-0051 because the cert and key paths sit on the same boundary.
WebSocket producer
The Producer has two modes. The mode is selected by the inbound Exchange and the URI:
- Outbound client mode when no local Consumer matches the URI. The Producer opens a Tokio-tungstenite connection, sends the body, and reads the first reply.
- Server-send mode when a local Consumer matches the URI. The Producer never opens a new socket. It writes to the local connection registry.
The mode picker inspects three signals: the CamelWsSendToAll header, the CamelWsConnectionKey header, and the presence of a local Consumer on (host, port, path). Any one of them selects server-send mode. The producer then targets the keys in CamelWsConnectionKey (comma-separated) or, with CamelWsSendToAll=true, every active connection on the path.
The body becomes a Text frame by default. Set CamelWsMessageType to binary for a Binary frame. The URI parameter binaryPayload=true makes binary the default for that endpoint.
The client mode reconnects on transient failures with a shared NetworkRetryPolicy. The producer also surfaces backpressure: when a server-send channel is full, it sets CamelWsDeliveryDropped and returns Err on the next poll_ready call.
WebSocket security
Auth rides on the per-route SecurityContext (see Authentication and authorization). When a path has a context, the upgrade handler authenticates the bearer token and evaluates the route's SecurityPolicy before completing the upgrade. Failed auth returns 401. Failed policy returns 403. Policy evaluation errors return 500 and increment the e:ws:policy-eval metric (ADR-0012 class e). The health pin becomes the operator signal for bind failures (ADR-0012 class g).
CXF URI
cxf://http://host:port/path?wsdl=file.wsdl&service={ns}Name&port={ns}Port[&operation=opName][&profile=profile_name][&timeout_ms=N][&mtom_enabled=true|false]
| Parameter | Required | Description |
|---|---|---|
wsdl | yes | Path to the WSDL file |
service | yes | Service name in {namespace}Name form |
port | yes | Port name in {namespace}Name form |
operation | no | SOAP operation; falls back to the CamelCxfOperation header |
profile | yes (resolver) | Profile name from Camel.toml |
timeout_ms | no | Per-request timeout in ms |
mtom_enabled | no | true sets multipart/related and SOAPAction headers; MTOM encoding is partial ([CXF-014]) |
address (path) | yes | Producer: SOAP target URL. Consumer: bind address override |
The Rust component treats the SOAP envelope and WSDL as opaque bytes. No XML parser runs in this crate. The supervised Java bridge owns parsing, DTD handling, and entity resolution. The XXE boundary lives in the bridge process (ADR-0032).
The cxf-example ships a producer that fires every ten seconds and a consumer that returns a fixed response:
routes:
- id: cxf-producer-hello
from:
uri: "timer:hello-tick?period=10000"
steps:
- set-body:
constant: "<sayHello><name>World</name></sayHello>"
- to: "cxf://http://localhost:8080/hello?wsdl=wsdl/hello.wsdl&service={http://example.com/hello}HelloService&port={http://example.com/hello}HelloPort&operation=sayHello"
- log: "SOAP response: ${body}"
Consumer route (YAML)
routes:
- id: cxf-consumer-hello
from:
uri: "cxf://http://0.0.0.0:9090/hello?wsdl=wsdl/hello.wsdl&service={http://example.com/hello}HelloService&port={http://example.com/hello}HelloPort"
steps:
- log: "Received SOAP request: ${body}"
- set-body:
simple: "<sayHelloResponse><message>Hello from rust-camel!</message></sayHelloResponse>"
The body of the response Exchange is what the bridge writes back as the SOAP body. The bridge wraps it in an envelope and signs it with the profile's certificates.
The example ships with Camel.toml that declares the profile:
[components.cxf]
version = "0.8.1"
[[components.cxf.profiles]]
wsdl_path = "wsdl/hello.wsdl"
service_name = "{http://example.com/hello}HelloService"
port_name = "{http://example.com/hello}HelloPort"
CXF bridge
The component spawns a single GraalVM native Java bridge process on first use. The Rust side calls ensure_binary_for_spec to download (or reuse) the bridge binary, then starts it with CXF_PROFILES and CXF_PROFILE_<NAME>_* environment variables. The bridge prints a JSON readiness line on stdout; the Rust side reads the ephemeral gRPC port and the health endpoint URL from that line.
Profile names must match [a-z0-9_]+. The validator rejects uppercase, hyphens, and spaces at config load time. Each profile owns its own WSDL, service, port, and optional keystore and truststore. Multiple profiles share one bridge process.
A background health monitor probes the bridge on a configurable interval (default 5s). On a failed health check the monitor moves the slot to Degraded and triggers a restart with exponential backoff (capped at 30s). After 10 failed restart attempts the monitor transitions the slot to permanent Degraded. The route startup fails fast when the initial probe returns Degraded or Stopped.
On shutdown the order matters. Call pool.begin_shutdown() before ctx.stop() so the health monitor does not race a restart against shutdown. The CLI handles this for you with Ctrl+C.
CXF producer
cxf://http://host:port/path?... sends the Exchange body as a SOAP request. The body bytes go over gRPC with the configured security_profile selector. The bridge resolves the profile, signs the envelope with the profile's certificate when WS-Security is configured, and POSTs the request to the SOAP target. The response bytes come back as the Exchange body.
The profile query parameter is required. The endpoint resolver looks up the profile in CxfBridgePool::configured_profiles; an unknown name fails endpoint creation with unknown profile 'X'. The operation parameter is optional when the WSDL declares a single operation. Use the CamelCxfOperation header to override the operation per Exchange.
CXF consumer
cxf://http://0.0.0.0:9090/... hosts a SOAP endpoint. The bridge process publishes the endpoint under <base>/<profile_name> over Vert.x. Inbound SOAP requests arrive on the gRPC stream as ConsumerRequest messages. The Consumer builds an Exchange with the payload bytes as the body and the headers from the table below.
The response body is what the route writes to exchange.input.body (or exchange.output.body if the route set it). The bridge wraps the bytes in a SOAP envelope and signs with the profile's certificates.
A failed route handler returns a soap:Server fault with the error string. A response-marshalling failure (for example a Stream body, which CXF does not support) also returns a soap:Server fault, increments b-prime:cxf:response-marshalling, and logs at error! (ADR-0012 class b').
The inbound Exchange carries these headers from the bridge:
| Header | Value |
|---|---|
CxfRequestId | Bridge-generated correlation ID |
CxfOperation | Operation name from the SOAP body |
CxfSoapAction | Value of the SOAPAction header |
CxfSecurityProfile | Profile name that handled the request |
Other HTTP headers from the request land in the Exchange header map.
CXF security
Credentials flow through per-profile environment variables. CxfProfileEnvVars wraps keystore, truststore, and signature passwords in Redacted while it builds the child-process configuration. gRPC requests carry only the security_profile selector. They do not carry password bytes. CxfSecurityFields::fmt prints <redacted> for every password field. The redaction policy follows ADR-0051.
The XML parser boundary lives in the Java bridge. The Rust component does not run an XML parser. Audit parser hardening in the bridge process. WSDL files also live in the bridge. The Rust side reads them as paths and forwards them to the bridge over the environment.
[CXF-014]: the MTOM multipart body is not yet fully implemented. The flag sets headers but the binary part is not encoded.
Reference: WebSocket crate CONTEXT, CXF crate CONTEXT, ADR-0032 trust boundary, ADR-0012 log levels, ADR-0051 credential redaction. Example source: examples/ws-server, examples/cxf-example.
Kafka
The Kafka component produces to and consumes from Apache Kafka topics. One crate covers both directions. The Consumer subscribes to topics and submits one Exchange per record. The Producer publishes the Exchange body to a topic.
The kafka-example wires a timer-driven producer and a log consumer against a testcontainers broker:
let producer_route = RouteBuilder::from("timer:tick?period=3000")
.route_id("kafka-producer")
.set_body(Value::String(
r#"{"event":"heartbeat","source":"rust-camel"}"#.to_string(),
))
.to(format!(
"kafka:{topic}?brokers={brokers}&acks=all",
topic = TOPIC,
brokers = brokers
))
.build()?;
YAML equivalent
routes:
- id: kafka-producer
from: "timer:tick?period=3000"
steps:
- set_body: '{"event":"heartbeat","source":"rust-camel"}'
- to: "kafka:orders?brokers=127.0.0.1:9092&acks=all"
The Rust example reads the broker port from a testcontainers container. Substitute your real broker address in brokers.
let consumer_route = RouteBuilder::from(&format!(
"kafka:{topic}?brokers={brokers}&groupId=example-group&autoOffsetReset=earliest",
topic = TOPIC,
brokers = brokers
))
.route_id("kafka-consumer")
.to("log:info?showHeaders=true")
.build()?;
YAML equivalent
routes:
- id: kafka-consumer
from: "kafka:orders?brokers=127.0.0.1:9092&groupId=example-group&autoOffsetReset=earliest"
steps:
- to: "log:info?showHeaders=true"
The Rust example reads the broker port from a testcontainers container. Substitute your real broker address in brokers.
URI
kafka:<topic>?brokers=<host:port>[&groupId=<group>][&autoOffsetReset=<policy>][&partitionAssignmentStrategy=<strategy>][&acks=<level>][&securityProtocol=<protocol>]
| Parameter | Required | Default | Description |
|---|---|---|---|
brokers | yes | localhost:9092 | Comma-separated host:port broker addresses |
groupId | consumer | camel | Consumer group ID for coordinated consumption |
autoOffsetReset | consumer | latest | Offset reset policy: earliest, latest, or none |
partitionAssignmentStrategy | consumer | range | range, roundRobin, or cooperativeSticky |
acks | producer | all | Durability level: all, 1, or 0 |
securityProtocol | no | PLAINTEXT | PLAINTEXT, SSL, SASL_PLAINTEXT, or SASL_SSL |
Consumer
kafka:<topic>?brokers=localhost:9092&groupId=my-group subscribes to a topic. The Consumer submits one Exchange per record. The Exchange body carries the record value. The headers carry the topic, partition, offset, key, and timestamp (CamelKafkaTopic, CamelKafkaPartition, CamelKafkaOffset, CamelKafkaKey, CamelKafkaTimestamp).
The groupId coordinates consumption across instances. Consumers that share a group ID split the topic partitions. The partitionAssignmentStrategy picks how the broker assigns partitions across members: range (default), roundRobin, or cooperativeSticky. The autoOffsetReset policy picks the start position when the group has no committed offset: latest (default), earliest, or none.
Offset commit has two modes. Auto-commit is the default. The Consumer commits offsets on its auto-commit interval. Set allowManualCommit=true to commit from the route instead. The route reads a KafkaManualCommit handle from the kafka.manual_commit exchange property and calls commit_async() after it processes the record.
The Consumer uses the standard push model. It does not implement PollingConsumer. The consumer task starts with the Route and runs until the Route stops or reconnect attempts exhaust.
Producer
kafka:<topic>?brokers=localhost:9092&acks=all sends the Exchange body to a topic. The acks parameter controls durability. all waits for every in-sync replica. 1 waits for the leader only. 0 fires and forgets. On success the Producer returns the Exchange unchanged and writes delivery metadata to the CamelKafkaRecordMetadata header.
A send failure returns Err. The pipeline catches it and the route ErrorHandler owns the operational signal.
Security
The component supports four security protocols:
- PLAINTEXT (default). No encryption. The component warns at startup.
- SSL. TLS encryption. Gated behind the
sslorssl-vendoredcargo feature. - SASL_PLAINTEXT. SASL authentication without encryption. Gated behind the
saslfeature. - SASL_SSL. SASL authentication with TLS. Requires both the
saslandsslfeatures.
The component redacts SASL and SSL passwords to [REDACTED] in Debug output. A missing feature gate stops startup with the required cargo add command.
Error handling
The Consumer logs at error! for auto-commit failures, manual-commit handler failures, and exhausted reconnect attempts. ADR-0012 classifies these as outside-contract (b') and system-broken (c). The Producer logs send failures at warn!. The route handler owns these failures (ADR-0012 category a).
Reference: Kafka crate CONTEXT. Example source: examples/kafka-example.
JMS
The JMS component publishes to and consumes from JMS brokers through a Java bridge process. It supports ActiveMQ Classic and ActiveMQ Artemis. One crate covers both directions. The Consumer subscribes to a destination and submits one Exchange per message. The Producer publishes the Exchange body to a destination.
The component does not implement JMS in Rust. It delegates protocol work to a native Java bridge binary over gRPC. The bridge is downloaded once and cached.
Schemes
Three schemes share one bridge pool:
| Scheme | Shorthand | Locks broker type |
|---|---|---|
jms | rejected (ambiguous) | no |
activemq | activemq:orders → queue | yes |
artemis | artemis:orders → queue | yes |
The jms: scheme requires an explicit destination type. jms:orders returns an error. Use jms:queue:orders or jms:topic:orders.
The activemq: and artemis: schemes set the broker type at the URI level. They override any broker_type declared in the broker config. Use jms: when you want the broker type to come from configuration.
Example
The jms-example wires a timer-driven producer and a log consumer against a testcontainers broker:
let producer_route = RouteBuilder::from("timer:tick?period=3000")
.route_id("jms-producer")
.set_body(Value::String(
r#"{"event":"order","source":"rust-camel"}"#.to_string(),
))
.to("activemq:queue:orders")
.build()?;
YAML equivalent
routes:
- id: jms-producer
from: "timer:tick?period=3000"
steps:
- set_body: '{"event":"order","source":"rust-camel"}'
- to: "activemq:queue:orders"
The Rust example starts an ActiveMQ Classic container through testcontainers. The broker URL is read from the container port. Substitute your real broker address in production.
let consumer_route = RouteBuilder::from("activemq:orders")
.route_id("jms-consumer")
.to("log:info?showHeaders=true")
.build()?;
YAML equivalent
routes:
- id: jms-consumer
from: "activemq:orders"
steps:
- to: "log:info?showHeaders=true"
The consumer uses the activemq:orders shorthand. It is equivalent to activemq:queue:orders. The destination type defaults to queue for the broker-specific schemes.
URI
jms:queue:<name>[?param=value&...]
jms:topic:<name>[?param=value&...]
activemq:queue:<name>[?param=value&...]
activemq:topic:<name>[?param=value&...]
artemis:queue:<name>[?param=value&...]
artemis:topic:<name>[?param=value&...]
activemq:<name> # shorthand, defaults to queue
artemis:<name> # shorthand, defaults to queue
| Parameter | Default | Description |
|---|---|---|
broker | configured default_broker | Named broker from the [components.jms.brokers] table |
acknowledgementMode | Auto | Auto, Client, DupsOk, or Transacted |
messageSelector | — | SQL-92 selector expression for filtering inbound messages |
concurrentConsumers | 1 | Number of parallel consumer tasks for this endpoint |
transactionMode | None | None or Session (Session is not yet implemented) |
timeToLive | — | Message time-to-live in milliseconds |
priority | — | Message priority 0-9 (9 is highest) |
persistentDelivery | true | PERSISTENT or NON_PERSISTENT delivery mode |
mapJmsHeaders | true | Map JMS headers and properties to Exchange headers |
exchangePattern | InOnly | InOnly or InOut (InOut is not yet implemented) |
Credentials and the broker URL are not URI parameters. They live in the [components.jms.brokers.<name>] table.
Broker configuration
Brokers are declared in Camel.toml. The component creates one Java bridge process per broker. The bridge pool admits at most max_bridges (default 8) bridges concurrently.
[default.components.jms]
default_broker = "main"
[default.components.jms.brokers.main]
broker_url = "tcp://localhost:61616"
broker_type = "activemq" # "activemq" | "artemis"
username = "admin" # optional
password = "admin" # optional
The bridge binary downloads on first use. It comes from a configured release URL and is cached at ~/.cache/rust-camel/jms-bridge/. The download is SHA256-verified. Set CAMEL_JMS_BRIDGE_BINARY_PATH to point at a local build for development.
Consumer
activemq:queue:orders subscribes to a destination. The Consumer submits one Exchange per inbound JMS message. The Exchange body carries the message payload. With mapJmsHeaders=true (the default), the headers carry JMSMessageID, JMSCorrelationID, JMSTimestamp, JMSDestination, and JMSPriority.
The body is typed from the JMS content_type. text/* becomes Body::Text. application/json becomes Body::Json when valid JSON. Binary content becomes Body::Bytes. The consumer pre-flights the bridge slot before starting. A missing or degraded bridge fails fast with JMS bridge not available.
The concurrentConsumers parameter spawns N parallel consumer tasks on the same destination. Each task subscribes independently and submits Exchanges into the shared route pipeline. messageSelector filters messages at the broker with a SQL-92 expression.
Producer
activemq:queue:orders sends the Exchange body to a destination. The content type is inferred from the body. Body::Text sends text/plain. Body::Json sends application/json. Body::Xml sends text/xml. An explicit Content-Type header wins over inference.
The Producer uses a semaphore for backpressure. The default concurrency limit is 128 in-flight sends. When the limit is reached, poll_ready returns Pending. ADR-0024 classifies a closed semaphore as ConsumerStopping.
A successful send returns the Exchange unchanged. The JMSMessageID header carries the broker-assigned message ID. A send failure on a gRPC transport error refreshes the channel. The original send is not retried. A retry on a non-idempotent write would cause duplicates. The caller decides whether to retry.
Java bridge
The component does not speak JMS. A native Java bridge process handles the JMS protocol and the TCP connection. The component talks to the bridge over gRPC. The bridge binary is a jlink image. The host does not need a Java runtime.
The bridge pool assigns one bridge per broker. The first send or consume starts the bridge. The bridge's ephemeral gRPC port comes from its stdout. A health monitor pings the bridge every healthCheckIntervalMs (default 5s). A failed health check moves the slot to Degraded, then Restarting. Restarts use exponential backoff capped at 120 seconds. After 10 failed restart attempts the slot stays Degraded.
The Consumer observes bridge state through a watch channel. A pending bridge returns Pending from poll_ready. A degraded bridge returns Err with the reason. The producer waits for the bridge to become Ready before sending.
Trust boundary
Per ADR-0032, incoming JMS headers, bodies, correlation IDs, and destinations enter exchange.input without validation. The route is responsible for validation when data crosses into a control action, a resource decision, or an executable sink.
Credentials cross the gRPC boundary in plain text for the username and through a Redacted wrapper for the password. BrokerConfig redacts the password in Debug output. BridgeSlot omits its credentials field. redact_url strips user information from URLs before logging. The audit command rg '#\[derive.*Debug' crates/components/camel-jms/src/ checks that no type holding a password derives Debug.
Error handling
ADR-0007 governs Consumer shutdown. JmsConsumer::stop cancels the CancellationToken and waits up to 5 seconds for the consumer tasks to finish. Tasks that do not exit in that window are aborted. An in-flight ConsumerContext::send completes before the loop checks cancellation. The Consumer does not restart itself. Route supervision owns restart.
ADR-0012 classifies log sites as outside-contract or system-broken. A consumer-side ctx.send failure increments the b-prime:jms:consumer-send metric and logs at error!. A bridge restart that exhausts the attempt cap logs at error! and leaves the slot in Degraded.
Limitations
- The bridge uses
AUTO_ACKNOWLEDGE. Messages are acknowledged on delivery, not after processing. A failed route cannot request redelivery. - Durable topic subscribers are not supported.
- IBM MQ is not supported.
- The
InOutexchange pattern andSessiontransaction mode log a warning and fall back to the default.
Reference: JMS crate CONTEXT. Example source: examples/jms-example.
MQTT
The MQTT component publishes to and consumes from MQTT 3.1.1 brokers. The Consumer subscribes to topic filters and submits one Exchange per incoming publish. The Producer is a Tower Service<Exchange> that publishes the Exchange body to a topic.
The mqtt-example wires a timer-driven producer and a log consumer against a Mosquitto broker started by testcontainers:
let producer = RouteBuilder::from("timer:tick?period=3000&repeatCount=2")
.route_id("mqtt-producer")
.set_body("hello-mqtt")
.to("mqtt://test/sensors/temp")
.build()?;
YAML equivalent
routes:
- id: mqtt-producer
from: "timer:tick?period=3000&repeatCount=2"
steps:
- set_body: "hello-mqtt"
- to: "mqtt://test/sensors/temp"
The example reads the broker port from a testcontainers container. Substitute your real broker name and credentials in Camel.toml under [components.mqtt.brokers].
let consumer = RouteBuilder::from("mqtt://test/sensors/#")
.route_id("mqtt-consumer")
.to("log:info?showHeaders=true")
.build()?;
YAML equivalent
routes:
- id: mqtt-consumer
from: "mqtt://test/sensors/#"
steps:
- to: "log:info?showHeaders=true"
Wildcard subscriptions (sensors/#) match every topic under the prefix. + matches a single level.
URI
mqtt://<broker_name>[/<topic>][?query]
mqtts://<broker_name>[/<topic>][?query]
<broker_name> is a logical key, not a host:port. The component resolves it to a MqttBrokerConfig declared in Camel.toml under [components.mqtt.brokers.<name>]. The path segment becomes the default subscription filter for the Consumer and the default publish topic for the Producer. Use the topics query parameter for multi-filter subscriptions.
| Parameter | Default | Description |
|---|---|---|
qos | 1 | Quality of Service: 0 (AtMostOnce), 1 (AtLeastOnce), or 2 (ExactlyOnce) |
ackMode | auto | auto acks on delivery. manual acks after the pipeline succeeds |
cleanSession | true | Must be false when ackMode=manual and QoS 1 or 2 |
retain | false | Retain published messages on the broker |
keepAliveSecs | 60 | MQTT keep-alive interval in seconds |
maxPayloadBytes | 262144 | Incoming payload limit (256 KB) |
clientId | auto | Override the auto-generated client ID |
topics | path | Comma-separated topic filters. Repeated topics= keys allowed |
Invalid qos or ackMode values fail endpoint creation with CamelError::Config.
Consumer
mqtt://<broker>/sensors/# subscribes to a topic filter. The Consumer opens a TCP connection to the broker, subscribes, and feeds each incoming publish into the route as an Exchange. The Exchange body carries the MQTT payload. The headers carry CamelMqttTopic, CamelMqttQos, CamelMqttRetained, CamelMqttDuplicate, CamelMqttClientId, and CamelMqttPacketId (the last only for QoS 1 and 2).
Each route Consumer opens its own TCP connection. v1 has no shared connection pool (ADR-0027). Account for the per-route connection when you size your broker.
Manual ack with QoS 1 or 2 requires cleanSession=false. With cleanSession=true the broker discards session state on reconnect and unacknowledged messages cannot be redelivered. Validation rejects the unsafe combination at endpoint creation. The ack decision uses the received packet QoS, not the subscription QoS. A subscription at QoS 1 can still receive QoS 0 messages, and those messages must never be manually acked.
Producer
mqtt://<broker>/sensors/temp publishes the Exchange body to a topic. Each route endpoint creates a Tower Service<Exchange> Producer. Each Producer opens its own TCP connection to the broker (ADR-0027).
The body becomes the MQTT payload. The URI path sets the default publish topic and QoS. The headers CamelMqttTopic, CamelMqttQos, and CamelMqttRetain override the defaults for that exchange. CamelMqttTopic must not contain + or #.
Connection retries use the shared NetworkRetryPolicy with exponential backoff and jitter. Every backoff sleep is cancellation-aware and stops when the route shuts down. The driver loop logs retried connection errors at warn!. It has no runtime handle so it cannot call error! with a replacement signal.
Configuration
Brokers live in Camel.toml under [components.mqtt.brokers]. The URI references the broker by name. Credentials stay in the config file, never in the route:
[default.components.mqtt]
client_id_prefix = "camel"
[default.components.mqtt.brokers.my-broker]
url = "mqtt://mqtt.example.com:1883"
username = "app-user"
password = "app-secret"
The url field accepts mqtt:// (plain TCP) or mqtts:// (TLS). mqtts:// requires the tls cargo feature. The default features include TLS, so connections use rustls out of the box. To compile without TLS, disable default features:
camel-component-mqtt = { version = "0.20", default-features = false }
The component redacts the broker password in Debug output. mTLS (client certificate authentication) is not yet supported in v1.
Connection lifecycle
Every Consumer and every Producer opens one TCP connection to the broker. The component uses no shared connection in v1. The connection carries a 10-second timeout. Unreachable brokers fail fast instead of hanging.
The auto-generated client_id follows the pattern {prefix}-{route_id}-{hash6} and truncates to 23 bytes (the MQTT 3.1.1 portable maximum). The hash input is the full endpoint URI for producers and the broker name plus subscription list for consumers. Set the clientId URI parameter to override.
Reference: MQTT crate CONTEXT. Architecture decisions: ADR-0027. Example source: examples/mqtt-example.
Redis
The Redis component executes Redis commands and subscribes to Redis channels. One crate covers both directions. The Producer sends the Exchange body to Redis as a command argument. The Consumer subscribes to Pub/Sub channels or blocks on a list key. The redis URI scheme uses plaintext. The rediss URI scheme uses TLS.
The redis-example wires a string producer, a Pub/Sub consumer, a queue consumer, and a Pub/Sub producer against a testcontainers Redis instance:
use camel_builder::{RouteBuilder, StepAccumulator};
use camel_component_redis::RedisComponent;
ctx.register_component("redis", Box::new(RedisComponent::new()));
// Producer: timer writes a key every 3s
let string_producer = RouteBuilder::from("timer:tick?period=3000&repeatCount=3")
.route_id("redis-string-producer")
.set_header("CamelRedis.Key", Value::String("greeting".into()))
.set_header(
"CamelRedis.Value",
Value::String("hello from rust-camel!".into()),
)
.to("redis://127.0.0.1:6379?command=SET")
.to("log:info?showHeaders=true")
.build()?;
YAML equivalent
routes:
- id: redis-string-producer
from: "timer:tick?period=3000&repeatCount=3"
steps:
- set_header:
CamelRedis.Key: "greeting"
- set_header:
CamelRedis.Value: "hello from rust-camel!"
- to: "redis://127.0.0.1:6379?command=SET"
- to: "log:info?showHeaders=true"
The example reads the Redis port from a testcontainers container. Substitute your real broker address in redis://.
// Consumer: BRPOP blocks on a list key, one Exchange per popped item
let queue_consumer = RouteBuilder::from(
"redis://127.0.0.1:6379?command=BRPOP&key=demo-queue&timeout=2",
)
.route_id("redis-queue-consumer")
.to("log:info?showAll=true")
.build()?;
// Consumer: SUBSCRIBE receives published messages as Exchanges
let pubsub_consumer = RouteBuilder::from(
"redis://127.0.0.1:6379?command=SUBSCRIBE&channels=demo-channel",
)
.route_id("redis-pubsub-consumer")
.to("log:info?showAll=true")
.build()?;
YAML equivalent
routes:
- id: redis-queue-consumer
from: "redis://127.0.0.1:6379?command=BRPOP&key=demo-queue&timeout=2"
steps:
- to: "log:info?showAll=true"
- id: redis-pubsub-consumer
from: "redis://127.0.0.1:6379?command=SUBSCRIBE&channels=demo-channel"
steps:
- to: "log:info?showAll=true"
URI
redis://host:port?command=<cmd>[&key=<key>][&channels=<list>][&timeout=<secs>][&password=<pwd>][&db=<n>][&ssl=<bool>]
| Parameter | Required | Default | Description |
|---|---|---|---|
command | no | SET | Redis command to execute |
key | per-command | none | Redis key for the operation |
channels | Pub/Sub | empty | Comma-separated channel names |
timeout | blocking | 1 | Blocking timeout in seconds |
password | no | none | Redis password |
db | no | 0 | Redis database number (0-255) |
ssl | no | auto | Force TLS on or off |
The command parameter picks the Redis command at Endpoint creation. Exchange data never becomes a command name. Dynamic values like keys, fields, values, channels, and scores cross the trust boundary as length-prefixed Redis protocol arguments. Argument contents cannot inject a second command or change the selected command (CONTEXT "Trust boundary"). Missing required headers return CamelError.
Commands
The component exposes 80+ commands across eight groups. The enum is exhaustive: an unknown command fails URI parsing with CamelError::InvalidUri. The component does not expose EVAL, EVALSHA, or script-loading commands. Script injection through the public surface is not possible.
| Group | Commands |
|---|---|
| String | SET, GET, GETSET, SETNX, SETEX, MGET, MSET, INCR, INCRBY, DECR, DECRBY, APPEND, STRLEN |
| Key | EXISTS, DEL, EXPIRE, EXPIREAT, PEXPIRE, PEXPIREAT, TTL, KEYS, RENAME, RENAMENX, TYPE, PERSIST, MOVE, SORT |
| List | LPUSH, RPUSH, LPUSHX, RPUSHX, LPOP, RPOP, BLPOP, BRPOP, LLEN, LRANGE, LINDEX, LINSERT, LSET, LREM, LTRIM, RPOPLPUSH |
| Hash | HSET, HGET, HSETNX, HMSET, HMGET, HDEL, HEXISTS, HLEN, HKEYS, HVALS, HGETALL, HINCRBY |
| Set | SADD, SREM, SMEMBERS, SCARD, SISMEMBER, SPOP, SMOVE, SINTER, SUNION, SDIFF, SINTERSTORE, SUNIONSTORE, SDIFFSTORE, SRANDMEMBER |
| Sorted set | ZADD, ZREM, ZRANGE, ZREVRANGE, ZRANK, ZREVRANK, ZSCORE, ZCARD, ZINCRBY, ZCOUNT, ZRANGEBYSCORE, ZREVRANGEBYSCORE, ZREMRANGEBYRANK, ZREMRANGEBYSCORE, ZUNIONSTORE, ZINTERSTORE |
| Pub/Sub | PUBLISH, SUBSCRIBE, PSUBSCRIBE |
| Other | PING, ECHO |
Producer
redis://host:port?command=GET sends the Exchange body to Redis. The Producer holds a single multiplexed connection per Endpoint. The connection opens lazily on the first call and stays open for the route lifetime.
The command parameter picks one of the 80+ commands listed above. The Exchange body, headers, and the URI parameters supply the command arguments. Different commands read different headers. For example, HSET reads CamelRedis.Key and CamelRedis.Value. LRANGE reads CamelRedis.Start and CamelRedis.End. Missing required headers return CamelError. A send failure returns Err to the route ErrorHandler.
The Producer is a Tower Service<Exchange>. It composes with any pipeline step and reports per-route metrics.
Consumer
redis://host:port?command=SUBSCRIBE&channels=foo,bar subscribes to one or more Pub/Sub channels. The Consumer submits one Exchange per published message. The CamelRedis.Channel header carries the channel name. CamelRedis.Pattern carries the matched pattern for PSUBSCRIBE.
redis://host:port?command=BLPOP&key=jobs&timeout=5 blocks on a list key and submits one Exchange per popped item. The CamelRedis.Key header carries the list key. The timeout parameter is the block duration in seconds. Use BLPOP for left pop and BRPOP for right pop.
The Consumer's mode comes from the URI command. SUBSCRIBE and PSUBSCRIBE use Pub/Sub mode. BLPOP and BRPOP use queue mode. A command that fits neither returns an error at consumer creation. The component does not silently fall back to BLPOP (REDIS-003).
Security
The component supports TLS through the rediss:// URI scheme or the ssl=true parameter. The two are equivalent. TLS auto-enables for non-loopback hosts when the redis crate is compiled with a TLS feature (tls-rustls-webpki-roots, tls-rustls-native-certs, or tls-native-tls). The component logs a tracing::warn! when auto-enabling TLS. A missing feature gate stops startup with the required cargo add command.
The component redacts passwords in Debug output. Passwords with special characters (@, :, /) are percent-encoded in the connection URL. The safe_endpoint() helper returns a credential-free identifier for tracing.
Connection handling
The Producer holds a single multiplexed connection per Endpoint. The Consumer holds one connection for Pub/Sub mode and one for queue mode. Each connection has a 10-second timeout by default. Transient transport errors trigger reconnect with the configured NetworkRetryPolicy. The route ErrorHandler owns the operational signal for non-transient errors.
The component registers an async health check that sends a PING command. The probe is healthy when Redis responds with PONG and degraded when PING fails or times out.
Error handling
The Consumer logs at error! for channel-closed conditions on Pub/Sub and BLPOP send paths and for retry-exhaustion. Each site reports a typed metric before the log line. The Producer logs send failures at warn!. Per-message non-transient Redis errors log at error! with a typed metric. The route handler owns the operational signal for transient producer errors.
Reference: Redis crate CONTEXT. Example source: examples/redis-example.
Database
The database components connect routes to SQL and SurrealDB. Two crates cover the two database families. The SQL component uses sqlx for PostgreSQL, MySQL, and SQLite. The SurrealDB component uses the SurrealDB SDK for document, graph, vector, and live-query operations.
SQL
The SQL component executes queries against relational databases through sqlx. It covers both directions. A Consumer polls query results. A Producer executes statements.
URI
sql:<query>[?outputType=<mode>&dataSource=<name>&allowDynamicQuery=<bool>&bridgeErrorHandler=<bool>]
| Parameter | Required | Default | Description |
|---|---|---|---|
query | yes | — | SQL query or statement. Can also come from a file |
outputType | no | SelectList | SelectList (JSON array) or StreamList (NDJSON stream) |
dataSource | no | default | Named datasource from Camel.toml |
allowDynamicQuery | no | false | Allow query from Exchange header or body (ADR-0032) |
bridgeErrorHandler | no | false | Route poll errors through the error handler |
Consumer
sql:SELECT * FROM users?outputType=SelectList polls the query result. The Consumer submits one Exchange per poll cycle. The body carries the result set as Body::Json.
outputType=StreamList exposes rows as a lazy NDJSON stream. Combined with the streaming split EIP, this gives at-least-once row processing. The Consumer fetches rows from the database as the stream drains. It does not commit the batch until the whole split pipeline completes. If the pipeline crashes mid-stream, the batch re-delivers on the next poll. When a streaming split sub-pipeline uses sql: as a producer, set the pool max_connections to at least 2 to avoid a deadlock.
Producer
sql:UPDATE users SET name = :#name WHERE id = :#id executes a statement with named parameters. The Producer supports positional # and named :#<token> placeholders. Named parameters resolve from the Exchange body, headers, and properties through ExchangeLookupPath.
The allowDynamicQuery parameter defaults to false. In this mode, the Producer ignores the CamelSql.Query header and the Exchange body. It uses only the query from its Endpoint configuration. This default enforces the exchange-data trust boundary (ADR-0032). Set allowDynamicQuery=true to let the route supply the query. The opt-in makes the route responsible for validating the source.
Placeholder syntax
The SQL placeholder parser accepts these forms:
- Positional
#. Body must be a JSON array. Bindings come from index. - Named
:#<token>. Resolves from the body JSON tree, headers, or properties. - IN-clause
:#in:<token>. Value must resolve to a JSON array. - Expression
:#${<expr>}. Escape hatch that usesExchangeLookupPath. - PostgreSQL
::cast.:#id::textresolves the placeholder and leaves::textas the SQL cast.
The full contract surface with accepted, rejected, and forbidden patterns lives in the SQL crate CONTEXT.
SurrealDB
The SurrealDB component connects routes to SurrealDB for document, graph, vector, function, and live-query operations. It uses the SurrealDB SDK directly.
URI
surrealdb:<operation>?[dataSource=<name>&...]
The operation is the path segment in the URI. The 12 supported operations are:
| Operation | Description |
|---|---|
query | Execute raw SurrealQL |
select | Select records by ID or table |
create | Create a new record |
update | Update an existing record |
upsert | Insert or update a record |
delete | Delete a record |
patch | Apply a JSON Patch to a record |
relate | Create a graph edge between records |
vector | Vector search operation |
search | Full-text search |
run | Run a SurrealDB function |
live | Subscribe to live query notifications |
Consumer
surrealdb:live?dataSource=my-db subscribes to live query notifications. The Consumer submits one Exchange per live event. Live queries require a WebSocket connection (ws or wss). HTTP connections are rejected for live operations.
Producer
surrealdb:query?dataSource=my-db executes SurrealQL. The query comes from the CamelSurrealDbQuery header, the Exchange body, or the Endpoint configuration. Results materialize as Body::Json. The output=stream option is rejected.
Security
Credentials belong in the named datasource, not in the Endpoint URI. The redact_db_url function removes user information before a URL enters a log or error message. Identifier validation rejects whitespace, quotes, semicolons, and backslashes in interpolated table, edge, and vector-field names.
The raw-query trust boundary is an open hardening gap. The query operation accepts SurrealQL from the CamelSurrealDbQuery header or the body before it falls back to Endpoint configuration. Route authors must filter these sources before the Producer. The SurrealDB crate CONTEXT documents the gap.
Datasource configuration
Both components use named datasources from Camel.toml:
[components.datasources.my-db]
db_url = "postgres://user:pass@localhost/mydb"
The SQL component also supports inline connection parameters in the URI. The SurrealDB component requires credentials in the named datasource.
Reference: SQL crate CONTEXT, SurrealDB crate CONTEXT.
SurrealDB
The SurrealDB component connects routes to a SurrealDB multi-model database. One scheme covers twelve operations: document CRUD, graph edges, vector search, function calls, and live-query change data capture. Both directions share the same surrealdb: scheme. The Consumer is push-based and limited to live; the Producer handles the other eleven operations.
A producer route writes a user and selects the table:
use camel_builder::RouteBuilder;
let create = RouteBuilder::from("timer:tick?period=2000&repeatCount=1")
.set_body(serde_json::json!({"name": "Alice", "age": 30}))
.to("surrealdb:create?datasource=demo&table=users")
.build()?;
let list = RouteBuilder::from("timer:tick?period=2000&repeatCount=1&delay=500")
.to("surrealdb:select?datasource=demo&table=users")
.build()?;
YAML equivalent
routes:
- id: create-user
from: "timer:tick?period=2000&repeatCount=1"
steps:
- set_body: '{"name":"Alice","age":30}'
- to: "surrealdb:create?datasource=demo&table=users"
- id: list-users
from: "timer:tick?period=2000&repeatCount=1&delay=500"
steps:
- to: "surrealdb:select?datasource=demo&table=users"
A live-query route turns table changes into a stream of Exchanges:
let live = RouteBuilder::from("surrealdb:live?datasource=demo&table=events")
.to("log:info?showBody=true")
.build()?;
YAML equivalent
routes:
- id: events-cdc
from: "surrealdb:live?datasource=demo&table=events"
steps:
- to: "log:info?showBody=true"
URI
surrealdb:<operation>?datasource=<name>[&table=<t>][&id=<r>][&edge=<e>][&from=<r>][&to=<r>][&function=<f>][&top_k=<n>][&metric=<m>][&vector_field=<f>][&query=<surrealql>]
The path segment is the operation. The component rejects the URI if datasource is missing, the operation is unknown, or output=stream is set. Streaming output is not supported. All results materialize as Body::Json(Vec<Value>).
| Operation | Direction | Required URI params | Description |
|---|---|---|---|
query | producer / polling | — | Run raw SurrealQL from URI, header, or body |
select | producer / polling | table | Select all rows or one row by id |
create | producer | table | Insert a new record from a JSON body |
update | producer | table, id | Merge JSON body fields into an existing record |
upsert | producer | table, id | Replace record content (id present or new) |
delete | producer | table, id | Delete a record by id |
patch | producer | table, id | Apply RFC 6902 JSON Patch operations |
relate | producer | table, edge, from, to | Create a graph edge between two records |
vector | producer | table | Store a vector field on a record |
search | producer | table, top_k | KNN vector similarity search |
run | producer | function | Run a SurrealDB function (fn::, math::, string::) |
live | consumer | table | Subscribe to live-query notifications (push model) |
Common parameters
| Parameter | Required | Default | Description |
|---|---|---|---|
datasource | yes | — | Named datasource from Camel.toml |
table | per operation | — | Target table for the operation |
id | per operation | — | Record id in table:key form |
from | relate | — | Source RecordId, full table:key form |
to | relate | — | Target RecordId, full table:key form |
edge | relate | — | Edge table name (the relationship) |
top_k | search | — | Number of nearest neighbors to return (must be > 0) |
metric | no | cosine | cosine, euclidean, or manhattan (case-insensitive) |
vector_field | no | embedding | Field that holds the vector |
function | run | — | Function name, validated as ASCII identifier with :: |
query | no | — | Inline SurrealQL for the query operation |
Retry policy parameters (retryEnabled, retryMaxAttempts, retryInitialDelayMs, retryMultiplier, retryMaxDelayMs, retryJitter) match camel-sql by name and default. They configure pool-establishment retry today. ADR-0013 owns the retry semantics; producer operations do not retry to avoid duplicating non-idempotent writes.
Producer
The Producer executes the eleven non-live operations. The body shape the Producer requires depends on the operation. create, update, upsert, patch, relate, vector, and run require a Body::Json body. query, select, search, and delete accept input through URI parameters or headers, so an empty body is valid.
select and query also expose a PollingConsumer. They integrate with the Poll Enrich EIP for on-demand reads. Other operations do not poll.
Result bodies land as Body::Json. The Producer sets CamelSurrealDbRecordId on writes when the id can be derived from the URI or the response. The id is the edge record for relate, not the source node.
The query operation reads SurrealQL from the URI, then the CamelSurrealDbQuery header, then the Exchange body. The CamelSurrealDbParams header binds $name placeholders. ADR-0032 classifies Exchange data as untrusted. The component has no allow_dynamic_query switch. The route is responsible for sanitizing SurrealQL from external sources before it reaches the Producer.
Consumer
surrealdb:live?datasource=<name>&table=<t> subscribes to a SurrealDB live query. SurrealDB pushes one notification per CREATE, UPDATE, or DELETE on the table. The Consumer submits one Exchange per notification. The body holds the affected record. The Consumer sets CamelSurrealDbAction to the action type and CamelSurrealDbTable to the table name.
Live queries require a WebSocket transport (ws:// or wss://). The component rejects http:// and https:// datasources for the live operation at endpoint creation.
The Consumer is event-driven. It does not implement PollingConsumer. The live operation exposes no receive() entry point.
Datasource
The datasource URI parameter names a Camel.toml entry. Credentials belong in the datasource, not the URI. The component redacts user information from db_url before any URL enters a log line or error message.
[default.datasources.demo]
db_url = "ws://localhost:8000"
provider = "surrealdb"
[default.datasources.demo.extra]
namespace = "test"
database = "test"
username = "root"
password = "root"
The extra map carries namespace, database, username, and password. The transport schemes are ws://, wss://, http://, and https://. The live operation requires ws or wss. See the Database page for the shared datasource contract.
Headers
| Header | Direction | Operations | Description |
|---|---|---|---|
CamelSurrealDbQuery | input | query | SurrealQL text (priority: header > body > URI) |
CamelSurrealDbParams | input | query | JSON object map of $name → value bindings |
CamelSurrealDbVector | input | search | Query vector as JSON array of f32 (alternative to body) |
CamelSurrealDbRecordId | output | all writes | Resolved table:key id when one can be determined |
CamelSurrealDbAction | output | live | CREATE, UPDATE, or DELETE |
CamelSurrealDbTable | output | live | Table that triggered the notification |
Security
Identifier validation runs on table, edge, and vector_field. The validator accepts ASCII letters, digits, and _. The first character must be a letter or _. Whitespace, quotes, semicolons, and backslashes are rejected. Record keys (id, from, to) go through SDK binding and accept the broader charset SurrealDB requires for numeric, UUID, and string keys.
from and to for relate must be full RecordIds in table:key form. Bare keys are rejected at endpoint creation. This prevents the edge from silently targeting the wrong record when a route author writes from=1 instead of from=user:1.
The raw-query trust boundary is the documented hardening gap for this component. The query operation accepts SurrealQL from the body and the CamelSurrealDbQuery header. The route must filter these sources before the Producer. The component exposes no default-deny switch equivalent to camel-sql's allowDynamicQuery=false.
The component follows the ADR-0012 log-level convention. It never emits error! in production code. Recoverable and handler-owned conditions use warn!. Errors that terminate an operation return CamelError or SurrealDbError. The route error handler or supervision owns the final signal. ADR-0020 classifies the SurrealDB SDK as a stable database-driver class. The crate does not wrap the SDK in a project-owned adapter trait.
Reference: SurrealDB crate CONTEXT. Example source: examples/surrealdb-example.
OpenSearch
The OpenSearch component runs document, index, and search operations against an OpenSearch cluster. The OpenSearchProducer is a Tower Service<Exchange> that owns a shared client and a 128-permit semaphore. The MULTISEARCH operation is reserved but not yet implemented. The component is producer-only. The Endpoint rejects create_consumer() with EndpointCreationFailed.
Index a document
The Producer sends the Exchange body as the document. The optional CamelOpenSearch.Id header sets the document ID. Without it, OpenSearch assigns a server-generated ID.
use camel_api::Exchange;
use camel_builder::RouteBuilder;
let route = RouteBuilder::from("direct:index-user")
.set_header("CamelOpenSearch.Id", serde_json::json!("user-42"))
.set_body(serde_json::json!({
"name": "Alice",
"age": 30
}))
.to("opensearch://localhost:9200/users?operation=INDEX")
.build()?;
YAML equivalent
routes:
- id: index-user
from: "direct:index-user"
steps:
- set_header:
CamelOpenSearch.Id: "user-42"
- set_body:
name: "Alice"
age: 30
- to: "opensearch://localhost:9200/users?operation=INDEX"
Search documents
The Producer sends the body as the search query. The size and from URI params fill in pagination when the body does not set them.
use camel_builder::RouteBuilder;
let route = RouteBuilder::from("direct:search-users")
.set_body(serde_json::json!({
"query": {
"match": { "name": "Alice" }
}
}))
.to("opensearch://localhost:9200/users?operation=SEARCH&size=25&from=0")
.build()?;
YAML equivalent
routes:
- id: search-users
from: "direct:search-users"
steps:
- set_body:
query:
match:
name: "Alice"
- to: "opensearch://localhost:9200/users?operation=SEARCH&size=25&from=0"
Bulk operations
The body is a JSON array of action-and-document pairs. The Producer serializes each element to a line and rejects the call when the total payload exceeds max_bulk_bytes.
use camel_builder::RouteBuilder;
let route = RouteBuilder::from("direct:bulk")
.set_body(serde_json::json!([
{ "index": { "_id": "1" } },
{ "name": "Alice", "age": 30 },
{ "index": { "_id": "2" } },
{ "name": "Bob", "age": 25 }
]))
.to("opensearch://localhost:9200/users?operation=BULK&max_bulk_bytes=1048576")
.build()?;
YAML equivalent
routes:
- id: bulk
from: "direct:bulk"
steps:
- set_body:
- index:
_id: "1"
- name: "Alice"
age: 30
- index:
_id: "2"
- name: "Bob"
age: 25
- to: "opensearch://localhost:9200/users?operation=BULK&max_bulk_bytes=1048576"
URI
opensearch://<host>:<port>/<index>?operation=<op>[&option=value...]
opensearchs://<host>:<port>/<index>?operation=<op>[&option=value...] (TLS)
| Parameter | Required | Default | Description |
|---|---|---|---|
host | no | localhost | OpenSearch hostname. Falls back to the global config |
port | no | 9200 | OpenSearch port. Falls back to the global config |
index | yes | — | Target index name. Lowercase letters, digits, hyphens, underscores. Max 255 bytes |
operation | no | SEARCH | One of the operations in the table below |
username | no | — | Basic auth username. Falls back to the global config |
password | no | — | Basic auth password. Falls back to the global config |
timeout_ms | no | 30000 | Per-request timeout in milliseconds |
size | no | — | Search result page size. Filled into the body when absent |
from | no | — | Search result offset. Filled into the body when absent |
max_bulk_bytes | no | — | Maximum serialized bulk payload size. BULK rejects larger payloads |
retryEnabled | no | true | Enable retry on transient failures |
retryMaxAttempts | no | 3 | Maximum retry attempts |
retryInitialDelayMs | no | 1000 | Initial backoff delay |
retryMultiplier | no | 2.0 | Backoff multiplier |
retryMaxDelayMs | no | 60000 | Maximum backoff delay |
retryJitter | no | 0.0 | Jitter factor between 0.0 and 1.0 |
Operations
The Producer selects the operation from the CamelOpenSearch.Operation header. The header overrides the URI operation param. Invalid header values fall back to the URI value. URI parsing rejects unknown operation names at config time.
| Operation | Body | Required headers | Description |
|---|---|---|---|
INDEX | document | optional CamelOpenSearch.Id | Index a document. Creates or replaces |
GET | — | CamelOpenSearch.Id | Retrieve a document by ID |
DELETE | — | CamelOpenSearch.Id | Delete a document by ID |
UPDATE | partial doc | CamelOpenSearch.Id | Apply a partial update |
EXISTS | — | CamelOpenSearch.Id | Check whether a document exists |
SEARCH | query | — | Run a search query |
BULK | action+doc array | — | Run multiple actions in one request |
MULTIGET | id list | — | Retrieve multiple documents |
DELETE_INDEX | — | — | Delete the entire index |
PING | — | — | Probe the cluster |
MULTISEARCH | — | — | Reserved. Returns a permanent not-implemented error |
Headers
| Header | Direction | Description |
|---|---|---|
CamelOpenSearch.Id | input | Document ID for INDEX, GET, DELETE, UPDATE, EXISTS |
CamelOpenSearch.Operation | input | Operation name. Overrides the URI operation param |
The Producer reads the index name from operator endpoint configuration. URI parsing validates the index name against OpenSearch naming rules and fails closed on null bytes, traversal segments, and invalid characters. The CamelOpenSearch.Id header is untrusted Exchange data. Current typed request builders pass it to _doc/{id} paths without component-side validation. This gap is tracked as audit finding I1.
TLS
Use the opensearchs scheme to enable HTTPS. The base URL builder selects https:// from the scheme. Basic auth credentials apply to both schemes.
routes:
- id: secure-search
from: "direct:search-secure"
steps:
- to: "opensearchs://opensearch.example.com:9200/users?operation=SEARCH&username=admin&password=secret"
Global configuration
Set defaults in Camel.toml under the opensearch key. URI params always override global values. Unset URI fields fall back to the global config after URI parsing.
[default.components.opensearch]
host = "opensearch.internal"
port = 9200
username = "app-user"
password = "${OPENSEARCH_PASSWORD}"
default_operation = "SEARCH"
index_name = "events"
timeout_ms = 30000
The OpenSearchConfig::Debug implementation redacts the password to <redacted>. The same redaction applies to OpenSearchEndpointConfig::Debug.
Concurrency and retry
The Producer holds a Semaphore that caps in-flight calls at 128. poll_ready() reserves a permit before the call. A closed semaphore maps to CamelError::ConsumerStopping (ADR-0019, ADR-0024). The shared client initializes lazily on the first call. Arc<Mutex<Option<OpenSearch>>> serializes initialization, then each call clones and reuses the client.
Retry uses NetworkRetryPolicy. The retry loop classifies failures as transient or permanent:
- Transient: HTTP 5xx, network failures, timeouts. Retried up to
max_attempts - Permanent: HTTP 4xx, missing headers, parse failures. Surfaced immediately
All operations except MULTISEARCH and UNKNOWN use typed request builders from the opensearch crate. The component never interpolates Exchange data into query text. Bodies become structured JSON values that serde supplies to the request builder.
Error handling
Operation failures log at warn! with the index name and error. Client initialization failures log at error! because the producer cannot function without a client. ADR-0012 classifies initialization failures as system-broken. The Producer surfaces transient retry exhaustion and permanent failures as CamelError::ProcessorError. The route ErrorHandler owns the operational signal.
Reference: camel-opensearch CONTEXT. Example source: not yet published.
LLM
The LLM component sends chat, embedding, and tool-calling requests to language model providers. It streams tokens or materializes full responses. Three providers ship: OpenAI, Ollama, and Mock. The component is producer-only. It has no Consumer in the current release.
The llm-example registers a Mock provider and runs three routes. The materialized route below sends a prompt, collects the response, and logs it with headers:
let route = RouteBuilder::from("timer:tick?period=3000")
.route_id("llm-materialized")
.set_body("Explain Tower middleware in one sentence.")
.to("llm:chat?provider=openai-prod&model=gpt-4o&stream=false")
.to("log:info?showBody=true&showHeaders=true")
.build()?;
ctx.add_route_definition(route).await?;
YAML equivalent
routes:
- id: llm-materialized
from: "timer:tick?period=3000"
steps:
- set_body: "Explain Tower middleware in one sentence."
- to: "llm:chat?provider=openai-prod&model=gpt-4o&stream=false"
- to: "log:info?showBody=true&showHeaders=true"
URI
llm:<operation>?provider=<name>[&model=<model>][&temperature=<n>][&max_tokens=<n>][&stream=<bool>][&system_prompt=<text>][&timeout_secs=<n>]
| Parameter | Default | Description |
|---|---|---|
provider | global default | Provider name from Camel.toml |
model | provider default | Override model |
temperature | provider default | Sampling temperature |
max_tokens | provider default | Max output tokens |
stream | true | true streams, false materializes |
system_prompt | — | System prompt override |
timeout_secs | provider default | Activity timeout (streaming) or total deadline (materialized) |
Operations
llm:chat runs a chat completion. llm:embed generates a vector embedding. The operation picks which LlmProvider method the producer calls.
Producer
llm:chat?provider=openai-prod&model=gpt-4o sends the Exchange body as a user message. The stream parameter selects streaming or materialized mode.
Streaming mode is the default. The producer returns Body::Stream(StreamBody). Each ChatEvent::Delta carries a token or text fragment. Usage metadata goes to tracing::info!. Usage metadata does not go to Exchange headers. The header CamelLlmUsageAvailable is false at Exchange return time.
Materialized mode (stream=false) collects every ChatEvent into a single Body::Text. The producer writes token counts to CamelLlmTokensIn and CamelLlmTokensOut. It writes the finish reason to CamelLlmFinishReason. The CamelLlmUsageAvailable header is true.
Tool calls flow through CamelLlmTools (input) and CamelLlmToolCalls (output). The component emits tool-call intent through ChatEvent::ToolCall. The component never executes tools. The route owns dispatch.
Cost and cache
Cost observability tracks spending per request. A config-driven PricingTable maps input and output tokens to USD rates. The producer computes cost from final usage. It writes cost to CamelLlmEstimatedCostUsd in materialized mode. It logs cost at info! in both modes. Missing pricing means no cost header and no failure.
The response cache deduplicates materialized requests. It sits at the producer level, not the provider level. A single-flight mechanism built on dashmap and tokio::sync::watch collapses concurrent lookups. Entries expire by TTL and evict by LRU. The cache stores usage, not cost. The cache lookup runs before the semaphore, retry, and timeout layers.
Providers
The Mock provider ships in the default build. OpenAI and Ollama require cargo features (--features openai and --features ollama). The SiumaiProvider adapter isolates the siumai SDK. If siumai breaks, only provider/siumai_adapter.rs changes (ADR-0020).
Retry and timeout
The manual retry loop honors provider retry_after over exponential backoff (ADR-0021). The loop only runs in materialized mode. Once content starts streaming, the loop stops.
The streaming timeout_secs covers each stream.next() call. A deadline lapse yields a Timeout error. The materialized total deadline covers the whole request.
Error handling
LlmError maps to three CamelError variants: Unauthenticated, Io, and ProcessorError. The mapping is lossy. Downstream handlers that need typed LLM errors should use CamelError::ProcessorErrorWithSource. The component has no Consumer. It rejects create_consumer with CamelError::InvalidUri.
The producer logs provider errors at warn!. The route ErrorHandler owns the error! level. Stream finish and tool dispatch log at info!. Cache hit and miss log at debug!.
Reference: LLM crate CONTEXT, ADR-0020, ADR-0021. Example source: examples/llm-example.
WASM
The WASM component runs operator-installed WebAssembly plugins in a Wasmtime sandbox. One crate covers the plugin, bean, authorization-policy, and source worlds. Each world receives a different capability grant. The sandbox limits damage from guest defects. The sandbox is not a security boundary for intentionally malicious plugins.
The wasm-example wires a timer-driven producer that calls an echo plugin, then logs the result:
let route = RouteBuilder::from("timer:tick?period=1000&repeatCount=3")
.route_id("wasm-example")
.to("wasm:echo.wasm?timeout=5&max-memory=10485760")
.to("log:info")
.build()?;
YAML equivalent
routes:
- id: wasm-example
from: "timer:tick?period=1000&repeatCount=3"
steps:
- to: "wasm:echo.wasm?timeout=5&max-memory=10485760"
- to: "log:info"
The Rust example resolves echo.wasm from the example fixtures/ directory. The YAML form takes the plugin path from the URI. Substitute your own base directory at component registration. The same wasm: scheme serves a source Endpoint that runs an inbound guest loop driven by an http-listener capability. The examples/wasm-source-webhook/ example shows that direction.
URI
wasm:<path/to/module.wasm>[?<param>=<value>...]
The path must be relative. Absolute paths and .. segments are rejected. The path resolves against the base directory passed to WasmComponent::new.
| Parameter | Type | Default | Description |
|---|---|---|---|
timeout | seconds | 30 | Per-call wall-clock deadline. Enforced by epoch interruption. |
max-memory | bytes | 52428800 | Maximum linear memory the guest can allocate. |
max-concurrent-calls | integer | 4 | Maximum concurrent call_process executions per producer. |
max-wasm-size | bytes | 10485760 | Reject modules larger than this at load time. |
allow-call | string | empty | Comma-separated URI schemes the guest may call via camel_call or camel_poll. |
max-stream-bytes | bytes | materializer default | Per-stream byte cap for the streaming body bridge. |
max-instances | integer | 10000 | Maximum core instances per store. |
max-tables | integer | 10000 | Maximum tables per store. |
max-table-elements | integer | unlimited | Maximum table elements when set. |
bind | address | 0.0.0.0:8080 | Source world only. Bind address for the granted HTTP listener. |
path | string | all paths | Source world only. URL path filter the listener accepts. |
Zero or invalid values fall back to the runtime default. The component does not hide defaults. ADR-0011 names the rule.
Worlds
The component ships four WIT worlds. Each world grants a different capability set.
| World | Host calls | Capability source | Camel host functions |
|---|---|---|---|
plugin | process() per Exchange | from_scheme_list() | camel_call, camel_poll, host_store, host_load, get_property, set_property |
bean | invoke() for a chosen method | from_scheme_list() | Same as plugin |
authorization-policy | evaluate(exchange) before pipeline | denied() | get_property, set_property only |
source | guest owns run(listener) loop | none (host grants http-listener) | none |
The authorization-policy world also backs the SecurityPolicy host type. The capability grant stays denied() for both roles.
The source world grants no Camel host functions. The host binds the TCP listener and hands the guest an http-listener resource handle. The guest then drives accept-http and submit-exchange on its own loop under Store::run_concurrent. ADR-0031 defines the source lifecycle.
Capability model
ADR-0050 defines the target capability posture for the WASI surface. The current linker registers the full WASI 0.2 surface. The runtime context denies filesystem preopens, environment variables, sockets, and IP-name lookup. Clocks and random remain usable. Processor, bean, and policy guests inherit host stderr. Source guests do not.
ADR-0050 selects per-world selective WASI registration as the target. The migration is in progress. The runtime grants today match the target grants. The linker surface is broader until the migration lands. A Wasmtime upgrade or a WasiCtxBuilder change must not widen capabilities by accident (ADR-0050).
The Camel host function grants depend on the world. The WasmCapabilities struct carries two fields.
| Field | Meaning |
|---|---|
call_schemes | URI schemes the guest may call. Empty set denies all schemes. |
host_kv | Whether host_store and host_load are available. |
Authorization and security policy guests use WasmCapabilities::denied(). Processor and bean guests use WasmCapabilities::from_scheme_list(schemes). from_scheme_list sets host_kv to true. Policy guests do not get host storage.
The allow-call URI parameter and the allow-call-schemes Camel.toml field both flow into call_schemes. An empty list denies every scheme. The component fails closed (ADR-0033).
Configuration via Camel.toml
Processor plugins read limits from the URI query string. Bean, authorization-policy, and security-policy plugins read limits from a [limits] block in Camel.toml. The block type is WasmLimitsConfig. ADR-0014 unifies these knobs across plugin kinds.
[default.beans.my-bean]
plugin = "my-bean"
[default.beans.my-bean.limits]
timeout-secs = 600
max-memory = 4294967296
max-concurrent-calls = 1
[security.permissions.providers.my-policy]
provider = "wasm"
path = "plugins/authz.wasm"
[security.permissions.providers.my-policy.limits]
timeout-secs = 5
max-memory = 10485760
| Field | Default | Description |
|---|---|---|
timeout-secs | 30 | Per-call wall-clock deadline. |
max-memory | 52428800 | Maximum linear memory. |
max-concurrent-calls | 4 | Maximum concurrent calls. |
max-wasm-size | 10485760 | Maximum module size. |
allow-call-schemes | empty | Comma-separated schemes for camel_call. |
max-stream-bytes | materializer default | Per-stream byte cap. |
max-instances | 10000 | Maximum core instances per store. |
max-tables | 10000 | Maximum tables per store. |
max-table-elements | unlimited | Maximum table elements. |
All fields are optional. None means use the runtime default. WasmConfig::from_limits is the single source of truth for defaults. No silent fallback lie exists elsewhere (ADR-0011).
Security
The component trusts the plugin the operator installs. The sandbox limits damage from guest defects. The sandbox is not a security boundary for intentionally malicious plugins. ADR-0032 classifies Exchange data as untrusted. The sandbox contains the guest while it processes that data.
Path validation rejects absolute paths. It rejects .. segments. The canonical path must start with the base directory. The DepthGuard prevents camel_call from re-entering the same Store. The guard releases the recursion count on return, error, or cancellation.
init_config sends trusted operator configuration to the guest. Operators must not place secrets there unless the guest needs them. Debug output for init_config or StateStore must not expose secret values.
A known gap remains: set_property and host-side StateStore allocations do not have independent size limits. Wasmtime store limits do not account for these host allocations. The finding is F-camel-component-wasm-I4.
Error handling
WASM transform execution inside the pipeline logs at warn!. The route ErrorHandler owns the error. The producer downgrades the level to warn! with the // log-policy: handler-owned marker. ADR-0012 classifies this as category (a).
| Variant | When raised |
|---|---|
WasmError::Timeout | Epoch deadline exceeded. |
WasmError::OutOfMemory | Guest exceeded memory limit. |
WasmError::Trap | Guest hit unreachable, stack overflow, or other trap. |
WasmError::GuestPanic | Guest panicked with a message. |
WasmError::Unhealthy | Plugin failed health check. |
After a Timeout, Trap, or OutOfMemory, the plugin runtime resets on the next call. The route does not need manual intervention.
Reference: WASM crate CONTEXT — ADR-0050: WASM sandbox capability posture. Example source: examples/wasm-example.
Cron
The cron component fires Exchanges on a Unix 5-field cron schedule. Use it for source routes that run at calendar times, not fixed intervals. The component mirrors the timer structure (Component to Endpoint to Consumer) but delegates scheduling to a pluggable CronService.
The cron-example fires a route every minute:
let route = RouteBuilder::from("cron:tick?schedule=*+*+*+*+*")
.route_id("cron-demo")
.set_body("cron-fired")
.set_header("source", Value::String("cron".into()))
.to("log:cron-result?level=info&showBody=true&showHeaders=true")
.build()?;
YAML equivalent
routes:
- id: cron-demo
from: "cron:tick?schedule=*+*+*+*+*"
steps:
- set_body: "cron-fired"
- set_header:
key: source
value: "cron"
- to: "log:cron-result?level=info&showBody=true&showHeaders=true"
URI
cron:<name>?schedule=<5-field-expr>[&timeZone=<IANA>&includeMetadata=true]
| Parameter | Required | Default | Description |
|---|---|---|---|
schedule | yes | — | Unix 5-field cron expression. Use + as the space separator |
timeZone | no | system | IANA timezone identifier (e.g. America/New_York) |
includeMetadata | no | false | Attach CronFire metadata to each Exchange |
Schedule format
The schedule is a standard Unix 5-field cron expression:
minute hour day-of-month month day-of-week
The URI uses + as the space separator (Apache Camel convention). The expression *+*+*+*+* fires every minute. Each field supports the standard operators: *, ranges (1-5), lists (1,3,5), and step values (*/2).
Consumer
cron:tick?schedule=*+*+*+*+* fires an Exchange on the schedule. The Consumer is a pure source. It reads from no external system. The first fire happens at the next matching schedule time, not at startup.
The Consumer delegates scheduling to Arc<dyn CronService>. The default implementation is TokioCronService. The service picks the fire time. The CronConsumer callback submits the Exchange.
Misfire behavior
A missed fire is not replayed. When the process is down at a scheduled fire time, the Consumer recomputes the next fire from the current time. This behavior is misfire-skip. Automatic catch-up of batch jobs is dangerous, so the component skips missed fires instead.
Metadata
Set includeMetadata=true to attach CronFire metadata to each Exchange:
scheduled_at. The time the fire was scheduled.fired_at. The actual time the Consumer submitted the Exchange. This differs fromscheduled_atunder load.counter. The number of fires since the Consumer started.
Error propagation
The CronCallback is an async, fallible closure. On Err, the error propagates to Route supervision (ADR-0007). The Route records the failure and, when a supervision policy is configured, restarts the Route.
Reference: cron crate CONTEXT. Example source: examples/cron-example.
Direct
The Direct component routes an Exchange between two routes in the same CamelContext over an in-memory channel. The Producer blocks until the Consumer's Pipeline finishes. No serialization, no network. The transformed Exchange returns to the caller.
The multi-route-direct example wires a timer-driven producer and a transform consumer:
use camel_api::body::Body;
use camel_api::Value;
use camel_builder::RouteBuilder;
use camel_component_direct::DirectComponent;
use camel_component_log::LogComponent;
use camel_component_timer::TimerComponent;
use camel_core::context::CamelContext;
#[tokio::main]
async fn main() -> Result<(), camel_api::CamelError> {
let mut ctx = CamelContext::builder().build().await.unwrap();
ctx.register_component(TimerComponent::new());
ctx.register_component(LogComponent::new());
ctx.register_component(DirectComponent::new());
// Route A: timer -> direct:pipeline
let route_a = RouteBuilder::from("timer:tick?period=1000")
.route_id("route-a")
.set_header("source", Value::String("timer".into()))
.to("direct:pipeline")
.build()?;
// Route B: direct:pipeline -> uppercase -> log
let route_b = RouteBuilder::from("direct:pipeline")
.route_id("route-b")
.map_body(|body: Body| {
if let Some(text) = body.as_text() {
Body::Text(text.to_uppercase())
} else {
body
}
})
.to("log:output?showBody=true")
.build()?;
ctx.add_route_definition(route_a).await?;
ctx.add_route_definition(route_b).await?;
ctx.start().await?;
Ok(())
}
YAML equivalent
routes:
- id: route-a
from: "timer:tick?period=1000"
steps:
- set_header:
key: "source"
value: "timer"
- to: "direct:pipeline"
- id: route-b
from: "direct:pipeline"
steps:
- to: "log:output?showBody=true"
Both routes use the same direct:pipeline name. The endpoint name must match on the producer and consumer sides.
URI
direct:<name>[?timeout_ms=30000][&failIfNoConsumers=true]
| Parameter | Required | Default | Description |
|---|---|---|---|
timeout_ms | no | 30000 | Producer call() timeout in milliseconds |
failIfNoConsumers | no | true | Reject the call when no Consumer is registered for the name |
block | no | true | Reserved for non-blocking send (TODO(DIR-001)) |
exchangePattern | no | (none) | Reserved for pattern override (TODO(DIR-005)) |
The endpoint name must not be empty and must not contain whitespace. The Component rejects both at Endpoint creation.
Consumer
from: "direct:name" registers a DirectConsumer in the shared registry. The Consumer starts a background loop that receives Exchanges from the in-memory channel. It submits each Exchange to the Route's Pipeline through send_and_wait. The reply carries the transformed Exchange or the failure.
One Consumer per name. A second Consumer on the same name returns CamelError::EndpointCreationFailed because the registry already holds an open channel. Routes that want many workers must use a different name, or pick a Component that supports fanout (SEDA with multipleConsumers=true).
The Consumer removes its registry entry on stop() or when the cancellation token fires. The next Producer call fails with EndpointCreationFailed until a new route registers.
Producer
to: "direct:name" builds a DirectProducer. The Producer holds one in-flight call at a time through a bounded semaphore. poll_ready checks the registry and acquires a permit. call hands the Exchange to the Consumer's channel and awaits the reply.
failIfNoConsumers=true (default) rejects the call when no Consumer is registered. Set it to false to let the Producer race against late registration. The Producer still waits for the Consumer to receive the Exchange, so false does not give a fire-and-forget guarantee. Use SEDA for that.
The default timeout_ms is 30 000. A timeout returns CamelError::ProcessorError with a timed out message. The error propagates through the route's error handler.
Request-Reply
Direct is the natural request-reply Component for in-process calls. The Producer blocks until the Consumer's Pipeline finishes. Steps that follow to: "direct:name" see the transformed Exchange. Steps that set the body inside the Consumer's Route reach the caller. A timer that sends to a Direct endpoint and logs the result is a synchronous in-process function call.
Error handling
The DirectConsumer reports unhandled pipeline failures through the b-prime:direct:send-and-wait metric and logs at error! (ADR-0012 category b'). Producer send failures (no Consumer, channel closed, reply dropped) are category (a) handler-owned. The Producer logs them at warn!. The route's error handler owns the operational signal.
Reference: Direct crate CONTEXT. Example source: examples/multi-route-direct.
SEDA
The SEDA (Staged Event-Driven Architecture) component stages exchanges in memory between routes that share one CamelContext. A producer sends to seda:name and returns. A consumer on the same name pulls from a bounded queue and processes asynchronously.
seda: is the asynchronous counterpart to direct:. Reach for it to decouple route lifetimes, smooth traffic bursts, or fan out to multiple subscribers.
The seda-demo wires a timer-driven producer against an asynchronous consumer that uppercases the body:
use camel_api::body::Body;
use camel_builder::RouteBuilder;
use camel_component_log::LogComponent;
use camel_component_seda::SedaComponent;
use camel_component_timer::TimerComponent;
use camel_core::context::CamelContext;
let mut ctx = CamelContext::builder().build().await.unwrap(); // allow-unwrap
ctx.register_component(TimerComponent::new());
ctx.register_component(LogComponent::new());
ctx.register_component(SedaComponent::new());
let route_a = RouteBuilder::from("timer:tick?period=1000&repeatCount=5")
.route_id("producer-route")
.to("seda:processing")
.build()?;
let route_b = RouteBuilder::from("seda:processing?concurrentConsumers=2")
.route_id("consumer-route")
.map_body(|body: Body| {
if let Some(text) = body.as_text() {
Body::Text(text.to_uppercase())
} else {
body
}
})
.to("log:output?showBody=true&showHeaders=true")
.build()?;
ctx.add_route_definition(route_a).await?;
ctx.add_route_definition(route_b).await?;
ctx.start().await?;
YAML equivalent
routes:
- id: producer-route
from: "timer:tick?period=1000&repeatCount=5"
steps:
- to: "seda:processing"
- id: consumer-route
from: "seda:processing?concurrentConsumers=2"
steps:
- to: "log:output?showBody=true&showHeaders=true"
Both interfaces compile to the same RouteDefinition. The example source is at examples/seda-demo.
URI
seda:<name>[?size=<n>][&concurrentConsumers=<n>][&multipleConsumers=<bool>][&blockWhenFull=<bool>][&discardIfNoConsumers=<bool>][&timeout=<ms>][&waitForTaskToComplete=<mode>][&exchangePattern=<pattern>]
| Parameter | Default | Description |
|---|---|---|
size | 1000 | Bounded queue capacity. Must be greater than 0 |
concurrentConsumers | 1 | Concurrency hint. Clamped to 1 minimum. 0 becomes 1 with a warning |
multipleConsumers | false | Fanout mode. One queue per subscriber. All-or-nothing delivery |
blockWhenFull | false | Block the producer up to timeout when the queue is full. Default fails fast |
discardIfNoConsumers | false | Drop silently when no consumer is active. Default returns an error |
timeout | 30000 | Timeout in milliseconds for enqueue and reply wait |
waitForTaskToComplete | IfReplyExpected | Never, IfReplyExpected, or Always |
exchangePattern | InOnly | InOnly (fire-and-forget) or InOut (request-reply) |
Endpoints that share a name must agree on size, multipleConsumers, exchangePattern, and concurrentConsumers. The component rejects mismatched shared options with an EndpointCreationFailed error.
Consumer
seda:processing?concurrentConsumers=2 registers a consumer that pulls from the endpoint's bounded queue. The Runtime starts one queue forwarder task per consumer. That forwarder awaits send_and_wait for InOut and waitForTaskToComplete=Always exchanges, so those exchanges remain serial even when concurrentConsumers is greater than 1. InOnly exchanges without a reply channel do not block the forwarder.
concurrentConsumers is reported to the Runtime through ConcurrencyModel::Concurrent. Finding I1 and bd issue rc-exa2 track the limitation that blocks true concurrent InOut processing.
The consumer transfers the primary forwarder handle to the Runtime through background_task_handle(). On shutdown, the Runtime aborts that handle, then calls stop(). stop() cancels the private token, aborts retained forwarders, and clears the active consumer registration.
Producer
seda:processing creates a producer that enqueues the exchange. The producer returns immediately for fire-and-forget patterns. Behavior depends on waitForTaskToComplete:
Neverreturns after enqueue. No reply channel is attached.IfReplyExpectedwaits only whenexchangePattern=InOut.Alwayswaits regardless of the exchange pattern.
When the producer waits, the forwarder on the consumer side uses send_and_wait to route the pipeline result back through a oneshot channel. A timeout returns EndpointCreationFailed. A closed channel returns ChannelClosed.
A full queue returns EndpointCreationFailed with the queue name and size. blockWhenFull=true makes the producer wait up to timeout for capacity. The route ErrorHandler owns both signals per ADR-0019.
Modes
SedaMode::Single owns one queue and permits one active consumer. A second registration on the same name returns an EndpointCreationFailed error.
SedaMode::Fanout, enabled by multipleConsumers=true, owns one queue per subscriber. A fanout producer reserves capacity for all subscribers before it sends, so delivery is all-or-nothing for the active subscriber set. Fanout rejects reply-waiting modes because one request has no single valid reply. The combination multipleConsumers=true with waitForTaskToComplete=Never is the only legal configuration.
SEDA vs Direct
direct: | seda: | |
|---|---|---|
| Synchrony | Synchronous. Producer blocks until consumer finishes | Asynchronous. Producer returns after enqueue |
| Queue | None | Bounded (size) |
| Multiple consumers | No | Yes with multipleConsumers=true |
| Reply semantics | Reply is the consumer's pipeline result | Reply is optional. Controlled by waitForTaskToComplete |
| Failure propagation | Synchronous to the producer | Surfaces as EndpointCreationFailed or ChannelClosed after enqueue |
| Use case | Modular route linking with strict ordering | Decoupling, burst buffering, fanout, staging |
Reach for direct: when two routes must share a call stack and ordering is strict. Reach for seda: when you need to decouple producer and consumer lifetimes, smooth bursts, or broadcast to multiple subscribers.
Error handling
A producer that targets an endpoint with no active consumer returns EndpointCreationFailed with the message SEDA endpoint '<name>' has no active consumers. Set discardIfNoConsumers=true to drop silently in this case.
A full queue with blockWhenFull=false returns EndpointCreationFailed with the queue name and configured size. Set blockWhenFull=true to wait up to timeout.
Route stop does not drain an in-flight reply. An interrupted InOut producer can receive CamelError::ChannelClosed. This is the current best-effort contract for in-memory staging. It is not an ADR-0004 hot-reload pipeline swap.
The public ExchangePattern and WaitForTaskToComplete enums are closed URI option sets. They stay exhaustive. ADR-0049 does not bind this component crate.
Reference: SEDA crate CONTEXT. Example source: examples/seda-demo.
ControlBus
The ControlBus component sends Route lifecycle commands through the RuntimeBus. It is a Producer-only Endpoint. It does not consume. It exposes no network API.
The controlbus-example uses a timer to suspend and resume a target route:
let suspend_route = RouteBuilder::from("timer:suspend?delay=5000&repeatCount=1")
.route_id("suspend-controller")
.process(|exchange| async move {
println!("[CONTROL] Suspending target-route...");
Ok(exchange)
})
.to("controlbus:route?routeId=target-route&action=suspend&authorizedRoutes=target-route")
.to("log:control?showBody=true")
.build()?;
YAML equivalent
routes:
- id: target-route
from: "timer:target?period=500"
steps:
- to: "log:target?showBody=true"
- id: suspend-controller
from: "timer:suspend?delay=5000&repeatCount=1"
steps:
- to: "controlbus:route?routeId=target-route&action=suspend&authorizedRoutes=target-route"
- to: "log:control?showBody=true"
The ControlBus URI declares the target routeId and the authorizedRoutes allowlist at config time. Exchange headers cannot set the target.
URI
controlbus:route?routeId=<id>&action=<action>&authorizedRoutes=<csv>
| Parameter | Required | Description |
|---|---|---|
routeId | yes | Target Route ID. Must differ from the calling Route |
action | yes | Lifecycle command. One of start, stop, suspend, resume, restart, or status |
authorizedRoutes | yes | Comma-separated allowlist. Endpoint fails closed when absent |
Actions
| Action | Runtime command | Response body |
|---|---|---|
start | StartRoute | empty |
stop | StopRoute | empty |
suspend | SuspendRoute | empty |
resume | ResumeRoute | empty |
restart | ReloadRoute | empty |
status | GetRouteStatus | Body::Text with the lifecycle status string |
restart performs an atomic Pipeline swap without drain semantics (ADR-0004). Suspend and resume support varies by component. status returns only the lifecycle string, not Route statistics.
Authorization
The Producer enforces three gates on every call (ADR-0034):
- The URI declares the target
routeId. authorizedRoutesexists and contains that target.- The target differs from the calling Route ID.
The CamelRouteId Exchange header cannot select or override the target. Exchange data is untrusted (ADR-0032). Only operator configuration drives the control plane. Authorization failures return CamelError::Unauthorized.
Errors
| Failure | Result |
|---|---|
| Missing or unauthorized target | CamelError::Unauthorized |
| Unknown action or unexpected status response | CamelError::ProcessorError |
| RuntimeHandle error | passes through unchanged |
The component declares no public enums. camel-api provides RouteAction, RuntimeCommand, and CamelError. Future variants use fallback match arms (ADR-0049).
Reference: ControlBus crate CONTEXT. Example source: examples/controlbus.
Master
The Master component runs a delegate Consumer only on the node that holds a leadership lock. Other nodes join the same lock name and stand by until they win. ADR-0035 establishes the leader-epoch fencing token that the bridge stamps on every emitted envelope.
let route_1 = RouteBuilder::from("master:mylock:timer:tick?period=1000")
.route_id("master-route")
.to("log:info")
.build()?;
let route_2 = RouteBuilder::from("timer:status?period=5000")
.route_id("status-route")
.to("controlbus:route?routeId=master-route&action=status")
.to("log:info")
.build()?;
ctx.add_route_definition(route_1).await?;
ctx.add_route_definition(route_2).await?;
The example drives a timer:tick source through a master:mylock lock. Only the elected leader consumes ticks. A second route uses ControlBus to poll the leader's status every five seconds.
YAML equivalent
routes:
- id: master-route
from: "master:mylock:timer:tick?period=1000"
steps:
- log: "DEBUG: tick"
The route definition runs on every node. Only the leader fires the log step. The YAML example in examples/master-leader-yaml registers the same lock from two route IDs to demonstrate the same behavior from config.
URI
master:<lockname>:<delegate-uri>
| Segment | Required | Description |
|---|---|---|
lockname | yes | Leadership lock name. Nodes with the same lock name compete for one leader. |
delegate-uri | yes | Full URI for any consumer Component (timer:, kafka:, http:, etc.). The Master wraps the Consumer this URI creates. |
The Master has no URI query parameters of its own. Query parameters belong to the delegate Component.
Consumer
master:<lockname>:<delegate-uri> gates a delegate Consumer on leadership. When the route starts, the node joins leader election under the lock name. The delegate Consumer does not start until the node wins. On leadership loss, the delegate stops and drains within drain_timeout_ms. If the node wins again, the delegate restarts with the same delegate URI.
Each ExchangeEnvelope the delegate emits carries an x-camel-leader-epoch Exchange property. The Master stamps the property with a monotonic fencing token at bridge start. A stale bridge retains the epoch from its own spawn, not the live epoch. Downstream sinks that need split-brain safety compare the property against the current leader's epoch and reject older envelopes.
Producer
to("master:mylock:http://api.example.com") passes through to the delegate Producer without leader gating. The Master's job is to gate Consumers, not Producers. A write that needs exclusive access should serialize through a queue that the leader Consumer drains.
Leadership backends
The leader election backend comes from the configured PlatformService. The default NoopPlatformService always elects the local node, so a single-node deployment works without external infrastructure. A Kubernetes deployment uses KubernetesPlatformService and Lease objects for distributed election across pods.
Every acquired term increments the leader epoch. The bridge stamps the new epoch on each envelope. A node that loses leadership stops the delegate within drain_timeout_ms and steps down. The route stays alive; only delegate intake pauses until the node wins again.
Configuration
The Master reads from [components.master] in Camel.toml:
| Key | Default | Description |
|---|---|---|
drain_timeout_ms | 5000 | Max time to wait for the delegate Consumer to shut down on leadership loss |
delegate_retry_max_attempts | unlimited | Backward-compat alias for reconnect.max_attempts. 0 means unlimited |
reconnect.max_attempts | 0 | Bounded retry attempts on delegate start failure. 0 means unlimited |
reconnect.enabled | true | Enable bounded reconnect retries on delegate start failure |
When both reconnect and delegate_retry_max_attempts are set, the explicit reconnect value wins. The delegate_retry_max_attempts field stays for backward compatibility with earlier configs.
Reference: Master component CONTEXT. Example source: examples/master-leader and examples/master-leader-yaml.
Template
The Template component renders MiniJinja templates loaded from the filesystem against the body, headers, and properties of each inbound exchange. It is producer-only — you place it on the to: side of a route to transform the exchange body into rendered output.
The template-basic example wires a timer-driven source through a header set and the template Producer:
// Route: timer → set body/headers → render template → log result
let route = RouteBuilder::from("timer:tick?period=2000")
.route_id("template-demo")
.set_body("World")
.set_header("title", Value::String("Template Demo".into()))
.to(&template_uri)
.log("Rendered template", LogLevel::Info)
.build()?;
YAML equivalent
routes:
- id: template-demo
from: "timer:tick?period=2000"
steps:
- set_body: "World"
- set_header:
title: "Template Demo"
- to: "template:file:///srv/templates/page.html.tmpl"
- log: "Rendered template"
The Rust example reads the template path from env!("CARGO_MANIFEST_DIR") so it builds a runnable path against the example's own templates/ directory. Substitute your real absolute path in production URIs.
URI
template:file:///<absolute-path-to-template>
The URI has two parts. The outer scheme is template. The inner scheme is file, followed by an empty authority and an absolute path. Bare paths (template:/srv/t/page.html) and non-file inner schemes are rejected at Endpoint construction. The path must be absolute and free of .. segments.
The component is zero-override. The URI is operator-configured at route construction. No Exchange header or property can replace the entry, the root, or the template source. The template:file:/// form is the only accepted shape; there is no template:http:// or header-driven loader.
Render contract
The Producer replaces exchange.input.body with the rendered output as Body::Text. Headers and properties are preserved unchanged. On any render failure — strict-undefined variable, output-size overflow, fuel exhaustion, recursion limit, or execution timeout — the body is left byte-identical to the inbound value. The route ErrorHandler then owns the operational signal.
The rendering context exposes three top-level keys: body, headers, and exchangeProperty. Body::Text and Body::Bytes (lossy UTF-8) are accepted. Body::Json exposes its fields as {{ body.field }}. Body::Stream is rejected with a guidance error pointing to stream_cache upstream. Body::Empty renders to an empty string without tripping strict-undefined.
Every template must declare a top-level {% autoescape %} block selecting html, json, or none. The component enforces the explicit declaration at compile time. There is no global default; per-render output context is an operator decision, not a framework assumption.
Limits
The bundle owns two independent limit layers under [components.template]. Both default when absent. A zero value is rejected at startup; a limit cannot be silently disabled.
[components.template.limits]
max-total-source-bytes = 16777216
max-include-count = 64
max-include-depth = 16
max-template-size = 1048572
reload-timeout-ms = 5000
[components.template.render-limits]
max-context-size = 65536
max-output-size = 1048576
fuel = 100000
| Layer | Field | Default | Bounds |
|---|---|---|---|
| acquisition | max-total-source-bytes | 16 MiB | total source bytes across the dependency closure |
| acquisition | max-include-count | 64 | included or imported templates per closure |
| acquisition | max-include-depth | 16 | nested include/extends depth |
| acquisition | max-template-size | 1 MiB | single template file in bytes |
| acquisition | reload-timeout-ms | 5000 | wall-clock budget for a full reload build |
| render | max-context-size | 64 KiB | serialized context bytes per render |
| render | max-output-size | 1 MiB | rendered output bytes per render |
| render | fuel | 100000 | per-render instruction accounting |
The two layers are checked at different times. Acquisition limits apply while building the compiled template set. Render limits apply per Exchange on the hot path. Exhausting any limit fails the operation; no limit truncates and reports success.
Lifecycle
Templates are compiled once at route startup (fail-closed). A missing file, a syntax error, or an acquisition-limit overflow prevents the route from starting. The compiled set is cached for zero-filesystem-I/O hot-path rendering. A control-plane ReloadTemplates command re-acquires the dependency closure, recompiles, and atomically swaps the compiled set without disturbing in-flight renders. A failed reload preserves the prior set.
File reads go through openat-relative handles. .. segments, symlinks, absolute path escapes, and include cycles are rejected at acquisition time. The MiniJinja environment registers no functions, filters, or globals — an unknown function call fails at render, not at the host boundary.
When to use it
Use the Template component when the rendered output needs blocks, loops, filters, macros, or context-aware escaping. Use the inline minijinja language under set_body: { language: minijinja, source: ... } for one-shot substitution where the template fits in a route definition. Use a different component when the transformation is XML, JSON bridging, or parameter-bound SQL.
The atomic-write contract and the accepted fileExist values live in the camel-template CONTEXT. The architectural rationale is in ADR-0047. Example source: examples/template-basic.
Validator
The Validator component validates message bodies against XSD, JSON Schema, and YAML Schema files. It runs in routes as a to: Producer. Validation failure returns a CamelError to the route's error handler.
The validator-example wires timer-driven producers against local schemas for each format:
let route_xsd = RouteBuilder::from("timer:xsd-valid?period=3000&repeatCount=2")
.route_id("xsd-valid")
.set_body("<order><id>A1</id><amount>5</amount></order>")
.log("Route 1: Validating XML order against XSD", LogLevel::Info)
.validate(&xsd)
.log("Route 1: XML is valid!", LogLevel::Info)
.to("log:info?showBody=true")
.build()?;
YAML equivalent
routes:
- id: xsd-valid
from: "timer:xsd-valid?period=3000&repeatCount=2"
steps:
- set_body: "<order><id>A1</id><amount>5</amount></order>"
- log: "Validating XML order against XSD"
- to: "validator:schemas/order.xsd"
- log: "XML is valid"
- to: "log:info?showBody=true"
The Rust example resolves the schema path with CARGO_MANIFEST_DIR. Substitute your own path in the URI.
URI
validator:<schema-path>[?type=xml|json|yaml&failOnNullBody=true|false&headerName=<name>&failOnNullHeader=true|false&maxPayloadBytes=<n>&schemaCacheMaxEntries=<n>]
| Option | Default | Description |
|---|---|---|
type | from extension | Schema type: xml, json, or yaml. rng and schematron are parsed but rejected at endpoint creation. |
failOnNullBody | true | Reject exchanges with empty bodies when true |
headerName | none | Validate this header's value instead of the body |
failOnNullHeader | true | Reject exchanges where the named header is missing |
maxPayloadBytes | none | Reject bodies larger than this many bytes before validation |
schemaCacheMaxEntries | 256 | Maximum XSD schema entries the bridge cache holds before eviction |
The schema type defaults to the file extension (.xsd, .json, .yaml, .yml). Pass type=xml to override the extension when the path is ambiguous. Paths accept percent-encoded characters.
Behavior
JSON and YAML schemas compile when the endpoint is created. A malformed schema fails endpoint creation. The compiled validator stays cached for the lifetime of the endpoint. XSD schemas defer registration to the first validation call. This lets the bridge start in an async context without blocking endpoint creation.
XSD validation delegates to the xml-bridge gRPC backend. The bridge starts as a child process on the first XSD validation. The XsdBridgeBackend caches registered schemas up to schemaCacheMaxEntries and re-seeds them on reconnect. JSON and YAML validation never start the bridge.
Validation failure returns a CamelError to the route's error handler. Empty bodies, when failOnNullBody=true (default), also return an error. Pass failOnNullBody=false to let empty bodies flow through without validation. The same toggle exists for headerName mode via failOnNullHeader. The validator endpoint supports Producers only. Consumer creation returns an error.
Bridge lifecycle
CamelContext::stop() does not clean up the xml-bridge child process. The camel run CLI registers a BridgeCleanup lifecycle service that calls XsdBridgeBackend::shutdown() on stop. Library embedders must retain the value from ValidatorComponent::xsd_bridge_backend() and call shutdown().await when it is Some. This requirement only applies after an XSD route starts the bridge. JSON and YAML routes never start it.
Reference: Validator crate CONTEXT. Example source: examples/validator.
Exec
The exec component runs external system processes from a route. It is producer-only. Each route binds to a named Profile. A Profile is a pre-configured capability bundle: executable, argument policy, environment, working directory, and caps. It is pinned at startup. The component runs with execvp semantics, not a shell. ADR-0037 defines the fail-closed capability model.
The exec-example shows two profiles wired against a timer source:
use camel_api::CamelError;
use camel_builder::{RouteBuilder, StepAccumulator};
use camel_component_api::ComponentBundle;
use camel_component_exec::ExecBundle;
use camel_component_log::LogComponent;
use camel_component_timer::TimerComponent;
use camel_core::context::CamelContext;
fn register_exec_bundle(ctx: &mut CamelContext) {
let toml_str = r#"
workspace_root = "."
[[profiles]]
name = "echo"
executable = "echo"
args = { allow = "any" }
timeout_secs = 10
working_dir = "."
accepted_exit_codes = [0]
[[profiles]]
name = "date"
executable = "date"
timeout_secs = 5
working_dir = "."
accepted_exit_codes = [0]
"#;
let value: toml::Value = toml::from_str(toml_str).expect("parse toml");
let bundle = ExecBundle::from_toml(value).expect("bundle");
bundle.register_all(ctx);
}
#[tokio::main]
async fn main() -> Result<(), CamelError> {
let mut ctx = CamelContext::builder().build().await?;
register_exec_bundle(&mut ctx);
ctx.register_component(TimerComponent::new());
ctx.register_component(LogComponent::new());
let route = RouteBuilder::from("timer:tick?period=500&repeatCount=1")
.route_id("exec-echo")
.set_header(
camel_component_exec::headers::CAMEL_EXEC_ARGS,
serde_json::json!(["-n", "Hello", "World"]),
)
.to("exec:echo")
.to("log:info?showBody=true&showHeaders=true")
.build()?;
ctx.add_route_definition(route).await?;
ctx.start().await?;
Ok(())
}
YAML equivalent
# Camel.toml
[components.exec]
workspace_root = "."
[[components.exec.profiles]]
name = "echo"
executable = "echo"
args = { allow = "any" }
timeout_secs = 10
working_dir = "."
accepted_exit_codes = [0]
# Route
routes:
- id: exec-echo
from: "timer:tick?period=500&repeatCount=1"
steps:
- setHeader:
name: CamelExecArgs
value: ["-n", "Hello", "World"]
- to: "exec:echo"
- to: "log:info?showBody=true&showHeaders=true"
The full example, with a second date profile, lives in examples/exec-example.
URI
exec:{profile-name}
The path segment names the Profile to run. The component verifies the profile exists at endpoint creation time. A missing profile fails route startup, not the first invocation.
| Aspect | Behavior |
|---|---|
| Direction | producer only |
| Profile selection | endpoint URI, not headers or body |
| Shell | rejected unless allow_shell = true |
| Default args policy | deny all non-empty args |
| Default exit codes | [0] |
Configuration
Config lives under [components.exec] in Camel.toml. The ExecBundle deserializes it and calls ExecGlobalConfig::validate() at startup. Validation pins the canonical executable path, validates every working_dir against the canonical workspace root, and rejects duplicate profile names.
| Field | Default | Description |
|---|---|---|
workspace_root | "." | Base for working_dir confinement |
default_timeout_secs | 30 | Per-profile timeout when the profile omits one |
default_concurrency | 1 | Producer semaphore capacity per profile |
deny_env | (see below) | Glob patterns stripped from every child env, last and always winning |
Default deny_env patterns: LD_*, DYLD_*, PYTHONPATH, RUSTFLAGS, GIT_*, SSH_AUTH_SOCK, *_TOKEN, *_KEY. They block library-preload and secret-injection vectors. PATH is opt-in.
Each [[components.exec.profiles]] entry has:
| Field | Default | Description |
|---|---|---|
name | — | Referenced as exec:{name} |
executable | — | Binary name (PATH-resolved at startup) or absolute path |
args | exact with empty values | ArgPolicy mode: any, exact { values }, or prefix { values } |
deny_flags | [] | Prefix-matched denylist applied before allow. Always wins |
allow_shell | false | Permit shell binaries as executable |
env.allow | [] | Host env var names the child may inherit |
env.set | {} | Explicit KEY=VALUE pairs |
working_dir | "." | Must be relative to workspace_root and must exist |
timeout_secs | global default | Process timeout. Force-kills the process group on Unix |
accepted_exit_codes | [0] | Exit codes treated as success |
concurrency | global default | Per-profile semaphore capacity |
A profile with zero profiles fails startup with no profiles configured (fail-closed: refusing to execute anything). There is no default profile, no allow-all mode, and no shell convenience syntax.
Security model
ADR-0037 fixes eleven decisions. The full text is the authority. The ones that shape every route:
Profile-pinned, fail-closed. A capability lives in a profile declared at startup. Exchange data cannot select an executable, change a path, or modify a policy. The component refuses to start with zero profiles. There is no exec:shell?cmd=... shortcut.
No shell by default. Commands run as binary + literal argv. No string concatenation, no sh -c wrapper. The component rejects known shells (sh, bash, zsh, cmd.exe, pwsh, …) at runtime unless the profile sets allow_shell = true. Even with allow_shell, the binary is the shell itself, called with explicit argv, never a concatenated command string.
Canonical pin at startup. The producer resolves the executable once during validate(). At runtime it uses the pinned path, never a fresh PATH lookup. The pin is not symlink-resolved, because multi-call binaries (BusyBox, uutils) dispatch on argv[0]. Canonicalization would break them.
Per-element argument policy. Every element in CamelExecArgs runs through ArgPolicy. deny_flags is applied first with prefix match. An arg that matches both deny_flags and the allow mode is denied. The default policy is exact { values: [] }, which denies every non-empty arg. The route must opt in to any or specify values.
Empty environment by default. The child starts with no host env. Three layers compose: env.allow (copy from host), env.set (explicit pairs), then global deny_env (strip globs, last and always winning). Operators must allow PATH explicitly for PATH-dependent binaries.
Working-directory confinement. working_dir is validated at startup against the canonical workspace root. Absolute paths fail. Paths containing .. fail. Resolved paths that escape the root fail. The component does not create missing directories. The operator must pre-create them.
No dynamic override from exchange data. The profile is fixed by the endpoint URI. Conditional dispatch between profiles lives in route EIPs (choice, recipient_list), where the route author controls the branching, not the exchange payload. This is the lesson from ADR-0034 (ControlBus).
Argument policy modes
| Mode | What passes |
|---|---|
any | Every element. Explicit opt-in. Operator-curated args only |
exact { values = ["a", "b"] } | Element must string-equal one of values |
prefix { values = ["--"] } | Element must byte-start-with one of values |
| omitted | Deny all non-empty args (fail-closed default) |
Combine deny_flags = ["--upload-pack"] with args = { allow = "any" } to accept arbitrary args but block a known-dangerous flag. The denylist always wins.
Headers
Input and output headers travel on the Exchange.
| Header | Direction | Type | Description |
|---|---|---|---|
CamelExecArgs | input | JSON array of strings | Argument list passed to the binary |
CamelExecProfile | output | string | Effective profile name |
CamelExecExitCode | output | integer | Process exit code (omitted on timeout) |
CamelExecExitAccepted | output | bool | true if exit_code is in accepted_exit_codes |
CamelExecTimedOut | output | bool | true if the timeout fired |
CamelExecStderr | output | string | Lossy-UTF8 stderr, for route predicates |
CamelExecStdoutTruncated | output | bool | true if stdout exceeded stdout_max_bytes |
CamelExecStderrTruncated | output | bool | true if stderr exceeded stderr_max_bytes |
The body after a producer call is a JSON ExecResult:
{
"exit_code": 0,
"stdout": "aGVsbG8K",
"stderr": "",
"stdout_truncated": false,
"stderr_truncated": false,
"timed_out": false,
"profile": "echo",
"duration_ms": 12
}
stdout and stderr are base64 strings. Raw bytes would make pathological JSON. The CamelExecStderr header is lossy-UTF8 for use inside choice() and log: predicates. The dual representation is intentional.
Non-error outcomes
A timeout or an exit code outside accepted_exit_codes does not return Err. The producer returns Ok(exchange) with the ExecResult body and headers set. This is forced by the Service<Exchange> contract: the Tower trait discards the mutated exchange on Err, and these outcomes carry output the route should see.
Branch on outcome with CamelExecExitAccepted:
- to: "exec:build"
- choice:
when:
- predicate: "${header.CamelExecExitAccepted} == true"
steps:
- to: "log:info?showBody=true"
- predicate: "${header.CamelExecTimedOut} == true"
steps:
- to: "log:warn?showBody=true"
otherwise:
- to: "log:error?showBody=true"
Only pre- and during-spawn failures return Err: arg policy denial, shell rejection, workdir escape, stdin over the cap, and OS spawn errors. Those route to the route's ErrorHandler.
Timeouts and process-group kill
timeout_secs bounds the whole spawn-to-exit window. The Child handle is held outside the tokio::select! so the kill path can fire after the timeout. On Unix, the producer sends SIGKILL to the entire process group (libc::kill(-pgid, SIGKILL)). On Windows v1, the producer calls child.start_kill() on the immediate child. Process-group tree-kill via Job Objects is a post-v1 change. kill_on_drop(true) is set as defense in depth.
When the timeout fires, drain tasks for stdout and stderr keep running. After the kill, pipes close and the tasks finish with whatever bytes they captured. The ExecResult carries the partial output plus timed_out: true and exit_code: null.
Errors
Pre- and during-spawn failures surface as CamelError::ProcessorErrorWithSource(msg, Arc<ExecError>). ExecError is #[non_exhaustive] with variants NotAllowlisted, ArgPolicyDenied, ShellRejected, InvalidWorkDir, StdinTooLarge, InvalidArgs, and Spawn(#[from] std::io::Error).
Log levels: arg-policy denial, shell rejection, and timeout fire at warn!. A non-zero exit outside the accepted list logs at info! because the route is expected to branch on CamelExecExitAccepted. A non-zero exit inside the accepted list logs at debug!. A spawn failure logs at error! because no route handler is running.
Every execution emits an ExecAuditEvent. The event carries the profile name, resolved executable path, args, env keys, cwd, exit code, timeout flag, truncation flags, and duration.
Metrics
The producer emits monotonic counters and histograms through MetricsCollector::record_counter and record_histogram. The full set:
| Metric | Type | Labels | Fires when |
|---|---|---|---|
exec_policy_denials_total | counter | reason, route | Arg policy or shell rejection denies a call |
exec_timeouts_total | counter | route | Timeout kills the process |
exec_exit_code | counter | code, route | Process exits with a code |
exec_stdout_truncated_total | counter | (none) | Stdout exceeds the cap |
exec_duration_secs | histogram | route | Every call |
The default trait methods on MetricsCollector are no-ops. PrometheusMetrics and OtelMetrics do not yet override them, so these counters are silently dropped in production until a backend implements the trait methods.
Reference: camel-component-exec CONTEXT, ADR-0037: Exec Component Fail-Closed Capability Model. Example source: examples/exec-example.
Keycloak
The Keycloak component connects routes to a Keycloak realm. One crate covers admin API writes, event polling, token introspection, JWKS lookup, and UMA permission evaluation. The component is a security adapter, not a messaging adapter. It does not move messages between queues.
Consumer (Events)
keycloak:events?realm=...&eventType=events|admin-events polls the Keycloak events or admin-events endpoint. The consumer delivers one exchange per event. The body is the event JSON. The CamelKeycloak* headers carry indexed fields.
routes:
- id: user-events
from: "keycloak:events?realm=myrealm&eventType=events&pollDelay=10000"
steps:
- log: "user=${header.CamelKeycloakUserId} type=${header.CamelKeycloakEventType}"
routes:
- id: admin-events
from: "keycloak:admin-events?realm=myrealm&eventType=admin-events&operationTypes=CREATE,DELETE"
steps:
- to: "log:info"
Producer (Admin API)
keycloak:admin?operation=...&realm=...&userId=... sends a request to the Keycloak Admin REST API. The request body is the exchange input. The response body replaces the exchange input. A bearer token is fetched from the configured client credentials before each request.
routes:
- id: create-user
from: "timer:tick?period=60000"
steps:
- set-body: '{"username": "alice", "email": "alice@example.com", "enabled": true}'
- to: "keycloak:admin?operation=createUser&realm=myrealm"
routes:
- id: get-user
from: "timer:tick?period=60000"
steps:
- to: "keycloak:admin?operation=getUser&realm=myrealm&userId=${header.userId}"
The exchange property camel.keycloak.userId overrides the URI parameter when set. The component obtains a fresh bearer token from the configured client credentials before each request.
URI
keycloak:<kind>?<params>
kind | Description | Reference |
|---|---|---|
admin | Admin REST API Producer | operations table |
events | Events Consumer (user and admin events) | events table |
admin-events | Alias of events with eventType=admin-events preset | events table |
The component rejects any other path with unknown keycloak endpoint kind. The admin producer does not support consumers. The events consumer does not support producers.
Camel.toml configuration
Camel.toml configures the realm under [security.keycloak]. The component reads server_url, realm, client_id, and client_secret from this section. Sub-sections tune validation, JWKS caching, and introspection caching.
[security.keycloak]
server_url = "https://kc.example.com"
realm = "myrealm"
client_id = "my-service"
client_secret = "${KEYCLOAK_CLIENT_SECRET}"
[security.keycloak.validation]
method = "local"
audience = ["camel-api"]
clock_skew_secs = 30
[security.keycloak.jwks]
cache_ttl_secs = 3600
refresh_skew_secs = 60
[security.keycloak.introspection]
max_entries = 10000
default_ttl_secs = 60
negative_ttl_secs = 5
allow_internal = true opts into HTTP and loopback addresses for local development against a Keycloak instance bound to 127.0.0.1. Production must keep it false. The default blocks private IP ranges to prevent SSRF.
Producer (Admin API)
| Operation | HTTP | Requires userId | Path |
|---|---|---|---|
createUser | POST | no | /admin/realms/{realm}/users |
deleteUser | DELETE | yes | /admin/realms/{realm}/users/{userId} |
getUser | GET | yes | /admin/realms/{realm}/users/{userId} |
createRole | POST | no | /admin/realms/{realm}/roles |
assignRole | POST | yes | /admin/realms/{realm}/users/{userId}/role-mappings/realm |
createClient | POST | no | /admin/realms/{realm}/clients |
createRealm | POST | no | /admin/realms |
A non-2xx response returns Err. The pipeline catches it and the route ErrorHandler owns the operational signal. The Admin Producer reads the request body from Body::Text or Body::Json. The component serializes JSON bodies to the wire format. The component parses JSON response bodies into Body::Json. The component leaves non-JSON response bodies as Body::Text.
Consumer (Events)
| Parameter | Required | Default | Description |
|---|---|---|---|
realm | yes | — | Realm to poll |
eventType | yes | — | events or admin-events |
pollDelay | no | 5000 | Milliseconds between polls |
maxResults | no | 100 | Max events per poll |
lookbackWindow | no | 300000 | Initial lookback in ms (5 min) |
dedupCapacity | no | 10000 | Max tracked event IDs |
maxAuthErrors | no | 3 | Consecutive auth errors before stop |
type | no | — | Filter by event type string |
client | no | — | Filter by client ID |
operationTypes | no | — | Filter admin events by operation (comma-separated) |
resourcePath | no | — | Filter by resource path |
The consumer deduplicates events using a bounded IndexSet keyed by event ID. The set evicts the oldest ID when the count exceeds dedupCapacity. The consumer tracks the highest event timestamp and resumes polling from last_event_time + 1 on the next cycle. Three consecutive 401 or 403 responses stop the consumer with a system-broken log (ADR-0012).
| Header | Type |
|---|---|
CamelKeycloakEventId | both |
CamelKeycloakEventTime | both |
CamelKeycloakRealmId | both |
CamelKeycloakEventType | both |
CamelKeycloakClientId | user |
CamelKeycloakUserId | user |
CamelKeycloakSessionId | user |
CamelKeycloakIpAddress | user |
CamelKeycloakResourceType | admin |
CamelKeycloakResourcePath | admin |
CamelKeycloakAuthUserId | admin |
CamelKeycloakAuthClientId | admin |
both means both events and admin-events. user is set on events. admin is set on admin-events.
Claim mapping
keycloak_claim_paths(client_id) returns the ClaimPaths struct for the realm. The subject is /sub. The role locations are /realm_access/roles and /resource_access/{client}/roles. The scope is /scope. The component RFC 6901 escapes the client_id segment (/ becomes ~1, ~ becomes ~0) before it is substituted into the path. An empty client_id produces /resource_access//roles. The caller must validate the client ID.
KeycloakRealmConfig::introspection_authenticator() builds an IntrospectionAuthenticator that wraps a CachingTokenIntrospector against the realm's /protocol/openid-connect/token/introspect endpoint. The introspector caches positive responses for default_ttl_secs and negative responses for negative_ttl_secs. The HTTP client is DNS-pinned to the introspection host and uses the SSRF policy from [security.keycloak].
JWKS and validation
The realm's /protocol/openid-connect/certs endpoint is the JWKS source. The JWKS cache holds keys for cache_ttl_secs and refreshes refresh_skew_secs before expiry. The component DNS-pins the HTTP client to the JWKS host to close the TOCTOU window between SSRF validation and the first request.
[security.keycloak.validation] controls local JWT validation. method = "local" performs signature and claim checks against the cached JWKS. audience lists the accepted aud claims. clock_skew_secs tolerates the difference between the local clock and the issuer clock.
UMA permission evaluation
KeycloakRealmConfig::uma_evaluator() returns a PermissionEvaluator that uses Keycloak's UMA ticket flow. The evaluator obtains a service-account token, then POSTs grant_type=urn:ietf:params:oauth:grant-type:uma-ticket to the realm's token endpoint. The claim_token form field carries the requesting principal's claims as a base64-encoded JSON string. A 200 response grants permission. A 403 response returns Denied with the Keycloak error_description. A 401 response signals rejected client credentials and surfaces as ProviderUnavailable.
[security.keycloak.uma]
provider = "keycloak"
[security.keycloak.uma.cache]
positive_ttl_secs = 30
negative_ttl_secs = 5
max_entries = 10000
The UMA evaluator pins its DNS to the realm's token endpoint. The connect timeout is 5 seconds. The request timeout is 30 seconds. The evaluator fails closed on transport errors and non-200, non-403, non-401 responses.
Transport hardening
The Keycloak HTTP client is hardened. It follows no redirects. A 302 or 303 response is treated as a misconfiguration or attack signal. Connect timeout is 10 seconds. Request timeout is 30 seconds. validate_server_url rejects non-HTTP schemes and rejects hosts that resolve to blocked IP ranges. The component redacts client_secret to REDACTED in Debug output and skips the secret in Serde serialization.
Error handling
The Admin Producer returns Err on HTTP failure. The route's ErrorHandler owns the operational signal. ADR-0012 classifies these as category a. The Event Consumer logs transient HTTP errors at warn! and increments no metric. Auth retries log at warn! and increment the e:keycloak:auth-material metric. Channel-closed send failures inside the consumer increment the b-prime:keycloak:response-body metric and log at error! as outside-contract (ADR-0012 category b'). The max-auth-errors arm stops the consumer with error! as system-broken (ADR-0012 category c). The component redacts request and response bodies that contain secrets.
Reference: Keycloak component CONTEXT. Example: examples/security-keycloak shows the native auth pipeline that consumes Keycloak-issued JWTs. ADRs: 0010 Security policy pre-pipeline authz, 0012 Log-level convention, 0033 Security defaults fail-closed startup validation.
XSLT and XJ
XSLT transforms XML with a stylesheet. XJ converts between XML and JSON. Both components are producer-only and both delegate to a Java/Saxon xml-bridge sidecar over gRPC. The Rust side never parses XML or executes XSLT.
XJ sits on top of XSLT. The same bridge compiles every stylesheet, whether you supply your own or use the bundled identity pair.
XSLT
xslt:<stylesheet> reads an XSLT stylesheet from disk and applies it to the Exchange body. The body must be XML. The Producer replaces the body with the transformation result.
let route = RouteBuilder::from("direct:in")
.to("xslt:/etc/transforms/order.xslt?output=xml¶m.locale=en")
.build()?;
YAML equivalent
routes:
- id: xslt-transform
from: "direct:in"
steps:
- to: "xslt:/etc/transforms/order.xslt?output=xml¶m.locale=en"
The Producer bounds the body before forwarding. When maxPayloadBytes is absent, XsltProducer uses DEFAULT_MATERIALIZE_LIMIT (10 MiB). Bodies that exceed the limit return an error before any bytes reach the bridge.
URI
xslt:<stylesheet>[?output=<method>][¶m.<name>=<value>][&transformerCacheSize=<n>][&failOnNullBody=<true|false>][&maxPayloadBytes=<n>]
| Parameter | Required | Default | Description |
|---|---|---|---|
stylesheet | yes | — | Path to the XSLT file. Accepts file:// prefix or a plain path. |
output | no | stylesheet default | Output method: xml, html, or text. |
param.<name> | no | — | XSLT parameter. Forwarded as a string. Becomes an <xsl:param> value inside the stylesheet. |
transformerCacheSize | no | unlimited | Maximum compiled stylesheets the bridge keeps in cache. 0 disables caching. |
failOnNullBody | no | false | Return an error when the body is empty. When false, the empty body is forwarded. |
maxPayloadBytes | no | 10 MiB | Reject Exchange bodies larger than this before sending to the bridge. 0 is rejected. |
No Exchange field can select or replace the stylesheet. The stylesheet is fixed at Endpoint creation. The Exchange body is the only XML the bridge sees.
XJ
xj:<stylesheet>?direction=<xml2json|json2xml> converts between XML and JSON. The bundled identity stylesheet at classpath:identity covers the common case. A custom stylesheet covers anything else.
let route = RouteBuilder::from("timer:tick?period=1000")
.set_body(Body::Xml("<root><name>Camel</name></root>".to_string()))
.to("xj:classpath:identity?direction=xml2json")
.log("JSON output: ${body}", LogLevel::Info)
.build()?;
YAML equivalent
routes:
- id: xml-to-json
from: "timer:tick?period=1000"
steps:
- set_body: "<root><name>Camel</name></root>"
- to: "xj:classpath:identity?direction=xml2json"
- to: "log:info"
xml2json takes an XML body and returns JSON. json2xml takes a JSON body and returns XML. The Producer replaces the body with the conversion result and preserves the inbound UTF-8 on the JSON side. Rust does not parse the JSON document. The bridge runs json-to-xml() on the XSLT 3.0 side.
The xml2json identity stylesheet follows the Apache Camel xj compatibility convention. Attributes become "@name" keys. Text content becomes "#text" when the element also has attributes or children. Repeated siblings become JSON arrays. A self-closing element with no attributes becomes null. A simple leaf with no attributes or children becomes a plain string.
URI
xj:<stylesheet>?direction=<xml2json|json2xml>[&maxPayloadBytes=<n>][&retryCount=<n>][&retryDelayMs=<n>][¶m.<name>=<value>]
| Parameter | Required | Default | Description |
|---|---|---|---|
stylesheet | yes | — | classpath:identity for the bundled pair, or a custom file:// path. |
direction | yes | — | xml2json or json2xml. |
maxPayloadBytes | no | 10 MiB | Reject Exchange bodies larger than this before sending to the bridge. |
retryCount | no | 3 | Retries on transient transport failure. |
retryDelayMs | no | 500 | Delay between retries. |
param.<name> | no | — | XSLT parameter forwarded to the bridge. |
The parser also accepts transformDirection and resourceUri, but Endpoint creation does not pass them to the Producer. Both options are silently ignored. Do not depend on them.
Bridge model
Both components share the xml-bridge sidecar. Rust does not parse XML or execute XSLT. It reads the stylesheet bytes at Endpoint creation, bounds the Exchange body, and forwards both as bytes through proto/xml_bridge.proto. The sidecar owns stylesheet compilation, transformation, XML parsing, DTD handling, and entity resolution.
The bridge process starts on first use. It exits when the Camel context stops. On transport failure, the runtime restarts the bridge and recompiles every cached stylesheet. Transient transport errors trigger retries with the configured backoff before the route sees a failure.
Trust model
ADR-0032 classifies endpoint configuration as trusted operator input and the Exchange body as untrusted exchange data. The stylesheet is read from disk during Endpoint creation. The Exchange body is bounded and forwarded without Rust-side XML parsing. The sidecar is the security location for XSLT secure processing, document() restrictions, XXE controls, entity-expansion limits, and XML-bomb protection. BridgeError.Kind.SECURITY_VIOLATION reports a policy rejection. The presence of that contract does not prove every defense is enabled. Audit those controls in bridges/xml/.
Error handling
The Producer logs stylesheet compilation failures and transform failures at warn!. The route ErrorHandler owns the resulting error (ADR-0012 category a). The bridge client logs reseed failures at error! with a matching metric. That category is outside-contract and signals transient recovery, not a handler call.
Reference: camel-xslt CONTEXT, camel-xj CONTEXT.
Mock
The Mock component is a producer-only testing utility. It records every Exchange a Route sends to it and exposes assertions you call from your test code.
use camel_builder::RouteBuilder;
use camel_component_mock::MockComponent;
use camel_core::CamelContext;
use tower::ServiceExt;
let mock = MockComponent::new();
let mock_ref = mock.clone();
let mut ctx = CamelContext::new();
ctx.register_component("mock", Box::new(mock));
let route = RouteBuilder::from("direct:input")
.map_body(|body: camel_api::Body| {
camel_api::Body::Text(body.as_text().unwrap_or("").to_uppercase())
})
.to("mock:result")
.build()?;
ctx.add_route(route).await?;
ctx.start().await?;
let producer = ctx.create_producer("direct:input").await?;
producer.oneshot(camel_api::Exchange::new(camel_api::Message::new("hello"))).await?;
let endpoint = mock_ref.get_endpoint("result").unwrap();
endpoint.assert_exchange_count(1).await;
endpoint.exchange(0)
.assert_body_text("HELLO")
.assert_no_error();
YAML equivalent
routes:
- id: mock-route
from: "direct:input"
steps:
- to: "mock:result"
The Mock endpoint is the same in both APIs. The Rust builder and the YAML DSL compile to the same RouteDefinition. A YAML-defined route produces Exchanges that the Rust test code can still assert on through the cloned MockComponent handle.
URI
mock:<name>
| Segment | Required | Description |
|---|---|---|
name | yes | Logical name for this endpoint. The same name creates the same recorded-exchange store, so two routes that send to mock:result share one buffer. |
The Mock has no query parameters. Every behavior is controlled through the Rust API on MockComponent and MockEndpointInner.
Why producer-only
The Mock only supports producer mode. The contract crate declares supports_producer: true and leaves consumer support disabled. A Mock Consumer would have no source to pull from and no peer to broadcast to. The value is the recording side, not a stub peer.
You use Mock as a to: target inside a Route. Your test code holds a clone of the MockComponent and reads back what arrived. Clone the MockComponent before you register it, because registration moves the value into the CamelContext.
Recording model
Each MockEndpointInner keeps a VecDeque<Exchange> behind a tokio::Mutex and a tokio::sync::Notify for wake-ups. The producer appends every Exchange it processes, optionally deep-cloning the body if MockConfig::copy_on_exchange is true. The default cap is 10 000 retained exchanges. Older entries drop when the cap is exceeded.
Multiple MockEndpoint instances with the same name share one MockEndpointInner through Arc. Two routes that both send to mock:result write to the same buffer. A multicast test can assert on every leg from one handle.
The recording is in-memory only. The component persists nothing to disk, supports no replay, and exposes no remote inspection. Stop the CamelContext and the recorded Exchanges disappear.
Assertions
The Rust API offers three assertion styles. Pick by what your test needs to express.
assert_exchange_count(n) is the first check in most tests. It panics with a descriptive message if the count does not match. Call it before any deeper inspection so a missing exchange fails the test at the count line, not inside a body assertion.
exchange(idx) returns an ExchangeAssert for fluent checks. Chain .assert_body_text("HELLO"), .assert_body_json(value), .assert_body_bytes(&[1, 2, 3]), .assert_header("x-source", json!("timer")), .assert_header_exists("trace-id"), .assert_has_error(), or .assert_no_error(). Every method panics with a message that names the endpoint, the exchange index, the expected value, and the actual value. Test output stays self-explanatory without extra assert! calls.
expect_body, expect_header, and expect_header_regex register a batch of expectations up front. Call assert_satisfied() after the exchanges have arrived. Batch mode matches in strict order by default. Set MockConfig::any_order = true to match each expected body against any received exchange exactly once.
await_exchanges(n, timeout) blocks until n exchanges arrive or the timeout elapses. It uses Notify, not polling, so it returns the instant the producer appends. Call it before exchange(idx) to avoid an out-of-bounds panic. The method needs a multi-threaded Tokio runtime; #[tokio::test(flavor = "multi_thread")] is the right shape.
MockConfig
| Field | Default | Effect |
|---|---|---|
max_retained | 10000 | Drop oldest exchanges past this cap. |
copy_on_exchange | false | Deep-clone the body on insert. Set true when the caller mutates the Exchange after sending. |
fail_fast | false | Stop processing after the first failing assertion and record the error. |
assert_period_ms | 0 | Default timeout for await_exchanges_with_timeout. 0 means use the explicit fallback. |
any_order | false | Match expected bodies against received bodies without position. |
fail_fast_error() returns the recorded error after a fail-fast stop. Use it in test cleanup to log the cause.
Reference: Mock crate CONTEXT. Vocabulary: Components CONTEXT.
Expression languages
rust-camel evaluates expressions and predicates against Exchange data through pluggable languages. Each language compiles a script into an executable that pipeline steps invoke.
Every language implements the Language trait from camel-language-api. Languages register into CamelContext by name at startup. Pipeline steps resolve them by name to evaluate predicates and expressions.
Available languages
| Language | Crate | Use case |
|---|---|---|
| Simple | camel-language-simple | Header, body, and property access with ${...} syntax |
| JSONPath | camel-language-jsonpath | Query JSON bodies with $. syntax |
| XPath | camel-language-xpath | Query XML message bodies |
| JavaScript | camel-language-js | Full JS expressions via embedded engine |
| Rhai | camel-language-rhai | Rust-native embedded scripting |
| MiniJinja | camel-language-minijinja | Jinja2-compatible templating |
Reference: Languages overview · Language SPI
Simple
A lightweight expression and predicate language for header, body, property, and exception access. It uses ${...} interpolation syntax and supports compound predicates with && and ||.
let lang = SimpleLanguage::new();
let order_pred = lang
.create_predicate("${header.type} == 'order'")
.expect("valid predicate"); // allow-unwrap
let high_priority_order_pred = lang
.create_predicate("${header.type} == 'order' && ${header.priority} == 'high'")
.expect("valid compound predicate"); // allow-unwrap
let body_present_pred = lang
.create_predicate("${body} != null")
.expect("valid body presence predicate"); // allow-unwrap
let approved_pred = lang
.create_predicate("${header.approved} == true")
.expect("valid boolean predicate"); // allow-unwrap
let type_expr = lang
.create_expression("${header.type}")
.expect("valid expression"); // allow-unwrap
let counter = Arc::new(AtomicU64::new(0));
let counter_clone = Arc::clone(&counter);
let types = ["order", "invoice", "order", "shipment"];
let priorities = ["high", "low", "high", "low"];
let handle = tokio::runtime::Handle::current();
let route = RouteBuilder::from("timer:tick?period=800&repeatCount=8")
.route_id("language-simple-demo")
// Step 1: assign rotating headers and alternate empty/non-empty body
.process(move |mut exchange: camel_api::Exchange| {
let c = Arc::clone(&counter_clone);
Box::pin(async move {
let n = c.fetch_add(1, Ordering::SeqCst) as usize;
let msg_type = types[n % types.len()];
let priority = priorities[n % priorities.len()];
let approved = n.is_multiple_of(2);
exchange
.input
.set_header("type", camel_api::Value::String(msg_type.to_string()));
exchange
.input
.set_header("priority", camel_api::Value::String(priority.to_string()));
exchange
.input
.set_header("approved", camel_api::Value::Bool(approved));
exchange.input.body = if n.is_multiple_of(3) {
Body::Empty
} else {
Body::Text(format!("message #{}", n + 1))
};
Ok(exchange)
})
})
// Step 2: demonstrate body null check
.filter({
let pred = Arc::clone(&body_present_pred);
let h = handle.clone();
move |ex: &camel_api::Exchange| h.block_on(pred.matches(ex)).unwrap_or(false)
})
.to("log:body-present?showBody=true&showHeaders=true")
.end_filter()
// Step 3: use Simple Language expression to append type info to body
.process({
let type_expr = Arc::clone(&type_expr);
move |mut exchange: camel_api::Exchange| {
let expr = Arc::clone(&type_expr);
Box::pin(async move {
if let Ok(camel_api::Value::String(t)) = expr.evaluate(&exchange).await {
let current = exchange.input.body.as_text().unwrap_or("").to_string();
exchange.input.body = Body::Text(format!("{current} [type={t}]"));
}
Ok(exchange)
})
}
})
// Step 4: filter — only 'order' messages pass
.filter({
let pred = Arc::clone(&order_pred);
let h = handle.clone();
move |ex: &camel_api::Exchange| h.block_on(pred.matches(ex)).unwrap_or(false)
})
.to("log:orders?showBody=true&showHeaders=true")
.end_filter()
// Step 5: compound predicate with && for high-priority orders
.filter({
let pred = Arc::clone(&high_priority_order_pred);
let h = handle.clone();
move |ex: &camel_api::Exchange| h.block_on(pred.matches(ex)).unwrap_or(false)
})
.to("log:high-priority-orders?showBody=true&showHeaders=true")
.end_filter()
// Step 6: boolean comparison against true literal
.filter({
let pred = Arc::clone(&approved_pred);
let h = handle.clone();
move |ex: &camel_api::Exchange| h.block_on(pred.matches(ex)).unwrap_or(false)
})
.to("log:approved-orders?showBody=true&showHeaders=true")
.end_filter()
.build()?;
YAML equivalent
- id: language-simple-demo
from: timer:tick?period=800&repeatCount=8
steps:
- set_header:
key: type
value: order
- set_header:
key: priority
value: high
- set_header:
key: approved
value: "true"
- set_body:
simple: "${header.type}"
- filter:
simple: "${body} != null"
steps:
- to: log:body-present?showBody=true&showHeaders=true
- filter:
simple: "${header.type} == 'order'"
steps:
- to: log:orders?showBody=true&showHeaders=true
- filter:
simple: "${header.type} == 'order' && ${header.priority} == 'high'"
steps:
- to: log:high-priority-orders?showBody=true&showHeaders=true
- filter:
simple: "${header.approved} == true"
steps:
- to: log:approved-orders?showBody=true&showHeaders=true
Simple parses its source once into an AST and reuses it for each Exchange. You build predicates and expressions up front with create_predicate and create_expression, then move them into route closures. Predicates return a boolean for filter and choice steps. Expressions return a Value for enrichment and transformation. The included example constructs four predicates and one expression before the route.
The language evaluates ${header.x}, ${body}, and ${exchangeProperty.y} against the Exchange. Missing headers, properties, and exception messages evaluate to Value::Null. At the predicate boundary, null is false. All other values, including an empty string, are true. Within && and ||, null and empty strings are false. Simple also supports language delegation through ${lang:expr}, which resolves the target language at evaluation time.
Simple is the most common language for predicates in EIP patterns. Use it for flat header and body access in filter, choice, and enrichment steps. It requires no external engine. For structured JSON queries, use JSONPath.
Reference: Language SPI · Simple crate
JSONPath
An RFC 9535 JSONPath expression and predicate language over jsonpath-rust. It evaluates $. queries against an Exchange JSON body.
ctx.register_language("jsonpath", Box::new(JsonPathLanguage::new()))
.expect("jsonpath not yet registered"); // allow-unwrap
let lang = JsonPathLanguage::new();
let customer_expr = lang
.create_expression("$.customer")
.expect("valid expression"); // allow-unwrap
let active_pred = lang.create_predicate("$.active").expect("valid predicate"); // allow-unwrap
let customer_expr = Arc::new(customer_expr);
let active_pred = Arc::new(active_pred);
let route = RouteBuilder::from("timer:tick?period=800&repeatCount=6")
.route_id("language-jsonpath-demo")
.process(move |mut exchange: camel_api::Exchange| {
let c = Arc::clone(&counter_clone);
Box::pin(async move {
let n = c.fetch_add(1, Ordering::SeqCst) as usize;
let active = (n as u64).is_multiple_of(2);
let customer = customers[n % customers.len()];
let body = json!({
"active": active,
"customer": customer,
"order": { "id": n + 1 }
});
exchange.input.body = Body::Json(body);
Ok(exchange)
})
})
.process({
let expr = Arc::clone(&customer_expr);
move |mut exchange: camel_api::Exchange| {
let expr = Arc::clone(&expr);
Box::pin(async move {
if let Ok(value) = expr.evaluate(&exchange).await
&& let Some(name) = value.as_str()
{
exchange
.input
.set_header("customer", camel_api::Value::String(name.to_string()));
}
Ok(exchange)
})
}
})
.to("log:all-orders?showBody=true&showHeaders=true")
.filter({
let pred = Arc::clone(&active_pred);
let handle = tokio::runtime::Handle::current();
move |ex: &camel_api::Exchange| handle.block_on(pred.matches(ex)).unwrap_or(false)
})
.to("log:active-orders?showBody=true&showHeaders=true")
.end_filter()
.build()?;
YAML equivalent
- id: language-jsonpath-demo
from: timer:tick?period=800&repeatCount=6
steps:
- set_body:
value: '{"customer": "Alice", "active": true}'
- set_header:
key: customer
jsonpath: "$.customer"
- to: log:all-orders?showBody=true&showHeaders=true
- filter:
jsonpath: "$.active"
steps:
- to: log:active-orders?showBody=true&showHeaders=true
JsonPathLanguage validates the query prefix and syntax when it creates an Expression or Predicate. You register the language into CamelContext by name, then build expressions and predicates up front. The included example registers jsonpath and constructs one expression ($.customer) and one predicate ($.active) before the route. Expressions extract values from the JSON body. Predicates gate filter steps.
The query is trusted operator configuration. The JSON body is untrusted, adversary-controlled data under ADR-0032. Exchange data never enters the query string. The implementation stores the operator query and passes body content separately to jsonpath-rust. Resource bounds protect against large or deeply nested input. max_input_bytes bounds a text body before JSON parsing. The default is 16 MiB. max_depth bounds JSON nesting. The default is 64 levels.
Use JSONPath when the body contains structured JSON and you need to extract nested fields or test conditions. For flat header and body access, Simple is lighter and needs no JSON parsing.
Reference: Language SPI · JSONPath crate
XPath
An XPath 1.0 expression and predicate language over sxd-document and sxd-xpath. It evaluates //node and /path queries against an Exchange XML body.
ctx.register_language("xpath", Box::new(XPathLanguage::new()))
.expect("xpath not yet registered"); // allow-unwrap
let lang = XPathLanguage::new();
let title_expr = lang
.create_expression("/catalog/book[1]/title")
.expect("valid expression"); // allow-unwrap
let in_stock_pred = lang
.create_predicate("/catalog/book[@in-stock='true']")
.expect("valid predicate"); // allow-unwrap
let title_expr = Arc::new(title_expr);
let in_stock_pred = Arc::new(in_stock_pred);
let route = RouteBuilder::from("timer:tick?period=800&repeatCount=6")
.route_id("language-xpath-demo")
.process(move |mut exchange: camel_api::Exchange| {
let c = Arc::clone(&counter_clone);
Box::pin(async move {
let n = c.fetch_add(1, Ordering::SeqCst) as usize;
let (title, in_stock) = books[n % books.len()];
let xml = format!(
"<catalog><book id=\"{}\" in-stock=\"{}\"><title>{}</title></book></catalog>",
n + 1,
in_stock,
title
);
exchange.input.body = Body::Xml(xml);
Ok(exchange)
})
})
.process({
let expr = Arc::clone(&title_expr);
move |mut exchange: camel_api::Exchange| {
let expr = Arc::clone(&expr);
Box::pin(async move {
if let Ok(value) = expr.evaluate(&exchange).await
&& let Some(name) = value.as_str()
{
exchange
.input
.set_header("book-title", camel_api::Value::String(name.to_string()));
}
Ok(exchange)
})
}
})
.to("log:all-books?showBody=true&showHeaders=true")
.filter({
let pred = Arc::clone(&in_stock_pred);
let handle = tokio::runtime::Handle::current();
move |ex: &camel_api::Exchange| handle.block_on(pred.matches(ex)).unwrap_or(false)
})
.to("log:in-stock?showBody=true&showHeaders=true")
.end_filter()
.build()?;
YAML equivalent
- id: language-xpath-demo
from: timer:tick?period=800&repeatCount=6
steps:
- set_body:
value: "<catalog><book in-stock='true'><title>The Rust Book</title></book></catalog>"
- set_header:
key: title
xpath: "/catalog/book[1]/title"
- to: log:all-books?showBody=true&showHeaders=true
- filter:
xpath: "/catalog/book[@in-stock='true']"
steps:
- to: log:in-stock?showBody=true&showHeaders=true
You register xpath into CamelContext by name, then build expressions and predicates up front. The included example constructs one expression (/catalog/book[1]/title) and one predicate (/catalog/book[@in-stock='true']) before the route. Expressions extract values from the XML body. Predicates gate filter steps.
The XPath query is trusted operator configuration. The XML body is untrusted, adversary-controlled data under ADR-0032. Exchange data never enters the query string. max_input_bytes bounds the raw XML body before parsing. The default is 1 MiB. Both sxd-document and sxd-xpath are pure Rust and register no filesystem or network resolver. The parser has no <!ENTITY> declaration handler. Recursive entity expansion, including a billion-laughs payload, is structurally unavailable. External entity declarations cannot trigger a file or network fetch.
Known limitations apply. Namespace prefixes are unsupported because the evaluation context has no prefix-to-URI map. Evaluation has no wall-clock timeout. The query is trusted, and the untrusted XML input has a byte bound. The sxd-xpath library is unmaintained. A replacement must preserve the security posture above.
Use XPath when the body carries XML and you need to select elements or test attributes. For structured JSON bodies, use JSONPath.
Reference: Language SPI · XPath crate
JavaScript
A Boa-backed JavaScript implementation of the Language SPI. It provides Expression, Predicate, and MutatingExpression for the synchronous script: path defined by ADR-0006.
use std::time::Duration;
use camel_api::CamelError;
use camel_builder::{RouteBuilder, StepAccumulator};
use camel_component_log::LogComponent;
use camel_component_timer::TimerComponent;
use camel_core::context::CamelContext;
#[tokio::main]
async fn main() -> Result<(), CamelError> {
tracing_subscriber::fmt().with_target(false).init();
let mut ctx = CamelContext::builder().build().await.unwrap(); // allow-unwrap
ctx.register_component(TimerComponent::new());
ctx.register_component(LogComponent::new());
let route = RouteBuilder::from("timer:tick?period=1000&repeatCount=3")
.route_id("language-js-demo")
.script(
"js",
r#"
camel.headers.set("greeting", "Hello from JS");
camel.headers.set("processedBy", "language-js-example");
camel.body = "JS was here";
"done";
"#,
)
.to("log:js-output?showBody=true&showHeaders=true")
.build()?;
ctx.add_route_definition(route).await?;
ctx.start().await?;
println!("JS Language example running for ~3 ticks...");
tokio::time::sleep(Duration::from_millis(3500)).await;
ctx.stop().await?;
println!("Stopped.");
Ok(())
}
YAML equivalent
- id: language-js-demo
from: timer:tick?period=1000&repeatCount=3
steps:
- script:
language: js
source: |
camel.headers.set("greeting", "Hello from JS");
camel.headers.set("processedBy", "language-js-example");
camel.body = "JS was here";
"done";
- to: log:js-output?showBody=true&showHeaders=true
The route above uses a .script("js", ...) step, which runs as a MutatingExpression. The script reads and writes exchange data through the camel global. camel.headers and camel.properties are map-like views with get, set, has, remove, and keys. camel.body holds the body value. After successful evaluation, the implementation writes body, header, and property changes back to the Exchange. An error leaves the Exchange unchanged.
Each evaluation creates a fresh Boa Context. State does not leak between evaluations. Boa receives no filesystem, network, environment, stdio, or WASI capability. The only host bindings are the exchange snapshot under camel and a tracing-backed console. Script source is trusted operator configuration. Exchange data is untrusted under ADR-0032. The implementation converts exchange data to JavaScript values. It never concatenates exchange data into script source or evaluates it as code. Untrusted JavaScript must run out-of-process through the function: path from ADR-0005.
Resource limits bound runaway scripts. The default execution timeout is 5,000 ms, enforced by tokio::time::timeout around spawn_blocking. The timeout returns control to the route but cannot cancel a running blocking task. Boa runtime limits eventually stop it. Boa caps loop iterations at 100,000, recursion depth at 512, and stack slots at 10,240. Source size is bounded at 1 MiB. Boa 0.21 has no heap-size limit. A small script can still allocate a large object graph. Do not run untrusted JavaScript in-process.
Reference: Language SPI · JS crate
Rhai
A Rhai implementation of the Language SPI. It provides Expression, Predicate, and MutatingExpression with an unconditional in-process sandbox and Rust-native type safety.
let lang = RhaiLanguage::new();
// Expression 1: prepend "[URGENT] " when priority header is "high"
let enrich_expr = lang
.create_expression(
r#"
if header("priority") == "high" {
"[URGENT] " + body
} else {
body
}
"#,
)
.expect("valid expression"); // allow-unwrap
// Expression 2: in-script mutation — compute a tax string using
// set_header to store an intermediate value, then read it back.
let tax_expr = lang
.create_expression(
r#"
let tax = header("amount") * 0.1;
set_header("tax", tax);
"tax=" + header("tax")
"#,
)
.expect("valid expression"); // allow-unwrap
// Predicate: only let through messages where amount > 100
let high_value_pred = lang
.create_predicate(r#"header("amount") > 100"#)
.expect("valid predicate"); // allow-unwrap
let enrich_expr = Arc::new(enrich_expr);
let tax_expr = Arc::new(tax_expr);
let high_value_pred = Arc::new(high_value_pred);
let route = RouteBuilder::from("timer:tick?period=900&repeatCount=6")
.route_id("language-rhai-demo")
// Step 1: assign headers and body
.process(move |mut exchange: camel_api::Exchange| {
let c = Arc::clone(&counter_clone);
Box::pin(async move {
let n = c.fetch_add(1, Ordering::SeqCst) as usize;
let priority = priorities[n % priorities.len()];
let amount = amounts[n % amounts.len()];
exchange
.input
.set_header("priority", camel_api::Value::String(priority.to_string()));
exchange
.input
.set_header("amount", camel_api::Value::from(amount));
exchange.input.body = Body::Text(format!("order #{}", n + 1));
Ok(exchange)
})
})
// Step 2: Rhai expression enriches the body based on priority
.process({
let expr = Arc::clone(&enrich_expr);
move |mut exchange: camel_api::Exchange| {
let expr = Arc::clone(&expr);
Box::pin(async move {
if let Ok(camel_api::Value::String(enriched)) = expr.evaluate(&exchange).await {
exchange.input.body = Body::Text(enriched);
}
Ok(exchange)
})
}
})
.process({
let expr = Arc::clone(&tax_expr);
move |mut exchange: camel_api::Exchange| {
let expr = Arc::clone(&expr);
Box::pin(async move {
if let Ok(camel_api::Value::String(tax_info)) = expr.evaluate(&exchange).await {
let body = exchange.input.body.as_text().unwrap_or("").to_string();
exchange.input.body = Body::Text(format!("{body} ({tax_info})"));
}
Ok(exchange)
})
}
})
// Step 4: .script() — mutating Rhai expression tags the order and
// appends a status suffix. Changes propagate back to the Exchange.
.script(
"rhai",
r#"
headers["processed"] = true;
let status = if headers["priority"] == "high" { "PRIORITY" } else { "STANDARD" };
body = body + " [" + status + "]";
"#,
)
// Step 5: log every message (after enrichment)
.to("log:all-orders?showBody=true&showHeaders=true")
// Step 6: filter — only high-value orders (amount > 100) to alert log
.filter({
let pred = Arc::clone(&high_value_pred);
let handle = tokio::runtime::Handle::current();
move |ex: &camel_api::Exchange| handle.block_on(pred.matches(ex)).unwrap_or(false)
})
.to("log:high-value-alert?showBody=true")
.end_filter()
.build()?;
YAML equivalent
- id: language-rhai-demo
from: timer:tick?period=900&repeatCount=6
steps:
- set_header:
key: priority
value: high
- set_header:
key: amount
value: 200
- set_body:
value: "order #1"
- script:
language: rhai
source: |
headers["processed"] = true;
let status = if headers["priority"] == "high" { "PRIORITY" } else { "STANDARD" };
body = body + " [" + status + "]";
- to: log:all-orders?showBody=true&showHeaders=true
- filter:
rhai: 'header("amount") > 100'
steps:
- to: log:high-value-alert?showBody=true
You register rhai into CamelContext by name, then build expressions and predicates up front. The included example constructs two expressions and one predicate before the route. Read-only expressions and predicates expose body and headers variables plus header(), set_header(), property(), and set_property() host functions. Their writes affect only the current evaluation. A MutatingExpression exposes body, headers, and properties as mutable scope variables. The implementation writes all three back to the Exchange only after successful evaluation.
Rhai integrates with Rust types without a foreign-function boundary. Exchange values bind directly as Rhai values, and the engine has no external runtime dependency. The sandbox closes filesystem, module, and network access through independent layers. The workspace enables Rhai's no_module feature, each evaluation uses Engine::new_raw(), and disable_symbol blocks eval and import. The sandbox has no configuration opt-out. Rhai source is trusted operator configuration. Exchange data is untrusted under ADR-0032 and never evaluated as source code.
Resource limits bound CPU and memory use. Defaults are 100,000 max operations, 1 MiB max string size, 10,000 max array elements, 10,000 max map entries, 64 max expression depth, and a 5,000 ms execution timeout. The timeout wraps synchronous evaluation in spawn_blocking. It returns control to the route after five seconds but cannot cancel the blocking task. The operation limit eventually stops a CPU-bound script.
Use Rhai for complex logic in pipeline steps: branching, computation, and multi-step mutation. For flat header and body access, Simple is lighter and needs no engine. For JavaScript-syntax scripting, use JavaScript.
Reference: Language SPI · Rhai crate
MiniJinja
A MiniJinja (Jinja2-compatible) template rendering implementation of the Language SPI. It renders structured output such as HTML, JSON, or prompts from Exchange data.
use camel_language_api::Language;
use camel_language_minijinja::MinijinjaLanguage;
let lang = MinijinjaLanguage::default();
let expr = lang.create_expression(
r#"{% autoescape "html" %}
<h1>Hello, {{ headers.name }}!</h1>
{% endautoescape %}"#,
)?;
YAML equivalent
- id: language-minijinja-demo
from: timer:tick?period=1000&repeatCount=3
steps:
- set_header:
key: name
value: World
- script:
language: minijinja
source: |
{% autoescape "html" %}
<h1>Hello, {{ headers.name }}!</h1>
{% endautoescape %}
- to: log:rendered?showBody=true&showHeaders=true
Each MinijinjaExpression owns an Arc<minijinja::Environment<'static>>. Templates are added once during construction and compiled immediately. Subsequent evaluations look up templates by name with no recompilation. At evaluation time, the expression renders the template against the exchange context and returns the output as the expression value. Exchange headers are available as headers.name inside the template.
Every template source must wrap in exactly one top-level {% autoescape "html"|"json"|"none" %}...{% endautoescape %} block. A lexical validator enforces this at compile time and rejects malformed templates immediately. This gives render output a declared escape strategy before any data interpolation occurs. Synchronous MiniJinja rendering runs on a Tokio blocking thread via spawn_blocking. The route future wraps the join handle in tokio::time::timeout for the configured render deadline. MiniJinja fuel provides an instruction budget that stops runaway templates, infinite loops, and algorithmic-complexity attacks.
Use MiniJinja for template-driven body generation: HTML pages, JSON payloads, and prompt strings assembled from multiple fields. For a body derived from a single expression, the transform step is simpler. Phase 1 covers inline templates only. External file loading, {% include %}, template inheritance, and hot-reload belong to Phase 2.
Reference: Language SPI · MiniJinja crate
Data Formats
A data format converts a message body between a wire representation and a structured type. Each format implements the DataFormat trait from camel-api. The trait defines a marshal operation (body to wire) and an unmarshal operation (wire to body). Per ADR-0030, the trait also exposes Exchange-aware hooks for formats that read or write Exchange metadata.
The Marshal and Unmarshal EIP page covers route-level usage.
Available formats
| Format | Crate | Body mapping |
|---|---|---|
json | built-in (camel-processor) | Text ↔ Json |
csv | built-in (camel-processor) | Text ↔ Json |
xml | built-in (camel-processor) | Text ↔ Json |
protobuf | camel-dataformat-protobuf | Json ↔ Bytes |
JSON, CSV, and XML are registered by default. Protobuf ships as a separate crate.
Reference: DataFormat trait
Protobuf
The protobuf data format converts between JSON and binary protobuf wire format. It uses prost-reflect for dynamic message descriptors that the format compiles at runtime. The format requires no compile-time code generation. It ships as a separate crate, camel-dataformat-protobuf.
Marshal converts Body::Json to Body::Bytes. Unmarshal reverses the conversion and returns Body::Json. The round trip preserves field values through the JSON bridge.
When to use protobuf
Choose protobuf when the contract is a gRPC service or when the schema must evolve without breaking older clients. Protobuf carries typed fields, forward and backward compatibility, and a compact binary encoding. Choose JSON instead when the consumer is a browser, a REST API, or any system that reads text. JSON is readable, universal, and cheaper to debug. See Data Formats for the full format catalog.
Construction
ProtobufDataFormat takes a proto file path and a fully-qualified message name:
use camel_dataformat_protobuf::ProtobufDataFormat;
let df = ProtobufDataFormat::new("protos/helloworld.proto", "helloworld.HelloRequest")?;
The constructor compiles the proto file at runtime through camel-proto-compiler. Pass a shared ProtoCache to new_with_cache to reuse the compiled descriptor pool across formats.
The protobuf format is not built-in. Register it before the route starts:
ctx.data_format_registry()
.register("protobuf", std::sync::Arc::new(df));
Body type support
| Body type | Marshal | Unmarshal |
|---|---|---|
Body::Json | Encodes to protobuf bytes | Passes through |
Body::Text | Parses as JSON, then encodes | Rejected |
Body::Bytes | Validates and passes through | Decodes to JSON |
Body::Empty, Body::Stream, Body::Xml | Rejected | Rejected |
DoS protection
The format rejects payloads larger than 64 MiB by default. The cap prevents out-of-memory errors from oversized inputs. Raise or lower the limit with with_max_decode_bytes:
let df = ProtobufDataFormat::new("schema.proto", "my.Message")?
.with_max_decode_bytes(128 * 1024 * 1024);
Prost enforces a recursion limit of 100 levels. Deeply nested payloads return RecursionLimitReached at depth 100.
Reference: camel-dataformat-protobuf source
YAML DSL
The YAML DSL declares routes as configuration files. The parser converts each
file into the same RouteDefinition the Rust builder API produces. The runtime
treats both authoring forms identically.
Reach for the YAML DSL when routes change more often than the application binary. Operations teams ship route edits through config management, and the hot-reload subsystem applies them without a redeploy. The same grammar also parses as JSON.
- Route structure: anatomy of a route file
- Step verbs: reference for every verb and field
Route structure
A YAML route file defines one or more routes. Each route has an identifier, a source endpoint, and an ordered list of processing steps.
Minimal route
The smallest useful route reads from a source and writes to a destination.
routes:
- id: "hello-timer"
from: "timer:tick?period=2000&repeatCount=3"
steps:
- log: "Hello from config-loaded route!"
- to: "log:info"
The file opens with a routes list. Each list entry is one route object. The
route above, hello-timer, reads from a timer endpoint, logs a greeting, and
forwards the exchange to the log:info endpoint.
Route fields
Each route object accepts these top-level fields. Only id and from are
required. The nested config objects (error_handler, circuit_breaker,
security_policy) are documented in the step verbs reference.
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
id | string | yes | — | Unique route identifier |
from | string | yes | — | Source endpoint URI |
steps | list | no | [] | Ordered step verbs |
auto_startup | bool | no | true | Start the route when the context starts |
startup_order | integer | no | 1000 | Ascending start order; shutdown reverses it |
sequential | bool | no | false | Process exchanges one at a time |
concurrent | integer | no | — | Maximum concurrent exchanges |
error_handler | object | no | — | Per-route error handler |
circuit_breaker | object | no | — | Route-level circuit breaker |
security_policy | object | no | — | Route-level authorization |
on_complete | string | no | — | Producer URI for the success hook |
on_failure | string | no | — | Producer URI for the failure hook |
auto_startup: false registers the route but does not start its consumer. Start
it later through the route controller or control bus. concurrent caps how many
exchanges the pipeline processes in parallel; omit it to let the runtime decide.
on_complete and on_failure fire when an exchange exits the pipeline, on
success or on error respectively.
Error handling
Set error_handler to retry failed exchanges and send them to a dead letter
channel when retries run out. The handler holds a redelivery policy and optional
per-exception clauses. The full field set lives on the
step verbs reference.
List-form variant
The hot-reload subsystem consumes a flatter form. When a file holds only routes
and omits the top-level routes wrapper, the parser reads the file as a bare
list of route objects:
- from: timer:hot-reload?period=1000
route_id: hot-reload-route
steps:
- to: log:info?showHeaders=true
Each entry uses route_id instead of id and carries from and steps. This
list form maps to the canonical route contract the runtime exchanges over the
control bus, not the full authoring model.
Next
- Step verbs: every verb and its fields
Reference: DSL crate
Step verbs reference
Every YAML step verb and field, derived from the authoritative source
crates/camel-dsl/src/route_ast.rs. Each verb maps to a struct that
Route structure.
Where a verb takes a predicate or value expression, the standard language fields
apply: simple, rhai, jsonpath, xpath, or language paired with
source. The tables below list them in full.
Step verbs
to
Send the exchange to an endpoint URI.
| Field | Type | Required | Description |
|---|---|---|---|
to | string | yes | Target endpoint URI |
- to: "log:info"
log
Log the exchange state.
| Form | Syntax |
|---|---|
| Short | log: "message" |
| Full | log: { message: "...", level: "DEBUG" } |
The message field accepts a bare string or an expression object (simple,
rhai, jsonpath, xpath, or language+source). level is optional.
- log: "Processing exchange"
- log:
message: "Body is ${body}"
level: "DEBUG"
set_header
Set a message header.
| Field | Type | Required | Description |
|---|---|---|---|
key | string | yes | Header name |
value | any | no | Literal value |
simple | string | no | Simple expression |
rhai | string | no | Rhai expression |
jsonpath | string | no | JSONPath expression |
xpath | string | no | XPath expression |
language | string | no | Named expression language |
source | string | no | Expression source for language |
- set_header:
key: "MyHeader"
value: "hello"
remove_header
Remove a message header from the input message. If the header is absent, the step does nothing (no error). Removal is input-only: output message headers are not changed.
| Field | Type | Required | Description |
|---|---|---|---|
key | string | yes | Header name to remove |
- remove_header:
key: "CamelHttpPath"
set_property
Set an exchange property. Same expression fields as set_header but keyed by
name.
| Field | Type | Required | Description |
|---|---|---|---|
name | string | yes | Property name |
value | any | no | Literal value |
simple | string | no | Simple expression |
rhai | string | no | Rhai expression |
jsonpath | string | no | JSONPath expression |
xpath | string | no | XPath expression |
language | string | no | Named expression language |
source | string | no | Expression source for language |
- set_property:
name: "MyProperty"
value: 42
set_body
Set the exchange body.
| Form | Syntax |
|---|---|
| Literal | set_body: "value" |
| Config | set_body: { value: ... } or set_body: { simple: "..." } |
The config form accepts value plus any expression field (simple, rhai,
jsonpath, xpath, language+source).
- set_body: "static value"
- set_body:
value: "Hello World!"
- set_body:
simple: "${header.foo}"
transform
Alias for set_body. Same forms and fields.
- transform:
simple: "${body.field}"
filter
Conditionally run child steps. When the predicate is false, the exchange skips
the steps list and continues down the pipeline.
| Field | Type | Required | Description |
|---|---|---|---|
simple | string | no | Simple predicate |
rhai | string | no | Rhai predicate |
jsonpath | string | no | JSONPath predicate |
xpath | string | no | XPath predicate |
language | string | no | Named expression language |
source | string | no | Expression source for language |
steps | list | no | Child steps when the predicate holds |
- filter:
simple: "${header.type} == 'important'"
steps:
- to: "log:important"
choice
Content-based router. Evaluates when clauses in order and runs the first that
matches. otherwise runs when no clause matches.
| Field | Type | Required | Description |
|---|---|---|---|
when | list | no | Predicate blocks (expression fields + steps) |
otherwise | list | no | Fallback steps |
- choice:
when:
- simple: "${header.type} == 'a'"
steps:
- to: "log:a"
- simple: "${header.type} == 'b'"
steps:
- to: "log:b"
otherwise:
- to: "log:other"
do_try
Protected block with catch and finally clauses.
| Field | Type | Required | Description |
|---|---|---|---|
steps | list | yes | Protected steps |
catch | list | no | Catch clauses |
finally | object | no | Finally clause |
Each catch entry accepts exception (list of error kinds), when and
on_when predicates, disposition (defaults to handled), and steps. The
finally object carries an optional on_when and a required steps list.
- do_try:
steps:
- to: "direct:fragile"
catch:
- exception: ["ProcessorError"]
steps:
- to: "log:error"
finally:
steps:
- to: "log:cleanup"
delay
Pause processing.
| Form | Syntax |
|---|---|
| Short | delay: 500 (milliseconds) |
| Full | delay: { delay_ms: 500, dynamic_header: "X-Delay" } |
- delay: 500
- delay:
delay_ms: 200
dynamic_header: "X-Delay"
loop
Repeat child steps.
| Form | Syntax |
|---|---|
| Count | loop: 3 |
| Full | loop: { count: 3, steps: [...] } |
| While | loop: { while: { simple: "..." }, steps: [...] } |
Full-form fields:
| Field | Type | Required | Description |
|---|---|---|---|
count | integer | no | Fixed iteration count (exclusive with while) |
while | object | no | Predicate block; loops while it holds |
steps | list | no | Child steps per iteration |
max_iterations | integer | no | Safety cap on iterations |
The while block accepts the standard predicate fields (simple, rhai,
jsonpath, xpath, language+source).
- loop: 3
- loop:
count: 5
steps:
- to: "log:iteration"
split
Split the body into fragments and process each.
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
expression | string/object | no | — | Split expression (string or language block) |
aggregation | string | no | last_wins | Aggregation strategy |
parallel | bool | no | false | Process fragments in parallel |
parallel_limit | integer | no | — | Max parallel fragments |
stop_on_exception | bool | no | true | Stop on first error |
streaming | bool | no | false | Stream the split |
stream | object | no | — | Stream config (format, max_record_bytes, batch_size, chunk_size) |
steps | list | no | [] | Per-fragment steps |
- split:
expression: "body_lines"
aggregation: "last_wins"
steps:
- log: "Split item: ${body}"
- to: "log:split-item"
- split:
expression:
simple: "${header.items}"
aggregation: "collect_all"
steps:
- to: "log:fragment"
aggregate
Group exchanges by correlation key. Emits one combined exchange when a
completion condition fires. The combined exchange then continues down the
pipeline, so aggregate has no nested steps block.
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
header | string | yes | — | Header used as the correlation key |
correlation_key | string | no | — | Alternative correlation expression |
completion_size | integer | no | — | Complete after N exchanges |
completion_timeout_ms | integer | no | — | Complete after timeout |
completion_predicate | object | no | — | Predicate-block completion trigger |
strategy | string | no | collect_all | Aggregation strategy |
max_buckets | integer | no | — | Max concurrent buckets |
bucket_ttl_ms | integer | no | — | Bucket time-to-live |
force_completion_on_stop | bool | no | — | Emit pending buckets on route stop |
discard_on_timeout | bool | no | — | Drop buckets that time out |
- aggregate:
header: "CorrelationId"
completion_size: 10
marshal
Serialize the body to a data format.
| Field | Type | Required | Description |
|---|---|---|---|
marshal | string | yes | Format name (json, protobuf, ...) |
config | object | no | Format-specific config |
- marshal: "json"
unmarshal
Parse the body from a data format. An optional schema validates the parsed
JSON and rejects mismatches.
| Field | Type | Required | Description |
|---|---|---|---|
unmarshal | string | yes | Format name |
schema | object | no | JSON Schema for validation |
config | object | no | Format-specific config |
- unmarshal: "json"
convert_body_to
Convert the body type.
| Field | Type | Required | Description |
|---|---|---|---|
convert_body_to | string | yes | Target type (json, ...) |
- convert_body_to: json
bean
Invoke a registered bean method.
| Field | Type | Required | Description |
|---|---|---|---|
name | string | yes | Bean name |
method | string | yes | Method name |
- bean:
name: "myBean"
method: "handle"
script
Run a script inline.
| Field | Type | Required | Description |
|---|---|---|---|
language | string | yes | Script language (rhai, ...) |
source | string | yes | Script source |
- script:
language: "rhai"
source: "1 + 1"
function
Run a function in an external runtime.
| Field | Type | Required | Description |
|---|---|---|---|
runtime | string | yes | Runtime name (deno, ...) |
source | string | yes | Function source |
timeout_ms | integer | no | Execution timeout |
- function:
runtime: "deno"
source: "export default (ctx) => ctx.body = { processed: true }"
stop
Stop route processing. The exchange returns to the consumer as a successful response.
- stop: true
stream_cache
Materialize a stream body into bytes.
| Form | Syntax |
|---|---|
| Bool | stream_cache: true |
| Config | stream_cache: { threshold: 65536 } |
- stream_cache: true
- stream_cache:
threshold: 65536
wire_tap
Send a fire-and-forget copy of the exchange to another endpoint.
- wire_tap: "log:tap"
multicast
Fan the exchange out to multiple endpoints.
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
parallel | bool | no | false | Send in parallel |
parallel_limit | integer | no | — | Max parallel sends |
stop_on_exception | bool | no | false | Stop on first error |
timeout_ms | integer | no | — | Per-endpoint timeout |
aggregation | string | no | last_wins | Aggregation strategy |
steps | list | no | [] | Target endpoints as steps |
- multicast:
steps:
- to: "log:a"
- to: "log:b"
scatter_gather
Fan out to a fixed set of endpoints and aggregate the results.
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
endpoints | list | no | [] | Target endpoint URIs |
aggregation | string | no | last_wins | Aggregation strategy |
- scatter_gather:
endpoints:
- "log:a"
- "log:b"
recipient_list
Resolve recipients from an expression and send to each.
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
simple | string | no | — | Simple expression for the recipient list |
rhai | string | no | — | Rhai expression |
language | string | no | — | Named expression language |
source | string | no | — | Expression source for language |
delimiter | string | no | , | URI delimiter |
parallel | bool | no | false | Send in parallel |
parallel_limit | integer | no | — | Max parallel sends |
stop_on_exception | bool | no | false | Stop on first error |
strategy | string | no | — | Aggregation strategy |
- recipient_list:
simple: "${header.recipients}"
routing_slip
Route through a list of endpoints carried on the exchange.
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
simple | string | no | — | Simple expression for the slip |
rhai | string | no | — | Rhai expression |
language | string | no | — | Named expression language |
source | string | no | — | Expression source for language |
uri_delimiter | string | no | , | URI delimiter |
cache_size | integer | no | 1000 | Endpoint cache size |
ignore_invalid_endpoints | bool | no | false | Skip invalid endpoints |
- routing_slip:
simple: "${header.routeSlip}"
dynamic_router
Resolve the next endpoint at each step until the expression returns empty.
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
simple | string | no | — | Simple expression |
rhai | string | no | — | Rhai expression |
language | string | no | — | Named expression language |
source | string | no | — | Expression source for language |
uri_delimiter | string | no | , | URI delimiter |
cache_size | integer | no | 1000 | Endpoint cache size |
ignore_invalid_endpoints | bool | no | false | Skip invalid endpoints |
max_iterations | integer | no | 1000 | Max routing iterations |
- dynamic_router:
simple: "${header.nextEndpoint}"
throttle
Rate-limit the exchange flow.
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
max_requests | integer | yes | — | Max requests per period |
period_secs | integer | no | 1 | Time period in seconds |
strategy | string | no | — | Throttle strategy |
steps | list | no | [] | Child steps |
- throttle:
max_requests: 10
steps:
- to: "log:throttled"
load_balance
Distribute exchanges across target endpoints.
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
strategy | string | no | round_robin | Load balance strategy |
distribution_ratio | string | no | — | Weighted distribution |
steps | list | no | [] | Target endpoints |
- load_balance:
strategy: "round_robin"
steps:
- to: "log:a"
- to: "log:b"
enrich
Enrich the exchange by requesting data from an endpoint.
| Form | Syntax |
|---|---|
| Short | enrich: "http:..." |
| Full | enrich: { uri: "...", strategy: "...", timeout: 5000 } |
The full form takes uri (required), strategy, and timeout.
- enrich: "http:my-service/api/data"
- enrich:
uri: "http:my-service/api/data"
strategy: "use_enriched_body"
timeout: 5000
poll_enrich
Enrich the exchange by polling an endpoint. Same fields as enrich.
- poll_enrich: "file:data"
validate
Assert a predicate over the exchange. A failed assertion fails the exchange.
- validate: "${body.field} != null"
idempotent_consumer
Deduplicate exchanges by message ID.
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
repository | string | yes | — | Repository name |
expression | string | yes | — | Message ID expression |
steps | list | no | [] | Steps for first-time exchanges |
eager | bool | no | — | Reserve the key before processing |
remove_on_failure | bool | no | — | Remove the key if the child fails |
- idempotent_consumer:
repository: "memory"
expression: "${header.messageId}"
steps:
- to: "log:first-time"
claim_check
Stash or retrieve the message body in a claim check repository.
| Field | Type | Required | Description |
|---|---|---|---|
repository | string | yes | Repository name |
operation | string | yes | set, get, get_and_remove, push, or pop |
key | string | yes | Claim check key expression |
filter | string | no | Selective merge-back filter |
- claim_check:
repository: "memory"
operation: "set"
key: "${header.claimKey}"
cache
Cache a computed body by key with TTL. On hit, serves the cached body. On miss, runs the on_miss sub-pipeline and stores the result.
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
repository | string | no | "memory" | Repository name |
key | string | yes | — | Cache key expression (None = bypass cache) |
ttl | duration | no | — | Time-to-live for the cached entry |
max_entry_bytes | integer | no | 10 MiB | Maximum body size to cache |
on_miss | list | yes | — | Sub-pipeline to run on cache miss |
- cache:
key: "${header.cacheKey}"
ttl: "5s"
on_miss:
- set_body: "computed"
cache_invalidate
Remove a single key from the cache repository.
| Field | Type | Required | Description |
|---|---|---|---|
key | string | yes | Cache key expression |
- cache_invalidate:
key: "${header.cacheKey}"
cache_peek_stale
Serve a cached entry, ignoring its in-band expiry. Used as a stale-read fallback.
| Field | Type | Required | Description |
|---|---|---|---|
key | string | yes | Cache key expression |
- cache_peek_stale:
key: "${header.cacheKey}"
sampling
Process one exchange out of every N.
| Form | Syntax |
|---|---|
| Short | sampling: 5 (period) |
| Full | sampling: { period: 5 } |
- sampling: 5
- sampling:
period: 10
sort
Sort the body array by a key expression.
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
expression | string | yes | — | Sort key expression |
reverse | bool | no | false | Descending sort |
language | string | no | — | Expression language |
- sort:
expression: "${body.field}"
reverse: true
resequence
Reorder exchanges by sequence number. Batch mode collects and sorts; stream mode is reserved for future use.
| Field | Type | Required | Description |
|---|---|---|---|
batch | object | no | Batch config: correlation, sort, completion |
stream | object | no | Stream config (not yet implemented) |
The completion object accepts size, timeout, and size_or_timeout.
- resequence:
batch:
correlation: "${header.seq}"
sort: "asc"
completion:
size: 100
timeout: 5000
Route-level config
These objects attach to a route. See Route structure for where each one goes.
Error handler config
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
dead_letter_channel | string | no | — | DLC endpoint URI |
retry | object | no | — | Redelivery policy |
on_exceptions | list | no | — | Per-exception clauses |
use_original_message | bool | no | false | Use original message in DLC |
Redelivery policy
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
max_attempts | integer | yes | — | Max retry attempts |
initial_delay_ms | integer | no | 100 | Initial delay in ms |
multiplier | float | no | 2.0 | Backoff multiplier |
max_delay_ms | integer | no | 10000 | Max delay in ms |
jitter_factor | float | no | 0.0 | Jitter factor (0.0-1.0) |
handled_by | string | no | — | Route here after retries are exhausted |
OnException clause
| Field | Type | Required | Description |
|---|---|---|---|
kind | string | no | Error variant name to match |
message_contains | string | no | Substring match on error message |
retry | object | no | Per-clause redelivery policy |
steps | list | no | Handler steps |
handled | bool | no | Absorb the error |
continued | bool | no | Clear error and continue the pipeline |
Circuit breaker config
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
failure_threshold | integer | no | 5 | Failures before opening |
open_duration_ms | integer | no | 30000 | Duration in the open state |
Security policy config
Choose exactly one form: roles, scopes, ref, wasm, or permission.
| Field | Type | Required | Description |
|---|---|---|---|
roles | list | no | Required roles |
scopes | list | no | Required scopes |
all_required | bool | no | All roles/scopes required |
trust_upstream_principal | bool | no | Accept a pre-populated principal with no token |
ref | string | no | Reference to a policy |
wasm | string | no | WASM policy source |
config | map | no | Policy-specific config |
permission | object | no | Permission-based policy |
Top-level blocks
A route file also accepts these top-level keys alongside routes.
REST DSL
The rest key defines REST API blocks.
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
host | string | no | 0.0.0.0 | Listen host |
port | integer | no | 8080 | Listen port |
path | string | no | "" | Base path |
operations | list | no | [] | HTTP operations |
REST operation:
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
method | string | yes | — | HTTP method (GET, POST, ...) |
path | string | no | / | Sub-path |
operation_id | string | no | — | Unique operation ID |
to | string | no | — | Target endpoint URI |
steps | list | no | [] | Child steps |
consumes | string | no | application/json | Request content type |
produces | string | no | application/json | Response content type |
success_status | integer | no | — | Success HTTP status |
request_schema | object | no | — | Request body schema |
response | object | no | — | Response definition |
description | string | no | — | Operation description |
parameters | map | no | {} | Additional parameters |
Template declaration
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
id | string | yes | — | Template identifier |
parameters | list | no | [] | Template parameters |
routes | list | no | [] | Route definitions with {{param}} placeholders |
Templated route instantiation
| Field | Type | Required | Description |
|---|---|---|---|
route_template_ref | string | yes | Template ID to instantiate |
route_id | string | no | Override route ID |
parameters | map | no | Concrete parameter values |
Reference: DSL crate
Configuration
Camel.toml is the operator surface for rust-camel. CamelConfig deserializes the file into a profile-aware tree. Fields live under [default] and [<profile>] sections, deep-merged with includes and CAMEL_* overrides.
Top-level sections: [default.routes] (discovery globs), [components.*] (per-component defaults, untyped TOML), [supervision] (retry and backoff), [observability] (tracing and metrics), [idempotent_repo] (persistent idempotent backend).
Set CAMEL_PROFILE to select a profile. The [default] section always applies. The named profile merges on top. Use include = ["path/to/file.toml"] to pull shared sections from other files.
- Environment variable interpolation: substitute
${env:VAR}tokens in route files before parse - Hot reload: swap pipelines at runtime without downtime
Reference: Config crate
Camel.toml schema
Camel.toml is the operator surface for rust-camel. The file is TOML, parsed by CamelConfig::from_file, and deserialized into the CamelConfig struct. Most fields live under [default] and merge with named profiles ([production], [staging], ...) per the profile rules described in Configuration.
A minimal file sets the route discovery glob and the log level. Everything else has a runtime default.
[default]
routes = ["routes/**/*.yaml"]
log_level = "INFO"
A fuller file adds supervision, tracing, and shared component defaults. Profiles override per environment.
[default]
routes = ["routes/**/*.yaml"]
log_level = "INFO"
# Optional supervision configuration
[default.supervision]
max_attempts = 5
initial_delay_ms = 1000
backoff_multiplier = 2.0
max_delay_ms = 60000
# Optional tracing configuration
[default.observability.tracer]
enabled = true
detail_level = "minimal"
[default.observability.tracer.outputs.stdout]
enabled = true
format = "json"
# Component defaults (optional) - apply to all endpoints unless overridden by URI
# Uncomment to enable global component settings:
# [default.components.http]
# connect_timeout_ms = 5000
# response_timeout_ms = 30000
# max_connections = 100
# allow_internal = false
# [default.components.kafka]
# brokers = "localhost:9092"
# group_id = "camel"
# session_timeout_ms = 45000
# [default.components.redis]
# host = "localhost"
# port = 6379
# [default.components.sql]
# max_connections = 5
# min_connections = 1
# idle_timeout_secs = 300
# [default.components.file]
# delay_ms = 500
# read_timeout_ms = 30000
# [default.components.container]
# docker_host = "unix:///var/run/docker.sock"
# Optional persistent idempotent repository (opt-in). When set under the
# active profile, registers a "redb" backend for `idempotent_consumer` steps;
# keys persist across restart. The default "memory" repo stays available.
# durability: "immediate" (default, fsync per key) | "eventual" (no fsync, faster)
# [default.idempotent_repo]
# path = ".camel/idempotent.redb"
# durability = "immediate"
[development]
log_level = "DEBUG"
[production]
log_level = "ERROR"
Top-level fields
The fields below live directly under [default]. They are the spine of the file. Every other section in this page is optional.
| Field | Type | Default | Description |
|---|---|---|---|
routes | array of strings | [] | Glob patterns for route files (YAML or JSON). Discovery runs at startup and on every file change when watch is enabled. |
watch | bool | false | Enable the file watcher. When true, route file changes trigger a hot reload. See Hot reload. |
watch_debounce_ms | integer (ms) | 300 | Delay after the last file event before a reload. Increase if one save triggers several reloads. |
log_level | string | "INFO" | One of TRACE, DEBUG, INFO, WARN, ERROR. |
timeout_ms | integer (ms) | 5000 | Per-exchange timeout enforced by the runtime. Must be > 0. |
drain_timeout_ms | integer (ms) | 10000 | Maximum time the runtime waits for in-flight exchanges to finish on shutdown. Must be > 0. |
include | array of strings | — | Paths to other TOML files merged before the profile pass. See Configuration. |
The watcher and the file are wired through camel-core::reload_watcher::watch_and_reload (see ADR-0004). The --watch and --no-watch CLI flags override this field at startup.
[components]
Component configuration is untyped TOML keyed by component name. Each component bundle parses its own block. The schema below is the union of fields the runtime and core components recognize.
[components.http]
connect_timeout_ms = 5000
response_timeout_ms = 30000
allow_internal = false
[components.kafka]
brokers = "localhost:9092"
group_id = "camel"
The full per-component schema lives in each component's documentation. The Kafka block is the most complex and supports a [components.kafka.brokers_named.<name>] sub-table for pre-configured clusters referenced from URIs with ?brokerName=<name>. See Kafka for the named-broker fields.
| Common field | Type | Default | Description |
|---|---|---|---|
brokers | string or list of strings | component-specific | Connection string for the underlying client. Kafka accepts a comma-separated string. |
host, port | string, integer | component-specific | Listen address for components that open servers. |
connect_timeout_ms | integer | component-specific | Timeout for establishing a connection. |
allow_internal | bool | false | Allow private/loopback endpoints. Set true only for local development. |
Custom component bundles follow the same convention. A bundle named echo reads its config from [components.echo]. See Custom component for the bundle contract.
[supervision]
The runtime uses these knobs to restart a failed Consumer with capped exponential backoff. Defaults match a one-second start, doubling each attempt, capped at one minute, with five attempts.
| Field | Type | Default | Description |
|---|---|---|---|
max_attempts | integer or null | 5 | Maximum restart attempts. null retries forever. |
initial_delay_ms | integer (ms) | 1000 | Delay before the first attempt. Must be > 0. |
backoff_multiplier | float | 2.0 | Multiplier applied to the delay after each failure. Must be >= 1.0. |
max_delay_ms | integer (ms) | 60000 | Cap on the per-attempt delay. Must be > 0. |
[observability]
The observability block holds four optional sub-tables. The [observability.tracer] block configures the built-in tracing layer. The other three activate optional exporters.
| Field | Type | Default | Description |
|---|---|---|---|
tracer | table | (built-in defaults) | Built-in tracing layer config. |
otel | table | absent | OpenTelemetry exporter. Absent disables OTLP. |
prometheus | table | absent | Prometheus scrape endpoint. Absent disables the endpoint. |
health | table | absent | HTTP health/readiness endpoint. Absent disables the endpoint. |
[observability.otel]
OTLP export configuration. The protocol and sampler accept the values listed below; values not in the enum fail at load.
| Field | Type | Default | Description |
|---|---|---|---|
enabled | bool | false | Master switch for OTLP export. |
endpoint | string | "http://localhost:4317" | OTLP collector endpoint. |
service_name | string | "rust-camel" | Resource attribute identifying the service. |
protocol | string | "grpc" | OTLP transport: grpc or http. |
sampler | string | "always_on" | Sampling strategy: always_on, always_off, ratio. |
sampler_ratio | float | null | Sampling probability for the ratio strategy. Range 0.0-1.0. |
metrics_interval_ms | integer (ms) | 60000 | Period for the metrics export loop. Must be > 0. |
logs_enabled | bool | true | Include log records in the OTLP stream. |
resource_attrs | table | {} | Extra resource attributes attached to every export. |
[observability.prometheus]
| Field | Type | Default | Description |
|---|---|---|---|
enabled | bool | false | Start the scrape endpoint. |
host | string | "0.0.0.0" | Bind address. |
port | integer | 9090 | Bind port. |
[observability.health]
| Field | Type | Default | Description |
|---|---|---|---|
enabled | bool | false | Start the HTTP health endpoint. |
host | string | "0.0.0.0" | Bind address. |
port | integer | 8081 | Bind port. |
handler_timeout_ms | integer (ms) | 6000 | Per-probe timeout. Must exceed the internal 5s registry tick. |
forced_ttl_ms | integer (ms) or null | null | Optional TTL for forced-unhealthy entries. Disabled by default. |
[security]
The security block holds five optional sub-tables. Pick one. Most deployments choose exactly one of oidc, native, or keycloak. The permissions and policies sub-tables extend the chosen identity layer with authorization.
[security.keycloak]
server_url = "https://kc.example.com"
realm = "my-realm"
client_id = "camel"
client_secret = "{{env:KC_SECRET}}"
| Field | Type | Default | Description |
|---|---|---|---|
oidc | table | absent | Generic OIDC token validation. |
native | table | absent | Built-in issuer with m2m clients. |
keycloak | table | absent | Keycloak realm with validation, JWKS, introspection, and UMA. |
permissions | table of tables | absent | Named permission evaluators keyed by policy name. |
policies | table | absent | Registry of WASM security policies referenced by route configuration. |
[security.oidc]
| Field | Type | Default | Description |
|---|---|---|---|
issuer | string | (required) | OIDC issuer URL used to discover endpoints. |
jwks_uri | string | null | JWKS endpoint. Defaults to ${issuer}/protocol/openid-connect/certs. |
audience | array of strings | [] | Required aud claim values. |
client_id | string | null | OAuth2 client ID. |
client_secret | string | null | OAuth2 client secret. Prefer a placeholder over a literal. |
token_endpoint | string | null | Token endpoint for client credentials flows. |
introspection_endpoint | string | null | Token introspection endpoint. |
[security.native]
The native block provides a built-in token issuer plus a list of m2m clients. Set subject to the m2m principal.
| Field | Type | Default | Description |
|---|---|---|---|
subject | string | (required) | Principal name for the m2m identity. |
issuer | string | null | Issuer claim on issued tokens. |
bearer_token | string | null | Pre-issued bearer token. Prefer a placeholder. |
api_key | string | null | Pre-shared API key. Prefer a placeholder. |
roles | array of strings | [] | Roles granted to the identity. |
scopes | array of strings | [] | Scopes granted to the identity. |
token_issuer | table | absent | Built-in token issuer config. |
clients | array of tables | [] | m2m clients allowed to authenticate. |
[security.native.token_issuer] fields:
| Field | Type | Default | Description |
|---|---|---|---|
issuer | string | (required) | Issuer URL. |
audience | array of strings | [] | Required aud claim. |
token_ttl_secs | integer (s) | 900 | Issued-token lifetime. |
signing_key_env | string | (required) | Environment variable holding the signing key (PEM). |
[[security.native.clients]] array elements:
| Field | Type | Default | Description |
|---|---|---|---|
client_id | string | (required) | Client identifier. |
client_secret_env | string | (required) | Environment variable holding the client secret. |
roles | array of strings | [] | Roles assigned to tokens minted for this client. |
scopes | array of strings | [] | Scopes assigned to tokens minted for this client. |
[security.keycloak]
| Field | Type | Default | Description |
|---|---|---|---|
server_url | string | (required) | Keycloak base URL. |
realm | string | (required) | Realm name. |
client_id | string | (required) | Client ID. |
client_secret | string | (required) | Client secret. Prefer a placeholder. |
validation | table | (defaults below) | Token validation options. |
jwks | table | (defaults below) | JWKS cache tuning. |
introspection | table | (defaults below) | Introspection cache tuning. |
uma | table | absent | UMA authorization provider. |
allow_internal | bool | false | Allow HTTP and private addresses. Set true only for local Keycloak. |
[security.keycloak.validation] fields:
| Field | Type | Default | Description |
|---|---|---|---|
method | string | "local" | Validation method. "local" validates signature and claims locally. |
audience | array of strings | [] | Required aud claim values. |
clock_skew_secs | integer (s) | 30 | Tolerance for exp and nbf claims. |
[security.keycloak.jwks] fields:
| Field | Type | Default | Description |
|---|---|---|---|
cache_ttl_secs | integer (s) | 3600 | How long the JWKS cache holds keys. |
refresh_skew_secs | integer (s) | 60 | Refresh the cache this many seconds before expiry. |
[security.keycloak.introspection] fields:
| Field | Type | Default | Description |
|---|---|---|---|
max_entries | integer | 10000 | Maximum cached introspection results. |
default_ttl_secs | integer (s) | 60 | TTL for positive results. |
negative_ttl_secs | integer (s) | 5 | TTL for negative results. |
[security.permissions.<name>]
Named permission evaluators. A route references the name through route configuration.
[security.permissions.invoice-policy]
provider = "wasm"
path = "./policies/invoice-policy.wasm"
[security.permissions.invoice-policy.config]
mode = "enforce"
[security.permissions.invoice-policy.cache]
positive_ttl_secs = 60
negative_ttl_secs = 10
| Field | Type | Default | Description |
|---|---|---|---|
provider | string | (required) | Provider key. Currently wasm. |
path | string | null | Provider-specific path. WASM providers need a .wasm file. |
config | table | absent | Key-value pairs passed to the provider. |
cache | table | (defaults below) | Result cache tuning. |
limits | table | absent | WASM limits. See WASM limits. |
[security.permissions.<name>] cache fields:
| Field | Type | Default | Description |
|---|---|---|---|
positive_ttl_secs | integer (s) | 30 | TTL for allow decisions. |
negative_ttl_secs | integer (s) | 5 | TTL for deny decisions. |
max_entries | integer | 10000 | Cache size. |
[security.policies.wasm.<name>]
Registry of named WASM security policies. Each entry is a path plus limits plus a config map.
[security.policies.wasm.corp-auth]
path = "plugins/authz.wasm"
[security.policies.wasm.corp-auth.limits]
timeout-secs = 30
[security.policies.wasm.corp-auth.config]
ldap_url = "ldap://corp"
| Field | Type | Default | Description |
|---|---|---|---|
path | string | (required) | Path to the .wasm file. Relative to the project root or absolute. |
limits | table | absent | WASM limits. See WASM limits. |
config | table | {} | Key-value pairs passed to the guest init(). |
[runtime_journal]
The runtime event journal. When unset, runtime state is ephemeral and lost on restart. Set a path to enable a redb-backed journal.
| Field | Type | Default | Description |
|---|---|---|---|
path | string | (required) | Path to the .db file. Created if it does not exist. Must not be empty. |
durability | string | "immediate" | immediate fsyncs on every commit. eventual skips fsync for throughput. |
compaction_threshold_events | integer | 10000 | Trigger compaction after this many events. Must be > 0. |
[idempotent_repo]
Persistent idempotent repository for the Idempotent Consumer EIP. When unset, the runtime uses the in-memory MemoryIdempotentRepository, which is bounded and ephemeral.
| Field | Type | Default | Description |
|---|---|---|---|
path | string | (required) | Path to the .redb file. Must not be empty. |
durability | string | "immediate" | immediate fsyncs on every key. eventual skips fsync. |
[stream_caching]
The Stream Cache step buffers stream bodies past a threshold so the body can be read more than once. The cache applies to the whole runtime, not per route.
| Field | Type | Default | Description |
|---|---|---|---|
threshold | integer (bytes) | camel_api::stream_cache::DEFAULT_STREAM_CACHE_THRESHOLD | Bodies below this size pass through unread. Bodies above this size are buffered. |
[platform]
Platform selection for leader election, readiness, and identity. Defaults to noop, which always reports leader and ready. Set type = "kubernetes" to enable leader election through a Kubernetes lease.
| Field | Type | Default | Description |
|---|---|---|---|
type | string | "noop" | noop or kubernetes. |
[platform] with type = "kubernetes" accepts:
| Field | Type | Default | Description |
|---|---|---|---|
namespace | string | null | Namespace for the lease object. Defaults to the pod's namespace. |
lease_name_prefix | string | "camel-" | Prefix on the lease object name. |
lease_duration_secs | integer (s) | 15 | Lease lifetime. Must be > 0. |
renew_deadline_secs | integer (s) | 10 | Maximum time the leader can hold the lease between renewals. Must be > 0. |
retry_period_secs | integer (s) | 2 | How often a non-leader retries acquisition. Must be > 0. |
jitter_factor | float | 0.2 | Randomisation applied to the retry period. Range 0.0-1.0. |
[languages]
The languages block tunes the resource limits for the in-process scripting engines. Each sub-block is optional. Unset fields fall back to the rust-camel runtime default, never to the upstream engine's unlimited default.
[languages.rhai.limits]
Rhai sandbox limits.
| Field | Type | Default | Description |
|---|---|---|---|
max-operations | integer | runtime default | Maximum operations per script. Counter resets each call. |
max-string-size | integer (bytes) | runtime default | Maximum string size in bytes. |
max-array-size | integer | runtime default | Maximum array size in elements. |
max-map-size | integer | runtime default | Maximum map size in key-value pairs. |
max-expression-depth | integer | runtime default | Maximum expression nesting depth. |
max-function-expression-depth | integer | runtime default | Maximum nesting depth for function call expressions. |
execution-timeout-ms | integer (ms) | runtime default | Wall-clock timeout enforced by the consuming code. |
[languages.js.limits]
Boa JavaScript engine limits.
| Field | Type | Default | Description |
|---|---|---|---|
execution-timeout-ms | integer (ms) | runtime default | Wall-clock timeout enforced by the consuming code. |
max-loop-iterations | integer | runtime default | Maximum loop iterations before Boa terminates. |
max-recursion-depth | integer | runtime default | Maximum recursion depth for function calls. |
max-stack-size | integer (slots) | runtime default | Maximum VM stack size in slots, not bytes. |
[languages.minijinja.limits]
MiniJinja template engine limits.
| Field | Type | Default | Description |
|---|---|---|---|
max-template-source-size | integer (bytes) | runtime default | Maximum compiled template source size. |
max-context-size | integer (bytes) | runtime default | Maximum serialised context size. |
max-output-size | integer (bytes) | runtime default | Maximum rendered output size. |
fuel | integer | runtime default | MiniJinja VM instruction budget. |
max-recursion-depth | integer | runtime default | Maximum recursion depth for includes and blocks. |
execution-timeout-ms | integer (ms) | runtime default | Wall-clock timeout enforced by the consuming code. |
[components.template.limits]
The external Template component's resource limits. Set any subset of fields. Unset fields fall back to the resolved defaults listed below.
[components.template.limits]
max-total-source-bytes = 16777216
max-include-count = 64
max-include-depth = 16
max-template-size = 1048576
reload-timeout-ms = 5000
| Field | Type | Resolved default | Description |
|---|---|---|---|
max-total-source-bytes | integer (bytes) | 16777216 (16 MiB) | Maximum total source bytes across a template dependency closure. |
max-include-count | integer | 64 | Maximum number of included or imported templates per closure. |
max-include-depth | integer | 16 | Maximum include and extends nesting depth. |
max-template-size | integer (bytes) | 1048576 (1 MiB) | Maximum size of a single template file. |
reload-timeout-ms | integer (ms) | 5000 | Wall-clock budget for a full reload build. |
Zero values are rejected. The block uses deny_unknown_fields, so a typo is caught at load.
[beans]
Named beans are WASM plugins exposed to routes as lookup targets. Each bean is keyed by name and carries a plugin path plus an optional config map and WASM limits.
[beans.auth]
plugin = "my-auth"
[beans.auth.config]
api_key = "{{env:API_KEY}}"
[beans.auth.limits]
timeout-secs = 600
| Field | Type | Default | Description |
|---|---|---|---|
plugin | string | (required) | Plugin identifier or .wasm path. Must be non-empty. |
config | table | {} | Key-value pairs passed to the plugin. |
limits | table | absent | WASM limits. See WASM limits. |
WASM limits
WASM limits appear in three places: [beans.<name>.limits], [security.permissions.<name>.limits], and [security.policies.wasm.<name>.limits]. The struct is the same in all three. The fields use kebab-case. Unset fields fall back to the rust-camel runtime default. The defaults are finite: 50 MiB max memory, 10 MB max wasm size, 10 000 max instances, 10 000 max tables.
| Field | Type | Runtime default | Description |
|---|---|---|---|
timeout-secs | integer (s) | runtime default | Maximum execution time per guest call. |
max-memory | integer (bytes) | 52428800 (50 MiB) | Maximum linear memory the guest can allocate. Enforced by wasmtime. |
max-concurrent-calls | integer | runtime default | Maximum concurrent invocations against this plugin. |
max-wasm-size | integer (bytes) | 10485760 (10 MB) | Maximum .wasm file size. |
allow-call-schemes | string | null (deny all) | Comma-separated URI schemes the guest may call. Empty or null fails closed. |
max-stream-bytes | integer (bytes) | runtime default | Maximum body bytes streamed between host and guest. |
max-instances | integer | 10000 | Maximum core instances per store. |
max-tables | integer | 10000 | Maximum tables per store. |
max-table-elements | integer | unlimited | Maximum elements per table. |
[datasources]
Named datasource pools. Each entry is keyed by a name routes reference with the sql-ds://<name> URI scheme. The block is a map of DatasourceConfig values.
[default.datasources.appdb]
db_url = "sqlite:file:memdb?mode=memory&cache=shared"
max_connections = 5
| Field | Type | Default | Description |
|---|---|---|---|
db_url | string | (required) | Connection URL. postgresql://, postgres://, and ws:// (SurrealDB) are recognised. Must not be empty. |
provider | string | null | Provider override. Defaults from the URL scheme. |
max_connections | integer | null | Maximum pool size. |
min_connections | integer | null | Minimum pool size. |
idle_timeout_secs | integer (s) | null | Idle connection timeout. |
max_lifetime_secs | integer (s) | null | Maximum connection lifetime. |
ssl_mode | string | null | TLS mode. Provider-specific values. |
ssl_root_cert | string | null | Path to the root CA. |
ssl_cert | string | null | Path to the client certificate. |
ssl_key | string | null | Path to the client key. |
extra | table | {} | Provider-specific key-value pairs. SurrealDB reads namespace and database from here. |
Profile overrides
[default] always applies. A named profile like [production] is selected through CAMEL_PROFILE=production or the loader API. Profile blocks deep-merge on top of [default]. The example below shows a [production] block tightening timeouts and pointing Kafka at the production cluster.
# Shared component defaults live in a separate file so they can be reused
# across deployment configs without duplication.
include = ["config/components.toml"]
[default]
routes = ["routes/**/*.yaml"]
log_level = "INFO"
# Optional supervision configuration
[default.supervision]
max_attempts = 5
initial_delay_ms = 1000
backoff_multiplier = 2.0
max_delay_ms = 60000
# Optional tracing configuration
[default.observability.tracer]
enabled = true
detail_level = "minimal"
[default.observability.tracer.outputs.stdout]
enabled = true
format = "json"
# Development profile - more verbose, local services
[development]
log_level = "DEBUG"
[development.components.http]
allow_internal = true # Allow internal services in dev
[development.components.kafka]
brokers = "localhost:9092"
# Production profile - stricter settings, remote services
[production]
log_level = "WARN"
[production.components.http]
connect_timeout_ms = 5000 # Faster fail in production
allow_internal = false
[production.components.kafka]
brokers = "prod-kafka:9092"
group_id = "camel-prod"
session_timeout_ms = 60000
The [development] and [production] blocks set a log_level and override shared component defaults. Shared component defaults can also live in a separate file pulled through include. See Configuration for the merge rules and the include order.
Environment variable overrides
A small allowlist of CAMEL_* environment variables overrides specific fields when the runtime calls CamelConfig::from_file_with_env. The allowlist is the security boundary: any CAMEL_* variable not in the list is ignored at load and logged as a warning.
| Variable | Field |
|---|---|
CAMEL_TIMEOUT_MS | timeout_ms |
CAMEL_DRAIN_TIMEOUT_MS | drain_timeout_ms |
CAMEL_WATCH | watch |
CAMEL_WATCH_DEBOUNCE_MS | watch_debounce_ms |
CAMEL_LOG_LEVEL | log_level |
CAMEL_RUNTIME_JOURNAL_PATH | runtime_journal.path |
CAMEL_RUNTIME_JOURNAL_DURABILITY | runtime_journal.durability |
CAMEL_RUNTIME_JOURNAL_COMPACTION_THRESHOLD_EVENTS | runtime_journal.compaction_threshold_events |
CAMEL_IDEMPOTENT_REPO_PATH | idempotent_repo.path |
CAMEL_IDEMPOTENT_REPO_DURABILITY | idempotent_repo.durability |
CAMEL_SUPERVISION_INITIAL_DELAY_MS | supervision.initial_delay_ms |
CAMEL_SUPERVISION_MAX_ATTEMPTS | supervision.max_attempts |
CAMEL_CONFIG_FILE and CAMEL_PROFILE are also read, but outside the allowlist. CAMEL_CONFIG_FILE selects the file path before load. CAMEL_PROFILE selects the profile section.
Reference: Config crate
Environment variable interpolation
Substitute environment variables into YAML route files with ${env:VAR}
tokens. The tokens expand before YAML parsing, so they work in endpoint
URIs, log messages, header values, and any other string field.
Syntax
# =============================================================================
# env-interpolation example routes
#
# Uses ${env:VAR_NAME} to inject runtime environment variables.
# Supports default values with ${env:VAR_NAME:-default} syntax.
# Substitution happens before YAML parsing — works in URIs, messages, values.
#
# Try setting these before running:
# GREET_TARGET=log:my-app
# POLL_PERIOD_MS=2000
# LOG_PREFIX="[demo]"
#
# If unset, defaults kick in automatically (see route 2).
# =============================================================================
routes:
# ---------------------------------------------------------------------------
# Route 1: env var in log message and endpoint URI
# ---------------------------------------------------------------------------
- id: "env-greet"
from: "timer:env-tick?period=${env:POLL_PERIOD_MS}&repeatCount=3"
steps:
- log: "${env:LOG_PREFIX} Firing env-interpolation route"
- set_header:
key: "app"
value: "${env:APP_NAME}"
- to: "${env:GREET_TARGET}"
# ---------------------------------------------------------------------------
# Route 2: env var with default fallback syntax
# ---------------------------------------------------------------------------
- id: "env-defaults"
from: "timer:env-defaults?period=${env:DEFAULT_POLL_MS:-3000}&repeatCount=2"
steps:
- log: "${env:DEFAULT_LOG_PREFIX:-[defaults]} Using fallback defaults"
- to: "${env:DEFAULT_TARGET:-log:info}"
${env:VAR} reads the variable VAR. ${env:VAR:-default} uses
default when VAR is unset. An unset variable with no default fails
route discovery. The error names the variable. Set a default or export
the variable to avoid the failure.
How it works
The DSL loader (camel_dsl::interpolate_env) scans route source for
${env:...} patterns and replaces them before YAML parsing. Substituted
values pass through sanitize_env_value, which strips control characters
and newlines. This blocks newline injection from a hostile or malformed
variable.
The PropertiesResolver type in camel-config exposes the same
resolution as a public API for config-value placeholders.
Setup
let config =
CamelConfig::from_file("Camel.toml").map_err(|e| CamelError::Config(e.to_string()))?;
let mut ctx = CamelContext::builder().build().await.unwrap(); // allow-unwrap
ctx.register_component(TimerComponent::new());
ctx.register_component(LogComponent::new());
let routes = discover_routes(&config.routes).map_err(|e| CamelError::Config(e.to_string()))?;
The Camel.toml for this example is minimal. Route discovery and
component registration follow the standard pattern.
[default]
routes = ["routes/**/*.yaml"]
log_level = "INFO"
When to use
- Twelve-factor apps: inject configuration that varies per deploy through the environment, not through files in source control.
- Secrets: pass credentials and tokens from the environment. The route file never stores the secret value.
- Per-environment endpoints: point routes at different brokers, HTTP hosts, or databases without editing route files.
Reference: PropertiesResolver in the Config crate
Hot reload
Hot reload swaps route pipelines at runtime without stopping the context. When a route file changes on disk, the runtime compiles the new pipeline and swaps it atomically. In-flight exchanges complete against the pipeline snapshot they entered.
Architecture
The runtime stores the active pipeline behind ArcSwap. A swap
publishes a new Arc in one atomic step. New exchanges see the new
pipeline immediately. Exchanges already in flight hold their existing
Arc and finish against the old pipeline. Old and new pipelines coexist
until the last in-flight exchange drains.
Reference: ADR-0004
Configuration
Set watch_debounce_ms in Camel.toml to control the debounce delay.
The watcher waits this long after the last file event before it reloads.
Increase the value if one save triggers several rapid reloads.
[default]
routes = ["routes/**/*.yaml"]
log_level = "INFO"
# Debounce delay for the file watcher (milliseconds).
# Increase if you see multiple rapid reloads on a single save.
watch_debounce_ms = 300
Usage
Load the debounce from config
The hot-reload-yaml example reads watch_debounce_ms from Camel.toml
and passes it to watch_and_reload.
// ── 0. Load configuration (watch_debounce_ms etc.) ───────────────────────
// Falls back to the CamelConfig field default (300 ms) if Camel.toml is absent.
let debounce_ms = CamelConfig::from_file("Camel.toml")
.map(|c| c.watch_debounce_ms)
.unwrap_or(300);
println!("[0] watch_debounce_ms = {debounce_ms} ms (set in Camel.toml)");
Start the watcher
The hot-reload example resolves the directories to watch, then starts
watch_and_reload in a background task. A CancellationToken stops the
watcher on shutdown.
tokio::spawn(async move {
let watch_dirs = resolve_watch_dirs(&watch_patterns);
let result = watch_and_reload(
watch_dirs,
ctrl,
move || {
camel_dsl::discover_routes(&watch_patterns)
.map_err(|e| CamelError::RouteError(e.to_string()))
},
Some(shutdown_watcher),
std::time::Duration::from_secs(10),
std::time::Duration::from_millis(300),
)
.await;
The watched route file uses a plain YAML route:
- from: timer:hot-reload?period=1000
route_id: hot-reload-route
steps:
- to: log:info?showHeaders=true
How it works
- The file watcher monitors route directories for changes.
- After the debounce window, it calls
discover_routesto reload route definitions. - It computes reload actions (swap, add, remove) by comparing the old and new routes.
- It applies each action on the runtime controller.
The watcher runs in a background task. Pass a CancellationToken to stop
it on shutdown.
When to use
Use hot reload for zero-downtime updates. Edit a route, save the file, and the running context adopts the change within the debounce window. This fits long-running integration services that cannot restart during traffic. Do not use hot reload where route correctness needs a full compile-time check. Prefer the Rust builder API and a redeploy for that case.
Reference: reload_watcher::watch_and_reload in the Runtime crate
Services
Services are cross-cutting infrastructure that registers into the CamelContext lifecycle. They observe, manage, and secure the runtime. Components produce and consume Exchanges. Services do not.
Lifecycle trait
The Lifecycle trait coordinates service start and stop with
CamelContext. Register with with_lifecycle(...). Services start in
registration order, stop in reverse order, and report Stopped,
Started, or Failed through Lifecycle::status().
Available services
| Service | Crate | Description |
|---|---|---|
| Prometheus | camel-prometheus | Route metrics at /metrics plus health endpoints |
| OpenTelemetry | camel-otel | Traces, metrics, and logs over OTLP |
| Auth | camel-auth | Token validation, claim mapping, permission checks |
| Function runtime | camel-function | Out-of-process user code invoked as function: pipeline steps |
| Bridge | camel-bridge | Spawns and supervises Java bridge processes for JVM-only components (JMS, XML, CXF) |
Reference: Services crate. See also Health endpoints.
Metrics
rust-camel records route and exchange metrics through the
MetricsCollector trait. PrometheusMetrics is the production
implementation. You can supply your own.
Custom metrics collector
Implement MetricsCollector to receive route-level metrics. The
runtime calls your methods as exchanges flow, errors fire, and circuit
breakers change state. The trait covers exchange duration, error and
exchange counts, queue depth, and circuit-breaker transitions.
/// A simple console-based metrics collector that prints stats on shutdown.
#[derive(Debug)]
struct ConsoleMetrics {
exchanges_processed: AtomicU64,
errors: AtomicU64,
total_duration_ns: AtomicU64,
}
impl ConsoleMetrics {
fn new() -> Self {
Self {
exchanges_processed: AtomicU64::new(0),
errors: AtomicU64::new(0),
total_duration_ns: AtomicU64::new(0),
}
}
fn print_stats(&self) {
let processed = self.exchanges_processed.load(Ordering::Relaxed);
let errors = self.errors.load(Ordering::Relaxed);
let total_ns = self.total_duration_ns.load(Ordering::Relaxed);
let avg_ms = (total_ns.checked_div(processed).unwrap_or(0)) as f64 / 1_000_000.0;
println!(
"[METRICS] exchanges={} errors={} avg_duration_ms={:.2}",
processed, errors, avg_ms
);
}
}
impl MetricsCollector for ConsoleMetrics {
fn record_exchange_duration(&self, _route_id: &str, duration: Duration) {
self.exchanges_processed.fetch_add(1, Ordering::Relaxed);
self.total_duration_ns
.fetch_add(duration.as_nanos() as u64, Ordering::Relaxed);
}
fn increment_errors(&self, _route_id: &str, _error_type: &str) {
self.errors.fetch_add(1, Ordering::Relaxed);
}
fn increment_exchanges(&self, _route_id: &str) {
self.exchanges_processed.fetch_add(1, Ordering::Relaxed);
}
fn set_queue_depth(&self, _route_id: &str, _depth: usize) {
// Not tracked in this simple example
}
fn record_circuit_breaker_change(&self, _route_id: &str, from: &str, to: &str) {
println!("[METRICS] Circuit breaker: {} -> {}", from, to);
}
}
Register the collector with CamelContext::builder().metrics(...):
// Create context with custom metrics collector
let mut ctx = CamelContext::builder().metrics(metrics).build().await?;
ctx.register_component(TimerComponent::new());
ctx.register_component(LogComponent::new());
let route = RouteBuilder::from("timer:metrics?period=500&repeatCount=10")
.route_id("metrics-demo")
.process(|mut exchange| {
let start = Instant::now();
async move {
// Simulate some work
tokio::time::sleep(tokio::time::Duration::from_millis(10)).await;
exchange.input.body = Body::Json(serde_json::json!({
"message": "processed",
"elapsed_us": start.elapsed().as_micros(),
}));
Ok(exchange)
}
})
.to("log:output?showBody=true&showCorrelationId=true")
.build()?;
ctx.add_route_definition(route).await?;
Note: Service registration is Rust API only. YAML routes compile to the same
RouteDefinition. The service wiring stays in application code.
YAML equivalent for the route
routes:
- id: metrics-demo
from: timer:metrics?period=500&repeatCount=10
steps:
# The Rust route measures elapsed time and sets the body in an
# inline process() closure. Move that logic into a bean step.
- set_body:
value:
message: processed
- to: log:output?showBody=true&showCorrelationId=true
Reference: MetricsCollector contract
Prometheus integration
PrometheusService is a Lifecycle implementation. It owns a
PrometheusMetrics collector and an HTTP server, exposes metrics at
/metrics, and merges the health routes from camel-health.
let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0)), 9090);
let prometheus = PrometheusService::new(addr);
let mut ctx = CamelContext::builder()
.build()
.await
.unwrap() // allow-unwrap
.with_lifecycle(prometheus)
.with_tracing()
.await;
Note: Service registration is Rust API only. YAML routes compile to the same
RouteDefinition. The service wiring stays in application code.
The server exposes these endpoints:
| Endpoint | Purpose |
|---|---|
/metrics | Prometheus scrape |
/healthz | liveness probe (200 while alive) |
/readyz | readiness probe (200 healthy, 503 not) |
/health | detailed JSON health report |
Exposure posture
Diagnostic endpoints follow the Prometheus scrape convention: unauthenticated by default, with TLS and auth as opt-in hooks. The default bind address prefers loopback. Binding to 0.0.0.0 emits a startup warning.
See ADR-0052 for the full posture.
Cardinality contract
Metric label values must come from a closed or bounded set. Never pass raw Exchange body, header, property, or correlation-key data as a label value. Each distinct label combination creates a new Prometheus series. The registry has no cardinality cap or eviction.
See ADR-0032 for the exchange-data trust boundary.
Reference: camel-prometheus crate
Tracing
rust-camel integrates with OpenTelemetry for distributed tracing,
metrics, and log export. OtelService owns the lifecycle of the
global OTel providers.
OtelService
OtelService is a Lifecycle implementation. It installs and owns
the process-global OpenTelemetry tracer, meter, and logger providers.
Keep one active instance per process.
Configure the OTLP endpoint and service identity with OtelConfig:
// Create OpenTelemetry configuration
// Points to the OTLP collector (grafana/otel-lgtm) running locally
// metrics_interval_ms=15000 exports metrics every 15s (default 60s is too slow for demos)
let otel_config = OtelConfig::new("http://localhost:4317", "rust-camel-otel-demo")
.with_metrics_interval_ms(15000);
Note: Service registration is Rust API only. YAML routes compile to the same
RouteDefinition. The service wiring stays in application code.
Create the service and register it with the context:
// Create the OpenTelemetry service
// OtelService manages:
// - Global TracerProvider (spans exported via OTLP)
// - Global MeterProvider (metrics exported via OTLP)
// - Global LoggerProvider + Log bridge (logs exported via OTLP)
// - as_metrics_collector() for automatic route metrics
let mut otel_service = OtelService::new(otel_config);
// Initialize tracing subscriber with both stdout and OTel log bridge
let logger_provider = otel_service.init_logger_provider()?;
let otel_layer =
opentelemetry_appender_tracing::layer::OpenTelemetryTracingBridge::new(&logger_provider);
use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::util::SubscriberInitExt;
tracing_subscriber::registry()
.with(tracing_subscriber::fmt::layer())
.with(otel_layer)
.init();
// Build CamelContext with the OtelService as a lifecycle service.
// with_lifecycle() auto-registers the metrics collector from OtelService.
let mut ctx = CamelContext::builder()
.build()
.await
.unwrap() // allow-unwrap
.with_lifecycle(otel_service)
.with_tracer_config(TracerConfig {
enabled: true,
detail_level: DetailLevel::Medium,
outputs: TracerOutputs {
stdout: StdoutOutput {
enabled: false, // suppress noisy JSON to stdout
..Default::default()
},
file: None,
},
..Default::default()
})
.await;
Note: Service registration is Rust API only. YAML routes compile to the same
RouteDefinition. The service wiring stays in application code.
with_lifecycle() auto-registers the metrics collector from
OtelService::as_metrics_collector(). The runtime records route-level
metrics automatically.
Operational invariants
- One global provider set.
OtelService::start()installs providers throughopentelemetry::global. OpenTelemetry global setters replace the active provider and provide no reset API. Starting a second service can detach the first from global lookups. Its exporter tasks stay alive until you stop it. Keep one activeOtelServiceper process. - Start before metric use.
OtelMetricsresolves itsMeteron first use and caches it. Recording beforeOtelService::start()binds that instance to the no-op provider. Later startup does not replace the cached meter. Start the service before you register or record metrics.
See camel-otel CONTEXT.md for the full operational invariants.
Propagation helpers
The crate provides helpers that bridge W3C trace context between transport headers and an Exchange:
| Helper | Action |
|---|---|
extract_context | extract trace context from transport headers |
inject_context | inject trace context into transport headers |
extract_into_exchange | extract trace context into an Exchange |
inject_from_exchange | inject trace context from an Exchange |
Related decisions
- ADR-0007 motivates graceful provider shutdown. OpenTelemetry global ownership makes shutdown order operationally significant.
- ADR-0012 defines the log-policy classification for service start failures.
Reference: camel-otel crate
Authentication and authorization
The camel-auth crate validates bearer tokens, maps claims into a
Principal, and evaluates authorization decisions for route-level
security_policy. It is provider-neutral. OIDC presets for specific
providers live in component crates such as camel-component-keycloak.
Architecture
The auth pipeline has three layers:
- TokenAuthenticator validates a bearer or API token and returns a
Principal. Implementations includeIntrospectionAuthenticator(RFC 7662),ApiKeyAuthenticator,StaticTokenAuthenticator, andLocalJwtValidator. - ClaimsMapper maps token or introspection claims into
Principalfields: subject, roles, scopes, issuer, audience.JsonPointerClaimsMapperresolves JSON Pointer paths, so any OIDC provider works without code. - PermissionEvaluator evaluates resource, action, and scope requests and returns a
PermissionDecision. Route-levelsecurity_policy.permissioncalls it.
The enforcement boundary is SecurityPolicyLayer in camel-core. It evaluates BEFORE route steps run. A granted decision stores Principal properties on the Exchange. A denied decision returns Unauthorized into route error handling.
See ADR-0010 for the pre-pipeline authorization decision.
Keycloak-style auth
The native auth pipeline reproduces a Keycloak flow without external dependencies. It issues JWTs, validates them, and applies role-based policies. The same pipeline works with a real Keycloak through camel-component-keycloak.
Issue tokens with NativeTokenIssuer:
let alice_token = token_issuer
.issue_token("alice", "alice-secret", Some("read write"), None)
.await?;
let bob_token = token_issuer
.issue_token("bob", "bob-secret", Some("read"), None)
.await?;
Validate tokens with LocalJwtValidator and NativeJwksProvider:
println!("--- JWT Validation ---");
let alice_principal = validator
.authenticate_bearer(&alice_token.access_token)
.await;
match &alice_principal {
Ok(p) => println!("Alice OK subject={} roles={:?}", p.subject, p.roles), // allow-secret
Err(e) => println!("Alice invalid ({e})"), // allow-secret
}
let bob_principal = validator.authenticate_bearer(&bob_token.access_token).await;
match &bob_principal {
Ok(p) => println!(
"Bob JWT: VALID (subject={}, roles={:?})",
p.subject, p.roles
),
Err(e) => println!("Bob JWT: INVALID ({e})"),
}
Apply a RolePolicy that checks for required roles:
println!("--- Role-Based Security Policy ---");
let admin_policy: Arc<dyn SecurityPolicy> = Arc::new(RolePolicy::new(
vec!["admin".to_string()],
true,
false,
validator.clone(),
));
let mut alice_exchange = Exchange::default();
alice_exchange.input.headers.insert(
"authorization".to_string(),
Value::String(format!("Bearer {}", *alice_token.access_token)), // allow-secret
);
let mut bob_exchange = Exchange::default();
bob_exchange.input.headers.insert(
"authorization".to_string(),
Value::String(format!("Bearer {}", *bob_token.access_token)), // allow-secret
);
let alice_decision = admin_policy.evaluate(&mut alice_exchange).await;
let bob_decision = admin_policy.evaluate(&mut bob_exchange).await;
match alice_decision {
Ok(AuthorizationDecision::Granted { principal }) => {
println!(
"Alice vs RolePolicy[admin]: GRANTED (subject={})",
principal.subject
);
}
Ok(AuthorizationDecision::Denied { reason, .. }) => {
println!("Alice vs RolePolicy[admin]: DENIED ({reason})");
}
Err(e) => println!("Alice vs RolePolicy[admin]: ERROR ({e})"),
_ => println!("Alice vs RolePolicy[admin]: UNKNOWN decision"),
}
match bob_decision {
Ok(AuthorizationDecision::Granted { principal }) => {
println!(
"Bob vs RolePolicy[admin]: GRANTED (subject={})",
principal.subject
);
}
Ok(AuthorizationDecision::Denied { reason, .. }) => {
println!("Bob vs RolePolicy[admin]: DENIED ({reason})");
}
Err(e) => println!("Bob vs RolePolicy[admin]: ERROR ({e})"),
_ => println!("Bob vs RolePolicy[admin]: UNKNOWN decision"),
}
println!();
println!("--- Route with Security Policy ---");
let mut ctx = CamelContext::builder().build().await.unwrap(); // allow-unwrap
ctx.register_component(TimerComponent::new());
ctx.register_component(LogComponent::new());
let role_policy = RolePolicy::new(vec!["admin".to_string()], true, false, validator.clone());
let wrapped = BearerInjectingPolicy::new(alice_token.access_token.to_string(), role_policy);
let secured_route = RouteBuilder::from("timer:tick?period=2000&repeatCount=2")
.route_id("admin-only-route")
.security_policy(SecurityPolicyConfig::new(wrapped))
.to("log:info?showHeaders=true")
.build()?;
ctx.add_route_definition(secured_route).await?;
YAML equivalent for the secured route
The Rust example wraps RolePolicy in BearerInjectingPolicy to inject a
static demo token. In a YAML route, the bearer token arrives in the transport
Authorization header and the policy is declarative.
routes:
- id: admin-only-route
from: timer:tick?period=2000&repeatCount=2
security_policy:
roles: [admin]
all_required: true
steps:
- to: log:info?showHeaders=true
YAML security_policy accepts one of roles, scopes, ref, wasm, or
permission. Routes with security_policy do not support the canonical
hot-reload path.
WASM authorization policy
A WASM plugin can serve as an authorization policy. The plugin reads camel.auth.* properties from the Exchange and returns a grant or denial.
let fixtures_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("fixtures");
let wasm_path = fixtures_dir.join("role-check.wasm");
let registry = Arc::new(std::sync::Mutex::new(camel_core::Registry::new()));
// This example uses the programmatic WasmSecurityPolicy::new() API.
// For production routes, prefer Camel.toml registration via
// [security.policies.wasm.<name>] + YAML `security_policy: wasm: <name>`.
// See crates/components/camel-component-wasm/README.md for details.
let wasm_policy = WasmSecurityPolicy::new(
&wasm_path,
WasmConfig::default(),
Arc::new(camel_core::RegistryComponentContext::new(registry)),
HashMap::new(),
)
.await?;
let policy =
AuthenticatedWasmPolicy::new(validator, alice_token.access_token.to_string(), wasm_policy);
Note: Service registration is Rust API only. YAML routes compile to the same
RouteDefinition. The service wiring stays in application code.
YAML equivalent for a production route
Register the WASM policy in Camel.toml under
[security.policies.wasm.<name>], then reference it by name in the route.
routes:
- id: wasm-secured-route
from: timer:tick?period=1000&repeatCount=5
security_policy:
wasm: role-check
steps:
- to: log:info?showHeaders=true
For production routes, prefer Camel.toml registration through [security.policies.wasm.<name>] with YAML security_policy: wasm: <name>.
See ADR-0050 for the WASM sandbox capability posture.
Security defaults
The startup-validation phase enforces fail-closed security defaults. Routes refuse to start when required configuration is missing. See ADR-0033 for the full policy.
Reference: camel-auth crate
Function runtime
The camel-function crate runs user code in isolated containers, invoked as function: pipeline steps. The model follows serverless functions: stateless, event-driven units scoped to one execution each.
use camel_function::{ContainerProvider, FunctionConfig, FunctionRuntimeService};
use camel_core::context::CamelContext;
use std::time::Duration;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let provider = ContainerProvider::builder()
.image("kennycallado/deno-runner:latest")
.boot_timeout(Duration::from_secs(30))
.build()?;
let service = FunctionRuntimeService::with_container_provider(
FunctionConfig::default(),
provider,
);
let mut ctx = CamelContext::builder()
.with_lifecycle(service)
.build()
.await?;
ctx.start().await?;
Ok(())
}
A Function runs in its own container. The runtime starts a Deno runner per registered runtime, then loads each function: step's source into that runner. The step sends an Exchange snapshot, gets an ExchangePatch back, and applies it to the message.
The FunctionInvoker is the contract the function: step calls. It owns the RunnerPool keyed by runtime, manages function registration with ref counting, and dispatches invocations. Errors map into the pipeline as FunctionInvocationError with NotRegistered, RunnerUnavailable, Timeout, or Transport variants.
function: differs from script:. script: runs synchronously inside the pipeline for predicates and simple transformations. function: runs out of process for logic that needs its own runtime. See ADR-0006 for the engine split.
The runtime manages container lifecycle through ContainerProvider. Spawn on first use, warm pool, health check on a background task. ctx.stop() triggers clean shutdown of all runners. See ADR-0005 for the staged prepare, finalize, discard flow that keeps hot reloads transactional.
YAML equivalent for a function step
The Rust code above wires the FunctionRuntimeService into the context. The actual function: step lives in a YAML route and gets loaded at startup. The body, header, and property helpers match the Deno runner API.
routes:
- id: "enrich-users"
from: "timer:enrich?period=2000"
steps:
- set_body: "hello world"
- function:
runtime: deno
timeout_ms: 5000
source: |
export default (camel) => {
camel.setBody(String(camel.body()).toUpperCase());
camel.setHeader("X-Enriched", "true");
};
- log: "enriched"
Running the example
The function-deno-enrich example loads a route from YAML, spawns a Deno container, and enriches a timer-driven body. Build the runner image first:
cd crates/services/camel-function
docker build -t kennycallado/deno-runner:latest runner/
Then start the example with cargo run from examples/function-deno-enrich. The route fires every 2 seconds.
Reference: camel-function crate | ADR-0005: function out-of-process staged reload
Bridge
The camel-bridge crate spawns and supervises external Java bridge binaries for components that need JVM-only protocols. JMS, XML, and CXF bridges all use it. Communication is gRPC with mutual TLS. Ephemeral rcgen-generated certificates are issued per spawn and never persisted.
The crate is internal. Components depend on it. Application code does not.
Architecture
The bridge pipeline has four layers:
- BridgeSpec is a static descriptor for one bridge binary. It pins the name, cache subdir, release tag prefix, and stderr log template. Constants live in
spec.rsand are&'static str. Three specs ship today:JMS_BRIDGE,XML_BRIDGE,CXF_BRIDGE. - BridgeProcess owns one child process, its mTLS material, the announced gRPC port, the cancellation token, and the stdout-drain task. The process is the unit of lifecycle.
- BridgeTlsMaterial generates a fresh CA plus server and client certs on every spawn. PEM files go to a 0700
TempDiron Unix, 0600 on the key file. The directory is cleaned up when the material drops. - BridgeReconnectHandler is a trait components implement to re-seed stateful resources after a bridge restart. The bridge crate owns neither restart detection nor process replacement. Components do.
Per ADR-0007, consumers and Routes own supervision. The bridge crate is a primitive. It does not restart children on its own.
gRPC channel
BridgeProcess::start spawns the binary, binds :0 to let the OS pick a free port, then passes the port to the bridge through QUARKUS_HTTP_SSL_PORT. The bridge announces its chosen SSL port on stdout as one line of JSON: {"status":"ready","port":N}. The crate reads that line, then connects a mTLS tonic channel to https://127.0.0.1:{port}.
The TLS material is generated before spawn. The child receives the cert paths through four env vars. Build-time TLS properties live in each bridge's application.yml. Runtime cert paths and the SSL port are the only runtime surface.
| Env var | Purpose |
|---|---|
QUARKUS_HTTP_SSL_PORT | SSL port the bridge binds |
QUARKUS_TLS_BRIDGE_KEY_STORE_PEM_0_CERT | Server cert PEM path |
QUARKUS_TLS_BRIDGE_KEY_STORE_PEM_0_KEY | Server key PEM path |
QUARKUS_TLS_BRIDGE_TRUST_STORE_PEM_CERTS | CA cert PEM path (for client auth) |
The connect step retries the TLS handshake up to ten times. Quarkus PortAnnouncer fires on StartupEvent and can precede full SSL listener readiness by a few hundred milliseconds in native images. The first attempt often fails. The retry loop absorbs that.
Process lifecycle
BridgeProcess::start_and_connect performs the full bootstrap in one call. It validates the config, generates the TLS material, spawns the child, reads the ready line, then connects the channel. A bounded stdout drain runs in the background for the rest of the process lifetime.
BridgeProcess::stop cancels the drain task, sends SIGTERM, waits five seconds, then sends SIGKILL. The Drop impl cancels the drain and best-effort kills the child. Drop cannot wait. Long shutdowns need an explicit stop call.
Stdout drain is bounded. Single lines cap at 64 KiB. Logging rate-limits to 100 lines per second with a drop summary. The drain never stops reading. An undrained OS pipe fills and blocks the child. Bounded size prevents memory growth. Rate-limited logging prevents log flooding. Both are required for stable long-running bridges.
Binary acquisition
ensure_binary resolves the bridge binary in four steps:
CAMEL_JMS_BRIDGE_BINARY_PATH(or the equivalent var for the spec) overrides everything. Use this for local development.- A workspace-root build at
{workspace}/bridges/{name}/build/native/{name}. Picked up automatically whencargo xtask build-{name}-bridgehas run. - A previously downloaded and verified copy in the cache dir, default
~/.cache/rust-camel/{name}/. - A download from GitHub Releases with SHA256 verification.
The release URL must point at https://github.com/.... The crate rejects HTTP, non-GitHub hosts, and github.com.evil.com lookalikes. Tarball extraction rejects absolute paths and .. components. Path traversal during unpack is a hard error.
Reconnect contract
Components that hold stateful resources inside a bridge (compiled XSDs, compiled XSLT stylesheets, open JMS sessions) implement BridgeReconnectHandler. The component's reconnect loop detects failure, replaces the process, connects the new channel, then calls on_reconnect(&channel).
use camel_bridge::reconnect::BridgeReconnectHandler;
#[derive(Debug)]
struct XmlStylesheetCache {
// compiled XSLT handles
}
impl BridgeReconnectHandler for XmlStylesheetCache {
fn on_reconnect(
&self,
channel: &tonic::transport::Channel,
) -> Result<(), camel_bridge::process::BridgeError> {
// Re-seed state from the new bridge. Spawn async work; do not block.
Ok(())
}
}
The contract has two rules. on_reconnect must not block synchronously. Spawn a Tokio task for async work. Returning Err is advisory. The reconnect loop logs the error and treats the bridge as live. Individual resource re-seeds may be retried lazily.
Credential redaction
Password fields use a Redacted<T> wrapper. Its Debug and Display implementations emit [REDACTED]. The wrapper protects only values that stay inside it. A BridgeProcessConfig derived Debug shows [REDACTED] for the password field, but its env_vars vec is a separate Vec<(String, String)> used for process injection. That vec legitimately contains the raw password for the child. ADR-0051 applies. The full config must not be formatted or logged until the invariant violation tracked in bd rc-4tbt is resolved. A fix needs a sentinel regression test against the complete configuration, not just one Redacted<T> field.
Configuration in Camel.toml
Bridge-based components declare their brokers in Camel.toml. The bridge pool starts one process per broker. JMS uses this shape:
[default.components.jms]
default_broker = "main"
[default.components.jms.brokers.main]
broker_url = "tcp://localhost:61616"
broker_type = "activemq" # "activemq" | "artemis" | "generic"
username = "admin" # optional
password = "admin" # optional
The bridge downloads on first use and caches at ~/.cache/rust-camel/jms-bridge/. Set CAMEL_JMS_BRIDGE_BINARY_PATH to a local build for development. The pool admits at most max_bridges (default 8) bridges concurrently.
Environment overrides
| Variable | Effect |
|---|---|
CAMEL_JMS_BRIDGE_BINARY_PATH | Use a local JMS bridge binary, skip download |
CAMEL_XML_BRIDGE_BINARY_PATH | Use a local XML bridge binary |
CAMEL_CXF_BRIDGE_BINARY_PATH | Use a local CXF bridge binary |
CAMEL_JMS_BRIDGE_RELEASE_URL | Override release download URL (must be https://github.com/**) |
CAMEL_XML_BRIDGE_RELEASE_URL | Same override for the XML bridge |
CAMEL_CXF_BRIDGE_RELEASE_URL | Same override for the CXF bridge |
CAMEL_BRIDGE_LOG_STDERR | Directory path; bridge stderr is redirected per-bridge log files. Empty value uses /tmp. |
Reference: camel-bridge crate. See also ADR-0007, ADR-0012, and ADR-0051.
Platforms
Platforms connect rust-camel to the deployment environment. A platform
service implements the PlatformService trait from camel-api and
exposes three capabilities:
- Identity: node name, namespace, and labels. On Kubernetes, these come from the Downward API.
- Leader election: a
LeadershipServicethat coordinates which pod owns a named lock. Routes with themaster:scheme activate only on the leader. - Readiness gate: a
ReadinessGatethat reports route readiness to the orchestrator. On Kubernetes, it patchesstatus.conditions.
The default NoopPlatformService serves single-node deployments and
tests. Production on Kubernetes uses KubernetesPlatformService.
- Kubernetes: leader election, readiness patching, and route activation
Kubernetes platform
The Kubernetes platform integrates rust-camel with a Kubernetes cluster. It provides leader election through Lease objects, readiness patching on the pod status, and pod identity from the Downward API.
Source: crates/platforms/camel-platform-kubernetes/.
Platform service
KubernetesPlatformService implements the PlatformService trait. It
binds three parts:
KubernetesLeadershipService: leader election through Kubernetes Lease objects.KubernetesReadinessGate: patches the podstatus.conditionsto signal readiness.KubernetesPlatformIdentity: detects pod name, namespace, and labels from the Downward API.
See crates/camel-api/src/platform.rs for the trait contracts.
Leader election
Leader election uses Kubernetes Lease objects from the
coordination.k8s.io API group. Each named lock maps to one Lease in
the configured namespace.
The KubernetesLeadershipService runs a background loop for each lock:
- Read the current Lease from the API.
- If the Lease expired or does not exist, try to acquire it.
- If this pod holds the Lease, renew it before the lease duration expires.
- If another pod holds a valid Lease, wait and retry.
Fencing token
The Lease carries a camel.io/leader-term annotation. This annotation
is a monotonic fencing token. Each takeover increments the term. The
Master component stamps every Exchange from a master: route with the
current term. Downstream sinks can reject envelopes that carry a stale
term. See ADR-0035.
Configuration
KubernetesPlatformConfig controls the election timing:
| Field | Default | Description |
|---|---|---|
namespace | "" (auto-detect) | Namespace for Lease objects |
lease_name_prefix | "camel-" | Prefix for Lease names |
lease_duration | 15s | Validity duration of a Lease |
renew_deadline | 10s | Renew window before Lease expiry |
retry_period | 2s | Interval between election cycles when not leader |
jitter_factor | 0.2 | Random jitter for retry timing (0.0-1.0) |
Validation rules from KubernetesPlatformConfig::validate():
renew_deadlinemust be less thanlease_duration.retry_periodmust be less thanrenew_deadline.jitter_factormust be in the range[0.0, 1.0].
See crates/platforms/camel-platform-kubernetes/CONTEXT.md for the
dependency boundary and log-level policy.
Readiness gate
KubernetesReadinessGate patches the pod status.conditions through
the Kubernetes API. The pod spec must declare a custom readiness gate:
spec:
readinessGates:
- conditionType: "camel.apache.org/ready"
The gate exposes three transitions:
notify_starting(): sets the condition toFalsewith reason"Starting".notify_ready(): sets the condition toTruewith reason"CamelReady".notify_not_ready(reason): sets the condition toFalsewith the given reason.
The condition type defaults to "camel.apache.org/ready". Call
with_condition_type() to set a custom type.
When you configure a HealthSource, the platform service polls
readiness every 10 seconds and updates the gate.
Master/Leader pattern
Routes with the master: scheme activate only on the leader pod. The
URI format is:
master:<lock-name>:<component>:<component-uri>
For example, master:mylock:timer:tick?period=1000 starts only on the
pod that holds the lock named mylock. When the pod loses leadership,
the route stops. When the pod re-acquires leadership, the route starts
again.
The master: scheme works with any LeadershipService implementation.
Use KubernetesLeadershipService in production. Use
NoopLeadershipService for local testing.
Example: Rust API
let route_1 = RouteBuilder::from("master:mylock:timer:tick?period=1000")
.route_id("master-route")
.to("log:info")
.build()?;
let route_2 = RouteBuilder::from("timer:status?period=5000")
.route_id("status-route")
.to("controlbus:route?routeId=master-route&action=status")
.to("log:info")
.build()?;
ctx.add_route_definition(route_1).await?;
ctx.add_route_definition(route_2).await?;
Example: YAML DSL
routes:
- id: "master-route_1"
from: "master:mylock:timer:tick?period=1000"
steps:
- log: "DEBUG: 1"
- id: "master-route_2"
from: "master:mylock:timer:tick?period=1000"
steps:
- log: "DEBUG: 2"
- id: "status-route"
from: "timer:status?period=5000"
steps:
- to: "controlbus:route?routeId=master-route_1&action=status&authorizedRoutes=master-route_1"
- to: "log:info"
See the full examples:
examples/master-leader/: Rust API with simulated leadership.examples/master-leader-yaml/: YAML DSL with simulated leadership.examples/kubernetes-platform/: end-to-end Kubernetes leader election with K3s.
Route activation on the leader
When a route uses the master: scheme, the Master component wraps the
consumer with a leadership bridge. The bridge:
- Subscribes to leadership events from the
LeadershipHandle. - On
StartedLeading, starts the delegate consumer. - On
StoppedLeading, stops the delegate consumer. - Stamps every Exchange with the leader epoch fencing token.
The bridge uses a bounded channel (128-deep) between the delegate consumer and the pipeline. On delegate stop, the bridge drains its buffer and exits. On route shutdown, the bridge aborts at once.
See camel-master/src/leadership.rs and
ADR-0035.
Reference: Platform Kubernetes crate
Operations
Production concerns: health endpoints, graceful shutdown, and route lifecycle. The runtime exposes HTTP probes for orchestrators and drains in-flight exchanges on shutdown.
- Health: liveness and readiness probes, Degraded and Unhealthy states,
ObservabilityConfig.healthwiring. - health-demo: runnable server with
/healthz,/readyz, and/healthendpoints.
Graceful shutdown drains in-flight exchanges up to drain_timeout_ms during
CamelContext::stop. Route lifecycle control via ControlBus (ADR-0034) does
not yet have a narrative page.
Health
rust-camel exposes health endpoints through the ObservabilityConfig.health
block. When enabled, a HealthServer starts on the configured host and port. It
serves four HTTP endpoints:
/healthz: liveness probe. Returns 200 while the runtime is alive./readyz: readiness probe. Returns 200 when all routes are Healthy or Degraded. Returns 503 when any route is Unhealthy./startupz: startup probe. Same response rules as/readyz./health: detailed JSON report with per-route status. Always returns 200.
A probe handler that times out fails closed. The probe returns 503 with an
Unhealthy status.
Configuration
The ObservabilityConfig.health field accepts an optional
HealthCamelConfig:
health: Some(HealthCamelConfig {
enabled: true,
host: "0.0.0.0".to_string(),
port: health_port,
handler_timeout_ms: 6000,
forced_ttl_ms: None,
}),
Note: Service registration is Rust API only. YAML routes compile to the same
RouteDefinition. The service wiring stays in application code.
The enabled flag starts the HealthServer. host and port set the bind
address. handler_timeout_ms limits each probe handler. The default is 6
seconds (DEFAULT_HANDLER_TIMEOUT). forced_ttl_ms controls how long a
forced-unhealthy state persists before the registry re-evaluates the route.
Route
A route registered with the context participates in health checks:
let route = RouteBuilder::from("timer:health?period=5000")
.route_id("health-demo-route")
.to("log:health?showBody=false")
.build()?;
YAML equivalent
routes:
- id: health-demo-route
from: timer:health?period=5000
steps:
- to: log:health?showBody=false
The HealthServer evaluates every registered route. A route whose probes return
Healthy contributes a healthy status. A route with a Degraded probe still passes
readiness (200 on /readyz). A route with an Unhealthy probe fails readiness
(503).
Degraded vs Unhealthy
The health subsystem distinguishes two states below Healthy:
- Degraded: the component can still process Exchanges. The route passes readiness (200, pod Ready).
- Unhealthy: the component cannot process Exchanges. The route fails readiness (503, pod NotReady).
See the glossary for the canonical definition of these terms.
Reference: Health crate
Extending rust-camel
rust-camel exposes four extension points. Each one has a Rust trait or WIT contract and registers into CamelContext at startup.
| Extension | Contract | Registers |
|---|---|---|
| Custom component | ComponentBundle in camel-component-api | URI scheme; config under [components.<key>] |
| Expression language | Language in camel-language-api | Name; six built-in (Simple, JSONPath, XPath, JS, Rhai, MiniJinja) |
| Data format | DataFormat in camel-api | Name; JSON, CSV, XML, Protobuf built-in |
| WASM plugin | camel:plugin WIT in camel-wit | Guest component, bean, or source via camel-component-wasm |
Start with the custom component walkthrough.
Custom component
A custom component connects rust-camel to a system the built-in components do not cover. You implement the Component trait, wrap it in a ComponentBundle, and register the bundle against a TOML config key.
Implement the Component and Endpoint
pub struct EchoComponent {
prefix: String,
}
impl EchoComponent {
pub fn new(prefix: impl Into<String>) -> Self {
Self {
prefix: prefix.into(),
}
}
}
impl Component for EchoComponent {
fn scheme(&self) -> &str {
"echo"
}
fn create_endpoint(
&self,
uri: &str,
_ctx: &dyn ComponentContext,
) -> Result<Box<dyn Endpoint>, CamelError> {
Ok(Box::new(EchoEndpoint {
uri: uri.to_string(),
prefix: self.prefix.clone(),
}))
}
}
struct EchoEndpoint {
uri: String,
prefix: String,
}
impl Endpoint for EchoEndpoint {
fn uri(&self) -> &str {
&self.uri
}
fn create_consumer(
&self,
_rt: Arc<dyn camel_component_api::RuntimeObservability>,
) -> Result<Box<dyn Consumer>, CamelError> {
Err(CamelError::RouteError(
"echo component is producer-only".into(),
))
}
fn create_producer(
&self,
_rt: Arc<dyn camel_component_api::RuntimeObservability>,
_ctx: &ProducerContext,
) -> Result<BoxProcessor, CamelError> {
let prefix = self.prefix.clone();
// Log the exchange body with the configured prefix.
Ok(BoxProcessor::from_fn(move |exchange| {
let prefix = prefix.clone();
Box::pin(async move {
let body = exchange
.input
.body
.as_text()
.unwrap_or("<non-text body>")
.to_string();
tracing::info!("{}{}", prefix, body);
Ok(exchange)
})
}))
}
}
The contract layers from factory to worker. A Component is a factory for one URI scheme. create_endpoint builds an Endpoint for a specific URI. The Endpoint creates a Consumer for inbound traffic or a Producer for outbound traffic. A Producer is a Service<Exchange> that does the actual work.
EchoComponent::scheme returns "echo", so the runtime resolves any echo:... URI to this component. create_endpoint stamps the configured prefix onto each EchoEndpoint. This endpoint is producer-only. create_consumer returns an error to signal that inbound traffic is unsupported. create_producer returns a BoxProcessor that logs the exchange body with the prefix. The exchange passes through unchanged.
Wrap the component in a bundle
pub struct EchoBundle {
config: EchoConfig,
}
impl ComponentBundle for EchoBundle {
fn config_key() -> &'static str {
"echo"
}
fn from_toml(raw: toml::Value) -> Result<Self, CamelError> {
let config: EchoConfig = raw
.try_into()
.map_err(|e: toml::de::Error| CamelError::Config(e.to_string()))?;
Ok(Self { config })
}
fn register_all(self, registrar: &mut dyn ComponentRegistrar) {
registrar.register_component_dyn(Arc::new(EchoComponent::new(self.config.prefix)));
}
}
A ComponentBundle owns one TOML config key and registers every scheme the bundle owns. config_key returns "echo", which maps to [components.echo] in Camel.toml. from_toml deserializes the raw TOML block into EchoConfig. register_all receives a ComponentRegistrar and calls register_component_dyn for each component the bundle owns.
Register and use the component
if let Some(raw) = config.components.raw.get(EchoBundle::config_key()).cloned() {
EchoBundle::from_toml(raw)?.register_all(&mut ctx);
} else {
// No config block → use defaults
EchoBundle {
config: EchoConfig::default(),
}
.register_all(&mut ctx);
}
- route:
id: echo-demo
from: timer:tick?period=2000
steps:
- to: echo:hello
In main, read the config block from CamelConfig and call register_all. Fall back to defaults when the block is absent. The route references echo:hello like any built-in scheme. The timer fires every two seconds, the producer logs the body, and the exchange continues down the pipeline.
Reference: Component SPI · Example source
Architecture
The system-level architecture: the plane split, route lifecycle, shutdown and backpressure, and the crate dependency graph. The Core concepts section holds the mental model. This page builds on it and indexes the decisions that shaped it.
Data plane vs control plane
rust-camel separates message flow from lifecycle. The data plane is the hot
path. Every Exchange flows through a Tower Service<Exchange> pipeline. The
control plane is the cold path. It owns route lifecycle through a CQRS
RuntimeBus with optimistic versioning.
The Data plane vs control plane concept page covers the rationale, the performance and safety goals, and the Exchange trust boundary. ADR-0001 records the decision to adopt Tower as the data-plane foundation.
Route lifecycle
Route lifecycle follows a two-phase persist-then-execute pattern (ADR-0018).
The Runtime records intent before side effects, then confirms or compensates
after the side effect returns. For StartRoute, the sequence is:
- Record
RouteStartRequested, project Route asStarting - Start the Consumer and Pipeline
- Record
RouteStarted, projectStarted
If the side effect fails after intent was recorded, the Runtime records
RouteFailed, projects Failed, and publishes failure events. Compensation
applies to every non-atomic lifecycle flow (ADR-0018).
The lifecycle layer uses hexagonal architecture (ADR-0003). Domain and
application logic sit behind ports (RouteRepositoryPort,
ProjectionStorePort, RuntimeEventJournalPort, RuntimeExecutionPort).
Concrete adapters provide in-memory and redb implementations. ADR-0045 extends
this discipline crate-wide. Every behavioral area in camel-core is a vertical
slice with its own domain / application / ports / adapters layout.
Stateful pipeline steps (aggregators, resequencers, idempotent repositories)
implement the StepLifecycle trait (ADR-0022). The trait adds a drain hook
that outlives a single process() call. The drain lets background work
(timers, buckets, queues) complete before the step shuts down.
Shutdown and backpressure
A route stop signals in-flight pipelines through a tokio::task_local! cancel
token (ADR-0043). The step loop checks the token before each step and returns
Failed(ConsumerStopping) on cancel. This gives cooperative cancellation
without interrupting a step mid-.await. The
Data plane vs control plane page covers the token
mechanics and the graceful-drain ordering.
Route-admission backpressure (ADR-0044) caps concurrent in-flight Exchanges. The Concurrent consumer model acquires a semaphore permit before it dequeues work. When permits run out, the consumer blocks on the permit instead of buffering more work inside the pipeline task. This bounds memory under load.
Crate dependency overview
The crate dependency graph follows a layered structure. Contract crates sit at the bottom with zero or minimal internal dependencies. Runtime and processing crates depend on contracts. Components, languages, and services depend on the runtime and contracts. Platforms depend on services.
Platforms
|
Services
|
Components ----> Runtime ----> Processors
| | |
| v v
+---------> Contracts <--------+
^
|
Languages
Contracts (camel-api, camel-component-api, camel-language-api,
camel-wit, camel-config, camel-endpoint, camel-bean, camel-test,
camel-bench) define the types and traits that every other crate depends on.
They have no runtime dependency.
Runtime (camel-core, camel-cli, camel-health) owns the execution
engine, route lifecycle, hot reload, and registries. camel-core depends on
contract crates and drives every other family.
Processors (camel-processor, camel-builder) implement EIP patterns as
Tower middleware. They depend on camel-api for Exchange and Processor.
DSL (camel-dsl) parses YAML and JSON route definitions into
RouteDefinition. It depends on camel-api and camel-builder.
Components connect routes to external systems. Each component crate depends
on camel-component-api and camel-core. See the
component catalog for the full list.
Languages evaluate expressions and predicates. Each language crate depends
on camel-language-api. See the language catalog for
the full list.
Services provide cross-cutting infrastructure: auth, observability, and
function invocation. They register into CamelContext through the
service contracts. See the
service catalog for the full list.
Platforms expose deployment-aware behaviour. See the platform catalog for the full list.
Data formats convert between wire representations and structured body types. See the data format catalog for the full list.
For the complete bounded-context map and domain vocabulary, see CONTEXT-MAP.md.
ADR index
Architecture-shaping choices live as ADRs under
../adr/. The index below organizes them by topic. Each
entry links to the ADR file and gives a one-sentence summary.
See also Important Findings Summary for resolved P0 findings.
Architecture and Design
Core patterns, lifecycle, pipeline, and route authoring.
| ADR | Title | Summary |
|---|---|---|
| 0001 | Tower data plane, custom-trait control plane | Separates Exchange processing (Tower Service<Exchange>) from component lifecycle (custom traits). |
| 0002 | CQRS RuntimeBus for route lifecycle | Route lifecycle mutations go through RuntimeCommandBus with projections and optional event journal. |
| 0003 | Hexagonal lifecycle core | Lifecycle layer uses ports and adapters for persistence and testability. |
| 0004 | Hot reload via atomic pipeline swap | Pipeline swap uses ArcSwap so in-flight Exchanges complete against the snapshot they entered. |
| 0005 | Function out-of-process staged reload | function: steps run in isolated containers with prepare/finalize/discard registration. |
| 0006 | Script synchronous, async to function | JavaScript evaluation is synchronous; async paths delegate to function:. |
| 0007 | Route-supervised consumer failure | Consumer task failure is route-supervised with optional restart policy and backoff. |
| 0008 | Route templates via JSON tree substitution | Template placeholders expand via JSON tree walk before DSL deserialization. |
| 0009 | HTTP co-hosting API and static routes | API routes and static mounts share one server per host/port with deterministic dispatch. |
| 0011 | CanonicalRouteSpec minimal contract | v1 is a stable minimal route contract, not a full RouteDefinition mirror. |
| 0015 | Endpoint-created PollingConsumer | Pull-based adapter created from an Endpoint for pollEnrich and WASM camel_poll. |
| 0016 | CanonicalRouteSpec v2 contract | v2 adds lifecycle metadata with strict rejection for unsupported fields. |
| 0017 | DSL YAML snake_case naming | DSL keys use snake_case to match Rust field names and schema output. |
| 0018 | Two-phase route lifecycle persistence | Lifecycle commands persist intent before side effects, compensate on failure. |
| 0022 | StepLifecycle trait and drain | Stateful pipeline steps get a separate drain hook for background work. |
| 0024 | PipelineOutcome replaces CamelError::Stopped | PipelineOutcome enum replaces CamelError::Stopped for pipeline control flow. |
| 0025 | Outcome-aware structural EIPs | Structural EIPs return PipelineOutcome directly instead of Tower Result. |
| 0026 | JSON canonical route authoring | JSON is the canonical full-DSL format for SDKs and generators; YAML is human convenience. |
| 0029 | Resequencer continuation boundary | Compiler splits step list at Resequence; post-steps compile into a continuation owned by the service. |
| 0030 | Exchange-aware DataFormat hooks | DataFormat gains default marshal_in_exchange / unmarshal_in_exchange hooks. |
| 0031 | WASM source world | Fourth WIT world source lets WASM guests act as Consumers with their own consumption loop. |
| 0041 | Component metadata capabilities schema | ComponentMetadata schema with OptionKind, UriOption, ComponentCapabilities, CapabilityQuery. |
| 0042 | Arc<[CompiledStep]> shared snapshot | Shared snapshot avoids per-Exchange Vec clone for compiled pipeline steps. |
| 0043 | Pipeline cancellation between steps | task_local! cancel token checked between steps for cooperative cancellation. |
| 0044 | Route-admission backpressure | Semaphore permit acquired before dequeue prevents unbounded in-flight work. |
| 0045 | camel-core architecture charter | Codifies Clean + DDD + CQRS + vertical slices + hexagonal discipline crate-wide. |
| 0046 | Apache Camel inspiration, not conformance | Apache Camel is design inspiration, not conformance authority. |
| 0047 | Template rendering engine | MiniJinja-based external template engine with compile-once caching and atomic hot reload. |
| 0053 | WIT interface versioning | camel:plugin uses one package-level WIT SemVer, independent from Rust crate versions. |
Security
Authentication, authorization, trust boundaries, and capability models.
| ADR | Title | Summary |
|---|---|---|
| 0010 | SecurityPolicy pre-pipeline authorization | Route-level authorization wraps the Pipeline before any step runs. |
| 0032 | Exchange-data trust boundary | Operator config is trusted; exchange data is untrusted and must not drive control-plane actions. |
| 0033 | Security defaults and fail-closed startup validation | Five-disposition security policy enforced by a single startup-validation phase. |
| 0034 | ControlBus capability authorization | ControlBus requires an authorizedRoutes allowlist and denies self-restart. |
| 0035 | Leader-epoch fencing token | Every master: delegate envelope carries a monotonic fencing token for split-brain safety. |
| 0036 | Bridge IPC mutual TLS | Bridge uses mutual TLS with ephemeral certificates; fail-closed guard rejects placeholder paths. |
| 0037 | Exec component fail-closed capability model | Allowlisted binaries, argument policy, no shell, bounded stdin. |
| 0050 | WASM sandbox capability posture | Per-world grants for Camel host functions and selective WASI registration. |
| 0051 | Credential redaction at diagnostic boundaries | Credential-bearing types use manual redacting Debug; Serialize must not expose credential bytes. |
| 0052 | Diagnostic endpoint exposure posture | Diagnostic endpoints follow the Prometheus scrape model; network isolation is the operator's duty. |
Error Handling
Disposition, drain, supervision, and repository patterns.
| ADR | Title | Summary |
|---|---|---|
| 0012 | Log-level convention by handler-contract boundaries | Emitters inside a handler contract log at warn! or below; outside emitters may log at error!. |
| 0019 | Error disposition in-pipeline recovery | RouteErrorHandler trait injected into the pipeline decides disposition after each step failure. |
| 0023 | Idempotent Repository trait | Key-only IdempotentRepository trait in camel-api for duplicate detection. |
| 0028 | Claim Check Repository trait | Payload-bearing ClaimCheckRepository trait distinct from key-only IdempotentRepository. |
Performance and Limits
DoS caps, cardinality limits, and resource bounds.
| ADR | Title | Summary |
|---|---|---|
| 0038 | Configurable DoS caps | Per-format config channel for operator-overridable data-format DoS caps. |
| 0039 | Configurable loop iteration cap | Per-step max_iterations escape hatch for loop iteration limits. |
| 0040 | Configurable materialize limits | Configurable materialize limits for XSLT, XJ, and WASM producers. |
Integration
WASM, functions, components, and cross-cutting contracts.
| ADR | Title | Summary |
|---|---|---|
| 0013 | NetworkRetryPolicy and migration | Centralized retry semantics and migration boundaries for adapter retries. |
| 0014 | WASM plugin config unification | Unified WASM plugin runtime configuration across all plugin types. |
| 0020 | LLM component provider adapter boundary | LLM component isolates SDK behind a project-owned LlmProvider trait. |
| 0021 | LLM retry with retry-after manual loop | LLM retry honors provider retry_after via manual loop, diverging from ADR-0013. |
| 0027 | MQTT component 3.1.1 per-endpoint | MQTT 3.1.1 via rumqttc with one connection per Consumer or Producer. |
| 0048 | Attestation provenance (retired) | Retired HMAC-SHA256 attestation decision kept for history. |
| 0049 | Workspace non-exhaustive policy | Public contract enums are #[non_exhaustive] by default before the 1.0 API freeze. |
Architecture Decision Records
Every architectural choice has a recorded decision. Each ADR states the context, the decision, and the consequences.
Split Tower Data Plane from Control Plane
rust-camel separates exchange processing (data plane) from component/endpoint/consumer lifecycle (control plane). The data plane uses Service<Exchange> throughout — every Processor and Producer is a Tower service. The control plane uses its own trait hierarchy (Component, Endpoint, Consumer) with richer lifecycle semantics.
Forcing components into Tower's request/response model would break lifecycle operations (start, stop, suspend, health) that don't map to call(). Keeping Tower strictly for exchange processing makes EIP composition idiomatic while giving the control plane the contracts it actually needs.
CQRS RuntimeBus for Route Lifecycle Control
Route lifecycle mutations go through RuntimeCommandBus and reads through RuntimeQueryBus, backed by projections and an optional redb event journal. Direct mutable state would be simpler, but the CQRS model gives us crash recovery, command deduplication, and projection-backed reads that don't block the command path.
The added architectural weight is justified because route lifecycle operations (start, stop, hot-reload) are infrequent, and the journal makes the control plane auditable and recoverable without adding a separate database dependency.
Amendment: two-phase lifecycle persistence
ADR-0018 refines this decision: lifecycle commands that perform runtime side effects persist intent first, expose intermediate states such as Starting through projections, then confirm success or compensate to Failed. Aggregate writes use optimistic versions so command handling, projections, event publication, and journal replay stay consistent under concurrent lifecycle operations.
Clarification: synchronous-projection CQRS (amended by ADR-0045)
This is synchronous-projection CQRS, not eventual-consistency CQRS: the read-side projection is updated within the same optimistic-versioned UnitOfWork as the command that produced it, so there is no projection lag on the read path. Supervision decisions that read route status therefore see a state consistent with the last completed command. "Strong consistency" here refers to the read-model freshness guarantee, not CAP-theorem linearizability. CQRS scope is the control plane only; the data plane (Exchange/Pipeline processing) is not CQRS — see ADR-0045 §3.
Hexagonal Architecture for camel-core Lifecycle
The lifecycle layer of camel-core is structured as a hexagonal application: domain and application logic are isolated behind ports (RouteRepositoryPort, ProjectionStorePort, RuntimeEventJournalPort, RuntimeExecutionPort), with concrete adapters providing in-memory and redb implementations.
The extra indirection is intentional — it lets us swap persistence backends, test lifecycle logic without a real runtime, and keep the domain model free of Tokio or storage concerns. A flat concrete implementation would be faster to write but harder to test and extend.
Scope (amended by ADR-0045)
This decision was originally scoped to the lifecycle/ layer only. ADR-0045 extends the hexagonal
discipline crate-wide as the primary pillar of the camel-core architecture charter: every
behavioral area is a vertical slice with its own internal domain / application / ports /
adapters layout. The flat root modules (context.rs, health_registry.rs, datasource.rs,
template.rs, registry.rs, language_registry.rs, component_metadata_catalog.rs,
startup_validation.rs) are vertical slices awaiting that internal organization — they are
remediation targets, not a permanent exception.
ADR-0045 also declares the ceiling: because camel-core deliberately stays one crate through 1.0, the rings are enforced by module discipline + boundary tests rather than by compiler-enforced crate isolation.
Hot Reload via Atomic Pipeline Swap
When a route's steps change but its from: URI stays the same, hot reload compiles the new Pipeline and swaps atomically via ArcSwap. The Consumer is never stopped. Each in-flight Exchange completes against the Pipeline snapshot it entered — ArcSwap guarantees readers hold a stable reference for the lifetime of their access, so old and new Pipelines can coexist momentarily without corruption.
A simpler stop/recreate approach would work but adds unnecessary downtime and Consumer reconnection overhead. The atomic swap keeps the Consumer running and makes the reload effectively zero-downtime for the common case. The tradeoff is more complexity in the reload diffing logic (ReloadAction::Swap vs Restart). There is no explicit drain step — snapshot isolation via ArcSwap makes it unnecessary.
function: Executes Out of Process with Staged Registration
function: steps run in isolated containers managed by a ContainerProvider, not in-process. Registration follows a staged prepare/finalize/discard flow to keep hot reloads transactional — a new function version is prepared before the old one is discarded.
Embedded JS (Boa) or WASM would be simpler but cannot support async I/O, npm packages, or full language runtimes. Out-of-process execution gives functions a real event loop and filesystem access at the cost of transport overhead and a more complex lifecycle. The staged reload prevents half-registered functions from being served during a reload.
script: Is Synchronous; Async JS Belongs in function:
Status: Accepted Absorbs: DEC-1 (JS engine choice: Boa)
script: uses Boa for synchronous expression evaluation only — predicates, header/body transformations, simple logic. Async JS (await, fetch, npm imports) is explicitly out of scope for script: and belongs in function:.
Allowing async in script: would let user code evade circuit breakers, retry logic, and pipeline-level metrics, since those operate at the Tower layer above the script call. It would also introduce a JS event loop inside the Tokio runtime with no clean integration boundary. The function: step already handles the async use case with proper isolation and flow auditability.
Engine choice: Boa (absorbed from DEC-1)
In an integration framework, scripting languages serve as expression evaluators, not orchestration engines. This matches Apache Camel, where all scripting languages (Groovy, JS/Nashorn, Simple, OGNL) are strictly synchronous. Async I/O from a script would also obscure error-handling visibility and lose flow auditability (the YAML would no longer describe the full route). camel-language-js therefore stays on Boa; the original "Boa vs rquickjs" comparison is closed (rquickjs async support is no longer a motivating factor).
| Step | Engine | Scope |
|---|---|---|
script: | Boa (in-process, sync) | Predicates (camel.headers.get('type') === 'urgent'), body/header transformations, simple synchronous logic |
function: | Deno container (out-of-process, async) | Async I/O (fetch, camel.send, ProducerTemplate), imports/npm, complex logic needing a full event loop |
Route-Supervised Consumer Failure
Consumer internal task failure is route-supervised. A Consumer that cannot continue returns an error from its running task; the RuntimeBus records the Route as failed, and an optional supervision policy restarts the whole Route with backoff. Consumers may retry transient external operations inside their normal receive loop, but they must not restart their own long-running task after task-level failure.
Self-supervising Consumers would hide failures from the Route control plane and could emit Exchanges while hot reload is swapping Pipelines. Pure fail-fast would be simpler and safest, but would make transient source failures require operator action. Route-supervised failure keeps failure state auditable through the RuntimeBus, preserves hot-reload atomicity by treating Consumer crash as a Route lifecycle event rather than a Pipeline mutation, and centralizes restart policy outside Components.
Route Templates via JSON Tree Substitution
Route templates let users define parameterized route blueprints once and instantiate them multiple times with different parameter values. Instead of repeating nearly identical YAML route definitions, a template declares {{param}} placeholders in its route body, and each templated_route instantiation provides concrete values. The materializer substitutes placeholders via a JSON tree walk before parsing the result into a DeclarativeRoute — this pre-parse substitution means placeholders work in any string field, including numeric or boolean contexts like delay_ms: "{{delay}}".
The key architectural decision is where the materializer lives. camel-dsl already depends on camel-core, so camel-core cannot depend on camel-dsl without a circular dependency. The solution splits responsibilities: CamelContext stores only data (template specs and instance records) in a TemplateRegistry, while the materializer and materialize_and_compile helper live in camel-dsl. Discovery uses a two-pass approach — Pass 1 collects templates from all files into a HashMap, Pass 2 materializes each templated_route by looking up its template and calling materialize_and_compile. This enables cross-file template references: a template defined in base-routes.yaml can be instantiated in prod-routes.yaml.
An alternative would have been typed substitution on DeclarativeRoute (walking all 26 step variants and substituting string fields), but that approach is fragile — every new step variant requires updating the materializer, and it cannot substitute into non-string fields without special handling. JSON tree substitution is simpler, more future-proof, and leverages the existing parse pipeline unchanged. The camel-api crate holds shared types (RouteTemplateSpec, TemplatedRouteSpec, TemplateError) to avoid circular dependencies, and camel-dsl provides the placeholder engine, JSON tree materializer, and YAML/JSON parsing extensions with #[serde(default)] for backward compatibility.
HTTP Co-hosting for API and Static Routes
HTTP API routes (http:) and static file mounts (http-static:) share one server per host/port. A single listener dispatches each request by precedence: exact API path match first, then static mounts by longest prefix, then SPA fallback or custom error page handling within the winning static mount. This makes co-hosting a public routing behavior rather than an incidental registry detail.
The alternative was starting separate servers for API and static routes, or requiring separate ports. That would keep each Route simpler internally, but it would make common web deployments awkward: an application API and its static assets would need extra reverse-proxy configuration or distinct origins. Shared hosting matches user expectations for one web origin while keeping route definitions independent.
The trade-off is that the HTTP component owns shared dispatch state for a host/port and must define deterministic precedence between route kinds. Exact API paths win over static mounts so API endpoints cannot be shadowed by a broad static prefix. Static mounts use longest-prefix matching so more-specific asset trees win before generic fallbacks. SPA fallback and error pages run last within the winning static mount because they are catch-all behavior.
SecurityPolicy as Pre-Pipeline Authorization
Routes declare authorization with route-level security_policy rather than as a normal Step. The DSL accepts exactly one policy form (roles, scopes, ref, wasm, or permission) and the Runtime wraps the compiled Route Pipeline with SecurityPolicyLayer. A granted decision stores Principal properties on the Exchange before normal Route Steps run. A denied decision returns Unauthorized into the route error-handling path; downstream Route Steps do not run unless error handling routes or handles the error.
The alternative was modeling authorization as an ordinary Pipeline Step. That would make auth placement explicit and composable, but it would also make route safety depend on Step ordering. A transform, producer call, or side-effecting processor could run before authorization if the policy Step were misplaced. Pre-pipeline authorization makes the protected boundary the Route itself: all Step logic is inside the authorization gate.
This choice makes SecurityPolicy part of route lifecycle assembly rather than EIP processing. It is less flexible than arbitrary Step placement, but it gives one clear contract: protected Routes authorize before data-plane processing begins. Policies can still be backed by native role/scope checks, named references, WASM plugins, or permission engines; those choices affect the decision source, not the boundary where enforcement happens.
Current canonical/hot-reload compilation rejects security_policy until the canonical route contract carries SecurityPolicyConfig safely. See ADR 0011 for why canonical contracts stay serializable and do not carry trait-object policy implementations.
CanonicalRouteSpec as Minimal Route Contract
CanonicalRouteSpec v1 is a stable minimal route contract for runtime commands, config tooling, and hot-reload paths. It is not a full RouteDefinition mirror. The full DSL model remains the place for route authoring features such as templates, advanced lifecycle metadata, error handling, unit-of-work hooks, and route-level security declarations.
The alternative was expanding CanonicalRouteSpec toward full RouteDefinition parity. That would reduce feature gaps in canonical and hot-reload paths, but it would duplicate DSL validation, increase cross-crate coupling, and pull non-serializable or runtime-bound concerns into camel-api. SecurityPolicy is the clearest example: the runtime enforces a SecurityPolicyConfig containing trait-object policy implementations, while canonical contracts must remain serializable and safe to pass through tooling boundaries.
The chosen direction is a versioned, use-case-driven canonical contract. Fields are added only when runtime commands, config tooling, or hot reload need them and when they can be represented as stable serializable data. Unsupported user-set fields must be rejected or have explicit defaulting behavior; silent loss is not acceptable. Future expansion can add simple lifecycle metadata first (auto_startup, startup_order, concurrency), then richer error or unit-of-work models if their canonical representation is clear. Canonical expansion is not parity-driven.
v2 Amendment (2026-06-08)
See ADR-0016 for the full v2 contract.
v2 adds lifecycle metadata: auto_startup (Optionstartup_order (Optionconcurrency (Optionerror_handler, unit_of_work, security_policy. Lossy escape hatch via allow_loss parameter. Version bumped from 1 to 2. Backward compatible: v2 runtime accepts v1 specs.
Log-Level Convention: Handler-Contract Boundaries
Adopt a project-wide log-level convention based on handler-contract boundaries: emitters inside a contract boundary where a downstream handler will own the failure (taxonomy categories (a) Producer/Processor inside a pipeline, (b-bridged) Consumer→pipeline with bridge_error_handler=true) MUST log at warn! or below; emitters outside any handler contract (categories (b′) consumer side-effect failure after send_and_wait, (g) unrecoverable Producer/Endpoint creation failure, (e) transient server accept/retry loops) MAY log at error! but MUST pair the call with a signal replacement — MetricsCollector::increment_errors(route_id, "<category>:<component>:<site>") for (b′)/(e), HealthCheckRegistry::force_unhealthy_for_route(route_id, name, reason) for (g); emitters in system-broken categories — corruption, panic-equivalent, contract violation (c supervisor, d CLI/bootstrap, f corruption, h pre-pipeline authz fault) — MUST log at error! with no replacement required. The convention is enforced by xtask lint-log-levels requiring every error! in non-test code to carry one of three preceding annotations: // log-policy: system-broken | outside-contract | handler-owned (the last forbidding error! outright).
Background
A prior audit proposed a blanket downgrade of error! → warn!() across components that returned errors via Result<_, CamelError>. Rejected: the same emitter may be used both by routes with a configured handler (downgrade safe) and by routes without one (downgrade loses the only operational signal). Apache Camel's documented behavior matches our adopted model: DefaultErrorHandler propagates "as if there were no error handler at all" (emitter keeps the ERROR); Dead Letter Channel is the single place that logs at ERROR level (handler-owned). See https://camel.apache.org/manual/error-handler.html.
Taxonomy (normative)
| Code | Category | Inside handler contract? | Required level | Signal replacement |
|---|---|---|---|---|
| (a) | Pipeline Processor/Producer invocation | Yes | debug! or warn! | None — handler owns |
| (b-bridged) | Consumer deliberately hands an error-bearing Exchange to the route handler (e.g., bridge_*_error, set_error + send_and_wait with the sole purpose of invoking the handler) | Yes | debug! or warn! | None — handler owns |
| (b′) | Consumer side-effect failure, including a normal-data send_and_wait → Err (the route did NOT absorb it — see "b-bridged discriminator" below) | No | warn! or error! | increment_errors(route_id, "b-prime:<component>:<site>") required if downgraded |
| (g) | Endpoint/Producer creation failure (permanent) | No | error! (preferred) | force_unhealthy_for_route(route_id, "endpoint-creation", reason) if downgraded |
| (e) | Server-loop accept/retry transient (grpc, container, http) | No | warn! | increment_errors(route_id, "e:<component>:<site>") required if downgraded |
| (c) | Route lifecycle: start/stop/suspend/resume (consumer_management.rs, route_controller.rs) | System-broken | error! | None |
| (d) | CLI, bootstrap, application startup/shutdown | System-broken | error! | None |
| (f) | ControlBus dual-cut (origin side) | System-broken | error! | None |
| (h) | Pre-pipeline authorization/policy evaluation fault | System-broken | error! | None |
Tie-breaker
Ask: "Does this error path produce an Exchange that flows into a Route pipeline with an ErrorHandlerLayer?" If yes → INSIDE → categories (a)/(b-bridged) → emitter logs ≤ warn!. If the error stays inside consumer/server/lifecycle and only flows to CrashNotification / log / supervisor → OUTSIDE → categories (b′)/(g)/(e)/(c)/(d)/(f)/(h) → emitter follows the table above.
b-bridged discriminator (normative)
The b-bridged category applies only when the consumer constructs an error-bearing Exchange for the sole purpose of invoking the route error handler — i.e., the consumer decides "this failure should be treated as a route-level exchange failure" and explicitly bridges it.
The discriminator is NOT "did the call return Err?". Read the actual contract:
ConsumerContext::send_and_wait(crates/components/camel-component-api/src/consumer.rs:77-91) documents thatErris returned "if the pipeline failed without an error handler absorbing the error".error_handler.rs::send_to_handler(lines 248–276) returnsOk(exchange)in every branch (no-handler-configured, handler-not-ready, handler-call-failed) and logserror!itself in those branches.
Consequences:
- Handler absorbed →
send_and_waitreturnsOk→ theif let Err(...)emitter branch never fires. The route handler (orerror_handler.rsitself) is the single ERROR owner. send_and_waitreturnsErr→ no handler absorbed it →error_handler.rslogged nothing at ERROR → the consumer'sif let Err(...)is the only ERROR signal for that failure. Downgrading it towarn!deletes operator visibility for a genuinely unhandled failure.
Therefore:
| Site shape | Category | Required level |
|---|---|---|
bridge_*_error(...).await (synthetic Exchange with set_error whose only purpose is invoking the route handler) — handler succeeds → caller emits at most warn! | (b-bridged) | warn! |
bridge_*_error(...).await itself returns Err (the route did NOT absorb the bridge — e.g., no handler configured) | (c) system-broken | error! |
Normal-data send_and_wait(exchange).await where exchange was constructed from real input (not for error-handoff purposes) and Err means the pipeline failed unhandled | (b′) outside-contract | error! (with increment_errors metric when available) |
A regression test at crates/camel-processor/src/error_handler.rs (or sibling) MUST assert that an unbridged / no-handler send_and_wait → Err produces an ERROR-level signal somewhere — i.e., the consumer's emitter MUST stay at error! for that path. The test is the only mechanical check that the rule above is not silently inverted by a future contributor.
Signal replacement API constraints
MetricsCollector::increment_errors(route_id, error_type)— existing API atcrates/camel-api/src/metrics.rs:11. Label value MUST match the regex^(b-prime|e|g):[a-z][a-z0-9-]*:[a-z][a-z0-9-]+$. Examples:b-prime:sql:on-consume,e:grpc:accept,g:http:endpoint-create.HealthCheckRegistry::force_unhealthy_for_route(route_id, name, reason)— existing API atcrates/camel-core/src/health_registry.rs:49. Pins the route to Unhealthy (HTTP 503, pod NotReady). This is correct for category (g): a route without a Producer is non-functional, NOT Degraded. Supervision restart (ADR-0007) clears the pin viaregister_for_routeonce the endpoint is recreated. There is noforce_degraded_for_routeAPI and we explicitly do NOT add one — a half-functional route is worse than a removed-from-rotation one.
Lint annotations
Every error!(...) in non-test code MUST be preceded by exactly one of:
// log-policy: system-broken
error!(...); // categories (c)(d)(f)(h) — no further requirement
// log-policy: outside-contract
error!(...); // categories (b′)(e)(g) — REQUIRES on a nearby line:
// metrics.increment_errors(route_id, "<cat>:<component>:<site>")
// OR
// health_registry.force_unhealthy_for_route(route_id, name, reason)
// OR
// a `if !bridged { ... }` guard containing the call
// log-policy: handler-owned
warn!(...); // category (a)(b-bridged) — MUST NOT be error!
Enforced by xtask lint-log-levels (pattern at scripts/xtask/src/main.rs:868 for lint_unwrap). Two parallel ratchets:
- Allowlist (
scripts/xtask/allowlist-log-levels.txt) — explicit per-site<rel path>:<line>entries. Pairedallowlist-log-levels.txt.maxenforces monotone-non-increasing post-seed. - Inline escape (
// allow-log-levelson the same line aserror!) — counted across allsrc/**/*.rsfiles. Pairedallow-inline.maxenforcescount <= max; migrations adding deferred sites MUST bumpallow-inline.maxatomically in the same commit.
Every inline escape MUST be preceded (within 3 lines) by a TODO(ADR-0012-<flavor>): ... via bd <id> marker where <flavor> ∈ {e-metrics, g, ...} and <id> is a live bd chore id. Lint fails if the TODO marker is missing or the bd id is absent. (Liveness of the bd id is enforced by periodic CI job; the lint verifies format only.) This prevents deferred sites from becoming invisible and permanent — every escape is traceable to a deferral chore.
Migration scope (informational)
Roughly 116 error!() sites across crates/. Estimated ~25 relevelled, ~6 kept as system-broken, ~3 removed as duplicates of the handler log. First migration wave: crates/components/camel-sql/ (including the duplicate-error! bug at consumer.rs:429 on the bridged path) plus crates/components/camel-direct/ (dead bridge_error_handler field removal — direct is producer-driven synchronous, bridging is semantically incoherent for it; closes TODO(DIR-005) as won't-fix).
Two sites were initially mis-categorized as b-bridged and corrected after the second-expert review (see docs/superpowers/reviews/2026-06-04-adr-0012-second-expert-review.md): camel-sql consumer.rs:205 (StreamList downstream send_and_wait) and camel-direct lib.rs:296 (consumer send_and_wait). Both are normal-data sends whose Err means the failure was NOT absorbed by the route handler; they are categorized (b′) outside-contract and MUST keep error! (with // log-policy: outside-contract + increment_errors metric once rc-mf3 lands).
Cross-references
- ADR-0007 — Route-supervised consumer failure. CrashNotification path is system-broken category (c); retains
error!. - CONTEXT-MAP.md Key Terms — adds four new terms: Handler-contract boundary, System-broken error, Side-effect failure, Bridged error.
- Apache Camel — Error Handler (https://camel.apache.org/manual/error-handler.html), Log component (https://camel.apache.org/components/latest/log-component.html).
DefaultErrorHandlerpropagates "as if there were no error handler at all";Dead Letter Channelis the single ERROR-emitting handler.
NetworkRetryPolicy and Retry Migration Boundaries
Status: Accepted
Context
Components duplicated capped exponential backoff loops for transient network reconnects, each with its own config struct, classifier, and log shape. The duplication made retry behavior inconsistent (max_attempts=0 meant unlimited in some components and zero-attempts in others), backoff hard to tune, and reconnect observability noisy.
NetworkRetryPolicy in camel-component-api provides the shared config (max_attempts, initial_delay, multiplier, max_delay, jitter), shared defaults, and a single canonical classifier for CamelError (is_retryable_camel_error). Two execution helpers sit on top: retry_async for bounded retry of pure async operations, and retry_async_cancelable for the same operations when route or consumer shutdown must interrupt the inter-retry sleep.
Some consumers cannot be expressed as a single closure passed to these helpers. They borrow mutable caller state across await points, span polling or event-stream loops, need async lifecycle side effects between attempts (bridge restart, pool slot restart), share an attempt counter across multiple recovery sites, or must not resend non-idempotent writes after transport failure.
Decision
Provide two public retry primitives in camel-component-api::network_retry, plus the
is_retryable_camel_error classifier:
retry_async(policy, label, op, is_retryable)— bounded retry ofop: FnMut() -> Future<Output = Result<T, E>>.label: Option<&'static str>emits a component identifier as a structuredcomponenttracing field (e.g.,"ws-producer: transient error — retrying"). PassNonefor the unlabeled path. Cancels only via the policy'smax_attempts.retry_async_cancelable(policy, label, op, is_retryable, &CancellationToken)— same asretry_async, plus cancellation honored during inter-retry sleep. Cancel-during-sleep returns the last operation error (no syntheticCancelledvariant). Cancel duringopitself is the caller's responsibility.- The former
retry_camel_errorconvenience wrapper was removed (zero non-test consumers); callers whose error type isCamelErroruseretry_async(policy, label, op, is_retryable_camel_error)directly.
Do not add a public stateful HRTB/boxed-future variant (retry_async_with_state). The confirmed Redis producer and executor cases borrow mutable state across await, but they also require retry-specific side effects (reconnect, stale connection clearing) whose timing is clearer in explicit loops. These sites should keep manual loops using NetworkRetryPolicy::should_retry and NetworkRetryPolicy::delay_for.
Migration decision tree
- Use
retry_async: operation owns or clones its state, no shutdown-cancellation need. Examples: WS connect (Some("ws-producer")), CXF pool connect (Some("cxf")), gRPC producer (Some("grpc-producer")), OpenSearch producer (Some("opensearch-producer")), SQL pool connect (Some("sql-consumer"),Some("sql-producer")). - Use
retry_async_cancelable: same conditions asretry_async, but route/consumer shutdown must interrupt backoff. Example: Container Docker connect (Some("container-events"),Some("container-logs")). - Stay manual:
- Operation borrows mutable caller state across
await(e.g.,&mut Exchange,&mut Executor) or needs pre-retry side effects tied to that state. Do not wrap this in a public HRTB/boxed-future helper; keep the retry loop explicit and useNetworkRetryPolicy::should_retry/delay_forfor shared policy semantics. Examples: Redis producer, Redis executor. - Retry spans a polling or event-stream loop that runs until cancelled, not bounded attempts. Example: Kafka consumer
recv()loop, SQL consumer polling loop, Container event stream loop. - Retry needs async pre-retry lifecycle side effects (e.g.,
restart_bridge().await?,pool.restart_slot().await?) between attempts. Example: XSLT and XJ bridge restart, JMS consumer reconnect. - Retry counter is shared across multiple recovery sites in the same loop. Example: JMS consumer's three nested retry sites sharing one
attemptcounter. - Resend after transport error would duplicate non-idempotent writes. Example: JMS producer.
- Operation borrows mutable caller state across
Consequences
- Shared retry behavior for simple network reconnects. Consistent semantics for
max_attempts(1 initial + N-1 retries;0= unlimited), backoff shape, jitter, and log fields. - Manual loops remain intentional, each with a comment explaining which branch of the decision tree excluded it from the shared helpers.
- Follow-up work tracked in bd:
rc-cvqwas evaluated and closed wontfix: a stateful HRTB/boxed-future helper would add public API complexity without simplifying the confirmed Redis sites enough to justify it.rc-1nmresolved:retry_asyncandretry_async_cancelablenow acceptlabel: Option<&'static str>. All 8 migrated components pass component identity viaSome("ws-producer")etc., emitting a structuredcomponenttracing field. Operators can filter by component label.- Container test dead-store cleanup (pre-existing
attempt = 0beforebreak).
ADR-0014: Unify WASM plugin runtime configuration across all plugin types
- Status: Accepted
- Date: 2026-06-05
- Tracking: bd
rc-zdi - Supersedes: none
- Related: ADR-0001 (data-plane / control-plane split), ADR-0011 (no silent default surprises)
Context
Until this ADR, the WasmConfig struct exposed three knobs — timeout_secs,
max_memory_bytes, max_concurrent_calls — but:
- Only the
wasm:URI scheme (Processor) parsed them from configuration. Bean, AuthorizationPolicy, and SecurityPolicy were all instantiated withWasmConfig::default()hardcoded at the call site (camel-cli/main.rs:305,authorization_policy.rs::build_permission_registry, all SecurityPolicy callers). max_memory_byteswas never enforced for any plugin type, including Processor. The 50 MiB default inconfig.rs:14was documentation only; everyWasmHostStatestarted withStoreLimits::default(), which lets a guest grow its linear memory to wasmtime's 4 GiB address-space ceiling.
This was discovered during the OSM PBF ingest spike (rust-camel_GEO-69f)
when a bean parsing a 180 MB PBF was killed at the 30-second timeout.
Decision
- Introduce a shared
WasmLimitsConfigtype incamel-configwithOption<T>fields fortimeout_secs,max_memory, andmax_concurrent_calls.Nonemeans "use the runtime default"; the defaults are explicitly applied inWasmConfig::from_limits, the single source of truth. - Embed
WasmLimitsConfiginBeanConfigandPermissionProviderConfig. Users tune plugins fromCamel.toml:[default.beans.<name>.limits] timeout-secs = 600 max-memory = 4294967296 - Keep the
wasm:URI parser for Processor. Processor endpoints continue to accept?timeout=X&max-memory=Y&max-concurrent-calls=Z. Internally the URI parser produces the sameWasmConfig; the URI form is the data-plane surface,Camel.tomlis the control-plane surface. - Enforce
max_memory_bytesviawasmtime::StoreLimitsBuilder::memory_sizeinWasmRuntime::create_host_state. Every consumer ofcreate_host_state(Producer, Bean, AuthorizationPolicy, SecurityPolicy) inherits the limiter.
Alternatives considered
Synthesise a fake wasm:plugin.wasm?timeout=X&max-memory=Y URI in camel-cli
Rejected. ADR-0001 separates data-plane endpoint concerns (URI parsers,
producers, consumers) from control-plane lifecycle configuration
(Camel.toml, plugin manifests). Reusing the URI parser for bean
configuration would breach that boundary and create a confusing dual
interpretation of the same syntax.
Defer memory enforcement
Rejected by the maintainer. There are no existing clients of the WASM plugin system today, so the cost of the breaking change (effective ceiling drops from unbounded to 50 MiB enforced unless raised) is zero now and grows monotonically with adoption. Shipping a documented safety net that does not exist is worse than fixing it.
Add fuel and other wasmtime knobs
Out of scope. We can add them later as new Option<T> fields on
WasmLimitsConfig without breaking anyone.
Consequences
- Breaking change for any workload relying on the implicit 4 GiB ceiling.
None exist today; documented in the
rc-zdibody and in release notes. WasmConfig::default()still exists for tests and as the source of truth for the defaults, but production callers go throughfrom_limitsorfrom_uri.WasmHostState::create_host_statenow requiresmax_memory_bytes. All call sites must be updated when adding new WASM consumers.WasmSecurityPolicyhas no production callers today; this ADR fixes its potential path without adding one.
Drive-by fix: EpochTicker migrated to a dedicated OS thread
The new behavioral test timeout_kills_infinite_loop_guest (added under
Task 9 of the implementation plan) needs the wasmtime epoch to advance
while a malicious guest is spinning inside call_async. The pre-existing
EpochTicker::start was implemented with tokio::task::spawn +
tokio::time::sleep, which cannot make progress when a tight CPU loop
inside call_async is starving the same tokio runtime — most notably
under single-worker or current_thread configurations.
To make the timeout enforced end-to-end (and not just in the test's
re-implementation), EpochTicker::start now spawns a dedicated OS thread
with std::thread::spawn + std::thread::sleep, matching the
"Surrealism approach" already cited in epoch.rs. Drop was changed
from handle.abort() to handle.join() so we synchronously know the
thread is no longer touching the engine before WasmRuntime::Drop
drops the Engine itself.
This change has no public-API impact (EpochTicker is internal to
camel-component-wasm) but is recorded here because it was discovered
through the new behavioral test for timeout_secs.
§4 Closure — Resolved by bd rc-0te
The §4 deferral ("WasmSecurityPolicy has no production callers today; this ADR fixes its potential path without adding one") is closed by bd rc-0te.
Decision recap
- Approach: Option A (Camel.toml-driven), consistent with the Permission variant precedent.
- Schema: New
[security.policies.wasm.<name>]block withpath+[limits]+[config]sub-tables.WasmSecurityPolicyConfigstruct incrates/camel-config/src/config.rs. - Builder:
build_security_policy_registryincrates/components/camel-component-wasm/src/security_policy.rs, parallel tobuild_permission_registry. - Wiring:
camel-cli/src/lib.rsbuild_security_compile_context_from_configpopulates theSecurityPolicyRegistryfrom Camel.toml + threads throughSecurityCompileContext::with_security_policy_registry. - DSL semantics: YAML
wasm: <name>references the registry name. Per-routeconfig:block is rejected with a hard error citing this section (silent-drop forbidden by ADR-0011).
Why per-route config is rejected
The SecurityPolicyRegistry stores Arc<dyn SecurityPolicy> instances (not factories). All routes referencing the same <name> share one policy instance with one init_config. Per-route config would require redesigning the registry as a factory, which is out of scope for v1. Multi-tenant users must use distinct names for distinct configs.
Sessions
- Oracle:
ses_15cb70293ffeoF7nj74LiZSZLG(Option A + schema (b) + reject per-route config) - Reviewer:
ses_15ca73f3cffetpVgc2XtnRdMel(APPROVED_WITH_MINOR_ISSUES → plan v2) - Discovery: bd rc-c5f (closed with finding) → bd rc-0te
Commits
- Commit 1:
87c184a8— Schema in camel-config (WasmSecurityPolicyConfig+ tests) - Commit 2:
c0ced1e6— Setter + builder + camel-cli wiring - Commit 3:
301099c2— compile.rs rejection + YAML semantics + tests - Commit 4:
b761fb91— README + ADR amendment + behavioral test
Endpoint-Created PollingConsumer for pollEnrich
Status: Accepted (2026-06-07)
Implemented in branch rc-3y0-eip-7 per plan
docs/superpowers/plans/2026-06-07-rc-3y0-eip-7-content-enricher.md. All
acceptance criteria from bd rc-3y0 met; quality gates green.
The pollEnrich DSL verb resolves its URI to an Endpoint, calls the
existing Endpoint::polling_consumer() -> Option<Box<dyn PollingConsumer>>
opt-in method, and invokes receive(timeout) on the result. We rejected the
producer-side alternatives (file:...?mode=read bridge, new resource:
component) because they invert the Tower Service<Exchange> producer invariant
(write/send only) and would constitute fake URI reuse across the data plane,
violating ADR-0001 and ADR-0014. The PollingConsumer trait already lives in
crates/components/camel-component-api/src/endpoint.rs; this ADR records the
decision to wire it up for the EIP-7 Content Enricher use case rather than
introduce a new mechanism.
Considered Options
- A. Endpoint-created PollingConsumer (chosen) — reuse the existing
PollingConsumertrait; endpoints opt in by overridingpolling_consumer(). File/SEDA/JMS can returnSome(...); HTTP server, Kafka returnNone. - B. Producer
file:...?mode=readbridge — REJECTED: violates ADR-0014 (fake URI reuse) and inverts the producer semantic. - C. New
ReadEndpointtrait — REJECTED: component-specific, doesn't reuse the existing consumer logic (filters, idempotency, path traversal). - D.
resource:standalone component — REJECTED: same flaws as B plus duplicates filesystem logic. - E. Consumer downcast — REJECTED: ugly, doesn't generalize, requires
every pollable component to share a concrete
Consumershape.
Consequences
- Adding
timeout: DurationtoPollingConsumer::receiveis a breaking change for any external implementor of the trait. Acceptable while the trait has zero implementors in-tree (all currentEndpoint::polling_consumeroverrides returnNone). Bumpcamel-component-apiminor version. FileEndpoint::polling_consumer()is the first non-Noneimplementation in the workspace; it must establish the eager-finalization lifecycle (delete/move/idempotency-mark happen insidereceive, before the Exchange is returned) that future implementors will follow.- WASM gains a sibling host function
camel_poll(uri, timeout_ms). Plugins remain backward compatible (additive WIT imports). No versioned worlds for v1 — carto-kit is the only consumer and is pre-production. - The DSL strategy trait is named
EnrichmentStrategy, notAggregationStrategy, to avoid collision with the existing EIP-22AggregateStrategyDeffamily.
ADR-0016: CanonicalRouteSpec v2 Contract
Date: 2026-06-08 Status: Accepted Amends: ADR-0011 Issues: rc-5iy, rc-ph7, rc-14b Oracle: ses_158f99ba3ffeAbgqfO9DQ9G3cn
Decision
CanonicalRouteSpec v2 adds lifecycle metadata fields with strict rejection for unsupported fields and a versioned expansion policy.
v2 Schema
pub const CANONICAL_CONTRACT_VERSION: u32 = 2;
pub struct CanonicalRouteSpec {
// v1
pub route_id: String,
pub from: String,
pub steps: Vec<CanonicalStepSpec>,
pub circuit_breaker: Option<CanonicalCircuitBreakerSpec>,
// v2
pub auto_startup: Option<bool>, // None = true
pub startup_order: Option<i32>, // None = 0
pub concurrency: Option<CanonicalConcurrencySpec>, // None = Sequential
pub version: u32,
}
pub enum CanonicalConcurrencySpec {
Sequential,
Concurrent { max: usize },
}
Defaults
| Field | None default | Rationale |
|---|---|---|
auto_startup | true | Matches current behavior |
startup_order | 0 | Neutral ordering |
concurrency | Sequential | Safe default |
Versioning Policy
versionis a monotonic schema counter: 1, 2, 3, ...- New runtime accepts old canonical specs (backward compatible):
validate_contractacceptsversion >= 1 && version <= CANONICAL_CONTRACT_VERSION - Old runtime rejects newer canonical specs with clear error
- Added fields are
Option<T>— old JSON deserializes in new runtime without breaking CANONICAL_CONTRACT_NAMEis a protocol identifier, not versioned
Expansion Principle
A field deserves canonical inclusion only if ALL six criteria are met:
- Needed across a stable boundary (runtime command, config, hot-reload, persisted config)
- Stable data-only representation exists
- Runtime can enforce it without the full DSL model
- Default behavior is safe and deterministic
- Losing it changes observable behavior
- Backward compatibility story is clear
Serializability is necessary but not sufficient.
Strict Rejection Policy
compile_declarative_route_to_canonical enforces strict rejection for unsupported fields:
| Field | Behavior |
|---|---|
security_policy | Always error, no override |
error_handler | Error by default, droppable with allow_loss=true |
unit_of_work | Error by default, droppable with allow_loss=true |
DeclarativeConcurrency::Concurrent { max: None } | Error by default, droppable with allow_loss=true |
No silent loss.
Lossy Escape Hatch
allow_loss: bool parameter (default false) permits explicit field dropping with structured diagnostics via CanonicalLossReport:
pub struct CanonicalLossReport {
pub dropped_fields: Vec<CanonicalFieldLoss>,
}
pub struct CanonicalFieldLoss {
pub field: &'static str,
pub reason: String,
pub target_version: u32,
}
security_policy is never dropped, regardless of allow_loss.
Compile Path Changes
compile_declarative_route_to_canonical
- Signature:
(route: DeclarativeRoute, allow_loss: bool) -> Result<(CanonicalRouteSpec, Option<CanonicalLossReport>), CamelError> - Propagates
auto_startup,startup_order,concurrency - Rejects
error_handler,unit_of_work, unbounded concurrency unlessallow_loss=true
compile_canonical_route
- Respects
spec.auto_startup(defaulttrueifNone) - Propagates
startup_orderandconcurrency - No longer forces
auto_startup(true)
Roadmap
| Version | Concern | Fields |
|---|---|---|
| v2 | Lifecycle/execution | auto_startup, startup_order, concurrency |
| v3 | Metadata + observability | description, group, metrics_disabled, trace_enabled |
| v4+ | Resilience/ops | supervision_config, health_check_config, error_handler |
unit_of_work stays out until it has a data-only, registry-safe representation. security_policy is permanently rejected (trait-object bound, non-serializable).
ADR-0017: DSL YAML Key Naming Convention
Date: 2026-06-09 Status: Accepted Issues: rc-co1
Decision
All YAML DSL step keys and field names use snake_case. No camelCase, no kebab-case, no PascalCase.
Rule
- YAML step keys (
set_header,wire_tap,poll_enrich, etc.) are snake_case. - YAML field names inside step structs (
timeout_ms,cache_ttl_secs, etc.) are snake_case. serde(rename = "...")is only allowed for Rust reserved words (loop,while,type, etc.).- EIP pattern names in prose/docs/comments ("pollEnrich", "wireTap") remain as-is — they reference the pattern, not the YAML key.
Rationale
Before this ADR, pollEnrich had a serde(rename = "pollEnrich") that forced camelCase for one step key while all ~30 other step keys (set_header, set_body, wire_tap, stream_cache, convert_body_to, load_balance, dynamic_router, routing_slip, recipient_list, etc.) were already snake_case. This was an inconsistency that broke the established pattern.
Consequences
- The YAML key for poll-enrich is
poll_enrich(notpollEnrich). - Existing YAML route files using
pollEnrichmust update topoll_enrich. - Future DSL additions follow snake_case by default (Rust field naming already enforces this via serde default behavior).
Amendment (2026-06-26)
The snake_case rule applies to both YAML and JSON DSL keys. The original wording
said "DSL YAML" because YAML was the only authoring format at the time (2026-06-09);
JSON inherited the rule silently via the shared RouteDsl* AST
(crates/camel-dsl/src/route_ast.rs).
ADR-0026 (JSON Canonical Route Authoring Format) formalizes JSON as a first-class authoring format. To remove ambiguity, this amendment makes explicit that:
- All DSL keys (step keys AND field names) are snake_case regardless of whether the user authors routes in YAML or JSON.
serde(rename = "...")policy (Rule 3) is unchanged.- EIP pattern names in prose/docs/comments (Rule 4) remain as-is.
No behavior change. Existing JSON route files already follow this rule; the amendment is a documentation clarification.
Referenced by: ADR-0026.
ADR-0018: Two-Phase Route Lifecycle Persistence
Date: 2026-06-12 Status: Accepted Amends: ADR-0002, ADR-0003, ADR-0004, ADR-0007
Decision
Route lifecycle commands that perform runtime side effects persist control-plane intent before executing the side effect, then confirm or compensate after the side effect returns.
For StartRoute, the Runtime records RouteStartRequested, projects the Route as Starting, starts the runtime Consumer/Pipeline, then records RouteStarted and projects Started. If the runtime side effect fails after intent was persisted, Runtime records RouteFailed, projects Failed, and publishes failure events. Non-atomic lifecycle flows use the same compensation rule when a side effect fails after repository or projection state changed.
Route aggregate writes use optimistic version checks. The expected version is captured before mutation; compensation captures the stored version before fail() increments it. Journal replay, repository state, projections, and published events therefore agree on the same lifecycle sequence.
Context
The previous lifecycle path could make runtime side effects and persisted lifecycle state disagree under failures. A start attempt can fail after accepting intent, and hot reload / supervision can race with operator commands. Because RuntimeBus is CQRS with optional journaling, control-plane history must be monotonic and replayable; pretending a persisted intent never happened creates divergent projections after crash recovery.
Starting is externally observable through RouteStatusProjection. It is intentionally visible to operators because it marks accepted intent that has not yet become a running Consumer.
Considered Options
Persist only after side effect succeeds
Rejected. Simpler happy path, but a crash between side effect success and persistence loses the lifecycle transition and can orphan a live Consumer outside the journal.
Roll back persisted intent on side-effect failure
Rejected. Rollback creates a second hidden state machine and conflicts with append-only event replay. Future readers would see missing history rather than accepted intent followed by failure.
Persist intent, then confirm or compensate
Accepted. It makes Starting visible and requires more projection reconciliation code, but preserves auditable history, replay consistency, and optimistic concurrency boundaries.
Consequences
Startingis a public lifecycle state, not an implementation detail.RouteRuntimeAggregate::fail()increments version so replayedRouteFailedevents and live compensation paths converge.- Repository writes must use
save_if_versionor an atomic unit-of-work port with the same expected-version semantics. - Projection updates after non-unit-of-work writes must reconcile from aggregate state; failure to persist compensation is a system-broken inconsistency requiring manual reconciliation.
- Hot reload and route-supervised Consumer failure remain Route lifecycle events, not data-plane mutations.
ADR-0019: Error Disposition — In-Pipeline Recovery via RouteErrorHandler
Date: 2026-06-13 Status: Accepted Amends: ADR-0012 Amended by: ADR-0024
Amended by ADR-0024: the route pipeline executor (
run_steps) now returnsPipelineOutcome(Completed | Stopped | Failed) instead ofResult<Exchange, CamelError>. The in-pipeline disposition table below is unchanged; see ADR-0024 for thePipelineOutcomesemantics and reply-channel adapter.Phase 4 amendment (2026-06-22, ADR-0025):
retry_stepretries the failed compiled step viaRetryableStep(generalised from&mut BoxProcessor).RetryOutcome::Stoppedexits before the disposition phase — Stop is successful control flow, not exhausted error handling. TheStepDispositionmodel itself is unchanged.
Decision
Error handling decisions are made INSIDE the pipeline step loop, not by an outer Tower middleware layer. A RouteErrorHandler trait is injected into SequentialPipeline / TracedPipeline, and after each step failure the handler decides the disposition:
Propagate— return the error upstream; the route aborts (default).Handled— absorb the error, send to DLC, and returnOk(exchange)immediately; the route terminates normally.Continued— clear the error, send to DLC, and continue to the next step in the pipeline.
A new RouteChannelService wraps the pipeline with explicit Security and CircuitBreaker gates. Boundary errors (Security denials, CB rejections) flow through the same RouteErrorHandler::handle_boundary method, ensuring they reach the DLC without double-counting or provenance hacks.
The previous ErrorHandlerLayer / ErrorHandlerService Tower middleware is deprecated (since 0.16.0) and remains only as a backward-compatibility shell for routes that have no errorHandler configured.
Context
The old architecture wrapped the entire SequentialPipeline with an ErrorHandlerLayer Tower middleware. When a step failed, the pipeline loop aborted immediately and returned Err to the outer layer. The handler could absorb the error (handled: true) and return Ok(exchange), but it could not instruct the pipeline to continue to the next step — the loop had already exited. This made Camel's continued=true semantics impossible to implement.
Additionally, the old layer-based composition made CircuitBreaker and SecurityPolicy interact with error handling opaquely: errors flowed through Tower layers in stack order, and the handler could not distinguish whether an error originated from a pipeline step, a CB rejection, or a Security denial.
The pipeline needs to be "recovery-aware": after a step fails, the handler must be able to say "clear the error and continue to the next step" without re-entering the failed step.
Comparison with Apache Camel 4.x
Apache Camel models error handling with an outer ErrorHandler wrapping the route processor. After a step throws, the handler applies onException policies: handled=true absorbs the error and breaks out of the original route (optionally routing to a sub-route); continued=true clears the error and resumes the original route. Redeliveries retry from the point of failure. When all retries are exhausted the exchange is sent to the Dead Letter Channel. These semantics are faithfully mirrored by ExceptionDisposition (Propagate/Handled/Continued), retry_step, and the DLC catch-all policy.
The architectural divergence is forced by Tower. Camel's JVM runtime has no readiness concept — every call is synchronous from the error handler's perspective. Tower splits poll_ready (readiness) from call (execution), and treats a readiness Err as a permanently broken service. This is incompatible with Camel's model where all errors are routable, retryable events. rust-camel resolves the mismatch by having poll_ready return Ready(Ok(())) unconditionally (multicast, error handler) and routing readiness errors through the same in-pipeline RouteErrorHandler. The consequence is that continued=true is implemented inside the pipeline loop rather than by an outer layer — a structural necessity, not a stylistic choice.
Enumeration of processors bound by the Ready(Ok(())) readiness contract
Processors whose semantics are incompatible with Tower's "readiness Err =
permanently broken service" assumption MUST NOT propagate readiness errors from
poll_ready. They MUST return Ready(Ok(())) unconditionally, or preserve only
Pending backpressure while mapping readiness Err to Ready(Ok(())), and move
per-endpoint or per-fragment readiness checks into call() where the route error
handler can apply retry, handled, continued, failover, or stop-on-exception
semantics.
| Processor | Reason | Status |
|---|---|---|
MulticastService | Parallel/sequential fan-out must honour stop_on_exception; per-endpoint readiness belongs in call(). | migrated |
ErrorHandlerService | Deprecated compatibility shell; retry/DLC handling happens in call(). | migrated |
AggregatorService | Aggregation buckets and timeout work are call-time state; readiness has no external endpoint to validate. | migrated |
RecipientListService | Recipients are dynamically resolved; readiness is per resolved recipient in call(). | migrated |
WireTapService | Fire-and-forget tap failures MUST NOT block the main pipeline. | pending-fix |
LoadBalancerService | Failover/selection strategies must skip broken endpoints in call(), not fail before selection runs. | pending-fix |
SplitterService | Fragment sub-pipeline readiness is checked per fragment in call(); outer readiness MUST NOT bypass split error policy. | pending-fix |
StreamingSplitterService | Streaming fragment readiness is checked per fragment in call(); outer readiness MUST NOT abort before stream policy runs. | pending-fix |
FilterService | Conditional sub-pipeline; predicate is evaluated in call(), and sub-pipeline readiness MUST be checked there so the route error handler can apply continued/handled semantics — outer readiness MUST NOT bypass the filter's conditional dispatch. | pending-fix |
SecurityPolicyService is intentionally excluded. Route-level authorization is a
pre-pipeline boundary per ADR-0010:
authorization faults are system-boundary faults and MUST surface before normal
EIP processing runs.
| Concept | Apache Camel 4.x | rust-camel |
|---|---|---|
| Error handler placement | Outer layer wrapping route | In-pipeline RouteErrorHandler injection |
handled=true | Break original route; optional sub-route | Handled: absorb → DLC → route terminates normally |
continued=true | Resume original route after error | Continued: clear error → DLC → advance to next step |
| Redelivery | Retry from point of failure | retry_step retries the failed step |
| DLC default | DeadLetterChannel handler | Catch-all Handled policy when no onException |
| Readiness errors | N/A (no readiness concept) | Routed through RouteErrorHandler (not permanent) |
| CircuitBreaker + handled error | Separate EIP; open = onCallNotPermitted | Handled counts as CB success (handler absorbed it) |
Considered Options
Keep outer layer; add a "resume from step N" mechanism
Rejected. The pipeline would need to carry cursor state, and the outer layer would need to re-invoke the pipeline starting from step N+1. This splits the error-handling state machine across two components (the layer and the pipeline), making retry, DLC routing, and disposition logic hard to follow. It also re-enters the Tower ready() / call() protocol mid-pipeline, which is not designed for resumption.
Move handler decision inside the pipeline loop (accepted)
The handler is injected as Option<Arc<dyn RouteErrorHandler>>. After each step, on Err, run_steps calls match_policy → retry_step → handle_step. The disposition returned by handle_step determines whether the loop continues (Continued), returns early (Handled), or propagates (Propagate). This keeps the entire error state machine in one place and makes the Continued variant trivial to implement — the loop simply clears the error and advances to the next step.
Remove ErrorHandlerLayer entirely
Rejected. Routes with no errorHandler config still use the Tower layer path for backward compatibility. Removing it would be a breaking change for users who compose routes programmatically without configuring error handlers.
Consequences
ExceptionDispositionreplaceshandled: boolthroughoutcamel-api.ExceptionPolicycarries adisposition: ExceptionDispositionfield. The DSL gains acontinued: truefield ononException, mutually exclusive withhandled: true.RouteChannelService(constructed only when anerrorHandleris configured) chains Security → CB(before_call) → Pipeline(run_steps) → CB(after_result). Boundary errors from Security or CB gates go throughhandle_boundary, which routes them to the DLC.- CircuitBreaker
after_resultreceives the post-handler pipeline result. AHandlederror counts as CB success — the handler absorbed it, so the CB does not trip. Users who want the CB to count absorbed errors must usePropagatedisposition instead. - When a DLC is configured with no explicit
onExceptionclauses,resolve_error_handlerinjects a catch-allHandledpolicy, preserving the old "DLC absorbs all errors" behaviour. ErrorHandlerLayer/ErrorHandlerServiceare deprecated. New routes should use theRouteChannelServicepath (automatic whenerrorHandleris configured). The old Tower layer path is used only when noerrorHandleris present.RouteChannelServiceispubbut gated behind theinternal-adaptersfeature flag; it is not part of the stable public API.send_to_handleralways returnsOk(exchange)— theErrbranches inhandle_step/handle_boundaryare dead code by construction (documented with comments).
ADR-0020: LLM Component Provider Adapter Boundary
Date: 2026-06-13 Status: Accepted
Decision
Define a project-owned LlmProvider trait with Camel-shaped request/response types. Confine all production siumai imports to the adapter file (provider/siumai_adapter.rs) plus one test-only file (provider/siumai_adapter_tests.rs, gated by #[cfg(all(test, feature = "openai"))]). provider_factory.rs selects and dispatches to adapter constructors without importing siumai directly.
The original boundary included two production files. Refactoring provider_factory.rs to delegate narrowed it to one production file without changing the architectural decision.
Test fixtures legitimately need siumai types (StubChat, StubEmbed implement siumai traits) and cannot be expressed through the public adapter API.
No other file in the crate may import siumai. A test (tests/boundary.rs) scans all .rs files for direct siumai references outside its allowlist. The allowlist still permits provider_factory.rs for historical compatibility, although that file has no direct siumai imports.
Context
The camel-component-llm component needs an LLM provider abstraction to support multiple backends (OpenAI, Ollama, etc.). Two approaches were considered:
- Depend on siumai types directly — Similar to how
camel-sqldepends onsqlxtypes directly in its producer. - Define a project-owned
LlmProvidertrait — Isolate siumai behind a strict adapter boundary.
The SQL precedent (direct sqlx dependency) does not transfer because:
sqlxis mature (0.8 stable) with a stable API surface.siumaiis0.11.0-beta.9— beta quality, API may churn.- Database concepts (connection, pool, query) are stable and well-understood.
- LLM API semantics (chat, streaming, tool calling, structured output) are still rapidly evolving.
Considered Options
Depend on siumai types directly
Rejected. The beta status of siumai (0.11.0-beta.9) means API churn is likely. Direct dependency would spread siumai types across the component, producer, endpoint, and config — making every breaking change a crate-wide refactor. The SQL precedent does not apply because sqlx is stable and its domain concepts are well-established.
Define a project-owned trait (Accepted)
Accepted. A project-owned LlmProvider trait with Camel-shaped types isolates production siumai imports to one adapter file. Mock provider works without siumai at all (--features mock only). If siumai breaks, only the adapter file changes. Future non-siumai providers are possible without breaking the component API.
Consequences
Positive:
- siumai API churn is confined to one production adapter file.
- Mock provider works without siumai dependency (
--features mockonly). - Testing is deterministic without network.
- Future non-siumai providers are possible without breaking the component API.
- Hot-reload, multi-context, and test isolation are safe (own provider map, not global registry).
Negative:
- Boilerplate: own request/response types that don't mirror siumai.
- Manual translation between Camel types and siumai types in the adapter.
Failure mode: If production siumai imports or siumai-shaped public types leak past provider/siumai_adapter.rs, the design has failed.
ADR-0021: LLM Retry Honors Provider retry_after via Manual Loop
Date: 2026-06-13 Status: Accepted
Decision
camel-component-llm uses a manual retry loop (NetworkRetryPolicy::should_retry
delay_for) instead of the sharedretry_asynchelpers from ADR-0013.
Context
LlmError::RateLimit carries retry_after: Option<Duration> — a
provider-requested back-off that must override exponential backoff when
present. The retry_async helpers do not accept a per-attempt delay override.
New manual-retry criterion
Per-attempt delay override. When the error specifies a delay
(retry_after), that delay replaces the policy's computed backoff. ADR-0013's
existing "stay manual" criteria (mutable state across await, retry spanning
a polling/event-stream loop, async pre-retry lifecycle side effects,
non-idempotent resend) do not cleanly cover this.
The loop is also justified by two hardening-spec decisions:
- Permit release during backoff — the semaphore permit is dropped before
the backoff
sleepand re-acquired on the next attempt, freeing the concurrency slot for other requests during the wait. - Total timeout wraps everything — one
tokio::time::timeoutdeadline encloses all attempts and all backoff sleeps; there is no per-attempt timeout (which would let N attempts run for N × timeout).
Consequences
- One explicit loop in the LLM producer, with a comment pointing here.
- If a second component needs per-attempt delay override, extend
retry_async_cancelablewith adelay_overridecallback at that time (do not pre-emptively grow camel-component-api). - Cancellation is delivered via Rust's standard future-drop semantics
(dropping
Body::Streamdrops the inner provider stream). No explicitCancellationTokenis needed — in Rust, drop IS cancellation.
ADR-0022: StepLifecycle Trait and Drain Policy for Stateful Pipeline Steps
Date: 2026-06-26 Status: Accepted (Phase 0) Amends: ADR-0004, ADR-0018, ADR-0024, ADR-0025
Decision
Separate StepLifecycle trait (not on Processor)
Stateful pipeline steps (aggregators, idempotent repositories, resequencers, gap-detectors) own background work — timers, buckets, queues, timeout tasks — that outlives a single process() call. They need a shutdown hook. This hook is a separate trait (StepLifecycle), NOT a method on Service<Exchange>. Motivation: type erasure. BoxProcessor = BoxCloneService<Exchange, Exchange, CamelError> erases the concrete type; adding a trait method to Service would require a custom wrapper or vtable extension. A standalone trait collected at compile time avoids this.
File: crates/camel-api/src/step_lifecycle.rs:31
#[async_trait]
pub trait StepLifecycle: std::fmt::Debug + Send + Sync + 'static {
fn name(&self) -> &'static str;
async fn shutdown(&self, reason: StepShutdownReason) -> Result<(), CamelError>;
}
&self receiver (NOT &mut self)
StepLifecycle uses &self, not &mut self. The runtime dispatches shutdown through Arc<dyn StepLifecycle> carried inside ArcSwap<PipelineAssembly> snapshots. Shared-reference dispatch means Arc cloning and concurrent snapshot reads work without mutation races. Implementations requiring mutable state use interior mutability (e.g. Mutex, AtomicBool).
Documented in doc comment at crates/camel-api/src/step_lifecycle.rs:20-24.
Supertrait: Debug + Send + Sync + 'static
#[async_trait] provides Future-returning method support. Debug is required because CompiledStep derives Debug (#[derive(Debug)] on CompiledStep at step_compilers/mod.rs:42). Send + Sync + 'static are the standard Arc bounds for trait-object storage.
StepShutdownReason
Two variants:
pub enum StepShutdownReason {
RouteStop, // stop_route is draining the pipeline
HotSwap, // pipeline being replaced via hot reload (Restart path)
}
File: crates/camel-api/src/step_lifecycle.rs:6-11.
Storage: collected at compile time
Each stateful processor registers its Arc<dyn StepLifecycle> at compile time:
CompiledStep::Process.lifecycle:Option<Arc<dyn StepLifecycle>>— single lifecycle handle for a stateful processor (step_compilers/mod.rs:49).CompiledStep::Segment.lifecycle:Option<Vec<Arc<dyn StepLifecycle>>>— multiple lifecycle handles for a structural EIP's nested stateful children (step_compilers/mod.rs:64). UsesVec(not flattening into a singleArc) so multiple stateful children inside a structural EIP each register independently.PipelineAssembly.lifecycle:Vec<Arc<dyn StepLifecycle>>— flat aggregated list in the runtime pipeline snapshot (pipeline_runtime.rs:22).
Why Option<Vec<...>> for Segment, not Option<Arc<dyn StepLifecycle>>? A segment wraps an entire sub-pipeline (Filter, Choice, Loop, etc.) that may contain zero, one, or multiple stateful children. Each child has its own lifecycle. The Vec preserves independent identity — important for ADR-0025's outcome-aware structural EIPs where children can be nested segments themselves.
compile_children_segments aggregates child lifecycle
CompilationContext::compile_children_segments() (step_compilers/mod.rs:123) recursively compiles child steps and accumulates lifecycle handles via extend:
CompiledStep::Process { lifecycle: Some(lc) }→ pusheslcinto the accumulating vec (lines 144-146).CompiledStep::Segment { lifecycle: Some(lcs) }→ extends the vec withlcs(lines 170-171).- Stateless steps (
lifecycle: None) contribute nothing.
The returned (Vec<Box<dyn OutcomePipeline>>, Vec<Arc<dyn StepLifecycle>>) pair is then stored in the parent CompiledStep::Segment.lifecycle. This recursive flattening ensures nested stateful children (e.g. Idempotent+Resequencer inside Filter) are discovered at compile time and reachable at drain time.
Drain in stop_route_internal: POST-join, PRE-token-reset
Drain placement is precise. stop_route_internal (consumer_management.rs:135) follows this ordering:
- Cancel consumer intake token (line 154).
force_complete_allon aggregator (line 160) — completes open buckets; emits exchanges into pipeline.- Cancel pipeline token (line 166).
- Take handles and join both consumer + pipeline tasks (lines 171-193). Zero
process()in flight after join. - Drain stateful steps: iterate
assembly.lifecyclefrom ArcSwap snapshot, callshutdown(RouteStop)in route order (lines 202-220). - Drain aggregator via
StepLifecycle::shutdownonManagedRoute.agg_service(lines 225-240). - Reset cancellation tokens for future restarts (lines 245-246).
Invariant: intake cancelled + pipeline task joined BEFORE shutdown() is called. This ensures no concurrent process() when the lifecycle hook fires.
Shutdown Err is best-effort: tracing::warn! and continue (does NOT fail stop_route). Precedent: CamelContext::stop service handling. See consumer_management.rs:212-218.
Hot-swap policy: REJECT lifecycle-bearing routes
DefaultRouteController::swap_pipeline (route_controller.rs:659) checks:
let assembly = managed.pipeline.load();
let has_lifecycle = !assembly.lifecycle.is_empty();
if has_lifecycle || managed.agg_service.is_some() {
return Err(CamelError::RouteError(
"Route '...' contains stateful steps (lifecycle-bearing). Hot-swap not supported — use restart."
));
}
Rationale: Atomic ArcSwap swap cannot safe-drain lifecycle handles. An in-flight request may hold the old Arc while the new pipeline is swapped in. If that request holds the last reference, the old pipeline (and its lifecycle) is dropped concurrently with the new pipeline starting — races with background timers/buckets from the old pipeline. The safe protocol is stop → raw swap → start:
stop_route— drains lifecycle viaStepLifecycle::shutdown.swap_pipeline_raw— bypasses the lifecycle check (route is stopped, no in-flight).start_route— recreates consumer + pipeline.
swap_pipeline_raw lives at route_controller.rs:702 and pipeline_runtime.rs:70.
Aggregator: side-channel + unified drain
The aggregator is a side-channel: ManagedRoute.agg_service (route_helpers.rs:120), NOT a CompiledStep. It implements StepLifecycle directly (aggregator.rs:181-191) for unified drain.
Dual-phase shutdown:
- Pre-join:
force_complete_all(line 160 ofconsumer_management.rs) — completes open buckets, emits exchanges into the pipeline. The join then processes those emits. - Post-join:
shutdown(RouteStop)on theArc<AggregatorService>(lines 225-240) — drains remaining timeout tasks that the pipeline task's select loop may have spawned.
aggregator.rs:186-190:
async fn shutdown(&self, reason: StepShutdownReason) -> Result<(), CamelError> {
tracing::debug!(reason = ?reason, "Aggregator shutdown via StepLifecycle");
self.shutdown_inner().await;
Ok(())
}
Segment lifecycle is Option<Vec<...>> — multiple stateful children
CompiledStep::Segment.lifecycle (step_compilers/mod.rs:64) is Option<Vec<Arc<dyn StepLifecycle>>> rather than Option<Arc<dyn StepLifecycle>>. This is deliberate:
- A structural EIP (Filter, Choice, Loop, doTry, Split) may contain zero, one, or multiple stateful processors in its sub-pipeline.
- Each child registers its own
Arc<dyn StepLifecycle>. - The
Vecaccumulates viaextendincompile_children_segments(lines 135-177). - At drain time,
PipelineAssembly.lifecycleis also a flatVec— theOption<Vec<...>>from eachCompiledStepis flattened intoPipelineAssembly.new().
This design does NOT preclude ADR-0024/0025 propagation of PipelineOutcome::Stopped through Segment: the Segment's OutcomePipeline::run returns PipelineOutcome independently of its lifecycle Vec. The two are orthogonal — lifecycle for shutdown, PipelineOutcome for data-plane control flow.
Context
Problem
Before Phase 0, stateful pipeline steps had no shutdown hook. stop_route cancelled the pipeline task and reset tokens, but never notified background state (aggregator buckets, idempotent caches, resequencer queues, timeout tasks) that the route was stopping. This allowed:
- Orphaned timeout tasks firing after token reset.
- Aggregator buckets leaking when the route was stopped mid-completion.
- No mechanism for hot-swap to safely transfer or drain stateful resources.
ADR-0004 (hot-reload atomic pipeline swap) assumed stateless pipelines. ADR-0018 (two-phase route lifecycle) established stop_route as the canonical drain site. ADR-0024/0025 introduced PipelineOutcome and outcome-aware segments — these expanded the set of possible stateful steps (segments with child lifecycle handles). Phase 0 fills the gap.
Quiescence policy
Old pipeline assembly retires via Arc refcounting. After ArcSwap::store(), the old Arc<PipelineAssembly> survives as long as any in-flight request holds it. No explicit quiescence queue is needed because the Restart path drains via stop_route (which joins before draining) before the new pipeline starts.
swap_pipeline_raw exists specifically for the Restart path — it bypasses the lifecycle check because the route is already stopped and drained.
Shutdown Err handling: best-effort
Err from StepLifecycle::shutdown is logged and skipped — it does NOT fail stop_route. This mirrors CamelContext::stop's handling of service shutdown errors. Rationale: a failing step should not prevent subsequent steps from draining, nor should it leave the route in a half-stopped state.
Implementation at consumer_management.rs:208-218:
if let Err(e) = step.shutdown(StepShutdownReason::RouteStop).await {
tracing::warn!(step = step.name(), error = %e,
"StepLifecycle shutdown failed during stop_route for route {}", route_id);
}
Consequences
Trait location
StepLifecycle lives in camel-api (step_lifecycle.rs), NOT in camel-processor or camel-core. This allows any crate (including component crates) to implement it without depending on core lifecycle internals. Imported by camel-core's step_compilers (step_compilers/mod.rs:11).
Interface stability
StepLifecycle is pub in camel-api. It is not gated behind an internal-adapters feature flag — any stateful processor or component may implement it. The trait is simple (one method + one accessor) and unlikely to change except to add additional reason variants.
PipelineAssembly growth
PipelineAssembly gains a Vec<Arc<dyn StepLifecycle>> field. Cost: one allocation per stateful step (amortized by Vec::extend). Stateless routes carry an empty Vec (zero cost at runtime, 24 bytes in the struct). See pipeline_runtime.rs:22.
No quiescence queue
The Restart path (stop → drain → swap → start) avoids the need for an explicit quiescence or deferred-drain mechanism. swap_pipeline_raw (route_controller.rs:702, pipeline_runtime.rs:70) is the non-checking variant for this path.
ADR-0024/0025 interaction
Segment.lifecycle being Option<Vec<Arc<dyn StepLifecycle>>> (not a single Option) ensures multiple stateful children inside a structural EIP each register independently. ADR-0025's OutcomePipeline trait and PipelineOutcome::Stopped propagation through Segment are orthogonal to lifecycle drain — they operate on the data plane, not the control plane.
PipelineOutcome::Stopped propagation through Segment is a Phase 1/3 concern; Phase 0 does NOT implement it but does NOT preclude it.
Phase 0 boundary
Phase 0 Tasks 1-7 implement:
StepLifecycletrait +StepShutdownReason(camel-api).CompiledStepfield additions (Process.lifecycle,Segment.lifecycle).PipelineAssembly+new_shared_pipeline_with_lifecycle.- Drain loop in
stop_route_internal. swap_pipelinelifecycle check +swap_pipeline_raw.AggregatorService::StepLifecycleimpl.compile_children_segmentslifecycle aggregation.
What Phase 0 does NOT do:
- Implement
StepLifecycleon every stateful processor (Phase 1+). - Wire
PipelineOutcome::Stoppedthrough Segment data plane (ADR-0025, already done in Phase 4 — but lifecycle collection predates it and is compatible).
Load-bearing citations
| File:line | Element |
|---|---|
camel-api/src/step_lifecycle.rs:31 | pub trait StepLifecycle |
camel-api/src/step_lifecycle.rs:6 | StepShutdownReason enum |
step_compilers/mod.rs:43 | pub enum CompiledStep |
step_compilers/mod.rs:49 | Process { lifecycle: Option<Arc<dyn StepLifecycle>> } |
step_compilers/mod.rs:64 | Segment { lifecycle: Option<Vec<Arc<dyn StepLifecycle>>> } |
step_compilers/mod.rs:123 | fn compile_children_segments |
step_compilers/mod.rs:135-177 | Lifecycle aggregation via push/extend |
pipeline_runtime.rs:20 | struct PipelineAssembly |
pipeline_runtime.rs:22 | lifecycle: Vec<Arc<dyn StepLifecycle>> |
pipeline_runtime.rs:50 | fn new_shared_pipeline_with_lifecycle |
pipeline_runtime.rs:70 | fn swap_pipeline_raw (no lifecycle bypass) |
consumer_management.rs:135 | fn stop_route_internal |
consumer_management.rs:160 | force_complete_all (pre-join) |
consumer_management.rs:202-220 | Drain loop (post-join, pre-token-reset) |
consumer_management.rs:225-240 | Aggregator shutdown via StepLifecycle |
route_controller.rs:659 | fn swap_pipeline (lifecycle check) |
route_controller.rs:702 | fn swap_pipeline_raw (bypass) |
route_helpers.rs:120 | ManagedRoute.agg_service |
aggregator.rs:181 | impl StepLifecycle for AggregatorService |
ADR-0023: Idempotent Repository Trait Boundary
Date: 2026-06-27 Status: Accepted (Phase 1) References: ADR-0024, ADR-0025 Related: Phase 1 — Tasks 2, 3 (Idempotent Consumer EIP)
Decision
IdempotentRepository trait in camel-api (key-only, Result-returning)
The IdempotentRepository trait lives in camel-api (crates/camel-api/src/idempotent.rs:13) so any crate (component, processor, test) can implement it without depending on camel-core lifecycle internals. This mirrors StepLifecycle placement in camel-api (ADR-0022 §Trait location).
Contract C1: contains() returns Result<bool, CamelError> (NOT bool). Backends (Redis, SQL-backed, S3) have transient read failures. The Idempotent Consumer propagates Err — it must never treat a failed read as "not a duplicate" (crates/camel-api/src/idempotent.rs:32-36).
Key-only: The trait stores keys (String), not full messages. Motivation: an idempotent repository tracks which messages have been seen, not what they contained. Storing full messages would blow memory/backing-store and is the job of a different pattern (Claim Check, Phase 2). Future: a key_fn: Arc<dyn Fn(&Exchange) -> String> on the Idempotent Consumer step derives the key from the exchange (e.g. exchange.message_id(), header-based, body-hash).
// File: crates/camel-api/src/idempotent.rs:14-46
#[async_trait]
pub trait IdempotentRepository: Send + Sync + Debug + 'static {
fn name(&self) -> &str;
async fn contains(&self, key: &str) -> Result<bool, CamelError>;
async fn add(&self, key: &str) -> Result<bool, CamelError>;
async fn remove(&self, key: &str) -> Result<(), CamelError>;
async fn clear(&self) -> Result<(), CamelError>;
}
MemoryIdempotentRepository in camel-core (DashMap-backed)
The in-memory implementation uses DashMap<String, ()> for concurrent read/write access without a coarse lock (crates/camel-core/src/idempotent/memory_repository.rs:24). All trait methods take &self, so the repository can be shared via Arc<dyn IdempotentRepository> across pipeline steps.
add() uses DashMap::insert and returns Ok(!was_present) — None from insert means the key was new (memory_repository.rs:43).
Registered as the default "memory" repository during CamelContextBuilder::build() (crates/camel-core/src/context_builder.rs:305-311).
NamedRegistry<T> in camel-core (Mutex-based, fallible register)
A generic named-object registry with duplicate detection. Unlike the auth crate's NamedRegistry (crates/services/camel-auth/src/registry.rs:23, DashMap-based, infallible register()), this registry uses Mutex<HashMap<String, Arc<T>>> so register() can reject duplicates with Err(RegistryError::AlreadyRegistered) (crates/camel-core/src/registry.rs:10-54).
Type alias: IdempotentRegistry = NamedRegistry<dyn IdempotentRepository> (crates/camel-core/src/registry.rs:79).
The auth crate's DashMap version overwrites silently — appropriate for security policies where last-writer-wins is acceptable. The Phase 1 registry rejects duplicates because idempotent repository registration is a programming error (two repos with the same name would cause unpredictable routing). If an override is needed, the caller must first remove the existing registration (not yet exposed — future work).
Segment-not-Process decision for Idempotent Consumer (Task 3)
The Idempotent Consumer EIP (Task 3) MUST use OutcomeSegment (not BoxProcessor/Process mode). Rationale:
compose_pipeline (crates/camel-core/src/lifecycle/adapters/route_compiler.rs:35) converts PipelineOutcome::Stopped to Ok(ex) via into_tower_result(). If the Idempotent Consumer ran as a BoxProcessor (Process mode), a duplicate-detected Stopped(ex) would become Ok(ex) — indistinguishable from a successful first-time pass. Downstream steps would continue processing a duplicate exchange.
Using OutcomeSegment (compose_outcome_segment via crates/camel-core/src/lifecycle/adapters/outcome_composition.rs) preserves PipelineOutcome::Stopped across the Idempotent Consumer boundary. The sub-pipeline after the duplicate check is skipped (Stopped propagates to run_steps which terminates the current branch). This is the core fix for Option E/ADR-0024.
RegistryError visibility
RegistryError is pub — it's the error type returned by register_idempotent_repository(), which is a public method on CamelContext. Making RegistryError pub allows external callers to match on AlreadyRegistered.
Supertrait bounds
IdempotentRepository requires Send + Sync + Debug + 'static. Debug enables logging/tracing of repository identity. Send + Sync + 'static are the standard Arc<dyn ...> bounds for trait-object storage in registries.
Context
Problem
Before Phase 1, the Idempotent Consumer EIP had no pluggable key store. The processor would need to hard-code an in-memory HashSet or HashMap, making it impossible to share state across restarts or cluster nodes. Real deployments need Redis, SQL-backed (via SQLx in camel-sql), or other backends.
Key store requirements
- Pluggable: Backend must be swappable without changing the EIP processor.
- Thread-safe: Multiple pipeline steps may check/add concurrently.
- Read-failure-transparent (C1): Transient backend failure must propagate, not silently be treated as "not a duplicate."
- Key-only: Duplicate detection checks keys, not full messages.
- Default memory backend: Zero-dependency setup for simple cases.
- Context-scoped: Repository lives in
CamelContextso it is available to all routes and lifecycle-managed.
Why not a single global registry?
Idempotent repository scope is per-route or per-pattern instance, not global. A global registry would require name-scoping at call sites and prevent clean isolation between contexts. CamelContext-scoped registration provides the right granularity: the context owns the lifecycle of its repositories.
Why Mutex, not DashMap for NamedRegistry?
DashMap insert() always succeeds (overwrites silently). NamedRegistry needs fallible register() — reject if name is taken. Mutex<HashMap> provides atomic check-and-insert. Contention is negligible: registrations happen at context-build time, not per-exchange.
The auth crate's DashMap NamedRegistry (crates/services/camel-auth/src/registry.rs:23) overwrites silently, which is appropriate for security policies (last-writer-wins for policy evaluation). The two registries coexist for different use cases.
Phase 2 reuse
NamedRegistry<T> will be reused in Phase 2 (Claim Check) with no structural changes — ~3 lines per call site. The generic bound T: ?Sized + Send + Sync + 'static accommodates both dyn IdempotentRepository and dyn ClaimCheckStore.
Consequences
Trait location
IdempotentRepository in camel-api (idempotent.rs) means any crate can implement it without depending on camel-core. Future backends (Redis in camel-component-redis, SQL in camel-sql via SQLx) can implement the trait remotely.
Interface stability
The trait has no #[non_exhaustive] attribute — adding methods would break existing implementations. Phase 1 considers the 5-method interface stable. If a future backend needs a len() or keys() method, a separate trait or default method with unimplemented!() can be added.
Default memory backend
MemoryIdempotentRepository is registered as "memory" in CamelContextBuilder::build(). Phase 1 is default-only: register() rejects duplicates (RegistryError::AlreadyRegistered), so re-registering "memory" after build() fails. A replace/remove API is future work if override is needed.
No autowiring
The Idempotent Consumer EIP (Task 3) does not auto-discover repositories by name — the DSL step explicitly names which repository to use. This is intentional: auto-discovery would introduce implicit behavior that breaks when multiple repositories of the same type exist.
RegistryError is not CamelError
RegistryError is a separate enum because it represents a compile-time/configuration error (duplicate name), not a data-plane error. Callers inside camel-core can match on AlreadyRegistered directly. If a future layer needs to propagate it through Tower, an Into<CamelError> impl can be added.
PipelineOutcome interaction (Task 3)
The Idempotent Consumer returns PipelineOutcome::Stopped(ex) when a duplicate is detected. This requires OutcomeSegment mode because Stop → PipelineOutcome::Stopped → Stopped(ex) is preserved through run_steps only for Segment steps. Process steps (BoxProcessor) go through into_tower_result() which maps Stopped → Ok(ex) — losing the semantic distinction. See ADR-0024 §3 for the Tower outcome boundary.
Phase 1 boundary
Phase 1 Tasks 2-4 implement:
IdempotentRepositorytrait +MemoryIdempotentRepository(this ADR).NamedRegistry<T>+IdempotentRegistry+CamelContextwiring (this ADR).- Idempotent Consumer EIP processor + DSL + test (Task 3).
- ADR-0023 (this document).
What Phase 1 does NOT do:
- Implement Redis/Jdbc backends (future phases).
- Add
len(),keys(), or pagination to the trait. - Add autowiring or repository discovery.
- Implement Claim Check (Phase 2).
Load-bearing citations
| File:line | Element |
|---|---|
camel-api/src/idempotent.rs:13 | pub trait IdempotentRepository |
camel-api/src/idempotent.rs:32-36 | Contract C1: contains returns Result<bool, CamelError> |
camel-core/src/idempotent/memory_repository.rs:24 | struct MemoryIdempotentRepository (DashMap-backed) |
camel-core/src/idempotent/memory_repository.rs:43 | add() via DashMap::insert, returns Ok(!was_present) |
camel-core/src/registry.rs:10-54 | struct NamedRegistry<T> (Mutex-based, fallible register) |
camel-core/src/registry.rs:79 | type IdempotentRegistry = NamedRegistry<dyn IdempotentRepository> |
camel-core/src/context.rs:27-28 | idempotent_repositories: IdempotentRegistry field |
camel-core/src/context.rs:329-342 | register_idempotent_repository() / idempotent_repository() methods |
camel-core/src/context_builder.rs:305-311 | Default "memory" repository registration in build() |
camel-core/src/lifecycle/adapters/route_compiler.rs:35 | compose_pipeline — into_tower_result() maps Stopped→Ok |
camel-core/src/lifecycle/adapters/outcome_composition.rs | compose_outcome_segment — preserves Stopped across Segment |
camel-api/src/step_lifecycle.rs:31 | Parallel: StepLifecycle in camel-api (ADR-0022) |
crates/services/camel-auth/src/registry.rs:23 | Auth NamedRegistry — infallible register() overwrites |
ADR-0024: PipelineOutcome Replaces CamelError::Stopped for Pipeline Control Flow
Date: 2026-06-21 Status: Accepted Amends: ADR-0019
Decision
Introduce PipelineOutcome as the return type of the route pipeline executor (run_steps), NOT as the Response type of every Service<Exchange>. The Tower data plane (BoxProcessor = BoxCloneService<Exchange, Exchange, CamelError>) is unchanged.
/// Result of executing a full route pipeline (multiple steps).
/// Produced by `run_steps`; consumed by the route controller and the
/// consumer reply-channel adapter. Individual processors keep returning
/// `Result<Exchange, CamelError>` — `PipelineOutcome` lives one layer up.
pub enum PipelineOutcome {
/// Normal end of pipeline (all steps completed, or handler returned Handled).
Completed(Exchange),
/// `Step::Stop` was hit. Exchange is the response state (NOT discarded).
/// Stop is successful control flow, not an error.
Stopped(Exchange),
/// Unhandled error escaped the pipeline (handler returned Propagate, or
/// no handler was configured and a step errored).
Failed(CamelError),
}
CompiledStep gains a Stop variant that run_steps recognises and converts into PipelineOutcome::Stopped(ex) WITHOUT invoking a Tower service. The existing StopService (crates/camel-processor/src/stop.rs:27) that returns Box::pin(async { Err(CamelError::Stopped) }) is removed.
CamelError::Stopped is removed entirely — no #[deprecated], no #[allow(deprecated)], no legacy alias retention. Project policy (user directive 2026-06-20): "no deprecamos xq no tenemos usuarios". The README confirms pre-release status ("APIs will change").
Context
The Stop EIP (<stop/>) terminates route processing without error semantics. In Apache Camel it is a successful break — the exchange is returned to the consumer, not discarded. rust-camel implemented it as Err(CamelError::Stopped), which leaked control flow into the error type. This forced every reply finaliser to treat "Stop" as a special case: HTTP's consumer (lib.rs:1185-1188) returned a hardcoded 204 No Content with an empty body when it received Err(CamelError::Stopped), discarding the Exchange state that the Stop step had preserved.
The same CamelError::Stopped variant was also misused by JMS and OpenSearch producers (camel-jms/producer.rs:140, camel-opensearch/producer.rs:539) to signal "shutting down, don't retry" during poll_ready — a completely different concept from the Stop EIP. This conflation of "consumer stopping" with "route stopped" meant any fix for the EIP had to first disentangle the misuse before the variant could be removed.
Phase 3 fixes this by:
- Introducing
PipelineOutcomeat the correct layer (one above Tower). - Making
Stopa route-internal compiled step (not a Tower service returning an error). - Replacing
CamelError::Stoppedin JMS/OpenSearch withCamelError::ConsumerStopping. - Removing
CamelError::Stoppedentirely (no deprecation, no alias).
Comparison with Apache Camel 4.x
Apache Camel models Stop as a control-flow processor that returns true from process(Exchange, AsyncCallback), signalling "stop processing" to the pipeline. The exchange is passed through normally — all modifications (body, headers, properties) are preserved. The consumer builds its response from the exchange state, exactly as it would for normal completion. There is never an error or empty body involved.
| Concept | Apache Camel 4.x | rust-camel (after ADR-0024) |
|---|---|---|
| Stop EIP semantics | Successful break, exchange preserved | PipelineOutcome::Stopped(ex) → Ok(ex) at Tower boundary |
| Consumer response on Stop | Built from exchange state | Built from exchange state (same as Completed) |
| Error handler interaction | Stop bypasses error handler | CompiledStep::Stop bypasses the handler loop |
| Control-flow encoding | In-pipeline return value | PipelineOutcome (one layer above Tower) |
| "shutting down" signal | ShutdownStrategy / lifecycle | CamelError::ConsumerStopping (separate variant) |
Considered Options
Keep CamelError::Stopped as an error variant
Rejected. The invariant "every Err from Service<Exchange> is a real failure" is fundamental to Tower's design. Stop is semantically successful control flow — it should never be an error. Keeping it as CamelError::Stopped forces every consumer that processes pipeline results to know about Stop, duplicate the response-build logic, and risk discarding Exchange state (the actual Bug B — HTTP consumer returning empty 204 on Stop).
Use Exchange.stopped: bool flag
Rejected by e_gpt (spec §3.1). Leaks control state into the data object; every processor and consumer would need to check the flag. A boolean flag is invisible in the type system — no compiler guarantees that any consumer actually checks it.
Keep StopService as a Tower service but change its error variant
Rejected. StopService::call currently returns Box::pin(async { Err(CamelError::Stopped) }). Even if the error variant changed, it would still be an Err at the Tower layer, violating "errors are failures". The correct fix is to remove StopService entirely and recognise Stop at the run_steps layer, before the Tower boundary.
Consequences
run_steps return type changes
Breaking change (acceptable pre-release per README "APIs will change"). The return type of run_steps changes from Result<Exchange, CamelError> to PipelineOutcome. Every call site within camel-core that matches on the return of run_steps is updated. RouteChannelService::call and SequentialPipeline::call / TracedPipeline::call gain a trivial translation shim:
fn outcome_to_reply(outcome: PipelineOutcome) -> Result<Exchange, ReplyError> {
match outcome {
PipelineOutcome::Completed(ex) | PipelineOutcome::Stopped(ex) => Ok(ex),
PipelineOutcome::Failed(err) => Err(ReplyError::Failed(err)),
}
}
Note:
ReplyErrorshown here is the consumer-side wrapper; in production the actual translation site (SequentialPipeline::call/TracedPipeline::call) returnsResult<Exchange, CamelError>directly —Failed(err)maps toErr(err). TheReplyErrorshape is the spec's illustrative name; the implemented adapter (see Task 2) isPipelineOutcome::into_tower_result(self) -> Result<Exchange, CamelError>.
This adapter lives at exactly one site per pipeline — the body of SequentialPipeline::call / TracedPipeline::call in crates/camel-core/src/lifecycle/adapters/route_compiler.rs (see ADR-0018 for route lifecycle context) — which is the only place PipelineOutcome crosses back into Result<Exchange, CamelError>.
RouteErrorHandler retry parameter generalised
Phase 4 (bd rc-5uv) changes retry_step's step argument from &mut BoxProcessor to &mut dyn RetryableStep. This is semantic preservation, not new handler responsibility: RouteErrorHandler remains sole owner of policy matching, redelivery counters, backoff, onException, and DLC routing. The generalisation exists only because compiled steps may now be Tower processors OR outcome-aware internal segments. PipelineOutcome still does not cross public Service<Exchange> boundaries. Implementation details governed by ADR-0025.
CompiledStep type alias becomes an enum
Every arm in route_compiler.rs that constructs CompiledStep is migrated. The compiler gains a CompiledStep::Stop variant. CompiledStep::Processor(BoxProcessor) remains for all other steps. Every arm that previously pushed StopService as a BoxProcessor now pushes CompiledStep::Stop.
StopService is removed
The file crates/camel-processor/src/stop.rs is deleted (or gutted if other concerns remain). No trace of the Tower service that returned Err(CamelError::Stopped) survives.
CamelError::Stopped is removed (hard removal, no deprecation)
Removal COMPLETED in Phase 4 (bd rc-5uv). StopService, CamelError::Stopped, eip_outcome_to_result, flatten_stop flag, and the Err(Stopped) bypass in run_steps are all deleted.
Mapping table: error handler ↔ PipelineOutcome
The following table governs how run_steps translates handler outputs into PipelineOutcome:
| CompiledStep variant | Handler returns | PipelineOutcome |
|---|---|---|
Process completes normally | (not called) | continue loop with returned Exchange |
Process errors, handler matches and absorbs | StepDisposition::Handled(ex) | PipelineOutcome::Completed(ex) |
Process errors, handler clears and continues | StepDisposition::Continued(ex) | continue loop with ex |
Process errors, handler propagates | StepDisposition::Propagate(err) | PipelineOutcome::Failed(err) |
Segment returns Completed(ex) | (not called) | continue loop with ex |
Segment returns Stopped(ex) | (handler bypassed) | PipelineOutcome::Stopped(ex) |
Segment returns Failed + retry recovers | RetryOutcome::Recovered(ex) | continue with recovered ex |
Segment returns Failed + retry returns Stopped | RetryOutcome::Stopped(ex) | PipelineOutcome::Stopped(ex) |
Segment returns Failed + retry exhausts | StepDisposition::* (via handle_step) | per disposition |
Reply-channel adapter
Consumers that today expect Result<Exchange, CamelError> from the pipeline get the translation inside SequentialPipeline::call / TracedPipeline::call:
PipelineOutcome::Completed(ex) | PipelineOutcome::Stopped(ex)→Ok(ex).PipelineOutcome::Failed(err)→Err(err).
Stopped(ex) and Completed(ex) are INDISTINGUISHABLE to the reply channel — both deliver the Exchange as a successful response. This is the core fix for Bug B: HTTP consumer's reply finaliser does not need to know whether the pipeline completed normally or stopped; it builds the response from ex identically in both cases.
CircuitBreaker interaction
RouteChannelService::after_result (route_compiler.rs:393-396) sees Ok(ex) for both Completed and Stopped because the PipelineOutcome → Result<Exchange, CamelError> translation happens upstream. Therefore after_result counts Stop as success — no code change required in RouteChannelService itself. A regression test is mandatory (Task 14).
UnitOfWork interaction
ExchangeUoW<S>::call (exchange_uow.rs:94-133) sees Ok(ex) for both Completed and Stopped, so on_complete fires for Stop. The ex.has_error() branch remains for explicit set_error() calls. A regression test is mandatory (Task 15).
Sub-pipeline boundary (amendment 2026-06-22)
SUPERSEDED by Phase 4 (bd rc-5uv, ADR-0025). The interim Option E mechanism described below is REMOVED. Structural EIPs now propagate
PipelineOutcome::Stopped(ex)directly via theOutcomePipelinetrait +CompiledStep::Segmentvariant, preserving Exchange mutations made inside nested blocks before Stop. The text below is preserved for historical context only.
The original boundary rule above ("PipelineOutcome MUST NOT cross any
ServiceCamelError::Stopped sentinel.
Why this exception exists: Apache Camel semantics require .stop() inside
any nested block to halt the entire route, not just the block. ADR-0024's
PipelineOutcome::Stopped(ex) cannot cross the sub-pipeline's
Service
Migration contract (per e_gpt oracle Option E, 2026-06-22):
- During Phase 3: nested Stop is mapped to
StopServiceat sub-pipeline compilers (step_compilers/{control_flow,routing,splitting}.rs). The outerrun_stepsrecognisesErr(CamelError::Stopped)from a step and translates it toPipelineOutcome::Stopped(original)— bypassing the route error handler. The Exchange from before the step ran is preserved. - Future epic (post-Phase-3): introduce outcome-aware internal sub-pipeline
execution OR a dedicated internal control adapter that propagates
PipelineOutcome::Stoppedacross sub-pipeline boundaries without going through Tower Response. Once that lands,StopServiceandCamelError::Stoppedcan be removed entirely (Tasks 7 and 22 deferred).
Boundary rule clarification: PipelineOutcome must not cross any public
ServiceCamelError::Stopped sentinel is permitted
only as an internal control flow signal between a nested structural
sub-pipeline and its outer run_steps; it MUST NOT surface to consumer reply
finalisers (HTTP/Kafka/WS/gRPC), which continue to see Ok(ex).
HTTP consumer fix (Bug B)
The root cause of Bug B is that StopService::call returns Err(CamelError::Stopped) and discards the Exchange. The HTTP consumer's reply finaliser (camel-http/src/lib.rs:1185-1188) cannot read the Exchange state — there is no Exchange to read. After ADR-0024, Stopped(ex) arrives as Ok(ex) at the Tower boundary, so the HTTP consumer builds its response from the Exchange state using the same code path as Completed(ex). The hardcoded 204 No Content special case is removed.
ADR-0019 amendment
ADR-0019's in-pipeline disposition table is unchanged (the three StepDisposition variants and their meanings are untouched). However, the scope note in ADR-0019 is updated to reference ADR-0024 for PipelineOutcome semantics. Specifically: ExceptionDisposition governs decisions inside the step loop; PipelineOutcome governs the result of the loop. They are complementary, not overlapping.
Audit (Task 16, 2026-06-22)
Searched crates/components/ for Err(CamelError::Stopped) arms in consumer reply finalisers (the Bug B pattern).
Components scanned: camel-kafka, camel-ws, camel-component-grpc, camel-cxf, plus all other crates/components/*.
Findings: ZERO reply-finaliser special-cases found. The Bug B pattern was unique to camel-http.
Remaining CamelError::Stopped references in crates/components/ (all covered by other Phase 3 tasks):
| File:line | Site | Phase 3 task |
|---|---|---|
crates/components/camel-component-wasm/src/serde_bridge.rs:216 | WASM error bridge mapping | Task 20 |
crates/components/camel-component-api/src/network_retry.rs:331 | doc comment | Task 23 |
crates/components/camel-jms/src/producer.rs:140 | JMS poll_ready misuse | Tasks 17-18 |
crates/components/camel-opensearch/src/producer/mod.rs:539 | OpenSearch poll_ready misuse | Task 19 |
No additional Bug B fixes needed beyond camel-http (landed in commit 4f81a6e0).
Phase 4 audit (2026-06-22)
After Phase 4 (bd rc-5uv, ADR-0025) implementation:
StopServicedeleted (crates/camel-processor/src/stop.rsremoved).CamelError::Stoppedvariant removed entirely.eip_outcome_to_result()removed.flatten_stopflag onSequentialPipeline/TracedPipelineremoved.Err(Stopped)bypass inrun_stepsremoved.- Structural EIPs (Filter/Choice/Loop/Throttle/Split/StreamingSplit/Multicast/LoadBalance/doTry) migrated to
OutcomePipelinetrait +CompiledStep::Segmentvariant. - Stopped-exchange-state-preservation invariant locked as tested contract.
Zero remaining sites misuse CamelError::Stopped for control flow.
Phase 4a amendment: CamelStop drop signal (2026-06-27)
Context: SamplingService and ThrottleService::Drop set a CamelStop=true property on
the exchange, but this property was NEVER checked by the pipeline executor (run_steps).
Non-sampled/throttled exchanges continued through the pipeline — the "drop semantics" were
phantom for Process-mode processors and only partially effective for Segment-mode throttlers.
Decision (e_gpt BLESS Option A+): Honor the CamelStop property at ALL pipeline-executor
boundaries:
-
Shared constant —
CAMEL_STOPmoved tocamel-apiaspub const CAMEL_STOP(previously local inthrottler.rsandsampling.rs). Re-exported fromcamel_api. -
Helper —
pub fn is_camel_stop(exchange: &Exchange) -> boolin camel-api checksexchange.property(CAMEL_STOP)→as_bool()→unwrap_or(false). -
run_steps— after eachCompiledStep::Processstep returnsOk(next), checksis_camel_stop(&next). If true →PipelineOutcome::Stopped(next). -
BoxProcessorSegment— when convertingOk(ex)toPipelineOutcome, checksis_camel_stop. If true →PipelineOutcome::Stopped(ex). -
SequentialOutcomeSegment— defensive check after childCompleted(next). Ifis_camel_stop→Stopped(next). -
ThrottleSegment::Drop— changed to returnPipelineOutcome::Stopped(ex)directly. Still sets the property for backward compat + executor check catches Process-mode paths.
Rationale: Process-mode processors (SamplingService, ThrottleService) cannot return
PipelineOutcome::Stopped directly because they implement Tower Service<Exchange>, not
OutcomePipeline. The executor checks are the single, universal enforcement point.
Segment-mode throttlers (ThrottleSegment::Drop) use the direct return for minimal latency.
Regression tests (3):
- Top-level Sampling drop stops following step (
run_stepslevel) - Sampling inside
BoxProcessorSegmentstops sibling (Segmentlevel) - Throttle Drop exchange does not reach mock:sink (full route integration)
ADR-0025: Outcome-aware Structural EIPs
Date: 2026-06-22 Status: Accepted (Phase 4) Amends: ADR-0024 Bd issue: rc-5uv
Decision
Structural EIPs (Filter / Choice / Loop / Throttle / Split / StreamingSplit / Multicast / LoadBalance / doTry) implement the OutcomePipeline trait (internal, one layer above Tower) and propagate PipelineOutcome::Stopped(ex) directly via the new CompiledStep::Segment variant. Sub-pipelines NO longer cross Tower Service<Exchange> boundary.
This fixes the Option E interim bug where eip_outcome_to_result dropped Stopped(_ex) Exchange state at the sub-pipeline boundary, losing mutations made inside the nested block before Stop.
Context
Phase 3 (ADR-0024, commit 397a6cdc) introduced PipelineOutcome for top-level route Stop propagation (Bug B fix). Nested structural EIPs were left on an interim mechanism (Option E) using CamelError::Stopped as an internal sentinel. Oracle audit (ses_1102e7531ffekfddOnVOt5xB14, 2026-06-22) found this drops Exchange state at sub-pipeline boundaries — a silent semantic bug.
Comparison with alternatives
| Option | Description | Ruling |
|---|---|---|
| A | Trait OutcomePipeline only | Lacks ownership/cloning home |
| B | Tower Service w/ PipelineOutcome response | poll_ready boilerplate unjustified |
| C | Box<dyn Fn> pointer | Loses trait extensibility for stateful impls |
| D | Trait + wrapper struct | CHOSEN — cleanest clippy, extensibility hooks |
Oracle ruled: D. Trait is right primitive; wrapper struct is right ownership/API boundary. CompiledStep::Segment(OutcomeSegment) keeps CompiledStep clean, centralises cloning/tracing/metrics later, avoids leaking huge closure types or fake Tower errors.
Consequences
Public contract BoxProcessor = Service<Exchange, Response=Exchange, Error=CamelError> is UNCHANGED. PipelineOutcome remains internal / public-adapter-adjacent; does NOT become normal user-facing processor response.
Eight canonical invariants
Continuedafter Segment failure continues after the outer Segment step, NOT inside the child pipeline.- Retry retries the whole structural EIP from pre-step Exchange, NOT the failed child step.
- If retry attempt returns
Stopped(ex), use the retry-attempt stopped Exchange, NOT the original. doTryremains a local error-handler island; the outer handler sees only unhandledFailed.- Redelivery headers apply to Segment retries same as Process retries.
PipelineOutcomenever becomes publicService<Exchange>::Response.- Parallel Split/Multicast must not aggregate completed sibling output after any branch returns Stop.
- Tracing/metrics must classify Stop as successful control flow, NOT error.
Glossary
Stop EIP ≠ Route lifecycle Stop. The former is data-plane control flow (Phase 4 subject). The latter is route lifecycle state (ADR-0004/0007/0018 subject).
Migration
Per oracle M3 strategy: parallel construction, per-EIP switch. Build infrastructure beside current path; migrate one EIP at a time; delete StopService/CamelError::Stopped last. See docs/superpowers/plans/2026-06-22-rc-5uv-phase-4-outcome-aware-eip.md for task sequence.
ADR-0026: JSON Canonical Route Authoring Format
Date: 2026-06-26 Status: Accepted Issues: rc-iq7
Context
rust-camel supports two route authoring formats: YAML and JSON. Both deserialize
into the same AST and compile into DeclarativeRoute via the shared
route_dsl_to_declarative_route function
(crates/camel-dsl/src/yaml.rs, crates/camel-dsl/src/json.rs). The shared AST
types are RouteDslRoutes/RouteDslRoute/RouteDslStep
(crates/camel-dsl/src/route_ast.rs); both formats consume them directly (no
format-specific aliases).
Discovery supports both formats by extension
(crates/camel-dsl/src/discovery.rs); the CLI defaults to routes/*.yaml
(crates/camel-cli/src/commands/run.rs) and JSON requires explicit .json
glob — a deliberate ergonomic default, not an assertion of YAML primacy.
The existing canonical runtime contract (CanonicalRouteSpec) is minimal by design
and NOT a full authoring DSL (crates/camel-api/src/runtime.rs, ADR-0011/0016).
SDKs, programmatic route generators, IDE tooling, and machine-driven workflows all speak JSON natively. YAML is the better human authoring format but a worse machine contract (implicit-type quirks like the Norway problem, no native schema validation in popular tooling, ambiguous block scalars).
Decision
JSON is the canonical full route authoring format for SDKs, generators, schema validation, IDE completion, and machine workflows. YAML remains a supported human convenience derived from the same AST.
Concretely:
- Shared code names use
RouteDsl*(notYaml*). Both formats lower through the sameroute_dsl_to_declarative_routefunction. - The project publishes a generated full-DSL JSON Schema at
schemas/dsl/route-schema.jsonplus TypeScript types underschemas/ts/. Both are regenerated viacargo xtask schema; drift is detected bycargo xtask schema --check(CI gate, non-mutating). - New verbs MUST land with JSON schema + tests + examples first. YAML parity must follow before release.
- Discovery supports both formats when configured; default CLI glob remains
routes/*.yaml; JSON requires explicit.jsonglob. No API or doc implies YAML is the "primary" format beyond human-ergonomics defaults. - Error messages carry input format context via
InputFormatboundary annotation (crates/camel-dsl/src/input_format.rs): messages are prefixed with"YAML DSL error: "or"JSON DSL error: "so users know which parser failed.
Consequences
- ADR-0017 (snake_case naming) applies to DSL keys in both JSON and YAML.
- ADR-0011/0016 remain about the minimal runtime canonical contract, not authoring
format.
CanonicalRouteSpecand the full-DSL JSON Schema are distinct artifacts. - Maintainers must enforce JSON-first verb landing in code review until tooling automates the check.
- YAML users are not demoted: feature parity is release-blocking, but no longer implicit-primacy.
- Future: hosted stable schema URL (post-1.0); SDKs in target languages derive from the schema.
References
- Spec:
docs/superpowers/specs/2026-06-24-json-canonical-route-format.md(oracle-blessed loop 3, e_gpt; preserved on main worktree, gitignored — if absent, see bd rc-iq7 epic notes for summary). - bd epic: rc-iq7 (tracks all sub-tasks and merged commits).
- Implementation commits: d652b4e2 (A1 rename), 9dc7b494 (B1 parity), 65d0d5d4 (B2+B3), 29cab318 (C1-C3 schema+TS), 301b0cfb (D1 format-aware errors).
- Supersedes: rc-l6e (closed 2026-06-24; 4 gaps absorbed into B1/B2/B3/A1).
ADR-0027 — MQTT Component: MQTT 3.1.1, per-endpoint connections, TLS basic
Date: 2026-06-26
Status: Accepted
Context
Implementing an MQTT component (camel-mqtt) for rust-camel using rumqttc.
Decisions
MQTT 3.1.1 only (v1)
MQTT 3.1.1 is widely supported (Mosquitto, EMQX, HiveMQ, AWS IoT, Azure IoT Hub).
rumqttc supports both 3.1.1 (rumqttc::AsyncClient) and 5.0 (rumqttc::v5::AsyncClient)
via parallel APIs. v1 uses 3.1.1; 5.0 is deferred to v2 via a separate ADR.
One connection per Consumer/Producer (v1)
route_id — required for stable client_id generation — is not available at
create_endpoint() time. It arrives in ConsumerContext::route_id() (at start())
and ProducerContext::route_id() (at create_producer()). Therefore, connections are
created lazily, not in MqttEndpoint.
This mirrors the Redis and Kafka patterns (no shared pool in v1). The JMS shared-pool
pattern (actor with command channel) is documented for v2 as connectionMode=sharedSession.
client_id generation
MQTT 3.1.1 specifies a portable maximum of 23 bytes for ClientIdentifier.
Format: {prefix}-{route_id}-{uri_hash_6}, SHA-256 hashed and truncated to 23 bytes
if longer. Explicit clientId URI param overrides this.
The hash input for the producer is the full endpoint URI (not just the broker name)
so that two producers on the same broker but different publish topics receive distinct
client_ids. The consumer uses {broker_name}-{subscriptions} as the hash key.
TLS basic via rustls (v1); mTLS deferred (v2)
mqtts:// scheme activates TLS via rumqttc's use-rustls feature. Custom CA via
tls_ca_cert path. mTLS (client certificate) requires rumqttc::TlsConfiguration
client_auth field — deferred to v2.
manual ack requires cleanSession=false
With ackMode=manual and QoS 1/2, broker redelivery on reconnect depends on session
persistence. cleanSession=true discards session state on reconnect, making the
at-least-once guarantee best-effort only. Config validation rejects this combination.
The ack decision is computed from the received packet QoS (publish.qos), not the
statically configured subscription QoS. This matters because a subscription at QoS 1 can
still deliver QoS 0 messages, which must never be manually acked.
Reconnect backoff via NetworkRetryPolicy
Connection retries use the shared camel_component_api::NetworkRetryPolicy
(exponential backoff with jitter, capped by max_delay) rather than hardcoded sleeps,
consistent with the CXF consumer (ADR-0013). An endpoint-level override
(MqttEndpointConfig.reconnect) takes precedence over the component-level default.
Every backoff sleep is cancellation-aware: the tokio::time::sleep is wrapped in a
select! against the consumer/producer CancellationToken so shutdown is not delayed.
Crate migration: rumqttc 0.24 → rumqttc-v4-next 0.33.2
Post-implementation, the camel-component-mqtt crate was migrated from rumqttc 0.24 to
rumqttc-v4-next 0.33.2 (a community fork) to clear 4 active RUSTSEC advisories in
rustls-webpki 0.102.8, an EOL line that the original rumqttc 0.24/0.25 pins via
rustls 0.22. The fork tracks rustls 0.23 / rustls-webpki 0.103, which receives
patches. The dependency is aliased via package = "rumqttc-v4-next" in Cargo.toml,
preserving all rumqttc:: paths in source code.
Key API changes applied (see commit for full diff):
MqttOptions::new(id, (host, port))— broker is a tuple, not two separate args.set_keep_alive(u16)— arg is seconds asu16, notDuration.set_credentials(.., password: Into<Bytes>)— password is byte-oriented.AsyncClient::builder(opts).capacity(N).build()— builder pattern replacesAsyncClient::new(opts, N).Publish.topic→Bytes(wasString).
MSRV was bumped from 1.85 to 1.89 (the fork's minimum).
Consequences
- v1: one TCP connection per route Consumer and one per Producer endpoint.
- Broker connection limits are a user concern in v1; document as known limitation.
- v2: shared session actor pattern avoids connection growth.
error!calls classifiedoutside-contractfollow ADR-0012 and carry a replacement signal (rt.metrics().increment_errors(...)); the producer's driver loop logs retried connection errors atwarn!level (noerror!) since it has no runtime handle.
ADR-0028: Claim Check Repository Trait Boundary
Date: 2026-06-27 Status: Amended by rc-7qf (originally Accepted, Phase 2) References: ADR-0022, ADR-0023, rc-7qf
Decision
Separate ClaimCheckRepository trait (not on IdempotentRepository)
Claim Check EIP stashes large message payloads by key so the Exchange carries only a lightweight reference. This is a payload-bearing pattern (set/get with Message values), structurally distinct from the Idempotent Consumer (key-only contains/add). Each pattern owns its own trait; the shared NamedRegistry<T> wiring pattern is cross-referenced but not inherited.
File: crates/camel-api/src/claim_check.rs:16-52
#[async_trait]
pub trait ClaimCheckRepository: Send + Sync + std::fmt::Debug + 'static {
fn name(&self) -> &str;
async fn set(&self, key: &str, payload: Message) -> Result<(), CamelError>;
async fn get(&self, key: &str) -> Result<Message, CamelError>;
async fn get_and_remove(&self, key: &str) -> Result<Message, CamelError>;
async fn remove(&self, key: &str) -> Result<(), CamelError>;
async fn push(&self, key: &str, payload: Message) -> Result<(), CamelError>;
async fn pop(&self, key: &str) -> Result<Message, CamelError>;
}
Payload type: Message (body + headers)
The trait stores camel_api::Message (body + headers) instead of Body alone. This preserves headers across Claim Check operations — a Set stashes the full exchange.input Message, and a Get can selectively merge back headers via the filter option. Callers who only care about the body access stashed.body.
File: crates/camel-api/src/claim_check.rs:13 (import), crates/camel-api/src/message.rs:8 (Message struct).
Stack operations: push / pop
The trait includes LIFO stack operations (push, pop) so Claim Check can be used with the Push/Pop EIP variant without a second trait. The stack is key-scoped (push("stack-key", body)). Separate from the single-value key space (set("key", body)).
File: crates/camel-api/src/claim_check.rs:47-52.
Contract: get_and_remove is atomic
get_and_remove returns and deletes in one atomic step. This mirrors the Claim Check's "claim once" semantics — after checkout the payload is released. Implementations MUST ensure no concurrent reader can observe the payload after get_and_remove returns.
File: crates/camel-api/src/claim_check.rs:39-43.
remove is idempotent
remove succeeds even if the key does not exist (no-op). Matches IdempotentRepository::remove contract.
File: crates/camel-api/src/claim_check.rs:45, crates/camel-api/src/idempotent.rs:36.
Memory implementation: DashMap + VecDeque
MemoryClaimCheckRepository uses DashMap<String, Body> for single-value keys and DashMap<String, VecDeque<Body>> for LIFO stacks. Interior mutability allows &self for all trait methods, safe for concurrent Arc<dyn ClaimCheckRepository> sharing.
File: crates/camel-core/src/claim_check/memory_repository.rs:17-20.
Registration: NamedRegistry<T> reuse
The Claim Check registry reuses Phase 1's NamedRegistry<T> (Mutex-based, duplicate-detecting) with ClaimCheckRegistry and SharedClaimCheckRegistry type aliases. Pattern identical to IdempotentRegistry/SharedIdempotentRegistry.
File: crates/camel-core/src/registry.rs:90-98.
CamelContext wiring
CamelContext exposes register_claim_check_repository() and claim_check_repository() methods, mirroring the idempotent repository API. The builder registers a default "memory" repository.
File: crates/camel-core/src/context.rs:700-719, crates/camel-core/src/context_builder.rs:248-256.
Lifetime threading
CompilationContext carries claim_check_repositories: &'a ClaimCheckRegistry for future step compilers. No step compiler uses it yet (warning suppressed); the field exists for the upcoming Claim Check DSL step compiler.
File: crates/camel-core/src/lifecycle/adapters/step_compilers/mod.rs:105-106.
Does NOT implement StepLifecycle
Like MemoryIdempotentRepository, MemoryClaimCheckRepository holds only Arc-shared DashMaps with no background work (timers, buckets, queues). No StepLifecycle implementation needed. If a persistent backend (Redis, SQL) requires connection lifecycle management, it should implement StepLifecycle on the backend client, not the repository wrapper.
File: crates/camel-api/src/step_lifecycle.rs:31 (StepLifecycle trait).
Body is Clone, stored directly
Body implements Clone (file: crates/camel-api/src/body.rs:178-189). The memory repository stores Body values directly (not Arc<Body>). This is safe because Body variants clone cheaply: Bytes (Arc-backed copy-on-write), Text/Xml (String clone), Json (serde_json::Value clone), Stream (Arc<Mutex<Option<...>>> clone — shared stream handle).
Filter for selective merge-back (rc-7qf)
Phase 2 (rc-blw) implemented full-Message store/retrieve. Phase 2b (rc-7qf) adds a filter option on Get/GetAndRemove/Pop that controls which parts of the stashed Message are merged back into the current exchange:
body: Include (restore), Exclude (keep current), Remove (clear body)headers: Include all, Exclude all, Remove all, or by pattern (include/exclude/remove matching header keys)- Patterns support exact match,
*prefix wildcard, and regex attachmentskeyword is parsed as no-op (no attachment support in Phase 2b)
The filter is parsed at route-compile time into ClaimCheckFilter (camel-processor). ClaimCheckService::call() applies it via the merge_stashed() helper. No-filter = backward-compatible body-only restore.
CamelError::RouteError for "not found"
The memory repository returns CamelError::RouteError(format!("...")) for missing-key lookups and empty-stack pops. No new CamelError variant is introduced.
File: crates/camel-core/src/claim_check/memory_repository.rs:55-56, crates/camel-core/src/claim_check/memory_repository.rs:88.
References
- ADR-0022: StepLifecycle trait and drain —
docs/adr/0022-steplifecycle-trait-and-drain.md - ADR-0023: Idempotent Repository trait (structural parent) —
docs/adr/0023-idempotent-repository-trait.md - ADR-0024: PipelineOutcome replaces CamelError::Stopped —
docs/adr/0024-pipeline-outcome-replaces-camel-error-stopped.md NamedRegistry<T>:crates/camel-core/src/registry.rs:33-34IdempotentRegistryalias:crates/camel-core/src/registry.rs:77-78Bodyenum:crates/camel-api/src/body.rs:162-176- ClaimCheck EIP spec: Enterprise Integration Patterns (Gregor Hohpe, 2004), Chapter 10
ADR-0029: Resequencer Continuation Boundary
Date: 2026-06-27 Status: Accepted (Phase 3) References: ADR-0022 (StepLifecycle), ADR-0024 (PipelineOutcome/CamelStop), ADR-0025 (Outcome-aware structural EIPs) Related: Phase 3 — Tasks 1a, 1b, 2, 3 (Resequencer EIP)
Context
The Resequencer EIP reorders incoming message streams back into their original sequence. It has two modes:
- Batch: buffer a window of messages (by size/timeout per correlation key), sort, then burst-emit in order.
- Stream: hold out-of-order messages in a priority queue, emit the contiguous run starting at the next-expected sequence number, with gap detection and capacity management.
The fundamental architectural challenge: a Resequencer receives ONE input exchange via the unary Tower Service<Exchange> contract, but may produce ZERO outputs (buffering), ONE output (normal), or MULTIPLE outputs (batch burst / stream gap drain). This conflicts with the unary pipeline model where call(input) -> Result<Exchange>.
Decision
Continuation-boundary design
The route compiler splits the flat step list at the top-level Resequence boundary into three partitions:
pre_steps → ResequencerService → [post-steps compiled as a BoxProcessor continuation]
precompiles normally into the main pipeline (before the resequencer).postcompiles viacompose_pipeline_with_contractsinto aBoxProcessorcontinuation owned by theResequencerService.- The resequencer is the LAST step of the main pipeline.
ResequencerService::call(input) sends the exchange into a bounded actor channel and returns an ack (Body::Empty + property CAMEL_RESEQUENCER_ACCEPTED=true). The actual reordered payloads flow asynchronously through a post-driver task that drives the continuation:
input → actor channel → policy.accept() → ready exchanges → post-driver → continuation.call(ex)
The whole route is ONE PipelineAssembly (no side-channel) — the resequencer's CompiledStep.lifecycle is Some(Arc<ResequencerService>), so the Phase 0 StepLifecycle drain mechanism reaches it for stop/hot-swap.
Why NOT the aggregator split-route
The Aggregator uses find_top_level_aggregate_requiring_split + two independent SharedPipelines + agg_service side-channel + a warn-and-proceeds hot-swap. The resequencer deliberately avoids this shape:
- The resequencer's single
PipelineAssemblyswaps atomically with full lifecycle drain (unlike the aggregator's two-pipeline shape that cannot drain viaCompiledStep.lifecycle). - No
agg_serviceside-channel — the resequencer IS a pipeline step, reachable by the standard lifecycle drain. - Hot-swap for lifecycle-bearing routes uses the Restart path (stop → drain → swap → start), not a warn-and-proceeds no-op.
Hot-swap drain semantics
On HotSwap: complete in-flight exchanges through the OLD continuation (ADR-0004 in-flight-finishes-old semantics — NOT discard), then quiesce. The StepLifecycle::shutdown ordering:
- Set shutdown flag; close input channel (actor sees EOF).
- Await actor
JoinHandle(bounded deadline). policy.flush()— emit remaining in order via post-driver.- Close post-driver channel sender.
- Await post-driver
JoinHandle(5s deadline). - Drain post-step lifecycles (Phase 3: post-steps with lifecycle are rejected at compile time; this step is a structural placeholder for future use).
Backpressure
The input channel is bounded (tokio::sync::mpsc with configurable capacity, default 1024). Service::call uses send().await — backpressure propagates into the consumer when the actor falls behind.
InOnly/ack semantic consequence
Request-reply final responses are NOT preserved through the unary Tower contract — the route is effectively InOnly past the resequencer. A runtime InOut guard in ResequencerService::call inspects exchange.pattern:
- If
InOutand notallow_inout: true:- Increment a durable metric counter (
resequencer_inout_warnings_total). - Emit a rate-limited
warn!(once per 30s per route, NOT per-exchange). - Set diagnostic property
CAMEL_RESEQUENCER_INOUT_WARN=trueon the ack.
- Increment a durable metric counter (
CamelStop interaction (Phase 2)
The post-driver checks camel_api::is_camel_stop(&ex) before calling the continuation — if true, the exchange is skipped (analogous to route_compiler.rs:345). This prevents downstream processing of stop-signaled exchanges past the resequencer boundary.
Compile-time rejection rules
- N2 (mutual exclusion):
assert_no_mixed_top_level_splitsrejects any route containing BOTH a top-level aggregate-requiring-split step AND a top-levelResequence. The predicate tests EVERY top-levelAggregateforhas_timeout_condition || force_completion_on_stop(NOTfind_top_level_aggregate_requiring_split, whose first-match-then-break under-detects). - N3: Reject more than one top-level
Resequence. - N4: Reject any
Resequencereached viacompile_children(nested inside Choice/Split/Loop/Filter). The step-compiler registry arm returnsRouteError("resequence must be a top-level step")unconditionally.
Post-ack continuation failure taxonomy
A continuation.call(ex) failure happens AFTER call() returned the ack, so the exchange has left the ADR-0019 pipeline loop — RouteErrorHandler is NEVER consulted. The post-driver:
- Logs at
warn!(ADR-0012 best-effort). - Increments
resequencer_post_ack_failures_total{route}metric. - Does NOT count against the route's error budget unless explicitly configured.
Policy trait
#[async_trait]
pub trait ResequencePolicy: Send + Sync + 'static {
async fn accept(&self, input: Exchange) -> Vec<Exchange>;
async fn flush(&self) -> Vec<Exchange>;
fn name(&self) -> &'static str;
fn set_timeout_tx(&self, _tx: mpsc::Sender<Exchange>) { /* default no-op */ }
}
set_timeout_tx has a default no-op implementation — BatchPolicy and StreamPolicy override it to store the driver channel for self-spawned timeout tasks. PassthroughPolicy inherits the no-op.
Stream capacity + gap failure policies
The stream policy's failure modes use honest naming (no false promises of dead-letter routing that isn't wired):
CapacityPolicy::LogAndDrop— logwarn!+ drop the incoming exchange (queue full).GapPolicy::DropAndLog— gap timer fired, drop held exchanges + log.
Future: wire a DLQ sink for both policies.
Scatter-Gather reconciliation (spec §5 correction)
Spec §5 described Scatter-Gather as "feeding a single aggregator with a correlation key." This is INCORRECT — canonical Hohpe/Woolf Scatter-Gather is the stateless form: parallel fan-out to N endpoints, combine N responses into ONE exchange. No correlation key. The stateful form (with correlation key) is the separate Aggregator EIP.
scatter_gather is a pure DSL alias that lowers to Multicast with parallel: true and the configured aggregation strategy (LastWins/CollectAll/Original). No new processor, no new runtime primitive.
Rejected Alternatives
- Pure
Processmode (inline reordering inrun_steps): impossible — the unary Tower contract cannot emit multiple outputs from onecall(). - Couple emit to input (return the burst from
call()): breaks unary semantics and cannot flush on shutdown (no input arrives). - Reopen the spec (change Tower contract to multi-output): loses hot-swap drain and breaks every existing processor.
- Aggregator split-route shape: two pipelines + side-channel — cannot drain via
CompiledStep.lifecycle, hot-swap is a warn-and-proceeds no-op.
ADR-0030: Exchange-aware DataFormat hooks
Date: 2026-06-28
Status: Accepted (implemented in d4a423a2)
Issue: bd rc-v5xf
Oracle: e_gpt (ses_0f086bbe4ffepmh4MrdpurR0XU) — verdict Trait extension
Analysis: docs/superpowers/analysis/dataformat-coupling-2026-06-28.md
Context
The DataFormat trait in crates/camel-api/src/data_format.rs exposes only:
fn marshal(&self, body: Body) -> Result<Body, CamelError>;
fn unmarshal(&self, body: Body) -> Result<Body, CamelError>;
CSV full parity needs captureHeaderRecord=true, which writes Exchange header
CamelCsvHeaderRecord. The trait has no Exchange access, blocking this and
future metadata-sensitive formats (encryption, signing, schema-aware formats,
content-type negotiation).
Decision
Add default methods to DataFormat that receive &mut Exchange:
fn marshal_in_exchange(&self, exchange: &mut Exchange, body: Body) -> Result<Body, CamelError> {
let _ = exchange;
self.marshal(body)
}
fn unmarshal_in_exchange(&self, exchange: &mut Exchange, body: Body) -> Result<Body, CamelError> {
let _ = exchange;
self.unmarshal(body)
}
MarshalService and UnmarshalService (camel-processor) call the new hooks.
Existing impls (Json, Xml, Zip) inherit defaults unchanged. CSV overrides
unmarshal_in_exchange only when capture_header_record=true.
Alternatives considered
- Crate extraction (
camel-dataformat) — YAGNI today; defer until 3+ formats with heavy deps or registry/discovery pressure exists. - Trait signature change (breaking) — too high blast radius for one feature.
- Side-channel via Body metadata — hacky, pollutes Body type.
Consequences
- Additive, non-breaking. Existing trait impls compile unchanged.
- Two new trait methods to document and maintain.
- Unlocks Exchange-aware formats beyond CSV (encrypt, sign, audit metadata).
- ADR-0010 (SecurityPolicy pre-pipeline) precedent: project accepts Exchange-aware ports when justified.
ADR-0031: WASM Source World
Date: 2026-07-01 Status: Accepted (spike validated e2e — 5/5 integration tests pass)
Context
The three existing WIT worlds (plugin, bean, authorization-policy) are all guest-receives-exchange patterns. There is no WIT world for inbound sources — 3rd-party WASM components cannot act as Consumers. Half of the connectors that matter are sources.
Decision
Add a 4th WIT world source using the resource negotiation pattern (Approach 3):
- The guest IS the source — it owns the consumption loop via
run(listener). - The host provides raw capabilities (HTTP listener as a WIT resource).
- The guest calls
accept-http(listener)to receive events andsubmit-exchange(exchange)to push them to the pipeline. - Cancellation via channel close (blocking host functions) + epoch deadline (CPU-bound loops).
- Crash recovery via Consumer trait contract: trap → route Failed → restart recreates instance.
Consequences
- 3rd-party WASM components can now be sources, not just processors/beans/security/sinks.
- The guest is limited to host-known transports (spike: HTTP only). Arbitrary socket access is NOT supported.
- Backpressure is host-controlled via bounded tokio channels.
stop()is idempotent and safe to call on any exit path.
Binary answers (spike outcome)
-
Can WIT model guest-as-source cleanly? YES.
- Resource negotiation (
configure → source-plan → run(listener)) works end-to-end. - Backpressure via bounded channel (capacity 1) is visible to the guest:
submit-exchangeblocks until the pipeline accepts. - Cancellation via channel close wakes
blocking_recvinaccept-http; guest exitsrun()cleanly. - Integration tests: lifecycle start/stop, e2e webhook, backpressure sequential — all pass.
- Resource negotiation (
-
Is package distribution practical? YES.
- Guest dependencies are minimal:
wit-bindgenonly (no WASI SDK, no extra crates). - Debug .wasm is 3.5MB; release size not yet measured but expected <2MB with
opt-level = "z". - No signing/versioning in spike scope; existing
wasm:URI scheme and path validation reused.
- Guest dependencies are minimal:
-
Are crash/lifecycle semantics acceptable? YES.
- Guest trap →
call_runreturnsErr(wasmtime::Error)→spawn_blockingtask exits withCamelError::ProcessorError→ runtime detects viabackground_task_handle()→ route enters Failed state → restart recreates consumer. stop()cancels token +increment_epoch()+ graceful join with timeout — does NOT ownrun_task(runtime owns it viabackground_task_handle()).- Integration test: crash recovery — guest that traps on 3rd request → consumer reports error → test verifies error propagation.
- Guest trap →
Spike findings (implementation notes)
Critical lessons
- Epoch deadline must be set before any guest call. With
epoch_interruption(true), the store's default deadline is 0 (already expired). Withoutstore.set_epoch_deadline(N)beforecall_configure, the guest traps at the first epoch check — which occurs inside the component model's lift/lower machinery (cabi_realloc), producing a misleading error that looks like a WIT/bindgen bug. - Sync bindings, not async. The
exports: { default: async }option forceshandle.block_on()inspawn_blocking, which puts the blocking thread into a tokio runtime context. Host functions usingblocking_recv/blocking_sendthen panic. Solution: use sync bindings and callcall_rundirectly on the blocking-pool thread. with:mapping for resources. Wasmtime bindgen generates empty (uninhabited) enums for imported resources. Usewith: { "camel:plugin/source-host.http-listener": HttpListenerHandle }to map to a concrete type, following the wasmtime-wasi pattern.- Stale build artifacts.
wit_bindgen::generate!does not emitrerun-if-changedfor itspath:wit dir. Integration tests must resolve the guest .wasm viaCARGO_TARGET_DIRto avoid testing stale binaries.
Known tech debt
to_plugin_wasm_exchange: field-by-field converter between twobindgen!outputs (source vs plugin worlds). Eliminates when WIT-001 unifies type definitions.path_filterwired but minimally tested (axum routes on it, no filter-specific integration test).- Guest crash variant uses config toggle (
crash=run), not a separate .wasm artifact.
Amendment (rc-dn13, 2026-07-09)
The "Sync bindings, not async" lesson above is superseded. rc-dn13 migrates the source
world to async (run/accept-http/submit-exchange are now async func in WIT). The guest
is driven via Store::run_concurrent + call_run_async on a tokio task (no spawn_blocking).
Host imports use the HostWithStore pattern (receive &Accessor, .await outside with).
See docs/superpowers/specs/2026-07-08-wasm-source-async-stream-design.md (local, gitignored).
Body streaming & response timing
The body is no longer materialized via to_bytes before the response is sent. The axum
handler now returns 202 Accepted as soon as the request metadata is handed to the guest
via the request channel; the body streams asynchronously afterward. This is inherent to the
streaming shape — you cannot stream and wait for full receipt simultaneously. Mid-body
connection drops surface as stream errors to the guest (the body channel receives an Err
frame), not as HTTP-level failures to the client. A configurable
max_request_body_bytes cap (default 10 MiB, matching the old DEFAULT_MATERIALIZE_LIMIT)
restores the DoS backstop that the removed to_bytes path provided.
References
- bd
rc-g2kr— spike ticket - bd
rc-9484— cabi_realloc trap (closed; root cause: epoch deadline) crates/camel-wit/wit/camel-source.wit— WIT definitioncrates/components/camel-component-wasm/src/source_consumer.rs— host consumerexamples/wasm-source-webhook/— guest examplecrates/components/camel-component-wasm/tests/source_integration.rs— 5 integration tests
ADR-0032: Exchange-Data Trust Boundary
Date: 2026-07-02 Status: Accepted Amends: none Cross-refs: ADR-0010 (route SecurityPolicy pre-pipeline authz)
Decision
Operator configuration is trusted. Exchange data — message headers, body, properties, and correlation keys set by the data plane at runtime — is untrusted, adversary-controlled. No untrusted exchange datum may drive a control-plane action, an unbounded numeric or resource decision, or an executable/interpretable sink without validation, bounding, or a capability check.
This is one architectural principle violated 8 times in the pre-1.0 audit (H12 delayer, R3-C1 aggregator, H13 recipient-list, H7 SQL header, D-M8 throttler, H1 auth principal, R3-H1 CSV marshal, R4-H1 ControlBus). One rule; uniformly testable; uniformly enforced.
Context
Apache Camel's legacy in-process model trusts everything once inside the JVM. The
pre-1.0 audit (docs/audit/SECURITY-AUDIT-v1-pre-stabilization.md) showed that this
trust-everything model turns every EIP that reads an exchange-derived value into a
DoS, injection, or authz-bypass surface. ADR-0010 covers one slice — the principal
that drives route authz — but no ADR generalizes "untrusted exchange data must not
cross into control, numeric, or interpretable decisions."
Considered Options
Trust everything in-process (status quo)
Rejected. Every EIP becomes a DoS / injection surface; reproducing a fix in one EIP does not generalize. The audit surfaced 8 instances of the same bug-class.
Validate ad-hoc per component
Rejected. This is the current state. It produced 8 identical bugs in 5 different crates. Reviewers cannot recognize the pattern because the codebase has no shared vocabulary for it.
Document a cross-cutting boundary with per-site enforcement (chosen)
One principle, one review checklist, one regression-test shape (untrusted exchange datum → bounded / neutralized / denied / capability-required). Future components inherit the checklist; existing fixes re-state the same boundary case consistently.
Consequences
- Every EIP that touches exchange-derived numerics or sinks gains a bound, a validation, or a capability check. Six boundary cases are fixed in Batch 1 (R3-C1, H12, H13, R4-H1, H1, R3-H1). Two more (H7 SQL, D-M8 throttler) are fixed by the same principle but live in Disposition-3 (Require-Explicit-Choice) and Disposition-4 (Safety-Primitive) respectively.
- New components MUST answer: "does any exchange-derived value cross into a control / numeric / executable sink?" If yes, the boundary is enforced at the crossing.
- Diverges permanently from Camel's in-process trust model. Operators cannot opt out of the boundary per-route; they can only narrow it (more specific bindings).
- The CONTEXT-MAP glossary adds the term "Exchange-data trust boundary" so the rule is searchable in code review.
- Metric label values join the roster of unbounded resource sinks (amendment
2026-08-06, origin
FC-METRICS-CARDINALITY, bdrc-0pyv). A label value derived from exchange data (header, body, property, correlation key) creates a new time series per distinct value in backends such as Prometheus and OTel; the series registry grows without eviction, so an adversary who controls exchanges can inflate cardinality toward OOM. TheMetricsCollector::record_counterandrecord_histogramcontract (camel-api) and its implementations MUST receive only closed-set or otherwise bounded label values; a raw exchange-derived value is never an admissible label. Enforcement is the same shape as the other sinks: a rustdoc contract on the trait plus a crate-local soft cap (warn-and-drop past N distinct series per name). This is the same principle — untrusted datum must not cross into an unbounded resource decision — applied to a sink the original 8-instance roster did not name.
ADR-0033: Security Defaults & Fail-Closed Startup Validation
Date: 2026-07-02
Status: Accepted
Amends: none
Cross-refs: ADR-0017 (DSL snake_case naming — ADR-0033 owns the deny_unknown_fields
fail-closed policy), ADR-0032 (Exchange-data trust boundary — this ADR is the enforcement
arm), ADR-0010 (SecurityPolicy pre-pipeline authz)
Decision
A single startup-validation phase enforces the 5-disposition security-defaults policy
(Intent-Violation, Intent-Declaration, Require-Explicit-Choice, Safety-Primitive,
Untrusted-Data-Validation) before any route starts. In v1.0.0 only the safety-critical
Require-Explicit-Choice members refuse to start when unset (SQL dynamic query, WASM
per-world capability). Intent-Violation fixes fail closed (gRPC TLS). Safety-Primitive
bounds are asserted at construction (throttler max_requests != 0, aggregator has
≥1 completion bound, loop Count clamped). deny_unknown_fields rejects typo'd config.
The phase is designed so deferred Require-Explicit-Choice flips (broker/transport posture) slot in as additional checks in 1.0.x without re-architecting.
Context
Insecure defaults are documented ad-hoc per component. There is no unified policy for "what does the operator have to explicitly opt into?" — every Component CONTEXT.md files its own warning. Reviewers cannot tell which defaults are load-bearing for a v1.0.0 security posture. The pre-1.0 audit surfaced 28 blocker / near-blocker findings, of which 8 land in Batch 1 as DoS caps + CRITICALs.
Without a single enforcement arm, a future component could ship an insecure default and the policy would silently miss it. With the startup-validation phase, every default that requires explicit operator choice is one trait impl away from being enforced.
Considered Options
Warn-and-continue everywhere
Rejected. Leaves the insecure default live; operators miss the warning; no single
audit point. The audit shows that ad-hoc warnings (tracing::warn! at config parse)
are insufficient: gRPC has warned since v0.x that tls=true is ignored, and the
audit still found plaintext credentials traveling over h2c.
Flip every default silently
Rejected. Breaks existing deployments with no migration path and no operator visibility. Kafka / MQTT / Redis plaintext-by-default configs would refuse to start overnight with no doctor command to flag the upcoming breakage.
Fail-closed + camel doctor preflight + per-item escape hatch (chosen)
A single preflight scan (camel doctor / xtask preflight) enumerates every
opt-in now required and every default that changed. Each hardened default has its
own per-item flag — no global "disable hardening" switch. The startup phase
enforces; the doctor command warns; the operator chooses per item. This makes the
policy legible and the migration mechanical.
Consequences
- Some existing deployments will refuse to start until config is made explicit
(e.g. gRPC plaintext requires
tls=false/transport=plaintext; SQL dynamic query requiresallow_dynamic_query=true). - Migration is mechanical: run
camel doctoronce; flip the flags it lists; restart. - Defaults are sticky post-1.0. Once an operator declares an intent, that intent is enforced in subsequent runs; the policy does not silently re-flip.
- The startup-validation phase is implemented as a single trait
ConfigCheckand arun_startup_validation()entry point. Each Require-Explicit-Choice member becomes oneConfigCheckimpl. New members do not require a new architecture. deny_unknown_fieldsis the highest-friction flip (39 DSL structs). Thedoctorcommand diffs the operator's config against the known schema and lists every currently-ignored key that will become a hard error, because typo'd keys fail silently today.- Batch 1 implementation: the phase is delivered as a skeleton (types +
pub fn run_startup_validation() -> Result<(), CamelError>returningOk(())until later batches register checks). The skeleton compiles, the trait is public, and theConfigCheckimpls that Batch 1 would register (gRPC TLS, aggregator completion bound) are in their respective components. WiringCamelContext::start()to call the phase is Batch 5+ work; the skeleton is the first step.
ADR-0034: ControlBus capability authorization
Status: Accepted (implemented in 6fc4fde4)
Date: 2026-07-03
Context
The ControlBus component (controlbus:route) allows any route to stop,
start, suspend, resume, or restart any other route via exchange headers.
This is an intra-process privilege escalation (R4-H1, CRITICAL): an
adversary controlling untrusted exchange data can target critical routes
(auth gateway, leader election, etc.) or cause a self-restart DoS loop.
ADR-0032 (exchange-data trust boundary) establishes that exchange data
is adversary-controlled. The CamelRouteId header is exchange data —
trusting it for route-lifecycle commands violates the trust boundary.
Decision
-
Static route declaration: The target
routeIdMUST be declared in the endpoint URI (controlbus:route?routeId=target&action=stop). TheCamelRouteIdheader override is removed entirely. -
Authorized-routes allowlist: The endpoint MUST declare
authorizedRoutes(comma-separated). Only routes in this list can be targeted. IfauthorizedRoutesis absent, the endpoint fails closed (all commands rejected). -
Self-restart denial: If the target
routeIdequals the calling route's ID, the command is rejected. This prevents self-restart DoS.
Alternatives considered
- Named capability tokens: A separate capability registry. Rejected: too much ceremony for a single component; the allowlist is simpler and auditable.
- Security-policy extension: Route-level
SecurityPolicygrants for control operations. Rejected: SecurityPolicy is exchange-level auth, not component-configuration-level authz. Mixing the two conflates concerns. allowDynamicRouteIdopt-in: Permit header override with explicit opt-in. Rejected: even with opt-in, the header is untrusted data flowing into a control-plane action — the trust boundary should not be crossable regardless of opt-in.
Consequences
- Breaking change: Existing configs using
CamelRouteIdheader must declarerouteIdin the URI +authorizedRoutes. camel doctormigration (post-v1.0.0): A futuredoctorrelease will flag everycontrolbus:endpoint missingauthorizedRoutes. Until then, the CHANGELOG section documents the migration.- No global disable: Hardening cannot be turned off wholesale. Each
controlbus:endpoint must declare its authorized targets.
ADR-0035: Leader-Epoch Fencing Token for Split-Brain Safety
Date: 2026-07-03
Status: Accepted (Batch 4 — Leader Fencing)
Amends: none
Cross-refs: ADR-0033 (fail-closed policy — fencing is a safety primitive),
camel-master/src/leadership.rs (spawn_epoch_bridge, LEADER_EPOCH_PROPERTY),
camel-api/src/platform.rs (LeadershipHandle::leader_epoch)
Context
In a multi-node deployment, a node that loses leadership may still have in-flight pipeline Exchanges reaching downstream systems. Without a fencing token, downstream systems cannot distinguish exchanges from the current leader from those of a stale leader, leading to duplicate processing (split-brain).
Decision
Stamp every delegate-emitted ExchangeEnvelope with x-camel-leader-epoch,
a monotonic fencing token derived from the leadership backend.
Epoch Source
Kubernetes backend: The epoch is a server-authoritative annotation
counter (camel.io/leader-term) on the K8s Lease object. Each acquiring
pod reads the current term, increments it, and writes it back via the
Lease replace operation (which uses optimistic concurrency via
resourceVersion). Only one pod can win the replace; its term is globally
committed. The term is a simple incrementing u64 — not derived from any
pod's clock — so it is globally monotonic across pods. On renew (same
leader), the term is preserved unchanged.
Noop backend: Constant epoch=1. Models a single-node deployment with no split-brain risk. A constant epoch is valid fencing because there is only ever one leader.
Key invariants
- Global monotonicity: epoch from the K8s backend is a server-side
annotation counter (
camel.io/leader-term) — incremented on each takeover via optimistic concurrency. Monotonic across pods. - Snapshot semantics: the bridge stamps with its spawn-time snapshot, not a live read. Stale bridges carry stale terms.
- Sink contract: downstream systems (databases, message brokers,
external APIs) SHOULD check
x-camel-leader-epochand reject envelopes whose epoch is older than the current leader's epoch. Batch 4 supplies the token; sink-side enforcement is opt-in and deferred. - Trust boundary: the epoch property is set by the Master component's bridge task inside the process. It is not signed or authenticated — it assumes the process is not compromised. If untrusted processes can send to the pipeline, additional authentication is required.
Bridge lifecycle
The spawn_epoch_bridge function creates a bounded channel (128-deep)
between the delegate consumer and the pipeline. Each envelope is stamped
with the snapshot epoch before forwarding. On delegate stop (sender drop),
the bridge drains its buffer and exits. On route shutdown (parent_cancel),
the bridge aborts immediately. The bridge JoinHandle is stored in
DelegateState::Active and awaited by stop_delegate within drain_timeout.
Consequences
- Every ExchangeEnvelope from a
master:route carriesx-camel-leader-epoch. - Downstream sinks gain an opt-in rejection mechanism.
- The epoch-stamping bridge adds one hop (bounded 128-deep channel).
- The K8s backend extracts leader-term from Lease annotations — no additional API calls are needed.
ADR-0036: Bridge IPC mutual TLS
Date: 2026-07-09
Status
Accepted
Context
Bridge subprocesses (JMS, XML, CXF) communicate with the Rust runtime via gRPC over localhost. Until this ADR, the channel used HTTP/2 cleartext (h2c). Sensitive data flowing through plaintext included:
- JMS messages + broker credentials
- SOAP/XML payloads + WS-Security credentials
- Transformation payloads (arbitrary user data)
ADR-0033 identified this as a defense-in-depth gap: any local process (or container in a shared network namespace) can sniff or intercept bridge traffic.
Decision
Replace h2c with mutual TLS (mTLS) using ephemeral certificates:
-
Cert generation: The Rust parent generates an ephemeral CA + server cert
- client cert via rcgen on each
BridgeProcess::start(). Certs are valid for 90 days and written to a 0700 TempDir cleaned up on drop.
- client cert via rcgen on each
-
Quarkus native image: Build-time TLS properties (
use-separate-server,plain-text,ssl.client-auth,insecure-requests,tls-configuration-name) are hardcoded inapplication.yml. Placeholder self-signed certs insrc/main/resources/tls/enable SSL at native build time. Runtime cert paths are overridden via${ENV:default}expressions. -
Fail-closed guard: PortAnnouncer checks that resolved cert paths do NOT contain
placeholder-. If env vars are absent or unresolvable, the bridge aborts before readiness. -
Connection retry:
connect_channel()retries the TLS handshake 10×100ms because Quarkus PortAnnouncer fires onStartupEvent, which can precede full SSL listener readiness in native images. -
quarkus-config-yaml: Required Gradle dependency. Without it, Quarkus silently ignoresapplication.yml— all YAML config is inert.
Consequences
- Bridge IPC is encrypted and mutually authenticated.
- No persistent cert management needed — certs are ephemeral per process lifecycle.
- Bridge binaries must be rebuilt when TLS config changes (build-time fixed props).
- JVM-mode tests require test profile override (
src/test/resources/application.properties). - Native build skips Java tests (
-x test) because TLS config breaks them in JVM mode.
ADR-0037: Exec Component Fail-Closed Capability Model
Date: 2026-07-07 Status: Accepted Amends: none Cross-refs: ADR-0032 (exchange-data trust boundary), ADR-0033 (fail-closed startup validation), ADR-0034 (ControlBus capability authz — profile-pinning lesson)
Context
rust-camel needs a system-command-execution component for the agentic and tool-execution
use case. Direct shell execution (e.g., sh -c or cmd /C) is unsafe — it permits
argument injection, PATH hijacking, and environment-based privilege escalation. Apache
Camel's exec component allows arbitrary commands, which is incompatible with
rust-camel's fail-closed security posture (ADR-0033) and exchange-data trust boundary
(ADR-0032).
ADR-0034 (ControlBus) established that exchange-data-driven capability selection is dangerous — capabilities must be profile-pinned at configuration time, not dynamically selected from runtime data. The exec component should not repeat that mistake.
Decision
Eleven locked decisions govern the component's design:
1. Producer-only, execvp semantics (no shell)
The component is producer-only (no consumer/inbound). It executes commands via
execvp-style semantics: binary path + literal argument array. There is no
exec:shell variant. Shells (sh, bash, zsh, powershell, cmd) are rejected
at runtime unless allow_shell=true is explicitly set on the profile. Even with
allow_shell=true, the binary must be the shell itself (not a /bin/sh -c wrapper)
— the shell runs with explicit argv, not a concatenated string.
2. Allowlist fail-closed (ADR-0033)
The component refuses to start unless at least one profile is configured. With zero
profiles, ExecGlobalConfig::validate() returns an error at startup. This is the
Require-Explicit-Choice disposition from ADR-0033 — the operator must explicitly
declare every executable they intend to run.
3. Profile-pinned by endpoint URI only
The target profile is determined by the endpoint URI (exec:{profile-name}). There is
NO dynamic override from exchange headers or body (ADR-0032, ADR-0034). Conditional
dispatch to different profiles is achieved via choice() or recipient_list() in
the route definition — where the route author, not exchange data, controls the
branching logic.
4. Canonical executable pinning at startup
Each profile's executable is resolved once at startup validation: either from PATH
(lookup by name) or as an absolute path (used as-is). The resolved path is stored in
canonical_executable: Option<PathBuf>. At runtime, the producer uses this pinned
path — no re-resolving from PATH, which eliminates PATH hijacking.
Important caveat: The pinned path is NOT canonicalized via
std::fs::canonicalize(). Multi-call binaries (BusyBox, uutils on NixOS/coreutils)
dispatch based on argv[0] — canonicalize() would resolve the symlink to the
multi-call binary, breaking argv[0] dispatch. The which() implementation stores
the directory-path as-found. Inode/dev same-path-replacement detection is deferred to
post-v1.
5. Arg-policy per-element, first-class in v1
All args passed via the CamelExecArgs header (JSON array of strings) are validated
per-element against the profile's ArgPolicy. Four modes:
any— every element accepted (explicit opt-in, operator-curated).exact { values }— every element must string-equal one ofvalues.prefix { values }— every element must byte-start-with one ofvalues.- Default (omitted) =
exact { values: [] }— deny all non-empty args (fail-closed).
deny_flags is applied first with a broad prefix match (e.g., -- matches any flag
starting with --), then allow is evaluated. An arg that matches both deny_flags
and allow is denied — deny always wins.
Regex-based arg policy is deferred to post-v1.
6. Environment sanitized by default
The child process starts with an empty environment — the host environment is NOT inherited. Three layers control env:
env.allow— var names from the host env that the child may receive.env.set— explicitKEY=VALUEpairs.global.deny_env— glob patterns applied LAST, always win over allow/set.
Default deny_env patterns (LD_*, DYLD_*, PYTHONPATH, RUSTFLAGS, GIT_*,
SSH_AUTH_SOCK, *_TOKEN, *_KEY) block the most common secret-injection and
library-preload vectors. PATH is opt-in via env.allow — operators must explicitly
allow it for PATH-dependent executables.
7. cwd confinement
Every profile's working_dir is validated at startup relative to the pinned
canonical_workspace_root. The validation:
- Rejects absolute paths.
- Rejects paths containing
... - Requires the resolved path to
starts_withthe workspace root. - Does NOT create the directory if missing (fail-closed: operator must pre-create).
At runtime, the producer uses canonical_workspace_root (startup-pinned, never a
"." fallback — I-6 in the spec).
8. Non-error outcomes (timeout, exit-code mismatch)
Timeout and exit-code-not-in-accepted list return Ok(exchange) with the ExecResult
JSON body and headers — NOT Err. This is forced by the Service<Exchange> contract:
the Tower Service trait discards the mutated exchange on Err, and these outcomes
have useful output the route should inspect.
Only pre/during-spawn failures (arg policy denial, shell rejection, spawn failure,
stdin too large, workdir escape) return Err. Routes that want to branch on
exit-code outcomes use choice() checking CamelExecExitAccepted.
Key invariant: There are no "partial errors." A timeout produces a complete
ExecResult with partial output captured before the kill.
9. Timeout with process-group kill
Timeout uses tokio::select! (biased: child wait checked first, then timeout). The
Child handle is held outside the timeout region (spawned before select!) so
kill_tree can fire after timeout elapses.
- Unix:
libc::kill(-pgid, SIGKILL)kills the entire process group. - Windows v1:
child.start_kill()kills the immediate child only. Process-group tree-kill via Windows Job Objects is deferred to post-v1.
After kill, child.wait().await reaps the zombie. Drain tasks (stdin write, stdout
read, stderr read) continue running concurrently; when pipes close after kill, the
drain tasks finish and partial output is collected.
kill_on_drop(true) is set on the Command as defense-in-depth.
10. Error surface: CamelError::ProcessorErrorWithSource
All exec errors surface as:
CamelError::ProcessorErrorWithSource(msg, Arc<ExecError>)
ExecError is #[non_exhaustive] with the following variants:
NotAllowlisted, ArgPolicyDenied, ShellRejected, InvalidWorkDir,
StdinTooLarge, InvalidArgs, Spawn(#[from] std::io::Error).
11. Configuration structure
Config lives under [components.exec] in TOML:
[components.exec]
workspace_root = "."
default_timeout_secs = 30
default_concurrency = 1
deny_env = ["LD_*", "DYLD_*", "PYTHONPATH", ...]
[[components.exec.profiles]]
name = "echo"
executable = "echo"
args = { allow = "any" }
working_dir = "."
timeout_secs = 10
accepted_exit_codes = [0]
Spec Amendments
The following deviations from the original blessed spec's illustrative blocks are recorded here as authoritative corrections:
ExecResult.stdout/stderrare base64String, notBytes.Byteslacks aSerializeimpl without a serde feature flag, and raw byte arrays would be pathological JSON. Base64 is the standard encoding for binary data in JSON.ExecResult.duration_ms: u64, notDuration.Durationhas no stable JSONSerializeimplementation.ExecError::Iois collapsed intoSpawn(#[from] std::io::Error). They are the same type —std::io::Errorcovers all spawn failures.#[non_exhaustive]on the enum keeps the variant set consumer-safe for future additions.- Windows v1 kill is child-only. Process-group tree-kill via
start_kill()on the immediate child is the v1 implementation. Windows Job Object tree-kill is deferred to post-v1. - Timeout captures partial output. Drain tasks (stdout/stderr) continue running
inside
tokio::spawnduring theselect!. Whenkill_treefires and the pipes close, the drain tasks complete with whatever bytes they accumulated. The partial output is included in theExecResult.
Cross-crate Change
The MetricsCollector trait in camel-api gained a new default method:
fn record_counter(&self, name: &str, value: f64, labels: &[(&str, &str)]) { }
This is a backward-compatible addition — all existing implementors receive the
default no-op behavior. The exec component needs monotonic counters
(exec_policy_denials_total, exec_timeouts_total, exec_exit_code,
exec_stdout_truncated_total) which record_histogram(1.0) could not represent.
Consequences
Positive
- Fail-closed by default: No profiles → nothing executes. No shell injection surface. No PATH hijacking at runtime (pinned at startup).
- Injection-resistant: execvp-style argument passing, no string concatenation. Arg-policy per-element with deny-first ordering.
- Profile-pinned capabilities: Exchange data cannot select executables or modify profile configuration (ADR-0032, ADR-0034).
- Non-error outcomes enable route-level branching: Routes can
choice()onCamelExecExitAcceptedto handle success/failure without losing output. - Auditable: Every execution emits an
ExecAuditEventwith profile, args, env keys, exit code, and duration.
Negative
- No shell convenience: Operators cannot write
exec:shell?cmd=ls -la | grep foo. Every command must be a named profile. This is intentional but increases config overhead for simple ad-hoc commands. - Operator must pre-create working directories: The component does NOT create
working_dir— fail-closed over silent creation. Missing directories fail at startup validation. - Windows tree-kill is best-effort in v1: Only
child.start_kill()is used; job objects require a post-v1 change.kill_on_drop(true)is the only defense-in-depth for process cleanup on Windows. - Multi-call binary detection deferred: Same-path-replacement attacks are a theoretical concern in v1. Inode/dev resolution would break BusyBox/NixOS dispatch and requires post-v1 design.
- Metrics backends do not yet wire
record_counter/record_histogram: The exec component emitsexec_policy_denials_total,exec_timeouts_total,exec_exit_code,exec_stdout_truncated_total, andexec_duration_secsvia the trait API, butPrometheusMetricsandOtelMetricsinherit the default no-op for both generic methods. These metrics are silently dropped in production until the backends override them (tracked as a follow-up). Onlyrecord_exchange_duration/record_circuit_breaker_changeare wired today.
ADR-0038: Configurable DoS Caps via Per-Format Config Channel
Date: 2026-07-11
Status: Accepted (implemented in 28e61377)
Cross-refs: ADR-0032 (Exchange-data trust boundary), ADR-0033 (Security defaults), ADR-0017 (DSL snake_case)
Note: Numbered 0038 instead of 0034 because 0034 was already claimed by
0034-controlbus-capability-authz.md.
Context
ADR-0033 mandates that each hardened security default has its own per-item escape hatch
— no global "disable hardening" switch. Several data-format DoS caps (MAX_JSON_BYTES,
MAX_XML_DEPTH) were hardcoded const values with no operator override. Additionally,
ZipConfig and CsvConfig had full config structs that were unreachable from the DSL
because the factory (builtin_data_format(name: &str)) could only return
Default::default().
Root cause: the marshal/unmarshal DSL path carried only a format name string at every layer. There was no configuration lane.
Decision
Add an optional config: Option<serde_json::Value> field to MarshalStep,
UnmarshalStep, and DataFormatDef. A config-aware factory
(builtin_data_format_with_config) deserializes this value into the format's own typed
*Config struct with #[serde(deny_unknown_fields)], failing closed on unknown keys at
compile time.
Each format gains or already has a *Config struct with hardened defaults:
JsonConfig { max_bytes }— default 16 MiBXmlConfig { max_depth }— default 100ZipConfig— existing, gainsDeserializeCsvConfig— existing, gainsDeserialize
The DataFormat trait is unchanged. Config is applied at construction time.
Classification Rule
A cap needs a per-item escape hatch iff it bounds a resource decision driven by exchange data (untrusted, per ADR-0032). Caps that bound operator-supplied config artifacts are trusted-side and may stay hardcoded.
ADR-0033 Compliance
Setting a non-default max_bytes in YAML is the per-item explicit choice mandated
by ADR-0033:
- Scoped to exactly one route step (per-item, not global).
- Has a hardened default that applies when omitted.
- Raising it is an explicit, auditable, per-site operator decision.
There is deliberately no top-level disable_dos_caps: true switch.
Consequences
- Additive: existing route files behave identically (config absent → defaults).
- Fail-closed:
deny_unknown_fieldscatches typos at compile time. - Follow-up: a C-typed schema union for per-key autocomplete is a non-breaking refinement, noted for future work.
Out of Scope
MAX_LOOP_ITERATIONS(EIP cap, separate pattern).convert_body_to: xml→jsonpath (keeps default depth).- Custom/user-registered data format registry (deferred;
config: Valuelane generalizes).
ADR-0039: Configurable Loop Iteration Cap
Date: 2026-07-11
Status: Accepted (implemented in 5f6d6d10)
Cross-refs: ADR-0033 (Security defaults), ADR-0038 (Configurable DoS caps), ADR-0032 (Exchange-data trust boundary)
Context
MAX_LOOP_ITERATIONS = 10_000 was a hardcoded const bounding both Count-mode loop iterations and While-mode loop safety guards. It bounds a resource decision driven by exchange data (operator-supplied loop count / while predicate), qualifying it for a per-item escape hatch under ADR-0033's classification rule (established in ADR-0038).
Decision
Add max_iterations: Option<usize> to the loop DSL surface (LoopFullConfig). Thread it through LoopStepDef → BuilderStep::DeclarativeLoop → LoopConfig → LoopService/LoopSegment. When absent, default to MAX_LOOP_ITERATIONS (10,000).
No upper ceiling — the operator makes an explicit per-Step resource decision. max_iterations: 0 is rejected at compile time.
ADR-0033 Compliance
Per-item escape hatch: satisfied. Each loop step can independently set max_iterations. No global disable switch.
Out of Scope
DEFAULT_MATERIALIZE_LIMITcallers (tracked as bd rc-b9q8).- Per-Route (not per-Step) max_iterations setting.
ADR-0040: Configurable Materialize Limits for Producers
Date: 2026-07-11
Status: Accepted (implemented in db8340bb)
Cross-refs: ADR-0033 (Security defaults), ADR-0038 (Configurable DoS caps), ADR-0032 (Exchange-data trust boundary)
Context
Three producers (XSLT, XJ, WASM) used Body::materialize() or hardcoded DEFAULT_STREAM_MAX_BYTES without operator override. XJ had a bug where the operator-set maxPayloadBytes was checked after allocation.
Decision
- XSLT: new
maxPayloadBytesURI param, threaded through config → endpoint → producer. - XJ: switch from
materialize()tointo_bytes(effective); remove post-materialization check. - WASM: new
max_stream_bytesfield onWasmConfig+WasmLimitsConfig, sourced from URI or Camel.toml.
All default to 10 MiB (DEFAULT_MATERIALIZE_LIMIT).
Out of Scope
Body::materialize()convenience API stays with fixed default.- Changing
WasmConfig::from_uri()to fallible return. - Bounded JSON serialization for non-stream body types in
into_bytes().
ADR-0041: Component Metadata and Capabilities Schema
Date: 2026-07-15
Status: Accepted (implemented in 0114dee3)
Context
The project needs a foundation for components to declare their URI options, capabilities, and version. This is a cross-cutting dependency for the WASM SDK, camel-catalog, OpenAPI generation, and IDE/registry tooling — all of which need a stable introspection contract that does not rely on re-invoking component code at query time.
Decision
-
Define a schema in
camel-api(component_metadata.rs) with:OptionKind— 7-variant enum of URI option value types (String, Int, Bool, Float, Duration, Enum, List).UriOption— builder-pattern struct for a single URI-parameter definition: name, description, kind, required flag, default value, aliases, deprecation notice, and secret flag.ComponentCapabilities— named boolean flags (consumer, producer, polling_consumer, streaming).CapabilityQuery— tri-state query struct where each capability field isOption<bool>;Nonemeans "don't care".ComponentMetadata— top-level descriptor: scheme, schema_version, version, description, uri_syntax, capabilities, and uri_options.
-
Add a
metadata()method to theComponenttrait returningComponentMetadata, with a defaultComponentMetadata::minimal(scheme)implementation so new components compile without change. -
Harvest metadata at registration time in
Registry::register()— callcomponent.metadata()once, store the result indexed by scheme. Re-registering a component replaces its metadata. -
ComponentMetadataCatalogtrait returns ownedComponentMetadatavalues (not references) because the Registry is behindArc<Mutex<>>. -
RuntimeComponentMetadataCatalogin camel-core wraps the registry and implementsComponentMetadataCatalogfor trait-object use.
Out of Scope
- JSON Schema generation from the metadata types (future xtask work).
- Crate-level version extraction from Cargo.toml (uses a hardcoded constant for now).
Consequences
- Components can declare rich metadata without runtime overhead — harvested once at registration.
- Schema generation (xtask) can produce a JSON Schema type contract from the Rust types.
- SDK, catalog, OpenAPI, and IDE tooling have a stable contract to consume.
metadata()default is non-breaking for all existing components.
Amendment: Macro-Derived URI Options via #[derive(UriConfig)]
Rationale: Hand-written UriOption::new lists in component production
code duplicate the URI parameter information already present in config struct
fields and #[uri_param] attributes. The #[derive(UriConfig)] macro is now
the single source of truth for URI parameter metadata.
fn uri_options() Generation
#[derive(UriConfig)] generates an inherent pub fn uri_options() -> Vec<UriOption>
on the config struct. The method iterates over #[uri_param]-annotated fields
and produces one UriOption per field, using builder methods (.required(),
.secret(), .with_default(v), .deprecated(reason), .with_alias(a)) to
encode the semantic attributes.
OptionKind Inference Rules
The macro infers OptionKind from the Rust type of each #[uri_param] field:
| Rust Type | OptionKind |
|---|---|
Duration | Duration |
bool | Bool |
u8..u64, usize, i8..i64, isize | Int |
f32, f64 | Float |
String, &str | String |
Vec<T> | List(Box::new(infer_option_kind(inner))) |
| Anything else | String (NEVER Enum) |
Enum variant requires an explicit kind = "enum:A,B,C" override on the
#[uri_param] attribute. Inference never produces Enum.
Option<T> fields are unwrapped to their inner T before inference, and
their default required flag is false.
Semantic Attributes via #[uri_param]
The #[uri_param] attribute accepts these keys:
| Key | Type | Semantics |
|---|---|---|
desc = "text" | Lit::Str | Human-readable description |
required | flag or Lit::Bool | Marks the option as mandatory |
secret | flag or Lit::Bool | Credential-bearing field; must not appear in diagnostics |
deprecated = "reason" | Lit::Str | Deprecation notice shown in tooling |
aliases = ["a", "b"] | ExprArray | Alternative parameter names |
kind = "string" | Lit::Str | Explicit kind override ("duration", "bool", "int", "string", "float", "enum:A,B") |
If secret is true and default is non-empty, the macro emits a compile
error — a secret with a hardcoded default is a security hazard.
metadata() Generation via Opt-in
#[uri_config(metadata(scheme = "x", description = "d", producer, consumer, polling_consumer, streaming))]
generates an inherent fn metadata() -> ComponentMetadata on the config
struct. The method returns ComponentMetadata::minimal(scheme).with_description(desc).with_capabilities(ComponentCapabilities { ... }).with_uri_options(Self::uri_options()).
Without metadata(..), no metadata() method is generated — only uri_options().
skip_impl Path
Structs with bespoke URI parsing logic (e.g., HttpEndpointConfig: custom
impl UriConfig with multi-segment path handling and legacy compatibility)
use #[uri_config(skip_impl, metadata(..))]. This retains the manual
impl UriConfig (including from_uri) while deriving uri_options() and
(if opted in) metadata() from the field annotations.
Component-to-Config Delegation Convention
The Component trait's metadata() default returns ComponentMetadata::minimal(scheme).
Each migrated component MUST override metadata() to delegate:
fn metadata(&self) -> ComponentMetadata {
ConfigType::metadata()
}
Or, when the config has no metadata(..) opt-in but does have uri_options():
fn metadata(&self) -> ComponentMetadata {
ComponentMetadata::minimal(scheme).with_uri_options(ConfigType::uri_options())
}
Single-Source-of-Truth Invariant
cargo xtask lint-single-source scans component crate source for
UriOption::new calls outside #[cfg(test)] modules. A violation means
metadata is being hand-written instead of macro-derived. The lint enforces
that the macro is always the single source of truth for URI parameter metadata.
Inner-Config-Struct Mirror Pattern
Components with bespoke URI parsing (manual impl UriConfig on the public
config struct) use the inner-config-struct mirror pattern via skip_impl.
The mirror struct (e.g., HttpEndpointUriConfig, SedaUriConfig) is a
metadata-only anchor — its #[uri_param] fields must stay synchronized with
the bespoke parser's recognized params. Each component using this pattern
includes a parity test (uri_options_count_parity) that asserts the mirror
struct's uri_options().len() equals the expected param count. This catches
silent drift when a param is added to or removed from the manual parser but
the mirror is not updated.
Amendment: Open Namespace Pattern Matching
Rationale: Some components accept URI query keys of the form
param.<name>=<value> where any non-empty <name> is valid. For example,
camel-xj and camel-xslt accept stylesheet parameters via
param.foo=bar¶m.baz=qux pairs into a Vec<(String, String)> field. The
exact-name UriOption model cannot describe this open namespace — each key is
valid, so a fixed list of option names is impossible. Before this amendment,
these components returned ComponentMetadata::minimal(scheme) with empty
uri_options, causing the lint to silently no-op for them.
Decision:
- Add
UriOptionMatch, a#[non_exhaustive]enum incamel-api, with one initial variant:Prefix { separator: String }. - Add an optional
pattern: Option<UriOptionMatch>field toUriOption, serialized with#[serde(default, skip_serializing_if = "Option::is_none")]so existing JSON output stays byte-identical. - Add a consuming builder
UriOption::pattern_prefix(separator: &str) -> Selfthat setspattern: Some(UriOptionMatch::Prefix { separator: separator.to_string() }). - Add a
#[uri_param(pattern = "<separator>")]macro key, valid only onVec<(String, String)>fields, with eight compile-time guardrails: incompatible withrequired,default,secret,name,aliases, and any non-stringkind; empty separator rejected; separator without trailing.rejected. - Extend the shared lint helper
resolve_optionwith two-phase resolution: Phase 1 — combined exact-name OR alias match, considering only options whosepatternisNone; Phase 2 — longest-prefix-wins pattern match, considering only options whosepatternisSome(_), with a non-empty suffix requirement (bareparam.does NOT matchPrefix { separator: "param." }).
Consequences
- Components with open namespaces can now declare a single
UriOptionwith aPrefixpattern, making their metadata visible to lint, schema-gen, catalog, and doc-gen. - The
#[non_exhaustive]enum allows future match variants (e.g.Glob,Regex) without a Rust-side schema break. - Forward-compat cost: each future
UriOptionMatchvariant expands a closed JSON-Schema union. Stale validators or exhaustive generated consumers may reject the new variant at parse time despite the Rust-side forward-compat guarantee. Each future variant requires schema compatibility review and regenerated downstream consumers. - Component migration (
camel-xj,camel-xslt) is deferred to a follow-up change.
ADR-0042: Arc<[CompiledStep]> shared snapshot for pipeline steps
Status
Accepted
Context
SequentialPipeline and TracedPipeline held Vec<CompiledStep> and cloned
the entire Vec (including all boxed services) per Exchange in call(). Each
BoxProcessor (a BoxCloneService) may carry inner service state, so the
per-Exchange Vec clone is not a cheap internal Arc bump — it is a real
allocation and state-copy.
Decision
Store steps as Arc<[CompiledStep]> (wrapped in a SharedSnapshot newtype
that adds the necessary Send/Sync impls). call() does Arc::clone
(refcount bump). run_steps takes Arc<[CompiledStep]> by value, iterates
by reference, cloning only the single step being invoked.
Consequences
- In-flight Exchanges hold an Arc to the old snapshot during hot-reload swap.
Snapshot isolation (ADR-0004) is strengthened, not weakened: the old Arc
is dropped only after every in-flight
run_stepsfuture completes. - Lifecycle ownership remains a
PipelineAssemblyconcern. The newtype is private to the compiler module. - No observable behavior change — only allocation cost reduced.
Safety
CompiledStep contains BoxProcessor (BoxCloneService) and
Box<dyn OutcomePipeline>, both Send + !Sync. The std Arc<T>: Send
bound therefore fails to hold. We work around it with a private newtype
in route_compiler.rs:
-
SharedSnapshot(Arc<[CompiledStep]>)— adds unconditionalSend/Syncimpls. The!Syncon the inner types is an artifact of Tower's trait-object bounds (Box<dyn ... + Send>lacks+ Sync), NOT a sign of interior mutability. Concurrent&CompiledStepaccess is sound becauserun_stepsonly reads shared references and.clone()s owned copies before invoking.The
INVARIANTin the struct doc — noRc/RefCell/Cell/UnsafeCellinCompiledStep— is what makes theunsafe impl SendSyncsound. If any variant ever introduces interior mutability, this becomes UB.
Why by-value SharedSnapshot, not &[CompiledStep]
An earlier sketch took steps: &[CompiledStep] instead of
steps: SharedSnapshot. That was rejected: the resulting run_steps
future would borrow from a &[CompiledStep] with a non-'static lifetime,
which is incompatible with the BoxCloneService::Future associated type
Pin<Box<dyn Future + Send>> (a Box<dyn Future + Send> is implicitly
'static-bounded by trait-object vtable semantics). The future returned
by Service::call must be 'static so the executor can own it without
lifetime plumbing.
By-value SharedSnapshot lets the future own the Arc allocation
naturally — clone of the newtype is a refcount bump, and the future drops
the Arc on completion, keeping the allocation alive for the future's
full lifetime without any lifetime annotations.
ADR-0043: Pipeline cancellation between steps
Status
Accepted (amended 2026-07-16: drain-grace precedence)
Context
run_steps is a linear async fn with no cooperative cancellation. When a route
stops/suspends or the context shuts down, in-flight Exchanges stuck in a slow step
.await have no observable cancel path. The pipeline_cancel_token exists in
ManagedRoute and is checked at idle in the pipeline task's select! loop, but
once an Exchange enters pipeline.call(exchange).await, cancellation cannot
interrupt it until the next idle cycle.
Decision
Use a tokio::task_local! to propagate a per-start CancellationToken from the
pipeline task into run_steps. The pipeline task already creates a fresh child
token on each start_route call (route_controller_trait.rs:118). Before calling
pipeline.call(exchange), the task scopes the token via CANCEL_TOKEN.scope(...).
run_steps checks CANCEL_TOKEN at the top of each loop iteration — BETWEEN steps.
Why task-local, not compiled-in struct field (expert ruling): A token compiled into the pipeline at registration is a child of
ManagedRoute.pipeline_cancel_token. On stop, the parent is cancelled (killing the child), thenstop_route_internalreplaces the parent with a new token. On restart, the compiled pipeline still has the OLD cancelled child → every exchange fails immediately. A task-local is set per-start from the fresh child token, avoiding the lifecycle bug entirely.
Cancellation outcome: Failed(ConsumerStopping) — justified
We return PipelineOutcome::Failed(CamelError::ConsumerStopping) rather than
PipelineOutcome::Stopped(ex) for two reasons:
-
UoW hook semantics:
Stoppedis a successful termination — UoW completion hooks fire, and the exchange is delivered to the reply channel asOk(ex). A cancelled exchange is NOT successfully processed — its data may be incomplete.Failed(ConsumerStopping)routes through UoW failure hooks, giving the operator visibility. -
Error handler behavior:
Failed(ConsumerStopping)reaches the error handler'smatch_policy. Note: the defaultRouteErrorHandlerhas NO special handling forConsumerStopping— a handler with a catch-all policy MAY retry it. This is an accepted trade-off: the cancellation check happens between steps, so the exchange has already completed at least one step. A retry would re-invoke from the top ofrun_steps, hitting the cancel check immediately and returningFailed(ConsumerStopping)again — so retries are effectively self-limiting. Custom handlers that want to skip retry onConsumerStoppingcan match the variant in theirmatch_policy.
Token lifecycle (per-start, not per-compile)
ManagedRoute.pipeline_cancel_token— parent (created at registration).- On
start_route:pipeline_cancel = managed.pipeline_cancel_token.child_token()(route_controller_trait.rs:118). This child is FRESH on each start. - Pipeline task wraps
pipeline.call(exchange)inCANCEL_TOKEN.scope(cancel.clone(), ...). run_stepsreadsCANCEL_TOKENand checksis_cancelled()between steps.- On
stop_route:managed.pipeline_cancel_token.cancel()— propagates to the child in the pipeline task (which exits its select! loop). The pipeline struct itself is untouched — no lifecycle bug.
Checkpoint granularity
- Check at the top of the for-loop, before destructuring each
CompiledStep. - If the task-local is not set (tests calling
run_stepsdirectly), skip the check. - The in-flight step's
.awaitcompletes naturally; the NEXT iteration exits.
Consequences
- In-flight Exchanges drain to completion during graceful stop.
stop_route_internalcloses the channel and waits fordrain_in_flightto reach zero (bounded byshutdown_timeout) BEFORE cancellingpipeline_cancel_token. The B1 cancel-between-steps check is a backstop that fires only after the drain grace expires — not immediately on stop. - If the drain timeout expires with exchanges still in-flight, the cancel token fires
and those stragglers exit at the next step boundary with
Failed(ConsumerStopping). - HTTP consumer maps
ConsumerStopping → 503 Service Unavailable(not 500). - UoW failure hooks see
Failed(ConsumerStopping)— distinguishable from real errors. - No lifecycle bug: token is per-start via task-local, not compiled-in.
- Restart regression test required (stop → start → exchanges process normally).
Amendment (2026-07-16): drain-grace precedence
Original consequence: "In-flight Exchanges exit cleanly on route stop (one more step boundary at most)."
Problem: stop_route_internal cancelled pipeline_cancel_token BEFORE joining,
so the B1 check killed in-flight exchanges immediately — even with a 30s shutdown
timeout configured. HTTP consumers received ConsumerStopping → 500 for requests
the server had already accepted and could have completed.
Fix: Added drain_in_flight: Arc<AtomicU64> to ManagedRoute (always populated,
incremented by a DrainGuard RAII at dequeue, decremented on drop). stop_route_internal
now closes the channel, waits for the counter to reach zero (bounded by
shutdown_timeout), and only THEN cancels the pipeline token. The B1 check remains
as a safety backstop for stragglers past the grace window.
ADR-0044: Route-admission back-pressure
Status
Accepted
Context
The pipeline's poll_ready only checks the first step's readiness (Tower semantics).
The Concurrent model's pipeline task acquires the Semaphore permit AFTER dequeue
(rx.recv()) and spawn — the consumer buffers unbounded in-flight work internally
before the permit gate. The original spec proposal (poll all steps upfront) was
REJECTED by the oracle: (1) Tower readiness reserves capacity for the immediately
following call; (2) ADR-0019 allows composing Pending but never routing
readiness Err.
Decision
1. Fix permit acquisition: BEFORE dequeue (behavior change)
In the Concurrent model, restructure so the Semaphore permit is acquired BEFORE
rx.recv(). This ensures the consumer's tx.send() back-pressures when permits
are exhausted — the consumer cannot buffer unbounded in-flight work.
Implementation: acquire permit (owned) with cancel-awareness, then dequeue:
// Concurrent model — fixed admission (ADR-0044):
loop {
// B2: acquire permit BEFORE dequeue, with cancel-awareness.
let permit = tokio::select! {
permit = async {
match &sem {
Some(s) => Arc::clone(s).acquire_owned().await.map(Some),
None => Ok(None),
}
} => permit.expect("semaphore closed"),
_ = pipeline_cancel.cancelled() => return,
};
let envelope = tokio::select! {
envelope = rx.recv() => match envelope { Some(e) => e, None => return },
_ = pipeline_cancel.cancelled() => return,
};
// Spawn per-exchange task WITH the permit moved in (preserves parallelism).
let pipe_ref = Arc::clone(&pipeline);
let cancel = pipeline_cancel.clone();
tokio::spawn(async move {
let _permit = permit; // held for exchange lifetime — RAII release
// ... process exchange (load pipeline, ready_with_backoff, call)
});
}
Cancel-awareness: permit acquisition is wrapped in
select!againstpipeline_cancel.cancelled()so route stop is not blocked waiting for a permit.
acquire_owned()returns an ownedOwnedSemaphorePermitthat can be moved into a spawned task. This preserves per-exchange parallelism while ensuring the permit is held for the exchange's entire lifetime.
Sequential model: already correct — processes one at a time, natural back-pressure via
mpsc::channel(256).
2. Per-step readiness inside run_steps (existing behavior, documented)
RetryableStep for BoxProcessor::invoke() routes readiness errors into the
invocation path — poll_ready::Err becomes PipelineOutcome::Failed. This is
the per-step readiness check point. B2 documents this; no code change needed.
ADR-0019 compatibility proof
- Readiness
Pending: the step's.awaitcooperatively yields — runtime waits. - Readiness
Err: becomesPipelineOutcome::FailedviaRetryableStep— never escapes aspoll_ready::Err. Error handler sees it and may retry/handle. - No readiness state escapes the pipeline boundary.
What B2 does NOT do
- Does NOT poll all steps upfront (rejected by oracle).
- Does NOT add
CompiledStepbackpressure-marker metadata.
Consequences
- Concurrent model: consumer back-pressures when in-flight permits exhausted.
- Permit held for entire exchange lifetime (dequeue → process → reply).
- Sequential model unchanged.
ADR-0045: camel-core Architecture Charter
Status
Accepted
Context
camel-core was promised as Clean Architecture + DDD + CQRS + vertical slices + hexagonal. That promise was never codified in a single ADR — it was spread across ADR-0002 (CQRS) and ADR-0003 (hexagonal, scoped only to the lifecycle layer). During stabilization toward 1.0, drift appeared because there was no constitution to consult:
- a domain file derived Serde (
lifecycle/domain/runtime_event.rs) and derivedthiserror::ErroronLanguageRegistryError(lifecycle/domain/error.rs); - the hexagonal layering applied only to
lifecycle/,hot_reload/,shared/while ~9 flat root modules (~3700 LOC) ignored it; - the
internal-adaptersfeature gated public exports but not compilation; - CQRS reads bypassed the projection port on the hot path without a documented exception.
This charter codifies the promise in one place so future drift is detectable against a single authoritative source.
Decision
1. The five-pillar promise (crate-scoped)
camel-core is, in priority order:
- Hexagonal — every behavioral area exposes ports (traits) inboard of its adapters.
- Clean Architecture — the dependency rule: dependencies point inward toward domain. Domain
depends on nothing outside
std/crate::. No framework types (Tower, Tokio, Serde, redb) in entities. - DDD — aggregates, value objects, and domain events model the route lifecycle. Bounded contexts are the unit of decomposition (not technical layers).
- CQRS — scoped per bounded context (see §3). NOT global.
- Vertical slices — each bounded context is a self-contained vertical slice with its own internal hexagonal layout, NOT a horizontal technical layer shared across contexts.
2. Ceiling: module discipline, not crate-split
Canonical Clean Architecture is compiler-enforced via separate crates (domain / application / adapters / drivers). camel-core deliberately stays one crate for 1.0. The ceiling is therefore strong module discipline + boundary tests, not compiler-enforced ring isolation. Public paths are kept stable; internal module paths enforce the rings. This is an explicit trade: lower purity ceiling in exchange for no Semver break and no workspace churn during stabilization. A crate split remains a post-1.0 option if module discipline proves insufficient.
3. CQRS is scoped per bounded context
CQRS is a bounded-context-level decision, never a crate-wide one:
| Bounded context | CQRS flavor | Why |
|---|---|---|
| Route lifecycle control plane | Synchronous-projection CQRS (strong consistency, no projection lag) — command/query buses, event journal, projection updated within the same UnitOfWork as the command | Safety: supervision decisions need consistent reads; ADR-0002 + ADR-0018 |
| Data plane (Exchange / Pipeline processing) | Not CQRS — Tower data plane | Hot path; ADR-0001 |
| Hot-reload, metrics, audit history | Event-sourced / eventually consistent where tolerated | Lag acceptable |
The lifecycle CQRS uses synchronous projection (not eventual consistency): the projection is updated within the same optimistic-versioned UnitOfWork as the command, so projection lag cannot break supervision decisions. This terminology avoids confusion with CAP-theorem "strong consistency". (Clarifies ADR-0002.)
§3 describes the design contract (ADR-0002 + ADR-0018). §4 below records the implementation gaps where the code currently deviates from that contract; the gaps are remediation targets, not part of the contract.
4. Accepted exceptions (explicit, not accidental)
Implementation deviations from the design contract are recorded here so they do not multiply silently. Each is either remediated pre-1.0 or recorded as an accepted exception with justification; every new bypass must land a line here, or it is a boundary violation.
CQRS contract gaps (ACCEPTED — not remediated; remediation would regress correctness):
-
✅ Single backing store for repository + projection + events + dedup (
context_builder.rswires oneInMemoryRuntimeStoreviaArc::cloneinto 4 typed ports) — ACCEPTED PERMANENT. The ports are fully segregated at the trait level (RouteRepositoryPort/ProjectionStorePort/EventPublisherPort/CommandDedupPort);RuntimeBusdepends only ondyn Port, never on the adapter, so the dependency rule holds. The unified backing store is required, not incidental: Synchronous-projection CQRS (ADR-0002/0045) mandates that the aggregate and its projection be written in the same optimistic-versioned UnitOfWork (RuntimeUnitOfWorkPort::persist_upsert) under a single lock. Splitting the store into separate instances would break UoW atomicity and reintroduce projection lag — i.e. the eventual-consistency CQRS that ADR-0045 explicitly rejected. One adapter satisfying multiple segregated ports is a legitimate hexagonal pattern for a transactionally-unified infrastructure store. No boundary violation. -
✅
InFlightCountis served by the execution port, not the projection port (lifecycle/application/queries.rsreturns an exhaustiveness-guard error;RuntimeBus::askintercepts and delegates toRuntimeExecutionPort::in_flight_count) — ACCEPTED PERMANENT as an explicit low-latency operational read. The in-flight counter is volatile runtime telemetry held by the controller actor; it is never persisted, journaled, or recovered, and is therefore not a CQRS read model. Routing it throughProjectionStorePortwould require mirroring the live counter into the projection store on every in-flight increment/decrement — adding projection writes to the data-plane hot path to serve an O(1) in-memory read. The execution port is the correct port for volatile operational state; this is not a bypass of the right port but selection of it.
Entity-purity gaps (the dependency rule):
- ✅
RuntimeEventderivedSerialize(lifecycle/domain/runtime_event.rs) — REMEDIATED in Tier B (rc-d0pu.2): theRuntimeEventRecordDTO (lifecycle/adapters/runtime_event_record.rs) now carries Serde via a#[serde(with)]bridge onJournalEntry.event; the entity derive is stripped. Wire format byte-compatible (roundtrip test). Kept here for the historical record. - ✅
LanguageRegistryErrorderivedthiserror::Error(lifecycle/domain/error.rs:33) — REMEDIATED in Tier B (rc-d0pu.2): relocated tolanguage_registry.rs(languages application slice);domain/error.rsno longer imports thiserror (onlyDomainError's manual impl remains). A#[deprecated]re-export shim atlifecycle::domainkeeps the old path compiling; shim removal tracked in bdrc-rfr9. Kept here for the historical record.
Use-case purity gaps (Tier C C2):
- ✅
abort_contexttakes concrete&RouteControllerHandle— REMEDIATED (rc-d0pu.3-purge): abort_context now takes&dyn RouteOrderingPort + &dyn RouteDestructiveTeardownPort(two-param split — Rust stable does not support multi-trait objects); the destructiveshutdown()is exposed via the new narrowRouteDestructiveTeardownPort(one method). Impl on the concrete controller handle lives inlifecycle/adapters/route_ordering_impl.rs. Kept here for the historical record.
Port → adapter type leak (Tier C C1):
- ✅
ReloadExecutorPortreferencesPreparedRoutefromlifecycle::adapters— REMEDIATED (rc-d0pu.3-purge):PreparedRouteis now a thin{ route_id: String }token inlifecycle/domain/route_compilation.rs; the heavyManagedRouteis staged internally onDefaultRouteController.prepared_staging. Theport_traits_do_not_import_from_adapter_ringallow-list is now[]. Kept here for the historical record.
Domain ring framework field types — REMEDIATED (rc-d0pu.3-purge): health_registry + datasource were collapsed to single-ring adapter modules (stateful infra + framework-typed value types are correctly labeled Interface Adapters, no false "domain" ring remains). Kept here for the historical record.
Pre-1.0 deprecation removals (Tier C C5):
camel_core::lifecycle::ports::*glob-shim re-export (crates/camel-core/src/lib.rs:140-144, formerlylifecycle/ports.rs) — REMOVED (rc-rfr9): the compatibility shim that re-exportedlifecycle::application::ports::*at the old Tier B path during the slice-homes transition. Canonical replacement:crate::lifecycle::application::ports::*. Pre-1.0#[deprecated]-item removal with a canonical replacement available since Tier B; conventional pre-1.0 practice. NOT a wire-format break (no serialized form changes).camel_core::lifecycle::domain::LanguageRegistryError(lifecycle/domain/mod.rs:6-10) — REMOVED (rc-rfr9): the#[deprecated]re-export ofLanguageRegistryErrorat thelifecycle::domainpath. Canonical replacement:camel_core::LanguageRegistryError(re-exported atlib.rs:115fromcrate::language_registry). Pre-1.0#[deprecated]-item removal with a canonical replacement available since Tier B (rc-d0pu.2); conventional pre-1.0 practice. NOT a wire-format break (no serialized form changes). Completes the remediation recorded in the entity-purity gap forLanguageRegistryErrorabove.
5. Ring → module mapping
The four Clean Architecture rings map to camel-core modules as follows (the decomposition unit is the vertical slice):
| Module | Classification | Ring |
|---|---|---|
context.rs | composition root (thinned in C2) | Frameworks & Drivers |
context_builder.rs | composition root (wiring) | Frameworks & Drivers |
health_registry.rs | single-ring adapter module (stateful probe registry: RwLock<HashMap> + CancellationToken + Duration fields; check_all use-case orchestrates tokio::timeout + futures::join_all) | Interface Adapters |
datasource.rs | single-ring adapter module (DashMap<CacheKey, OnceCell<Handle>> + RwLock<HashMap<PoolFactory>> fields; get_pool/resolve_factory orchestrate lazy pool creation + health wiring) | Interface Adapters |
startup_validation.rs | single-ring slice (has trait, zero I/O) | Entities |
template.rs | single-ring slice (TemplateRegistry Mutex store) | Interface Adapters |
language_registry.rs | single-ring slice | Use Cases |
component_metadata_catalog.rs | single-ring adapter ("thin wrapper") | Interface Adapters |
registry.rs | single-ring (private shared-infra types) | Interface Adapters |
claim_check/ | single-ring (already ring-stable; out of Tier C scope) | Interface Adapters |
idempotent/ | single-ring (already ring-stable; out of Tier C scope) | Interface Adapters |
Consequences
- The boundary test (
hexagonal_architecture_boundaries_test.rs) is extended to cover root/shared slices and the CQRS read-path exceptions, not only the lifecycle tree. - New bounded contexts must declare their CQRS flavor in §3 before adding command/query handlers.
- This charter does not supersede ADR-0002 or ADR-0003 — it frames them crate-wide and clarifies the CQRS consistency model.
- Cross-cutting vocabulary introduced here (vertical slice, bounded context, synchronous-projection
CQRS, module-discipline ceiling) also lands in
CONTEXT-MAP.mdKey Terms per the project's term-landing rule.
Self-grill record
Questions generated:
- [glossary] Do the charter's terms conflict with / duplicate existing CONTEXT-MAP Key Terms?
- [sharpen] Is "strong-consistency CQRS" precise, or does it collide with CAP-theorem usage?
- [scenario] §1 forbids Serde in entities; §5 maps
RuntimeEvent(which derives Serialize) to Entities — does a constructed cross-check break? - [cross-ref] Does the code today implement §3's claim, or is §3 describing design intent?
Answers (with citations):
- [glossary] No conflict — CONTEXT-MAP Key Terms (
CONTEXT-MAP.md:74-90) define Message, CircuitBreaker, Supervision, etc. but NOT vertical-slice / bounded-context / CQRS-flavor. The terms are new cross-cutting vocabulary; per the term-landing rule (CONTEXT-MAP.mdterm-landing rule) they must also land in Key Terms. → added a Consequences bullet. - [sharpen] "Strong consistency" collides with CAP/linearizability usage. Canonical term is synchronous-projection CQRS (projection updated in the same UoW as the command). → §3 table + paragraph sharpened.
- [scenario] Constructed check: reader reads §1 ("no Serde in entities"), opens
domain/runtime_event.rs, finds#[derive(Serialize)], cross-refs §5 mapping → contradiction with no exception listed. → addedRuntimeEvent/DomainErrorto §4 entity-purity gaps. - [cross-ref] §3 matches the design contract (ADR-0018: "command handling, projections, event publication, and journal replay stay consistent";
RuntimeUnitOfWorkPortexists). The code has the two CQRS shortcuts already listed in §4. §3 = contract, §4 = gaps; made explicit with a callout block.
Outcome: refine — terminology sharpened, entity-purity gap reconciled, contract/implementation split made explicit, term-landing consequence added.
Post-grill verification: path-checking the cited files revealed the thiserror coupling belongs to LanguageRegistryError (error.rs:33), not DomainError (which is already clean via manual impl std::error::Error). Body corrected to match; this is the kind of imprecision the cross-ref technique is meant to catch.
Self-grill mode: self-grill-proposals skill
ADR-0046: Apache Camel as design-inspiration corpus, not conformance authority
Date: 2026-07-17 Status: Accepted Amends: none Cross-refs: epic rc-ca8z (the positioning decision this ADR codifies at the architectural level), ADR-0019 (ExceptionDisposition — basis for D2 divergences), ADR-0024 (PipelineOutcome — replaces CamelError::Stopped), ADR-0025 (outcome-aware structural EIPs), ADR-0032 (Exchange-data trust boundary), ADR-0033 (security defaults — policy-ADR precedent)
Decision
Apache Camel is a design-inspiration corpus, NOT a conformance authority. Decisions about what an EIP should do are designed against the project ADRs, not against the behavior observed in Camel. Camel stays valuable because it encodes 20 years of real production edge cases. But it is input to design, not an acceptance spec.
Consultation protocol (mandatory for new EIPs or major redesigns)
When you design or implement a new EIP, or substantially redesign an existing one:
-
Trigger by divergence density. Apply the full protocol only if the EIP touches at least one ADR that breaks conformance with Camel. Operational markers:
- Stateful EIP (aggregation, correlation, repositories)
- Timing with completion or timeout (aggregate completion, resequencer)
- Divergent control-flow (ADR-0019 ExceptionDisposition, ADR-0024/0025 PipelineOutcome, Stop EIP)
- Trust-boundary impact (ADR-0032 — untrusted data in sinks or numeric decisions)
- Backpressure or admission (ADR-0044 — Camel has no
poll_ready)
For stateless EIPs that are nearly identical to Camel (Filter, Content-Based Router, Throttle, SetBody/SetHeader), a pure coverage audit is enough. You do not need to read Camel.
-
Dose: 3 tests, not 5. Read 3 representative tests from
apache/camel/<comp>/src/test/java/.... Stop when 2 consecutive tests add no new scenario. A full table of 5 or more tests gives flat marginal value. -
Classify while you read. No separate tabulation phase. For each test, extract (a) the scenario, (b) the EIP invariant exercised, and (c) the decision: same or diverges. Document divergences inline in the EIP ADR (if one exists), or in the crate
CONTEXT.md(if it is cross-EIP). -
Native tests, not translations. Write tests with the project harness (
CamelTestContext,MockEndpoint, etc.) that assert our semantics. Never translate asserts literally. Literal translation produces invalid green or spurious red. -
KPI: divergences-documented/EIP, not bugs/hour. Bugs found by this protocol are bugs a coverage audit would also find. The irreplaceable value is the divergences forced into documentation. Those appear only when you read the feature space of Camel that we deliberately do not implement.
Context
Epic rc-ca8z fixes the positioning: "a distinct cloud-native runtime with EIP vocab compat ONLY, not a drop-in replacement". The operational consequence — "what does a dev do when they ask whether an EIP should behave like Camel" — was not codified. Without codification, two risks:
- Drift by inertia: a dev ports tests by habit. This produces invalid green (the test passes for the wrong reason) or spurious red (the test fails on behavior our ADRs declare correct).
- Memory loss: divergence decisions are made implicitly and lost in context compaction. They leave cognitive debt for the next contributor.
The spike rc-spt-camel-splitter-spike (branch spike/rc-spt-camel-splitter-spike, commit 8d31e74a) produced concrete evidence:
- 2 divergences forced into documentation (D1
parallelAggregate()does not apply architecturally —join_allis sequential; D2 aggregation receivesErr(e)in aVec, not an Exchange with an attached exception — ADR-0019). Both are decisions that only reading Camel reveals. - 1 real bug (G3
CAMEL_SPLIT_SIZEnever set on the last streaming fragment). This is a coverage gap a coverage audit would find. - 3 pinned pre-existing invariants (G1 unique IDs per fragment, G2 split JSON array, streaming semantics). Coverage, not bugs.
The spike also confirms that an automatic cargo xtask port-camel-test would: produce invalid green on parallelAggregate() (semantics that do not exist in rust-camel); produce spurious red on testSplitterWithException (Camel passes the failed exchange to the strategy; we return Err in the Vec); and lose G1 and G3 (asserts with no direct translation). Automatic porting institutionalizes the error of confusing "Camel does X" with "X is correct".
Consequences
Positive
- Divergence decisions survive context compaction once they land in tracked ADRs or
CONTEXT.md. - A new dev does not ask "why does rust-camel not have
parallelAggregate()?". The answer lives in the EIP ADR or CONTEXT. - Predictable design cost: 3 tests per divergent EIP, no more.
- Measurable and honest KPI: divergences-documented, not false coverage positives.
Negative
- Design time per divergent EIP (reading, classification, documentation). Accepted as the cost of being a reinvention, not a port.
- Risk of over-applying the protocol to stateless EIPs where an audit was enough. The density trigger mitigates this, but it needs judgment.
Scope (not retrospective)
The protocol applies to new EIPs or major redesigns after this ADR. It does not apply retrospectively to stable ADRs (for example ADR-0006 Script EIP, ADR-0019 error handling). For existing EIPs, consulting Camel is optional, and only when a concrete design question emerges.
Anti-patterns
- "Camel does X, therefore X is correct." Our ADRs prove otherwise. ADR-0024 calls the
CamelError::Stopped/HTTP-204 model a bug that Camel-the-design induces. ADR-0032 calls the Camel trust model a rejected security risk. Camel is a starting point, not an oracle. - Translate asserts literally.
expectedBodiesReceived(...)in an error-handling test assumes the Processor-chain model. The equivalent assert in rust-camel depends on the appliedExceptionDisposition. It is not a translation; it is a re-derivation. - Treat ported green as coverage. A ported test that passes through accidental coupling to semantics we do not share does not validate the correct invariant.
- Automate porting "to scale". Scaling the error does not correct it. The discipline of deciding divergence per EIP is not parallelizable or automatable. It is the design work that makes rust-camel a reinvention and not a port.
- Document divergences in ephemeral docs. Spike docs live under
docs/*(gitignored by policy). Documented divergences must land in tracked docs (ADRs,CONTEXT.md, or asnotesin bd referencing the ADR or CONTEXT), not in gitignored artifacts.
Rejected alternatives
cargo xtask port-camel-test: rejected on the Splitter spike evidence (see Context). It would produce invalid green, spurious red, and loss of non-translatable invariants.- Unified conformance TCK: none exists for Apache Camel, and it would break the positioning decision of epic rc-ca8z.
- Forbid reading Camel: excessive. It loses the real value (20 years of production edge cases). The protocol captures that value without becoming an acceptance spec.
- Ad-hoc policy without an ADR: each dev decides alone. This reproduces the two Context risks (drift and memory loss).
Measurement
The KPI divergences-documented/EIP applies as follows:
- For each EIP under the protocol, register findings in bd with
discovered-from: <EIP-issue>and label themdivergence,gap-coverage, orpin-invariant. - Close the bd once the divergence lands in a tracked doc (new ADRs, amendments, or
CONTEXT.mdupdates). - Protocol health metric: ratio
divergences / (divergences + gaps)per EIP. If it tends to 0, the EIP did not diverge and the protocol over-invested. Next time, apply a coverage audit.
Evidence
- Splitter spike: branch
spike/rc-spt-camel-splitter-spike, commit8d31e74a. Spike doc (gitignored):docs/spikes/camel-splitter-conformance-spike.md. - Oracle consultation (e_opus): 2 passes, session
ses_08fc0fd19ffei7uuZcFoOrbnyq. Verdict: the protocol is validated by divergences (D1/D2), not by bugs (G3 — coverage). - bd follow-ups:
rc-0dgq(D2 doc incrates/camel-processor/CONTEXT.md).
Self-grill record
Questions generated:
- [glossary] Does "Camel inspiration corpus" use terms that collide with CONTEXT-MAP entries?
- [sharpen] How do we operationalize "divergence density" so it is not subjective?
- [scenario] Does the protocol apply retrospectively to stable ADRs (for example Script EIP ADR-0006)?
- [cross-ref] Is the spike evidence traceable or ephemeral?
Answers (with citations):
- [glossary] No CONTEXT-MAP entry covers Camel as authority. "Documentation Authority & Refresh" (
CONTEXT-MAP.md:127-152) lists source code, then ARCHITECT.md, then CONTEXT-MAP, then README. Camel is outside the list. The ADR is consistent: it codifies that Camel stays outside the authority order. - [sharpen] Operationalized with 5 markers: stateful, timing/completion, divergent control-flow (ADR-0019/0024/0025), trust-boundary (ADR-0032), backpressure (ADR-0044). At least 1 marker triggers the full protocol; 0 markers triggers a pure audit. Reflected in Decision section 1.
- [scenario] Not retrospective. Clarified in the "Scope" section. ADR-0006 does not require re-applying the protocol. The protocol applies to new EIPs or major redesigns after this ADR.
- [cross-ref] The spike doc is gitignored (
docs/*policy, verified at.gitignore:3). Detected drift: divergences documented in spike docs would be lost. Fix: "Anti-patterns 5" forces divergences to land in tracked docs. rc-0dgq is already open for D2.
Outcome: refine (applied) Self-grill mode: self-grill-proposals skill
ADR-0047: MiniJinja Template Rendering Language
Date: 2026-07-18 Status: Accepted (implementation pending in bd rc-ao5) Issue: bd rc-ao5 Amends: none Cross-refs: ADR-0030 (Exchange-aware extension hooks), ADR-0033 (fail-closed security defaults), ADR-0039 (configurable resource caps), ADR-0046 (Apache Camel as design input, not conformance authority)
Context
rc-ao5 began on 2026-05-31 as an HTML/SSR processor for HTTP routes. The
2026-07-18 investigation found a broader structured-output gap: LLM prompts,
HTML, email/documents, HTTP producer bodies, and OpenSearch JSON DSL all need
conditional sections, loops, filters, and multiline output. LLM is a strong
existing consumer because the producer builds prompts and message history from
the Exchange (crates/components/camel-component-llm/src/producer.rs:164-186, 228-252) and the rendered prompt affects cache identity
(crates/components/camel-component-llm/src/producer_cache.rs:266-325).
OpenSearch is a weaker consumer because rendered JSON still requires an
explicit JSON unmarshal before its producer accepts the body
(crates/components/camel-opensearch/src/producer/mod.rs:287-298).
Inline source is sufficient for prompts, JSON snippets, and HTML fragments, but
not for the original full-page SSR case. A page containing shared layout,
styles, navigation, and footer must remain an external resource. Consequently,
the inline Language is one delivery slice of rc-ao5, not by itself completion
of the original use case.
This is not a missing Step or DSL shape. transform: is already an alias for
set_body:, and SetBodyConfig already accepts generic language and source
(crates/camel-dsl/src/route_ast.rs:426-464). The canonical API model is
LanguageExpressionDef { language, source }
(crates/camel-api/src/declarative.rs:16-25). Existing simple:, rhai:,
jsonpath:, and xpath: fields are compatibility conveniences, not a pattern
to extend.
Simple remains the right language for interpolation and predicates. It has
comparison plus binary and/or, but no unary not token or production
(crates/languages/camel-language-simple/src/parser.rs:38-42,69-81,379-421),
and its evaluator deliberately excludes arithmetic and string-concatenation
operators (crates/languages/camel-language-simple/src/evaluator.rs:84-93). A
rendering language fills a different role: producing structured text with
blocks, loops, filters, macros, and context-aware escaping.
The proposal is ADR-worthy: backend and crate boundaries are costly to reverse, the Language/Component split is non-obvious, and viable engines and placements have materially different security and lifecycle properties.
Decision
Adopt Option C, staged.
Phase 1: inline minijinja Language
Add crates/languages/camel-language-minijinja, backed by MiniJinja. It
implements the existing Language, Expression, and Predicate contracts
(crates/languages/camel-language-api/src/lib.rs:21-67). Route usage is:
set_body:
language: minijinja
source: |-
{% autoescape "html" %}
<h1>Hello {{ headers.name }}</h1>
{% endautoescape %}
No template: field and no template: Step are added. Runtime route
resolution calls create_expression/create_predicate once and stores the
compiled object (crates/camel-core/src/lifecycle/adapters/step_resolution.rs:59-86);
the MiniJinja AST/environment therefore belongs to that compiled object and is
reused for every Exchange.
Phase 1 accepts inline, configuration-authored source only. It does not install
a template loader and cannot read files, resolve URIs, or perform network I/O.
Rendering is CPU-local. MiniJinja 2.21 exposes a synchronous render API; that is
compatible with the async Language SPI because Phase 1 performs no blocking
I/O. Async resource acquisition must not be hidden inside Expression::evaluate.
Template source is never selected from an Exchange body, header, or property.
Escape-mode contract
There is no global HTML default. Each rendering expression must explicitly
select its output context with a top-level MiniJinja autoescape block:
"html", "json", or "none". Phase 1 rejects a rendering template without
an explicit top-level mode. none is an explicit operator decision, not an
implicit fallback. URL fragments use MiniJinja's urlencode filter; there is no
generic shell or SQL escaping mode.
This keeps the generic language/source DSL unchanged while preventing an HTML
default from corrupting JSON, URL, prompt, or plain-text output. It also avoids
extension-based inference: inline templates have no trustworthy filename.
Security model
- Template source comes from route configuration only. Message headers cannot
replace source or select a template resource. An external source URI declared
on a template Component Endpoint is configuration; bytes arriving through an
inbound Message are not. Apache Camel's FreeMarker, Mustache, and Velocity
components use the same default
(
allowTemplateFromHeader=false) because header-selected templates cross an untrusted-data boundary. - Undefined values are errors. The language configures MiniJinja strict
undefined behavior; missing
body, header, or property paths fail evaluation rather than silently rendering an empty string. - Template context exposes only
body,headers, and Exchange properties. CamelContext, registries, filesystem, network, environment variables, and arbitrary host objects are not exposed. - Template source bytes, serialized context bytes, execution fuel, recursion
depth, and output bytes all have non-zero finite defaults. Source/context
limits apply before compilation or rendering; fuel does not substitute for
either.
Body::Streamcontext is rejected with guidance to addstream_cache, whose materialization is independently bounded (crates/camel-processor/src/stream_cache.rs:37-54). Limit exhaustion fails startup, reload, or evaluation; it never truncates and reports success. Limits are configurable with bounded defaults, following ADR-0033 and ADR-0039. MiniJinja'sfuelfeature supplies per-render instruction accounting and its environment supplies a recursion limit; rust-camel supplies bounded input accounting and an output writer. - Loader, dynamic evaluation, and host-capability features remain disabled.
This mirrors Rhai's separation of unconditional capability sandboxing from
configurable DoS limits (
crates/languages/camel-language-rhai/src/lib.rs:6-43, 45-76).
Phase 2: external-template Component
Full-page SSR confirms demand for external templates. Add
crates/components/camel-template under bd rc-64if, linked
discovered-from:rc-ao5; rc-ao5 is not complete until this slice is delivered.
The Component owns route-declared URI/file loading, include resolution, path
policy, bounded source acquisition, compiled-template caching, invalidation, and
hot reload. It renders the current Exchange as context without replacing the
body with template bytes first.
Compiled state is bounded by entry count and total source bytes. Normal requests reuse it without parsing; only a changed resource builds replacement state. Compilation occurs off the active snapshot, and a successful complete build is the sole swap point.
The Endpoint resolves its resource URI at route compilation/startup. Incoming
body, headers, and properties cannot override that URI. Reload reads a complete
bounded snapshot, compiles and validates it, then swaps only a fully compiled
template set; failure preserves the prior set, matching the TLS reload pattern
(crates/components/camel-component-api/src/tls_source.rs:137-140,
crates/components/camel-component-grpc/src/tls_reload.rs:40-60).
MiniJinja's current extension point is Environment::set_loader, a synchronous
loader callback with environment caching. It is useful inside the future
Component, but it is not an async Source trait and does not by itself solve
TOCTOU, traversal policy, cache invalidation, or lifecycle. Those concerns are
why external loading does not belong in the Language crate.
Composing camel-file with pollEnrich remains useful for ordinary content
enrichment but is not the template-loading contract. camel-file returns a
single-consumption Body::Stream (crates/components/camel-file/src/poll_logic.rs:375-422),
and pollEnrich serializes access to one mutable PollingConsumer
(crates/camel-processor/src/content_enricher.rs:86-97). The default strategy
replaces the original body while retaining its headers/properties
(crates/camel-processor/src/enrichment_strategy.rs:24-36); there is no
USE_ORIGINAL DSL strategy
(crates/camel-core/src/lifecycle/adapters/step_resolution.rs:175-185). This
composition would require body preservation, stream_cache, runtime template
compilation, and unverifiable data-provenance assertions. The Component avoids
all five concerns.
Backend selection
MiniJinja is selected because it provides Jinja2-compatible syntax, dynamic Serde-compatible values, strict undefined behavior, configurable autoescape, per-render fuel, recursion limits, a loader extension point for Phase 2, and a small dependency/compile-time goal. These directly match dynamic Exchange data and the Rhai sandbox precedent.
Current evidence does not support two rationales from the initial review:
MiniJinja 2.21 is not async-native, and its public API does not expose a
Source trait. Neither is required by this decision: inline render is
synchronous and loader I/O belongs to the future Component. The previously
stated “same maintainer as Askama” claim is also not used as decision evidence.
Non-goals
- SQL text rendering.
camel-sqlalready binds named, expression, positional, and expanding-IN parameters (crates/components/camel-sql/src/query.rs:37-52,248-265). Rendering SQL text would replace parameter binding with injection-prone string construction. - Replacing Simple. Simple remains the default lightweight interpolation and predicate language.
- Replacing
camel-xsltorcamel-xj. They retain their XML/XSLT and XML/JSON bridge-based transformation contracts (crates/components/camel-xslt/src/producer.rs,crates/components/camel-xj/src/producer.rs). - External includes, inheritance, or hot reload in the Language crate. These require the Phase 2 Component. Inline macros remain available.
- Treating shell command construction as a safe rendering target. No context-free escaping rule can make arbitrary shell text safe.
Consequences
Positive
- One Language crate unblocks LLM prompts, HTML fragments, and inline email/document output; HTTP and OpenSearch can opt in without component coupling. The Component completes full-page SSR.
- Existing
set_body/transformandlanguage/sourcecontracts remain canonical. No schema, AST, or Step proliferation occurs. - Templates compile at route compilation, so syntax and explicit escape-mode failures prevent route startup rather than appearing on first traffic.
- Capability isolation and resource limits are explicit, testable contracts.
Negative
- External templates require Phase 2, so users see a Language crate for inline rendering and a Component crate for resource lifecycle.
- Strict undefined values and mandatory escape declarations reject templates accepted by permissive Jinja deployments; migration is intentional.
- Output-byte accounting needs a rust-camel bounded writer in addition to MiniJinja's built-in fuel and recursion controls.
Neutral
minijinjabecomes a workspace dependency and its selected features become part of dependency and MSRV governance.- Rendered JSON remains text until an explicit unmarshal step converts it to a structured body.
Alternatives Considered
- Add
template:toSetBodyConfig— rejected. It duplicates canonicallanguage/sourceand perpetuates legacy per-language convenience fields. - Add a
template:Step — rejected. Body-value transformation already belongs toset_body/transform; a second Step creates duplicate compiler, schema, and runtime paths. - Tera backend — rejected. Tera's public render path is synchronous and it lacks MiniJinja's per-render fuel control. MiniJinja is also synchronous in current releases, so synchrony alone is not the discriminator; enforceable execution bounds and lean embedding are.
- Handlebars backend — rejected. Its logic-light model is useful for simple substitution but requires custom helpers for transformations naturally expressed by Jinja filters and expressions. That weakens portability for the LLM/document use cases.
- Askama backend — rejected. Askama derives a template implementation for a compile-time Rust struct or enum. Route-authored templates and dynamic Exchange maps require runtime compilation and runtime-shaped context.
- Put rendering in
BeanProcessor— rejected.BeanProcessordispatches a named method with parameters (crates/camel-bean/src/processor.rs:5-22); it is not the expression-language registry or compilation boundary. - Put rendering in WASM — rejected. It adds serialization/ABI crossings and a broader capability policy to a deterministic in-process text operation. WASM remains available when isolation or non-Rust template code is itself a requirement.
- Put external loading in the Language crate — rejected. Filesystem and URI access add async I/O, traversal policy, TOCTOU behavior, cache invalidation, and reload lifecycle. A loader callback is not a lifecycle boundary.
- Load template bytes with
camel-file+pollEnrich, then render from the Exchange — rejected as the template contract.file:Endpoint paths denote directories and exact selection usesfileName(crates/components/camel-file/src/lib.rs:563-580); the returned body is a single-consumption stream, default enrichment replaces application data, and current DSL exposes onlyuseEnrichedBody/throwOnNoPoll. More importantly, bodies and properties have no intrinsic trust label. A route can copy adversarial Message data into either, so compile-time validation oftemplate_from: property.xwould assert provenance the runtime does not track (crates/camel-api/src/exchange.rs:54-65,115-123).
Self-grill Record
Questions generated:
- [glossary] Does “template” conflict with an existing domain term?
- [sharpen] Are rendering and external resource loading one responsibility?
- [scenario] What happens when one global HTML autoescape policy renders JSON, a prompt, or plain text?
- [cross-ref] Does the current DSL already express this operation, and when is source compiled?
- [cross-ref] Do the recorded MiniJinja API claims match the current public API?
Answers (with citations):
- [glossary] Yes. The route AST already uses “templates” for route-definition
expansion (
crates/camel-dsl/src/route_ast.rs:17-21). This ADR uses rendering Language for inline evaluation and external-template Component for resource lifecycle, avoiding a third undifferentiated “template” concept (CONTEXT-MAP.md:7-13,20-23). - [sharpen] No.
Languagecreates compiled expressions/predicates and evaluates them against an Exchange (crates/languages/camel-language-api/src/lib.rs:21-67); Components own URI-scheme integration and lifecycle (CONTEXT-MAP.md:8,21-23). The proposal is refined into two phases and two crate boundaries. - [scenario] HTML escaping changes quotes, angle brackets, and ampersands; that
can invalidate JSON or alter prompt/plain-text semantics. Conversely,
unescaped HTML permits markup injection. Mandatory per-expression
html/json/nonedeclaration resolves both cases; URL fragments require a context-specific filter. MiniJinja exposesAutoEscape::Html,AutoEscape::Json, andAutoEscape::None(MiniJinja 2.21 API, References). - [cross-ref] Yes.
SetBodyConfig.language/sourcealready exists (crates/camel-dsl/src/route_ast.rs:450-464), and route resolution invokescreate_expression/create_predicatebefore execution (crates/camel-core/src/lifecycle/adapters/step_resolution.rs:59-86). New DSL fields and Steps are dropped. - [cross-ref] Partly. Current MiniJinja has synchronous
Template::renderandEnvironment::set_loader, not an async render API or publicSourcetrait. Fuel, recursion, strict undefined, and explicit autoescape are present. The decision is refined to rely only on verified APIs and to keep loader I/O in Phase 2 (MiniJinja 2.21 API, References).
Outcome: refine — retain MiniJinja and the staged decision; make escape-mode
selection enforceable, separate rendering from resource lifecycle, and remove
unsupported async-native, Source-trait, and shared-maintainer rationales.
Self-grill mode: self-grill-proposals skill
Revision self-grill record (2026-07-18)
Questions generated:
- [glossary] Is an external template resource the same concept as a dynamic Exchange-sourced template?
- [sharpen] Can acquisition, compilation, and rendering share the Language SPI without hiding I/O or provenance?
- [scenario] Does
pollEnrichpreserve application data while supplying a reusable exact-file template under concurrent HTTP traffic? - [cross-ref] Can the DSL and Language SPI represent
template_fromandcontext_fromtoday? - [cross-ref] Does
camel-fileprovide template hot reload?
Answers (with citations):
- [glossary] No. A route-declared Component URI is configuration; an Exchange
body/header/property is runtime Message data. Exchange properties are merely
processor-to-processor storage, not a trust domain
(
crates/camel-api/src/exchange.rs:54-65,115-123). - [sharpen] No.
Language::create_expressionaccepts one static script andExpression::evaluatereceives an Exchange (crates/languages/camel-language-api/src/lib.rs:21-25,50-54). Resource I/O, cache lifecycle, and atomic reload remain Component responsibilities. - [scenario] No.
UseEnrichedBodyreplaces the original body (crates/camel-processor/src/enrichment_strategy.rs:24-36), file content is a single-consumption stream, and the shared poller is mutex-serialized (crates/camel-processor/src/content_enricher.rs:86-97).USE_ORIGINALis not supported. - [cross-ref] No.
SetBodyConfigandLanguageExpressionDefcarry only the existing staticlanguage/sourceform (crates/camel-dsl/src/route_ast.rs:450-464,crates/camel-api/src/declarative.rs:16-25). Adding two MiniJinja-specific fields would reverse the no-DSL-proliferation decision. - [cross-ref] No.
FilePollingConsumerrescans onreceiveand keeps a local seen set (crates/components/camel-file/src/polling_consumer.rs:55-92);ModificationDetectingStreamonly reports a file changed during one read (crates/components/camel-file/src/poll_logic.rs:28-93). It does not compile, atomically swap, or lifecycle-manage template sets.
Outcome: drop dynamic Exchange-sourced Phase 1; refine Phase 2 from optional
future work to the required external-template slice of rc-ao5.
Self-grill mode: self-grill-proposals skill
References
crates/languages/camel-language-api/src/lib.rs:21-67crates/languages/camel-language-rhai/src/lib.rs:6-76crates/camel-dsl/src/route_ast.rs:426-464,500-514crates/camel-api/src/declarative.rs:16-25crates/camel-core/src/lifecycle/adapters/step_resolution.rs:59-86crates/components/camel-component-llm/src/producer.rs:164-186,228-252crates/components/camel-component-llm/src/producer_cache.rs:266-325crates/components/camel-opensearch/src/producer/mod.rs:287-298crates/components/camel-sql/src/query.rs:37-52,248-265crates/components/camel-file/src/lib.rs:563-580crates/components/camel-file/src/polling_consumer.rs:55-92crates/components/camel-file/src/poll_logic.rs:28-93,375-422crates/camel-processor/src/content_enricher.rs:86-97crates/camel-processor/src/enrichment_strategy.rs:24-36crates/camel-processor/src/stream_cache.rs:37-54- MiniJinja 2.21 crate API
- MiniJinja
EnvironmentAPI - MiniJinja
TemplateAPI - Tera 2.0
TeraAPI - Handlebars 6.4
HandlebarsAPI - Askama 0.16 crate API
- Apache Camel FreeMarker component
- Apache Camel Mustache component
- Apache Camel Velocity component
docs/adr/0030-exchange-aware-dataformat-hooks.mddocs/adr/0033-security-defaults-fail-closed-startup-validation.mddocs/adr/0039-configurable-loop-iteration-cap.mddocs/adr/0046-apache-camel-inspiration-not-conformance.md
Revisions
- 2026-07-18: Confirmed external full-page templates as required scope;
rejected Exchange-sourced dynamic templates and promoted the external-template
Component from optional follow-up to required
rc-ao5delivery.
ADR-0048: HMAC attestation provenance — RETIRED
Status: Retired (2026-07-26) Supersedes: Original ADR-0048 (HMAC-SHA256 attestation provenance)
Decision
Retire HMAC-SHA256 attestation signing/verification. Replace with plain
.bless.json files containing {verdict, hash, expert} in text plain.
Rationale
The HMAC threat model was incoherent for this project's topology:
-
Workers share the conductor's environment. All subagents inherit
$ATTESTATION_HMAC_SECRETfrom the same devShell. A worker can compute a valid HMAC — the "only conductor can sign" assumption was false. -
The adversary doesn't exist. Workers are cooperative LLMs executing the conductor's instructions, not malicious actors trying to bypass gates. Cryptographic provenance solves a problem that doesn't occur in practice.
-
Ausence of human ≠ presence of attacker. Autopilot mode (no human supervision) was used to justify the crypto. But autopilot = interactive without pauses, not an adversarial environment.
What survives
hash-artifacts(xtask): kept for drift detection. If artifacts change after blessing, the hash changes, and/applycan detect it. This is process integrity, not cryptographic security..bless.json: plain JSON with{verdict, hash, expert, kind}. No HMAC, no secret, no plugin guard..review.json: plain JSON with{verdict, reviewer, impl_hash}.
What was removed
xtask sign-attestation/verify-attestationcommandsmod attestation(HMAC-SHA256, constant-time comparison, RFC 4231 test).opencode/plugin/attestation-guard.ts(runtime guard)- CI
verify-attestationstep in quality gates $ATTESTATION_HMAC_SECRETin flake.nix and GitHub Actions
ADR-0049: Workspace #[non_exhaustive] Policy for v1.0 Contract Enums
Date: 2026-08-05
Status: Accepted
References: ADR-0002, ADR-0011, ADR-0016, ADR-0024, ADR-0025, ADR-0045
Origin: camel-api quality audit (docs/audits/modules/camel-api-quality-2026-08-05.md, finding I2 / DP-6)
Decision
Public contract enums in the workspace's contract crates are #[non_exhaustive] by
default, applied before the 1.0 API freeze.
Scope
The policy binds the three public contract crates already named in
CONTEXT-MAP.md "CONTEXT.md coverage policy":
camel-apicrates/components/camel-component-apicrates/languages/camel-language-api
Within these crates, the policy applies to a contract enum: any pub enum that an
out-of-crate implementer or caller matches against as part of the stable contract. This
is exactly the surface that turns an additive change (a new variant) into a breaking
change (an external match that no longer compiles).
Concretely, the initial application set (the enums that must gain #[non_exhaustive] — the
mechanical fix is finding I2, executed via the code stream, not this ADR):
| Crate | Enum | Site | Contract role |
|---|---|---|---|
| camel-api | RuntimeCommand | runtime.rs:444 | CQRS command (ADR-0002) |
| camel-api | RuntimeQuery | runtime.rs:565 | CQRS query (ADR-0002) |
| camel-api | RuntimeCommandResult | runtime.rs:542 | CQRS result (ADR-0002) |
| camel-api | RuntimeQueryResult | runtime.rs:579 | CQRS result (ADR-0002) |
| camel-api | RuntimeEvent | runtime.rs:587 | lifecycle event (ADR-0002) |
| camel-api | CanonicalStepSpec | runtime.rs:113 | versioned route contract (ADR-0011/0016) |
| camel-api | CanonicalSplitExpressionSpec | runtime.rs:180 | versioned route contract |
| camel-api | CanonicalSplitAggregationSpec | runtime.rs:199 | versioned route contract |
| camel-api | CanonicalAggregateStrategySpec | runtime.rs:217 | versioned route contract |
| camel-api | CanonicalConcurrencySpec | runtime.rs:281 | versioned route contract |
| camel-component-api | ConsumerStartupMode | consumer.rs:38 | component contract |
| camel-component-api | ConcurrencyModel | consumer.rs:374 | component contract |
| camel-language-api | LanguageError | error.rs:4 | error contract |
CamelError and ConfigValidationError (camel-api/src/error.rs) already carry
#[non_exhaustive] and are guarded by exhaustive variant_name() tests — they are the
reference pattern, not new work.
Rule
- New contract enums added to a contract crate are
#[non_exhaustive]from birth. - Existing contract enums gain
#[non_exhaustive]before the 1.0 freeze (finding I2). - A contract enum deliberately kept exhaustive (a closed set that is itself the contract — see Exceptions) MUST carry a one-line rustdoc note stating why, so the omission is a documented decision, not an oversight.
Exceptions (enums that stay exhaustive by design)
#[non_exhaustive] is a default, not a mandate. An enum is exempt when its closed set
is the contract and an in-crate exhaustive match is a load-bearing safety property:
PipelineOutcome(camel-api/src/pipeline_outcome.rs) — the three-variant setCompleted | Stopped | Failedis the deliberate outcome algebra of ADR-0024. The translation siteinto_tower_result()relies on exhaustive matching; a silent_ =>arm there would be a correctness hole, not forward-compat. If a fourth outcome (e.g.Suspended) is ever needed it is a deliberate, reviewed breaking change, not an additive slip. Stays exhaustive.ExchangePattern(camel-api/src/exchange.rs) — theInOnly | InOutMEP set is a fixed, spec-level dichotomy. Stays exhaustive.- Enums whose exhaustive match is a compile-time safety guard (the
variant_name()pattern): these already work correctly with#[non_exhaustive]because the guard test is in-crate, where#[non_exhaustive]does not force a wildcard arm. They keep#[non_exhaustive]— no exception needed.
The #[non_exhaustive] cost is the forced _ => arm in out-of-crate matches; it has
no effect in-crate, so exhaustive-guard tests and internal executors keep compiling
without wildcards.
Context
Problem
The camel-api audit (2026-08-05) found that 148 of 149 public types carried no
#[non_exhaustive], including the entire CQRS control-plane surface (ADR-0002) and the
Canonical* versioned route contract (ADR-0011/0016). An external implementer of
RuntimeCommandBus::execute(cmd: RuntimeCommand) must match every variant; adding a new
lifecycle command post-1.0 (PauseRoute, DrainRoute) would break every such match — a
major version bump for what should be an additive change.
The same gap exists structurally in the other two contract crates
(camel-component-api, camel-language-api), which expose their own contract enums to
external component and language authors. The question is therefore workspace-wide, not
crate-local.
Why a workspace ADR and not a per-crate triage note
CONTEXT-MAP.md already treats "public contract crates" as one category with shared
obligations (CONTEXT.md coverage policy). API-stability posture is exactly such a shared
obligation: deciding it once, uniformly, prevents three crates from each choosing a
different answer as their audits land. Per the L6 rule "cross-crate semver/API decisions
default to a workspace ADR," this belongs in one authoritative document.
Why not amend an existing ADR
No existing ADR governs enum extensibility. ADR-0002 (CQRS) and ADR-0011/0016
(CanonicalRouteSpec) define what the contracts are, not how they evolve. ADR-0045 is
the camel-core architecture charter — a crate-scoped module-discipline document, not an
API-stability policy for the contract crates. This is a genuinely new decision, so it is a
new ADR.
Relationship to the "no deprecation" policy
ADR-0024 records the project directive "no deprecamos xq no tenemos usuarios" — pre-release,
breaking changes are made cleanly without deprecation shims. #[non_exhaustive] is not in
tension with that: it is a one-time, pre-1.0 investment so that post-1.0 additive
growth stays additive. It is applied now, while breaking changes are still free, precisely
because adding it after the freeze is itself the kind of change we want to stop needing.
Considered options
| Option | Description | Ruling |
|---|---|---|
| A | Resolve as triage note attached to finding I2, camel-api only | Rejected — leaves component-api/language-api to decide ad-hoc; reintroduces the cross-crate drift a workspace ADR exists to prevent |
| B | Blanket #[non_exhaustive] on every public enum in every crate | Rejected — over-broad; forces wildcard arms on internal/runtime enums with no external implementers, hurting maintainability for no semver benefit |
| C | #[non_exhaustive] default on contract enums in the three contract crates, with documented exceptions for deliberate closed sets | CHOSEN — matches the existing "contract crate" category, targets the real external-match surface, keeps closed-set safety enums exhaustive |
| D | Defer until more T1 contract crates are audited | Rejected — the three contract crates are already identified; the freeze is the deadline, and adding the attribute post-freeze is the exact cost we are avoiding |
Consequences
- Mechanical fix (I2) applies
#[non_exhaustive]to the enums in the scope table via the code stream (post-audit triage / conductor-light), not this ADR. This ADR is the policy; I2 is the execution. - In-crate exhaustive matches are unaffected — executors,
variant_name()guards, and test impls continue to match without wildcards, because#[non_exhaustive]only forces a_ =>arm outside the defining crate. - External implementers gain a forward-compat arm requirement — an intentional, one-time ergonomic cost that converts future additive variant growth from breaking to non-breaking.
- Future contract crates (e.g. as new
*-apicrates appear) declare their contract enums under this policy by default; a deliberate exhaustive enum carries the required one-line rustdoc justification. - Deliberate closed-set enums (
PipelineOutcome,ExchangePattern) are documented exceptions; changing their variant set remains a reviewed breaking change, which is the intended semantics. CanonicalConcurrencySpeccodegen note (audit L1): the missing#[ts(rename_all = "snake_case")]is a separate, non-breaking codegen consistency fix and is out of scope for this ADR — tracked with I2's mechanical batch.
Self-grill record
Questions generated:
- [glossary] Does "contract enum" / "non_exhaustive policy" collide with an existing CONTEXT-MAP Key Term or ADR-0045's "module-discipline ceiling"?
- [sharpen] Is this one decision or two — "MUST contract enums be non_exhaustive" vs "WHERE is the policy recorded"?
- [scenario] If
#[non_exhaustive]is added toPipelineOutcome, does theinto_tower_result()exhaustive match break or silently degrade? - [cross-ref] Do
camel-component-apiandcamel-language-apiactually exist with public contract enums, or is the cross-crate claim speculative?
Answers (with citations):
- [glossary] No collision.
CONTEXT-MAP.md:97"module-discipline ceiling" is ADR-0045's camel-core crate-split charter — an internal-layering term, not an API-stability term. The contract-crate category itself is already defined (CONTEXT-MAP.md:161), so this ADR names an existing category rather than inventing one. No existing Key Term covers enum extensibility. - [sharpen] Two questions, both resolved here: (a) the semver decision — contract enums
default to
#[non_exhaustive]— and (b) the recording site — a workspace ADR, because the surface spans three crates (L6 rule #4). The new-ADR criteria hold: hard-to-reverse (removing the attribute post-1.0 is breaking), surprising (a v1.0 crate mostly open is non-obvious), real trade-off (_ =>ergonomics vs forward-compat). - [scenario] It would degrade dangerously if applied blindly — an out-of-crate
_ =>onPipelineOutcomeat the Tower translation boundary could silently mishandle a future variant, which is a correctness hole, not forward-compat.into_tower_result()lives in-crate (camel-api), where#[non_exhaustive]does not force a wildcard, so the compiler still checks exhaustiveness there. Nonetheless, the outcome algebra is a deliberate closed set (ADR-0024), soPipelineOutcomeis listed as an explicit exception — the correct semantics is "changing it is a reviewed breaking change." (camel-api/src/pipeline_outcome.rs, ADR-0024 §Decision) - [cross-ref] Confirmed real, not speculative.
crates/components/camel-component-apiexposesConsumerStartupMode(consumer.rs:38) andConcurrencyModel(consumer.rs:374);crates/languages/camel-language-apiexposesLanguageError(error.rs:4). The cross-crate blast radius asserted in audit finding I2 / DP-6 is verified by mechanical enum enumeration.
Outcome: approve as new workspace ADR (0049) — scope narrowed to contract crates (rejecting the blanket Option B), deliberate closed-set enums carved out as documented exceptions, execution delegated to finding I2's code stream. Self-grill mode: self-grill-proposals skill
ADR-0050: WASM sandbox capability posture
Date: 2026-08-06
Status: Accepted; amended 2026-08-09 (command-adapter exception); implemented
Decision: Option B, selective WASI registration per world
References: ADR-0011, ADR-0014, ADR-0031, ADR-0032, ADR-0033
Origin: audit of camel-component-wasm, findings F-camel-component-wasm-I1 and F-camel-component-wasm-I2
Context
The WASM host exposes two capability surfaces. The first contains the Camel functions from wit/camel-plugin.wit. The second contains the WASI 0.2 interfaces that Wasmtime registers.
WasmCapabilities controls the first surface. The camel_call and camel_poll calls use an allowed-scheme list. An empty list denies all schemes. Policy worlds use WasmCapabilities::denied(). Processor and bean worlds enable the host store explicitly.
The second surface does not follow that posture. The current code calls wasmtime_wasi::p2::add_to_linker_async in all four worlds. The WASI context grants no preopens, environment variables, socket ports, or name resolution. However, the linker advertises the full WASI surface. In addition, the processor, bean, and policy worlds inherit stderr. The source world does not. This difference does not reflect a security policy.
The trust model accepts plugins installed by the operator. The sandbox limits guest defects. However, a capability the host does not need must not appear in the linker. A Wasmtime upgrade or a change to WasiCtxBuilder must not extend capabilities by accident.
Decision
We adopt Option B: selective WASI registration per world.
The host applies these rules:
- Each world has an explicit list of WASI interfaces.
- All four worlds may register
wasi:clocksandwasi:randomwhen their components import them. - No world registers filesystem, sockets, CLI, environment, or stdio by default.
- The source world keeps its
http-listenerinterface. This interface grants no general socket access. - Worlds that have Camel functions use
camel_callfor logging. The host does not callinherit_stderr(). - A new WASI interface requires a per-world grant, a negative test for the other worlds, and documentation in the crate context.
The Camel-function posture follows the same principle. The camel_call and camel_poll schemes use an allowlist. Policy worlds receive no call or store operations. Processor and bean grants stay explicit in WasmCapabilities.
This decision describes the target state. The current code still registers full WASI and keeps the unequal stderr inheritance. The audit findings cover that migration in the code stream.
Amendment 2026-08-09 — command-adapter exception
Commit 8ce1e455 implemented rule 3 by reducing the linker to wasi:clocks + wasi:random. That broke instantiation of every wasm32-wasip2 fixture, because the Rust wasm32-wasip2 target emits a WASI command-adapter component. The command adapter imports the full wasi:cli/* and wasi:io/* surface (environment, exit, stdin/stdout/stderr, terminal handles, and the IO streams/poll/error that back stdio) whether or not the guest uses it. The camel stream<u8> body is a component-model builtin (camel-plugin.wit %stream: stream<u8>); it does not require wasi:io/streams. The wasi:io/* imports come solely from the command adapter.
The toolchain is fixed at wasm32-wasip2. Building pure components that import only the camel host interfaces would require wasm32-unknown-unknown plus wasm-tools, which the project does not use. The owner rejected that path.
The host therefore registers the command-adapter surface that every fixture imports:
wasi:clocks(wall + monotonic),wasi:random(random + insecure + insecure_seed)wasi:io/{error, poll, streams}wasi:cli/{environment, exit, stdin, stdout, stderr, terminal-input, terminal-output, terminal-stdin, terminal-stdout, terminal-stderr}
This surface is registered identically in all four worlds, because every fixture imports the same command-adapter set and per-world denial of an imported instance breaks instantiation before the guest runs. Rule 3 is amended: filesystem and sockets stay unregistered; they are the testable denial boundary. CLI, environment, and stdio interfaces are registered because the command adapter imports them, but the WasiCtx and the WasiCliCtx inside it back them with no resources — empty environment and arguments, closed stdin, sink stdout/stderr, no preopens, no network, no name lookup.
A guest that imports wasi:filesystem/* or wasi:sockets/* still fails to instantiate, because those host implementations are absent from the linker. The regression guard test_no_filesystem_or_sockets_registration keeps that boundary. The WasiCtxBuilder never calls inherit_stderr, inherit_env, preopened_dir, or any network-allow method.
Rule 6 (per-world grant + negative test) remains valid for non-mandatory interfaces. The command-adapter imports are mandatory for the current toolchain; the denial that matters (filesystem, sockets) keeps its negative coverage.
Consequences
Positive
- The linker and the context express the same capability policy.
- Filesystem, sockets, and environment variables do not depend on Wasmtime defaults to stay denied.
- Each future extension leaves a reviewable per-world grant.
- Policy worlds keep a smaller surface than processor and bean worlds.
Negative
- Selective registration couples the host to submodule APIs of
wasmtime-wasi. - Wasmtime updates may require changes across several registrars.
- Guests that use
eprintln!stop working until they migrate to the Camel logging channel. Source guests will have no stderr output.
Neutral
- The memory, instance, table, and epoch limits from ADR-0014 do not change.
- The
http-listenerinterface of the source world stays under ADR-0031. - Operator configuration stays trusted. Exchange data stays untrusted per ADR-0032.
Options considered
Option A: full WASI with denial in the context
Rejected. It has lower immediate cost, but the linker advertises capabilities the host does not intend to grant. Security depends on defaults and on no future change extending the context.
Option B: selective registration per world
Chosen. It keeps compatibility with clocks and random, and removes interfaces guests do not need. The Wasmtime integration cost is acceptable for a verifiable surface.
Option C: remove WASI
Rejected. It is the minimum surface, but it breaks guests compiled with common clocks or random imports. Option B captures most of the benefit without that broad incompatibility.
Relation to other decisions
ADR-0014 unifies configuration and resource limits for the WASM runtime. It does not define which interfaces a guest may import. This ADR decides a different class: the sandbox capability surface. It therefore does not amend ADR-0014.
ADR-0031 defines the source-world lifecycle and its http-listener resource. ADR-0032 defines the trust direction of Exchange data. ADR-0033 requires safe defaults and specific grants. This decision applies those rules to the WASI linker.
Self-grill record
- Glossary: "WASM sandbox capability posture" does not replace Component, Endpoint, or SecurityPolicy. It names the union of two surfaces: Camel functions and WASI.
CONTEXT-MAP.mdrecords the cross-cutting term. - Precision: The decision does not claim all Camel functions are denied by default.
from_scheme_list()enables the store for processor and bean. The empty list denies only call schemes. - Scenario: A guest that imports filesystem will fail to instantiate. That failure is intentional. A guest that imports only clocks and random keeps compatibility.
- Code:
runtime.rs,wasm_plugin_context.rs, andsource_host.rsstill calladd_to_linker_async.runtime.rsstill usesinherit_stderr(). The ADR therefore declares a target state. It does not describe the current code as already conformant.
Outcome: approve Option B as a workspace-wide decision. The decision is costly to reverse, surprising without context, and resolves a real trade-off.
Mode: self-grill-proposals.
ADR-0051: Credential Redaction at Diagnostic Boundaries
Date: 2026-08-06
Status: Accepted
Amends: none
Related: ADR-0012, ADR-0032, ADR-0033
Origin: FC-DEBUG-SECRET-LEAK (rc-c9xo, rc-zb1b) and
FC-SERIALIZE-SECRET-LEAK (rc-xbl1)
Decision
Types that hold credential bytes must not expose those bytes through Debug or
general-purpose Serialize implementations.
Secret scope
Credential bytes include:
- passwords and passphrases;
- bearer, access, refresh, session, and identity tokens;
- API keys and client secrets;
- private or signing key material;
- credential-bearing URLs and connection strings;
- values in a container whose contract permits guest or operator credentials.
Usernames, client identifiers, public keys, certificates, secret hashes, and paths or locations of credential files are not credential bytes. A crate can redact this metadata under a stricter local policy.
Debug rule
A type that holds credential bytes must not derive Debug. It must use one of
these patterns:
- Implement
Debugmanually and replace each credential value with[REDACTED]or omit the field. - Store each credential in a dedicated wrapper whose
Debugimplementation redacts the value. The wrapper must have a regression test.
Each manual implementation must have a regression test. The test formats a unique sentinel and verifies that output does not contain the sentinel.
Zeroizing<String> protects memory after drop. It does not redact formatted
output. A type that contains Zeroizing<String> must still follow this rule.
Serialize rule
A runtime or configuration type that holds credential bytes must not derive
Serialize. Configuration types can derive Deserialize without deriving
Serialize.
A dedicated wire type can serialize credential bytes only when transmission of the credential is its explicit protocol contract. Such a type must not also serve as a configuration, diagnostic, or general-purpose state type. Its docs must name the protocol boundary.
Diagnostic export must use a separate redacted view. It must not reuse a wire serializer that emits credential bytes.
Context
The workspace already has manual redaction in Redis, Keycloak, gRPC, SurrealDB, JMS, SQL, Kafka, and other components. Recent audits found the same failure mode in public token responses and WASM guest state. Kafka also exposes an adjacent serialization vector through configuration derives.
The WASM StateStore shows why field-name checks are insufficient. Its field is
named data, but the store contract permits guest API keys and tokens. The HTTP
TLS finding shows the opposite problem. A field named client_key_path contains
metadata, not private-key bytes.
Debug and Serialize are transitive. A safe outer type can become unsafe when
a nested type adds a credential field. The redaction contract therefore belongs
to the type that owns the credential boundary.
Why This Is a New ADR
ADR-0012 assigns log levels and signal ownership. It does not define which payloads a formatter can expose. This decision also covers panic diagnostics, test output, and serialization outside logging, so an ADR-0012 amendment would be too narrow.
ADR-0032 defines exchange data as untrusted. Credentials can come from trusted operator configuration and still require confidentiality. Trust and disclosure are different concerns, so this decision does not amend ADR-0032.
ADR-0033 governs secure defaults and startup validation. It does not govern diagnostic representation or serialization.
Enforcement
Code review and crate-local regression tests enforce this policy. Existing
cargo xtask lint-secrets scans format and tracing macros for sink-pattern
violations and performs AST-based derive inspection for ADR-0051 consistency.
Types annotated with /// ADR-0051 credential boundary: <classification>
must comply with derive rules for their classification:
manual-redaction: must not deriveDebugorSerialize.redacting-wrapper: may deriveDebug; must not deriveSerialize.protocol-dto: may deriveSerialize; must not deriveDebug.
Zeroizing<T> fields trigger auto-detection: any struct or enum with a
Zeroizing field must carry a manual-redaction classification. Unknown,
malformed, or conflicting duplicate classifications produce violations.
Parse failures hard-fail. The lint is a CI quality gate with non-zero exit
on any violation.
Considered Options
Keep crate-local conventions
Rejected. The same representation bug crossed service and component crates. Local examples did not prevent new derived implementations.
Adopt the policy and add a field-name lint now
Rejected. The known positive and negative examples prove that names do not model the credential boundary accurately.
Adopt the policy and defer only mechanical enforcement
Chosen. Remaining audits can cite one rule now. The T2 sweep can design a lint from a larger verified corpus without delaying the security contract.
Consequences
- Audit findings distinguish credential bytes from file-path metadata.
- Secret-bearing types use manual redaction or a tested redacting wrapper.
- General-purpose configuration serialization cannot expose credentials.
- Explicit protocol DTOs can transmit credentials when that is their sole contract.
cargo xtask lint-secretskeeps its current sink-focused scope until the enforcement revisit.
Self-Grill Record
Questions generated:
- [glossary] Does "credential boundary" conflict with the exchange-data trust boundary or handler-contract boundary?
- [sharpen] Which values are credentials, and does a private-key file path count as credential bytes?
- [scenario] Can a field-name lint catch WASM guest secrets without flagging TLS paths and cancellation tokens?
- [cross-ref] Does the workspace already use the proposed redaction pattern, and do existing ADRs already own this rule?
Answers:
- [glossary] No.
CONTEXT-MAP.mddefines the exchange-data trust boundary as an input-validation rule and the handler-contract boundary as a log-ownership rule. This ADR defines confidentiality at representation boundaries. - [sharpen] Credential bytes grant access or prove identity. A path identifies
a file but does not contain its private-key bytes.
camel-http::TlsConfigstoresclient_key_path: Option<String>, while the auth token responses storeZeroizing<String>token values (camel-http/src/config.rs,camel-auth/src/native_issuer.rs,camel-auth/src/oauth2.rs). - [scenario] No.
StateStorestores arbitrary values underdata, so a name check misses the documentedapi-key = secret-123case. The same check overkeyortokenflagsclient_key_pathand runtime cancellation tokens. These cases require semantic type information (camel-component-wasm/README.md,camel-component-wasm/src/state_store.rs,camel-http/src/config.rs). - [cross-ref] Redis, Keycloak, gRPC, SurrealDB, JMS, SQL, Kafka, and auth types
already implement redacting
Debug. ADR-0012 covers log levels. ADR-0032 covers untrusted exchange data. Neither coversDebugandSerializeconfidentiality across trusted and untrusted sources.
Outcome: refine. Adopt the workspace policy now. Exclude credential-file
paths from the credential-byte rule. Defer only mechanical enforcement. A new
ADR is warranted because disclosure cannot be undone, Zeroizing<String> is a
surprising non-redactor, and the design trades derive ergonomics against
confidentiality and protocol serialization needs.
Self-grill mode: self-grill-proposals skill.
ADR-0052: Diagnostic endpoint exposure posture
Date: 2026-08-06
Status: Accepted
Amends: none
References: ADR-0009 (HTTP co-hosting of API and static routes — data plane), ADR-0032 (exchange data trust boundary), ADR-0033 (safe defaults and fail-closed validation at startup), ADR-0051 (credential redaction at diagnostic boundaries)
Origin: audit of camel-prometheus, finding F-camel-prometheus-I1 (FC-METRICS-EXPOSURE, bd rc-asm9); shared surface with camel-health.
Decision
Diagnostic endpoints — /metrics from camel-prometheus and /healthz, /readyz, /startupz, /health from camel-health — follow the Prometheus scrape model: unauthenticated by default, with TLS and authentication as optional hooks, and with loopback bind preferred by default. Network isolation (NetworkPolicy, firewall) is the operator's responsibility.
A diagnostic endpoint is an HTTP endpoint that exposes operational metadata (route names, error types, traffic volumes, queue depth, circuit-breaker state, liveness and readiness signals) for consumption by observability systems. It is not data plane: it does not process business messages and does not cross the ADR-0032 trust boundary.
Rules
-
Unauthenticated by convention. The endpoint mounts no authentication layer by default. This follows the Prometheus scrape model, where the canonical protection is network policy, not application-level authorization. It is not a business-authz gap: operational metadata is not the surface ADR-0010 protects (pre-pipeline route authorization).
-
TLS and authentication are opt-in hooks. The crate exposes an extension point to wrap the router with TLS (the
axum_server::tls_rustlspattern, as incamel-http,camel-grpc,camel-ws) and/or a bearer-token middleware (axum::middleware::from_fn). Neither is active by default. An operator who runs on an untrusted network enables them explicitly. -
Loopback bind preferred. The bind default should favor
127.0.0.1. A bind to a non-loopback interface (0.0.0.0) is an explicit operator decision and MUST emit awarn!at startup. The warning states that the endpoint is reachable from all interfaces with no application layer restricting it. -
Diagnostic metadata carries no credential bytes. Per ADR-0051, metric bodies and labels and health bodies never leak secrets. This ADR does not relax that rule. Unauthenticated endpoint exposure is acceptable precisely because its content is operational metadata, not credential material.
Scope
This posture binds the service crates that expose diagnostic endpoints (camel-prometheus, camel-health). It does not apply to data-plane components (camel-http, camel-grpc, camel-ws). Those are business inbound and DO mount TLS with certificate hot-reload (see CONTEXT-MAP "TLS cert hot-reload"). The distinction is deliberate: the data plane carries business payload and crosses the trust boundary; the diagnostic plane carries operational metadata and does not.
Context
camel-prometheus builds its axum router with no auth or TLS layer. Its default host is 0.0.0.0:9090 (crates/camel-config/src/config.rs, default_prometheus_host). camel-health shares that surface: its health_router mounts /healthz, /readyz, /startupz, /health with no auth, no TLS, and no middleware. The config-driven path requires enabled = true (default false), but the programmatic path (PrometheusService::new, as the README Quick Start shows) does not inherit that guard.
No prior ADR governs how diagnostic endpoints are exposed. Before we freeze v1.0 we need a recorded decision. Otherwise we ship an information surface with no declared posture. The Prometheus convention (unauthenticated, network isolation owned by the operator) is legitimate and widely adopted. But legitimate is not the same as documented. Without this ADR, a reviewer cannot tell "unauthenticated exposure by design" from "forgot to authenticate".
Options considered
Application-level authentication by default
Rejected. It breaks the Prometheus scrape model. Standard scrapers (Prometheus server, agents) expect /metrics unauthenticated, or with an auth scheme configured on the scraper side, not imposed by the target. Default auth creates operational friction with no real security benefit when network isolation is already present.
Mandatory TLS on diagnostic endpoints
Rejected. It imposes TLS termination overhead and certificate management on single-node and development deployments, where the endpoint sits behind loopback or behind a service mesh that already terminates TLS. The untrusted-network case is covered by the opt-in hook (rule 2), not by a global mandate.
Documented posture with opt-in hooks (chosen)
Chosen. It records the decision (unauthenticated by scrape convention), provides extension points for deployments that need TLS or auth, and prefers loopback bind with a warning on the opt-out. It makes the posture readable in review and leaves the choice to the operator per deployment, with no re-architecture.
Consequences
- The diagnostic endpoints of
camel-prometheusandcamel-healthdocument their unauthenticated posture as a recorded decision, not as an omission. - The bind default should move to
127.0.0.1. A non-loopback bind requires explicit opt-in and emits a startupwarn!(code work, correction stream, bdrc-asm9). - Crates that expose diagnostic endpoints in the future inherit this posture by default and declare any TLS/auth hooks they provide.
- The diagnostic-versus-data-plane distinction is fixed. The data plane mounts TLS with hot-reload (inbound components). The diagnostic plane does not authenticate by convention and offers optional TLS.
- The ADR-0051 redaction rule stays in force. Unauthenticated exposure is acceptable only while the content is operational metadata with no credential bytes.
Self-grill record
Questions generated:
- [glossary] Does "diagnostic endpoint" collide with the "HTTP co-hosting" of ADR-0009 or with the trust boundary of ADR-0032?
- [sharpen] Does "unauthenticated by default" contradict ADR-0033 (fail-closed defaults)?
- [scenario] If an operator binds to
0.0.0.0on an untrusted network, what protects them under this posture? - [cross-ref] Does any existing ADR already cover diagnostic endpoint exposure, so this should be an amendment rather than a new ADR?
Answers:
- [glossary] No collision. ADR-0009 governs the data plane (API routes
http:plus static mountshttp-static:that carry business payload and dispatch precedence). ADR-0032 governs untrusted exchange data crossing into control or resource decisions. A diagnostic endpoint processes no business payload and no exchange data. It exposes read-only operational metadata. It is a distinct third category. - [sharpen] No contradiction. ADR-0033 fails closed on the security choices the operator MUST declare explicitly (dynamic SQL query, per-world WASM capability, gRPC TLS). Unauthenticated exposure of operational metadata is not one of those choices. The canonical protection of the scrape model is the network, not application auth. What this ADR does adopt from the spirit of ADR-0033 is the preferred loopback bind with an explicit warning on the opt-out to non-loopback: the operator chooses to expose more widely, visibly.
- [scenario] Under this posture, they are protected by: (a) the preferred loopback bind default, which requires explicit opt-in for
0.0.0.0; (b) the startupwarn!that flags the wider exposure; (c) the opt-in TLS or bearer-token hook the operator enables for that case. The posture does not authenticate by default, but it provides the mechanisms and the signal for untrusted-network deployment. The network (NetworkPolicy or firewall) stays the primary defense by scrape convention. - [cross-ref] None covers this. ADR-0009 is data plane (API routes plus statics). ADR-0033 is startup validation of config opt-ins, not diagnostic surface exposure. ADR-0051 is credential redaction in representation, and it states explicitly that metrics carry no credentials. The decision is genuinely new: irreversible (v1.0 ships the surface), surprising (an unauthenticated endpoint in a security framework deserves a record), and with a real trade-off (scrape model versus application auth). It is a new ADR, not an amendment.
Outcome: approve as new ADR (0052). Unauthenticated posture by scrape convention, TLS/auth as opt-in hooks, loopback bind preferred with a warning on opt-out. Code execution (bind default, warning, hooks) is delegated to the correction stream (bd rc-asm9).
Self-grill mode: manual (4 L6 principles: consistency with CONTEXT-MAP, conflict with existing ADRs, redundancy with implicit ADRs, correct numbering — 0052 is the next free after 0051).
ADR-0053: WIT Interface Versioning
Date: 2026-08-07
Status: Accepted; implemented
Amends: none
Related: ADR-0014, ADR-0031, ADR-0049, ADR-0050
Origin: camel-wit quality audit, WIT-006 / DP-2
Context
camel-wit defines the component-model ABI between rust-camel and WASM
guests. Its four WIT files currently declare an unversioned
package camel:plugin;. The same unresolved WIT-006 note appears in those
files and in src/lib.rs. rg -n 'TODO\(WIT-006\)' crates/camel-wit verifies
five sites.
Compiled guests depend on package, interface, world, type, and function
identities. A Rust crate release can change without changing that ABI. A WIT
shape change can also break an existing guest even when the Rust API remains
compatible. The Rust crate version and ADR-0049's #[non_exhaustive] policy
therefore cannot express WIT compatibility.
Adding a version after stable guests exist changes package identity and forces a migration without a prior compatibility contract. WIT versioning is thus a v1.0 freeze decision, not post-v1.0 documentation work.
Decision
The camel:plugin WIT package uses an independent package-level SemVer.
- One version covers every interface and world in the package. We do not
version
plugin,bean,authorization-policy, orsourceseparately. - The rust-camel v1.0 release establishes
camel:plugin@1.0.0. Pre-v1 unversioned packages have no compatibility guarantee. - The WIT version does not follow the Rust workspace version. A Rust release that does not change the WIT contract keeps the existing WIT version.
- A change to an existing function, record, variant, enum, resource, import, or export is breaking unless the supported component toolchain proves it compatible in both host and guest directions. Breaking changes increment the WIT major version.
- A proven compatible contract addition increments the minor version. Documentation-only corrections increment the patch version only when a WIT package release needs a distinct identity.
@sinceannotations record the minor version that introduced an element when the supported toolchain can validate them. They supplement the package version and do not replace it.- The host links only package majors that it explicitly supports. It must not silently reinterpret a guest from another major. Supporting two majors requires separate bindings and an explicit migration period.
- Canonical WIT files, generated host bindings, shipped guest examples, and
compatibility tests change in one code change.
rc-aaxetracks the initial1.0.0application.rc-osj0tracks removal of the host's duplicate WIT source.
Consequences
- Package identity detects incompatible guest and host contracts during linking instead of allowing ambiguous runtime behavior.
- WIT evolution can remain stable across unrelated Rust crate releases.
- The package-wide version keeps shared
typesandhostinterfaces coherent across all worlds. - A post-v1 breaking ABI change requires a new package major and host bindings. This cost is deliberate because silently replacing the ABI would break compiled guests.
- The initial implementation changes package identities in canonical files, host bindings, copied files, and examples. It must land before the v1.0 freeze.
Options considered
Defer versioning until after v1.0
Rejected. Adding the first package version after stable guests exist is itself a package-identity break. Deferral would freeze ambiguity into the v1 contract.
Follow every Rust crate version
Rejected. Most Rust releases do not change the WIT ABI. Lockstep versions would signal false incompatibility and couple guest tooling to unrelated Rust work.
Version each world independently
Rejected. The worlds share package-level types and host interfaces.
Independent versions would either duplicate those interfaces or create a
compatibility matrix without a present use case.
Use one independent package version
Accepted. It matches the actual compatibility boundary and permits all worlds to evolve as one contract while remaining independent from Rust releases.
Why this is not an amendment
ADR-0014 governs runtime limits and configuration. ADR-0031 defines source world lifecycle. ADR-0049 governs Rust enum evolution. ADR-0050 governs sandbox capabilities. None defines ABI identity or compatibility across WIT releases. This decision is orthogonal and applies to all WASM worlds, so it needs its own ADR.
Self-grill record
Questions generated:
- [glossary] Does “WIT package version” overlap Rust crate SemVer or the WASM sandbox capability posture?
- [sharpen] Is the compatibility unit one package, one interface, or one world?
- [scenario] What happens if
wasm-exchangegains a field after v1.0 while the package remainscamel:plugin@1.x? - [cross-ref] Can an existing ADR own this decision, or can versioning wait until after the v1.0 release?
Answers:
- [glossary] It is separate from both. The workspace crate version is
0.26.0(Cargo.toml:54), while WIT files are unversioned (crates/camel-wit/wit/camel-plugin.wit:1). ADR-0050 controls granted host capabilities, not package compatibility. - [sharpen] The package is the compatibility unit.
plugin,bean, andsourcesharecamel:plugininterfaces and types (camel-plugin.wit:8-94,camel-bean.wit:12-18,camel-source.wit:9-82). Per-world versions would split shared types. - [scenario] Existing guests compiled against the old record shape can fail to link or lower/lift values correctly. Under this decision, that shape change defaults to a major bump unless compatibility tooling proves both directions safe. It cannot pass as an undocumented additive Rust change.
- [cross-ref] No existing ADR governs WIT evolution. ADR-0049 explicitly
covers Rust contract enums, and ADR-0050 covers the host capability surface.
Deferral fails because adding
@1.0.0later changes the identity consumed by the four Wasmtimebindgen!sites incamel-component-wasm/src/.
Outcome: confirm as new ADR. The decision is hard to reverse after guests compile, surprising without the Rust/WIT version distinction, and resolves a real trade-off between lockstep, per-world, and package-wide versioning. Self-grill mode: self-grill-proposals skill
ADR-0054: #[ignore] Test Classification and Enforcement Policy
Date: 2026-08-07 Status: Proposed Related: ADR-0012 (lint+policy pairing precedent), ADR-0049 (lint+policy pairing precedent), ADR-0053 (the WIT break that exposed the gap)
Context
Problem
cargo test --workspace silently skips #[ignore] tests. ADR-0053's
camel:plugin@1.0.0 change broke 13 buildable WASM tests in
camel-component-wasm that the merge gate never ran. The break surfaced only by
manual inspection during the ADR-0053 review cycle. No lint, no ADR, and no CI
coverage existed for #[ignore] discipline.
Existing project pattern
The project already has an integration-test architecture for external services.
Service tests live in camel-test/tests/ behind --features integration-tests,
self-provision dependencies via testcontainers, and run in CI's
full-tests-linux job. No #[ignore] annotation is involved. The pattern covers
Kafka (KRaft mode, no Zookeeper, camel-test/tests/kafka_test.rs:1-9), Redis
(camel-test/tests/redis_test.rs:1-9), OpenSearch, K3s, JMS/XML/CXF bridges,
and container/marshal integration. The camel-test/Cargo.toml already declares
testcontainers and testcontainers-modules with the redis, kafka,
postgres, and k3s features.
This pattern solves the external-service testing problem without #[ignore]. A
test that needs Kafka, Redis, OpenSearch, or K8s follows the camel-test pattern.
A test marked #[ignore = "requires live <service>"] duplicates that coverage
with inferior ergonomics.
Audit finding
7 inline #[ignore] tests in component crates (camel-kafka,
camel-redis, and others) duplicated existing camel-test coverage. They have
been deleted (commit 4d3810f1). 8 Ollama tests remain genuinely special: they
require a qwen3.5:4b model pull that testcontainers cannot provision.
Decision
The workspace adopts a closed vocabulary of two #[ignore] reason prefixes.
Every #[ignore] in non-test-support code MUST carry exactly one of these
prefixes as its reason string. A test may only be #[ignore] for a prerequisite
that CI cannot cheaply satisfy.
Closed vocabulary
requires pre-built <artifact detail>— a buildable artifact covered by a dedicated CI job. Example: WASM guests. The test file MUST appear inallowlist-ignore.txt(a path consumed by both this lint and the CI job). The CI job builds the artifact, then runscargo test --ignoredon each allowlisted file. Without the allowlist entry, the test will not run in CI.slow test: <description>— a self-contained test that is slow to run and is legitimately excluded from the per-PR gate. The prefix is documentary; a future scheduled job can pick these up by grep.
xtask lint-ignore enforces the vocabulary. It rejects bare #[ignore],
unrecognized prefixes, and requires live specifically with a migration error
pointing contributors to camel-test. The complete grammar and rejection rules
live in the xtask implementation; this ADR defines the policy, not the regex.
External-service tests are not a valid category
Any test that requires an external service (Kafka, Redis, OpenSearch, K8s, a
database, Keycloak, etc.) MUST follow the camel-test + testcontainers pattern
and MUST NOT be #[ignore]. The lint treats requires live as a migration
error, not a valid prefix, because accepting it would codify an anti-pattern
the project already outgrew. The migration message points contributors at
camel-test/tests/ and the existing testcontainers fixtures.
No escape hatch
A contributor who believes their #[ignore] reason does not fit either prefix
must either:
- Reconsider whether the test should exist at all and delete it.
- Reconsider whether the prerequisite can be cheaply satisfied in CI and
migrate the test to
camel-testwith testcontainers. - Propose a new prefix via a new ADR that amends this one.
A catch-all other: prefix or a free-text escape hatch would reintroduce the
exact ambiguity this ADR exists to eliminate. A closed vocabulary with an
escape hatch is not closed.
Considered options
| Option | Description | Ruling |
|---|---|---|
| A | Three-prefix vocabulary with requires live | Rejected — blesses an anti-pattern the project already solved with testcontainers in camel-test. The original ADR-0054 took this path; review rejected it. |
| B | Do nothing | Rejected — leaves the WASM CI gap open and any future #[ignore] ungoverned. |
| C | Docker Compose for live services | Rejected — testcontainers already provides superior self-provisioning, and no separate infra file is needed. |
| D | Delete #[ignore] from buildable tests | Rejected — breaks local dev for contributors without the wasm target installed. The annotation serves a legitimate local-dev purpose; the fix is the CI job, not deletion. |
| E | Closed vocabulary with an // allow-ignore escape hatch | Rejected — a closed vocabulary with an escape hatch is not closed. |
| F | Closed vocabulary of two prefixes (requires pre-built, slow test:) with requires live as migration error | CHOSEN — matches the actual categories in the workspace, gives CI a concrete contract, and rejects the anti-pattern. |
Consequences
- The ABI/contract break class is closed. Any future change that breaks a
buildable WASM test will be caught by the CI job that runs
--ignoredon the allowlisted files. - External-service tests cannot silently hide as
#[ignore]. The lint rejectsrequires liveand points contributors atcamel-test. - CI gains coverage for camel-test integration tests (Kafka, Redis,
OpenSearch, K8s) by wiring them into
full-tests-linux. This is the existing pattern, not new infrastructure. - Ollama tests remain
slow test:— genuinely special because a 4B model pull is gigabyte-scale and minutes long, not cheaply provisionable per-PR. A follow-up bd issue will decide whether to move them to a nightly workflow or keep them asslow test:. - Allowlist maintenance burden for
requires pre-builtis real. The bidirectional lint catches stale entries at merge time rather than letting them accumulate. - No escape hatch means occasional ADR amendments. If a genuinely new
#[ignore]category emerges, it requires a new ADR. This is intentional; the bar for expanding the vocabulary should exceed a single contributor's convenience.
Self-grill record
Questions generated:
- Why testcontainers over docker-compose for external-service tests?
- Why is Ollama special, given testcontainers solves the same problem for Kafka and Redis?
- Why two prefixes and not just one?
- Why delete the duplicate inline tests instead of moving them to camel-test?
- Why not run Ollama tests on a nightly schedule instead of marking them
slow test:?
Answers (with citations):
- Testcontainers over docker-compose.
camel-test/Cargo.tomlalready declarestestcontainersandtestcontainers-modules(kafka,redis,postgres,k3s) as workspace dependencies. The existing Kafka and Redis integration tests self-provision via testcontainers with no separate infra file. Adding a docker-compose layer would duplicate that mechanism and re-introduce a parallel infrastructure definition the project does not need. - Ollama is special because of model weights, not service lifecycle.
Kafka, Redis, OpenSearch, and K3s are stock images testcontainers pulls and
starts. Ollama tests need a 4B-parameter model (
qwen3.5:4b) that must be pulled on first use. The pull is gigabyte-scale, takes minutes, and runs per container, not per test run. Testcontainers has no provision for that class of artifact. The 8 remaining Ollama tests (crates/components/camel-component-llm/tests/ollama_live.rs) are genuinely out of reach of the camel-test pattern. - Two prefixes because they have different enforcement shapes.
requires pre-builtmust be coupled to an allowlist so the CI job knows which files to build artifacts for and run with--ignored.slow test:is documentary: it makes the performance characteristic visible and greppable for a future scheduled job but needs no allowlist because no CI job runs it today. Collapsing the two prefixes would either force everyslow test:to add a no-op allowlist entry, or drop the allowlist fromrequires pre-built, which is the wrong direction. - Deletion over move.
camel-test/tests/kafka_test.rsandcamel-test/tests/redis_test.rsalready exercise the same component surfaces with full lifecycle coverage (broker provisioning, message round-trip, error paths). The inline#[ignore]tests in component crates were truncated skeletons that asserted only that the constructor compiled. Moving them would produce duplicates of already-superior coverage. Deletion removes the dead code without losing any signal. - Nightly is a follow-up, not a prerequisite for this decision. A nightly
workflow that pulls
qwen3.5:4band runs the Ollama suite is a separate infrastructure decision. ADR-0054 records theslow test:classification that makes that future job trivial to implement; whether and when to run it belongs to its own ADR or CI change. The current decision is self-contained.
Outcome: approve as revised ADR-0054. The closed vocabulary drops to two
prefixes, requires live is a migration error, the existing camel-test +
testcontainers pattern is the canonical answer for external-service tests, and
the self-grill record grounds the Ollama exception in a specific artifact
(gigabyte-scale model weights) that testcontainers cannot provision.
Self-grill mode: self-grill-proposals skill
ADR-0055: Publish Topology — No Cyclic Dev/Build-Dependencies on Publishable Crates
Date: 2026-08-11 Status: Proposed Related: ADR-0045 (camel-core architecture charter), ADR-0049 (lint+policy pairing precedent)
Context
Problem
cargo publish resolves [dev-dependencies] and [build-dependencies]
against the crates.io registry during package verification, so weak
edges participate in the topological publish order. A cycle closed only
by weak edges cannot be sorted: the holder cannot publish before its
dev-dep target, and the target cannot publish before the holder. The
workaround was an xtask hack (commit 146c28ee, ~200 LoC) that mutated
each cyclic crate's Cargo.toml on disk at publish time — commenting
out the camel-* dev-dep lines, publishing with --no-verify, then
restoring the original bytes. This carried a real failure mode: a crash
between write and restore left a dirty tree, and the published manifest
silently differed from source.
Diagnostic discovery (the over-breaking bug)
The first analysis reported "25 broken weak edges / 16 cyclic crates"
and proposed mass test relocation. Tarjan SCC analysis of the real
combined normal+weak graph revealed this was an artifact of an
over-breaking greedy loop in resolve_publish_order: the loop
snipped weak edges from any still-unscheduled crate, not only crates
inside a non-trivial SCC. The real topology had two non-trivial
SCCs closed by four weak edges:
- SCC-A:
{camel-builder, camel-component-http, camel-component-ws, camel-core, camel-otel}— closed bycamel-core --dev--> camel-component-http,camel-core --dev--> camel-component-ws, and the mutualcamel-component-http --dev--> camel-core. - SCC-B:
{camel-endpoint, camel-endpoint-macros}— closed bycamel-endpoint-macros --dev--> camel-endpoint(a proc-macro derive-test dev-dep).
The other ~20 "broken edges" were collateral. The test-support
feature dev-deps (e.g. camel-component-api = { features = ["test-support"] })
are non-cyclic — camel-component-api's normal deps never reach back
to the holder — and needed no change.
Decision
A crate published to crates.io MUST NOT declare a camel-*
dev-dependency or build-dependency that closes a publish-order cycle.
Cycle detection is SCC-accurate: resolve_publish_order runs Tarjan
on the combined normal+weak graph and breaks only weak edges whose both
endpoints lie inside a non-trivial SCC, recomputing after each cut, with
deterministic lexicographic edge selection. This makes the
lint-publish-cycles gate and publish --show-cycles diagnostic
truthful (no phantom edges).
camel-test is the publish-order leaf sink: no publishable crate
declares it in any dependency kind, so it is topologically incapable of
joining a cycle. It stays published as the downstream-facing test
utility crate (cargo add camel-test).
Two remediation patterns resolve any cycle that does arise:
- StubComponent substitution — when a real component is incidental
scaffolding (registered only to assert scheme registration or a
count), a local stub implementing the
Componenttrait replaces it. - Proc-macro test relocation to the consumer — a proc-macro crate's derive-integration and trybuild UI tests live in the consumer crate, which already normal-depends on the proc-macro crate (the syn / serde_derive canonical pattern).
The manifest-mutation hack (comment_out_camel_dev_deps and the
strip/restore publish loop) is deleted. publish_crates is a plain
linear topological sort and fails closed if no_verify is non-empty.
(is_weak_dependency_section is retained — it is benign shared
edge-classification logic used by the publish-graph builder and the leaf
guard, not part of the strip/restore hack.)
Forces
- Publish-cycle constraint: crates.io resolves weak edges against published versions; cargo cannot topologically sort a weak-only cycle.
- Test locality vs. topology: tests want to live near the code they test, but a cyclic dev-dep blocks publish. The two remediation patterns resolve the tension with minimal code movement.
- Diagnostic correctness: the over-breaking loop made the lint untrustworthy (it would have failed on 16 phantom crates). SCC-gating is the load-bearing fix that makes the invariant enforceable.
- Rejected manifest-mutation hack: it mutated
Cargo.tomlon disk during publish (dirty-tree failure mode) and produced a published manifest that silently differed from source.
Alternatives considered
- Mass relocation of ~130 files across 8 crates: rejected — SCC analysis proved those crates were not in any real cycle; their "cyclic" edges were phantom output of the over-breaking loop. Pure churn.
*-test-supportsplit crate: rejected —camel-component-apidev-deps are non-cyclic (verified); the feared "I need test-support but dev-depending on it is cyclic" case does not exist in this workspace.- Set
camel-test publish = false: rejected — it is already a true leaf (the phantom edges were the holder direction decoded backwards) and is a downstream-facing public utility. - Keep the strip/restore hack, just add the lint: rejected — the hack's dirty-tree failure mode and published-manifest drift are unnecessary once the lint enforces acyclicity.
Enforcement
cargo xtask lint-publish-cycles (wired into AGENTS.md ## QUALITY GATES) fails when no_verify is non-empty OR when any publishable crate
declares camel-test in any dependency kind. It reuses the same
SCC-accurate resolve_publish_order predicate as
cargo xtask publish --show-cycles.
ADR-0056: Cache Repository Port
Date: 2026-08-11 Status: Accepted References: ADR-0001, ADR-0023, ADR-0028, ADR-0033, ADR-0046, ADR-0049, ADR-0055
Decision
Decision 1: memory-default despite the anchor
The default cache repository is "memory", registered during
CamelContextBuilder::build() with max_capacity = 10_000. This matches the
Idempotent (ADR-0023) and Claim Check (ADR-0028) precedent: every context gets a
zero-dependency in-process backend out of the box.
The motivating anchor (EFFIS/GIBS geo-layer tiles) needs a persistent backend
(redb). The user opts in via [default.cache_repo] in Camel.toml:
[default.cache_repo]
backend = "redb"
path = "data/cache.redb"
stale_retention = "168h"
This registers a second repository named "persistent" alongside the default
"memory". The DSL step selects the repository by name. The memory default is
unchanged — the anchor case is an opt-in override, not a reason to change the
default.
File: crates/camel-core/src/context_builder.rs:233-235.
Decision 2: in-band expires_at everywhere
Every CacheEntry carries an expires_at: Option<SystemTime> field. The
CacheRepository::set method accepts ttl: Option<Duration>, computes
expires_at = SystemTime::now() + ttl, and stores it in the entry. The
CacheRepository::get method checks expiry in-band: if expires_at <= now,
the entry is treated as absent (Ok(None)).
This design has three consequences:
-
No native TTL. Backends (moka, redb, Redis) do not use their own time-based eviction. The cache tier is a size-bounded key-value store; expiry is a correctness layer above it. This is the prerequisite for
peek_stale(Decision 5). -
SystemTime, notInstant.SystemTimeis clock-aware and serializable.Instantis monotonic but opaque — it cannot be stored in redb or compared across process restarts. The clock-skew caveat applies: if the system clock jumps backward, entries may live longer than their intended TTL. This is acceptable for a cache (not a security boundary). -
Serializable.
CacheEntryderivesSerialize/Deserializeso persistent backends (redb, Redis) can store and restore entries without a per-backend serialization layer.
File: crates/camel-api/src/cache.rs:18-25 (CacheEntry struct),
crates/camel-api/src/cache.rs:77-82 (set computes expires_at),
crates/camel-core/src/cache/memory.rs:72-91 (get checks expiry in-band).
Decision 3: moka size-eviction only (no Expiry, no time_to_live)
The MemoryCacheRepository configures moka with max_capacity only — no
expire_after or time_to_live. This is what makes peek_stale work on the
memory tier: moka never evicts by time, so an expired entry stays in the cache
until it is evicted by size pressure or explicitly invalidated.
get() checks expires_at in-band and returns Ok(None) for expired entries.
peek_stale() returns the raw entry from moka without checking expiry, so
callers can read stale data when the upstream is unavailable.
File: crates/camel-core/src/cache/memory.rs:50-54 (moka builder — no
time-based eviction), crates/camel-core/src/cache/memory.rs:72-91 (get
checks expiry), crates/camel-core/src/cache/memory.rs:105-107 (peek_stale
skips expiry check).
Decision 4: mandatory max_capacity on memory default
MemoryCacheRepository::new() requires a max_capacity parameter. The default
registration in CamelContextBuilder::build() uses 10_000. This follows
ADR-0033 (security defaults, D-A5: bounded resource consumption) and the
AggregatorConfig precedent: an unbounded default memory cache is a DoS risk.
The [default.cache_repo] config can override the capacity via
max_capacity = 5000 when backend = "memory". If omitted, the default
10_000 stands.
File: crates/camel-core/src/context_builder.rs:235 (default 10_000),
crates/camel-core/src/cache/memory.rs:44 (constructor requires max_capacity).
Decision 5: retention window != TTL
Persistent backends (redb, Redis) reclaim entries at expires_at + retention,
not at expires_at. The retention window is a configurable duration (default
168h / 7 days) that extends the entry's life in storage after its logical
expiry.
This gives peek_stale post-expiry reach on persistent tiers: an entry that
expired 6 hours ago is still readable via peek_stale if the retention window
is 168 hours. The entry is invisible to normal get() (which checks
expires_at), but the bytes remain in storage for the stale-read fallback.
The memory tier does not need a retention window — moka keeps entries until size-eviction removes them, which is effectively an unbounded retention.
File: crates/camel-core/src/cache/memory.rs:105-107 (peek_stale on memory —
no retention window needed).
Decision 6: no sweep() on the trait
CacheRepository does not expose a sweep() method. Reclamation of expired
entries is per-backend:
- Memory (moka): no sweep needed. Entries are evicted only by size
pressure. Expired entries consume space until evicted, which is acceptable
for an in-process cache bounded by
max_capacity. - Redb: a background sweep task runs at a configurable interval (default
60s) and deletes entries whoseexpires_at + retention < now. - Redis:
EXPIRE/PEXPIREhandles reclamation natively (the entry is deleted at the Redis server level when the TTL expires).
Adding sweep() to the trait would force every backend to implement a method
that is a no-op for memory and Redis. This is contract dishonesty — the trait
should not promise a capability that most backends do not need. The idempotent
and claim check traits set the same precedent: no sweep method.
File: crates/camel-api/src/cache.rs:63-101 (trait — no sweep method).
Rejected alternatives
cache:// component (ADR-0001 + ADR-0046)
A cache:// URI scheme would make caching a component endpoint, following the
Apache Camel pattern. Rejected because:
- A component endpoint implies a Consumer or Producer lifecycle (start, stop, health). Caching is a pipeline step, not an endpoint — it does not consume from or produce to an external system.
- The
cache://scheme would need to be a pseudo-component (no real endpoint), adding complexity to the component registry and URI resolution. - ADR-0046 establishes Apache Camel as inspiration, not conformance authority. The pipeline-step approach is more idiomatic for rust-camel's Tower-native architecture (ADR-0001).
Body as stored type (not Serialize, Stream un-cacheable)
Storing Body directly in the cache would avoid the Vec<u8> + ContentType
split. Rejected because:
Bodyis notSerialize— it containsStreamBody(anArc<Mutex<Option<BoxStream>>>) which cannot be serialized for persistent backends.Streamvariants are inherently un-cacheable: a stream can be consumed only once. Caching a stream would require materializing it first, which is the caller's responsibility (viaStreamCacheService).CacheEntrywithVec<u8>+ContentTypeis serializable, backend-agnostic, and reconstructible intoBodyby theCacheServicestep.
on_no_poll reuse (passthrough, no write-back)
An alternative design would let the cache step pass through the upstream response on cache miss without writing it back (passthrough mode). Rejected because:
- The cache step's contract is "check cache first; on miss, fetch and store." A passthrough mode that skips the store is a different pattern (memoize with expiry, not cache).
- If the caller wants passthrough behavior, they can omit the cache step entirely. Adding a mode that does half the job adds API surface without compositional benefit.
Native backend TTL eviction (breaks peek_stale)
Using moka's time_to_live or Redis's EXPIRE for TTL enforcement would let
the backend handle expiry natively. Rejected because:
- Native TTL eviction removes the entry from storage at expiry time. This
makes
peek_staleimpossible — there is nothing to peek. - In-band expiry (Decision 2) gives the trait control over the expired-but-readable window, which is the foundation of the stale-read fallback pattern.
Extending ClaimCheckRepository with TTL (breaks payload-ownership contract)
Adding TTL-aware set/get to ClaimCheckRepository would let it serve as
a cache. Rejected because:
ClaimCheckRepositoryowns payloads (fullMessagewith headers). A cache stores materialized bytes, not messages. The ownership contract is different: Claim Check returns the payload and removes it (get_and_remove); Cache returns a copy and keeps it.- The two traits serve different EIPs (Claim Check EIP vs Cache EIP). Merging them would couple two independent patterns under one trait, violating the single-responsibility precedent set by ADR-0023 and ADR-0028.
Context
Problem
Before this ADR, rust-camel had no pluggable cache backend. The Cache EIP (cache / cache_invalidate / cache_peek_stale) needed a storage abstraction that supports:
- TTL-based expiry with stale-read fallback.
- Multiple backends (memory, redb, Redis) selectable by name.
- In-band expiry for correctness across backends.
- Size-bounded memory usage (ADR-0033 D-A5).
The Idempotent (ADR-0023) and Claim Check (ADR-0028) repository traits
established the pattern: a trait in camel-api, a memory default in
camel-core, and a NamedRegistry<T> wiring in CamelContext. The cache
repository follows the same pattern with the addition of TTL and stale-read
semantics.
Forces
- Consistency with existing repository traits. The cache trait should
follow the same structural pattern as Idempotent and Claim Check: trait in
camel-api, memory default incamel-core,NamedRegistrywiring. - Stale-read fallback. The EFFIS anchor case needs
peek_stale— read an expired entry when the upstream is unavailable. This drives the in-band expiry design and the rejection of native TTL eviction. - Serializability. Persistent backends need to store entries on disk or
send them over the wire.
CacheEntrymust beSerialize. - Bounded memory. An unbounded memory cache is a DoS risk (ADR-0033 D-A5).
max_capacityis mandatory. - No trait bloat. The trait should not expose backend-specific operations (sweep, compaction). Each backend manages its own reclamation.
Consequences
Trait location
CacheRepository lives in camel-api (crates/camel-api/src/cache.rs:63).
Any crate can implement it without depending on camel-core. Future backends
(Redis in camel-component-redis, SQL in camel-sql) can implement the trait
remotely.
Interface stability
The trait has no #[non_exhaustive] attribute — adding methods would break
existing implementations. The 7-method interface (name, get, set,
peek_stale, invalidate, clear, stats) is considered stable. If a
future backend needs a len() or keys() method, a separate trait or default
method with unimplemented!() can be added.
Default memory backend
MemoryCacheRepository is registered as "memory" with max_capacity = 10_000
in CamelContextBuilder::build(). The replace_cache_repository method allows
overriding the default capacity when [default.cache_repo] backend = "memory"
supplies a custom max_capacity.
No autowiring
The Cache EIP step explicitly names which repository to use (default:
"memory"). No auto-discovery. This matches the Idempotent Consumer precedent
(ADR-0023 §No autowiring).
ContentType is exhaustive-by-contract
ContentType is a closed 4-variant enum (Bytes, Text, Json, Xml).
It carries the exhaustive-by-contract exception note (ADR-0049) because the
CacheService step matches all variants for ContentType → Body
reconstruction. Adding a variant would require updating the match in
CacheService.
File: crates/camel-api/src/cache.rs:29-41.
CacheStats is NOT #[non_exhaustive]
CacheStats is a plain struct — backends construct it with struct literals.
Adding fields is backward-compatible (existing literals still compile with
..Default::default()). No #[non_exhaustive] attribute.
File: crates/camel-api/src/cache.rs:46-56.
Load-bearing citations
| File:line | Element |
|---|---|
camel-api/src/cache.rs:18-25 | CacheEntry struct with expires_at: Option<SystemTime> |
camel-api/src/cache.rs:29-41 | ContentType enum (exhaustive-by-contract) |
camel-api/src/cache.rs:46-56 | CacheStats struct (not non_exhaustive) |
camel-api/src/cache.rs:63-101 | CacheRepository trait (no sweep, no non_exhaustive) |
camel-api/src/cache.rs:77-82 | set computes expires_at from ttl |
camel-core/src/cache/memory.rs:44 | MemoryCacheRepository::new requires max_capacity |
camel-core/src/cache/memory.rs:50-54 | moka builder — size-eviction only, no time-based eviction |
camel-core/src/cache/memory.rs:72-91 | get checks expires_at in-band |
camel-core/src/cache/memory.rs:105-107 | peek_stale skips expiry check |
camel-core/src/context_builder.rs:233-235 | Default "memory" registration with max_capacity = 10_000 |
ADR-0057: HTTP Header Emission Policy
Date: 2026-08-13 Status: Accepted References: ADR-0001, ADR-0024, ADR-0046, RFC 7230, RFC 7231
Decision
Decision 1: Three RFC-derived header buckets
The HTTP component classifies every header name into one of three buckets before it emits a request or response. The buckets come from RFC 7230 and RFC 7231.
Hop-by-hop / framing. The component uses this compatibility hop-by-hop
set (drawn from RFC 2616 section 13.5.1 conventions and the per-section
definitions in RFC 7230). RFC 7230 section 6.1 additionally requires
removal of Connection and every header named by its connection-options.
A proxy must not forward these headers to the next hop:
connection, keep-alive, proxy-authenticate,
proxy-authorization, te, trailer, transfer-encoding, upgrade,
proxy-connection.
proxy-connection is not in the RFC list. Apache Camel and common proxy
implementations treat it as a synonym for connection. This ADR adopts
that convention to avoid leaking the field to the next hop.
Request-only. These headers have meaning only on client requests. A proxy must not echo them back in a response:
host, user-agent, accept, accept-encoding, accept-language,
accept-charset, accept-datetime, authorization, cookie, expect,
from, if-match, if-modified-since, if-none-match, if-range,
if-unmodified-since, max-forwards, range, referer.
proxy-authorization is absent from this list. RFC 2616 section 13.5.1
places it in the compatibility hop-by-hop set. It appears only there.
Server-owned. RFC 7231 section 7.1.1.2 specifies that the origin
server sets the date field. A proxy must not copy a client-supplied
date into a response:
date.
Decision 2: Dynamic Connection-named stripping
RFC 7230 section 6.1 states that a Connection header field can name
additional headers that are hop-by-hop for that connection. The HTTP
component reads every Connection field value and treats each named
token as hop-by-hop in both directions.
The parsing rules are:
- Split each
Connectionvalue on,. - Trim whitespace from each segment.
- Lowercase the segment for comparison.
- Keep only segments that are valid RFC 7230
tokens. Atokenis one or moretcharcharacters. Atcharis an ASCII alphanumeric character or one of! # $ % & ' * + - . ^ _| ~`. - Drop empty or malformed segments. The parser never panics.
- De-duplicate the result, preserving first-seen order.
This makes the hop-by-hop set dynamic. A header that the static list in
Decision 1 does not name becomes hop-by-hop when a Connection field
names it.
Decision 3: Producer outbound direction rule
The producer builds the outbound request from the exchange headers. It excludes a header when any of these conditions hold:
- The header name is hop-by-hop / framing (Decision 1, static list).
- The header name is
content-length. The HTTP client re-derives this field from the request body. - The header name is
host. The HTTP client derives this field from the destination URL. - The header name appears in the dynamic Connection-named set (Decision 2).
The producer forwards request-only headers. A bridging proxy must pass
accept, authorization, and similar fields to the destination.
Decision 4: Consumer response direction rule
The consumer builds the outbound HTTP response from the exchange reply headers. It excludes a header when any of these conditions hold:
- The header name is hop-by-hop / framing (Decision 1, static list).
- The header name is request-only (Decision 1, static list).
- The header name is server-owned (Decision 1, static list).
- The header name is
content-length. The HTTP server re-derives this field from the response body. - The header name is
content-type. The HTTP server re-derives this field from the inferred or user-supplied content type. - The header name appears in the dynamic Connection-named set (Decision 2).
The consumer does not exclude cache-control, pragma, warning,
or via. These are valid response headers. RFC 7231 and RFC 7234 define
them for server-to-client communication. A bridging proxy must pass them
through.
Decision 5: ADR-0024 is not amended
ADR-0024 defines the PipelineOutcome contract. It covers HTTP status,
body, and the Stop signal. This ADR does not change that scope.
ADR-0057 governs header emission only. The two ADRs are complementary and do not overlap.
Rejected alternatives
Strip all non-standard headers
A policy that allows only a fixed allowlist of standard headers would be
safe but rigid. Custom headers (X-Request-Id, X-Correlation-Id,
vendor-specific fields) carry business value in proxy and integration
scenarios. The three-bucket denylist approach removes the fields that
break HTTP semantics and lets all other headers pass.
Forward hop-by-hop headers unconditionally
Some proxies forward connection and keep-alive without harm in
HTTP/1.1 keep-alive pools. This is incorrect per RFC 7230 section 6.1
and breaks when the next hop uses a different transport (HTTP/2, Unix
sockets). The denylist is the standards-compliant choice.
Static-only Connection handling (ignore dynamic tokens)
Ignoring the Connection field's token list would simplify the
implementation. It would also leak connection-specific headers that the
client or server intended to keep local. RFC 7230 section 6.1 makes
dynamic stripping mandatory. This ADR follows the RFC.
Exclude cache-control and via from responses
An earlier draft of the consumer reply logic excluded cache-control,
pragma, warning, and via. These are valid response headers defined
by RFC 7231 and RFC 7234. Stripping them breaks caching directives and
proxy traceability. This ADR explicitly preserves them.
Context
Problem
Before this ADR, the HTTP component had no documented header emission
policy. The producer forwarded the exchange host header to the
destination. The consumer stripped valid response headers such as
cache-control and via. Both behaviours violated RFC 7230 and RFC
7231.
The issues surfaced during a bridge-proxy correctness review (epic rc-vy6w). Four bd issues track the defects:
- rc-eoft: the producer forwards
hostand hop-by-hop headers outbound. - rc-2jj2: the consumer strips valid response headers (
cache-control,pragma,warning,via). - rc-d3o4: the
bridgeEndpointflag does not gate URL resolution. - rc-f0cn: no end-to-end test verifies the bridge header contract.
Forces
- RFC compliance. RFC 7230 section 6.1 and RFC 7231 section 7.1.1.2 define mandatory proxy behaviour for hop-by-hop and server-owned headers. The policy must follow these standards.
- Bridge-proxy correctness. A bridge proxy forwards requests to a destination and returns responses to the caller. It must not leak client-side or server-side headers into the wrong direction.
- Apache Camel as inspiration. ADR-0046 establishes Apache Camel as
a reference, not a conformance authority. The header lists follow the
RFCs first and align with Apache Camel where the RFCs permit
interpretation (e.g.
proxy-connection). - Determinism. The policy must be testable without network access. Header classification is a pure function. The dynamic Connection parser must not panic on malformed input.
- Non-amendment of ADR-0024. The
PipelineOutcomecontract is stable. Header policy must not alter it.
Consequences
Shared classification module
The policy lives in a single classification module
(crates/components/camel-http/src/header_policy.rs). The producer and
the consumer both call the same functions. This prevents drift between
the two emission paths.
Direction-aware exclusion
The producer and consumer use different exclusion predicates. The
producer excludes hop-by-hop, content-length, host, and
Connection-named headers. The consumer excludes hop-by-hop,
request-only, server-owned, content-length, content-type, and
Connection-named headers. Both predicates share the same Connection
parser.
Re-derived framing headers
The HTTP client and server re-derive content-length, content-type,
and host from the actual body and destination. The exchange never
supplies these fields to the wire. This prevents mismatched lengths and
stale host values.
Valid response headers pass through
cache-control, pragma, warning, and via reach the HTTP client.
Caching directives and proxy traceability work as the RFCs specify.
ADR-0024 scope unchanged
This ADR adds header policy. It does not modify the PipelineOutcome
contract. Status, body, and Stop behaviour remain governed by
ADR-0024.
Load-bearing citations
| Source | Element |
|---|---|
| RFC 2616 section 13.5.1 | Compatibility hop-by-hop header set (static members) |
| RFC 7230 section 6.1 | Dynamic Connection-named stripping; removal of Connection and connection-option headers |
| RFC 7231 section 7.1.1.2 | Server-owned date field |
| RFC 7234 | cache-control, pragma, warning, via as valid response headers |
| ADR-0024 | PipelineOutcome contract (status, body, Stop) - not amended |
| ADR-0046 | Apache Camel as inspiration, not conformance authority |
| rc-eoft | Producer forwards host and hop-by-hop headers outbound |
| rc-2jj2 | Consumer strips valid response headers |
| rc-d3o4 | bridgeEndpoint does not gate URL resolution |
| rc-f0cn | No end-to-end bridge header contract test |
| rc-vy6w | Epic: bridge-proxy correctness review |
ADR-0058: Outcome-aware Segment Composition Contract
Date: 2026-08-13 Status: Accepted References: ADR-0024, ADR-0025 Bd: rc-65fs (epic); ADR number reserved rc-zfov.
Context
A demo surfaced three defects that share one missing contract. Outcome-aware
Segments (EIPs that implement OutcomePipeline from ADR-0025, or that reach the
outcome layer through the Tower Result<Exchange, CamelError> adapter) had no
stated rule for what they may report as Completed after their work failed.
The visible defects:
- rc-20yn:
recipient_listreturnedOk(original)when every recipient failed. The adapter wrapped that intoCompleted(original). Thecache:Segment then wrote the inbound body back under the key for the full time-to-live. The corruption was silent ontimer:routes. - rc-n8rc:
Body::Streamwas consumed twice on the error path (the HTTP symptom-masker of rc-20yn). - rc-65yi: the body was lost when
cache_peek_staleran inside ado_trycatch that shared a key with acache:step.
ADR-0025 defined the PipelineOutcome type (Completed, Stopped, Failed).
It did not state the body-propagation contract for a Segment whose work produced
zero successes. This ADR pins that contract.
Decision
The invariant
When a Segment's attempted work results in zero successes (an operational
failure), the Segment SHALL report Failed(error). It MAY report
Stopped(exchange) only when the zero-success outcome is an intentional halt
governed by the Stop EIP (ADR-0025 section 3). It SHALL NOT report Completed.
The invariant is outcome-based. It is not body-equality-based. A zero-success
Segment may not return Completed even when its body differs from the inbound
body.
A Segment that attempted no work is not an operational failure. It MAY report
Completed(original). Example: a recipient_list whose expression resolves to
an empty list attempts no recipient call.
Per-Segment definitions
Each governed Segment defines "attempted work", "success", "operational failure", and "intentional halt" as follows.
recipient_list(TowerService<Exchange>, reaches the invariant through theResulttoPipelineOutcomeadapter): attempt = one recipient endpoint call. Success = the call returnedOk. Operational failure = at least one call was attempted and zero calls returnedOk. Intentional halt = none (recipient_listdoes not produceStopped).multicast(OutcomePipeline Segment, parallel sibling ofrecipient_list): attempt = one branch sub-pipeline run. Success = the branch returnedCompleted. Operational failure = at least one branch ran and zero branches returnedCompleted. Intentional halt = a branch returnedStoppedand that halt propagates.cache(OutcomePipeline Segment; propagator, not generator): attempt = theon_misssub-pipeline run on a cache MISS. Success =on_missreturnedCompleted. AStoppedorFailedpropagated fromon_missis not the cache's operational failure. The cache propagates it with no write-back. The cache's own operational failure is a repository error surfaced asFailed(Contract C1 from ADR-0023). Intentional halt = aStoppedpropagated fromon_miss.do_try(OutcomePipeline Segment): attempt =try_bodyrun. Success =try_bodyreturnedCompleted, ortry_bodyreturnedFailedand a matching catch clause ran and returnedCompleted. Operational failure =try_bodyreturnedFailedand no catch matched, or every matching catch re-propagated. Intentional halt = aStoppedfromtry_body(propagated; skips catch and finally per ADR-0025 section 5.1), or aStoppedfrom a catch body.
Cache write-back trust rule
The cache: Segment SHALL write back a body only when on_miss reports
Completed. It SHALL skip write-back when on_miss reports Stopped or
Failed. This is already true at crates/camel-processor/src/cache_eip.rs step
3 (the on_miss outcome match returns Stopped/Failed before any
repository.set call). The rule is the downstream guarantor of the invariant
for the cache-warming pattern. See the eip-cache spec capability.
Last-error determinism
For recipient_list and multicast, the last_error carried by a zero-success
Failed outcome is determined as follows.
- Sequential arm: the last error encountered in iteration order.
- Parallel arm (
recipient_list): the error from the task returned by the lastJoinSet::join_next().awaitcall that completed with an error (completion order). - Parallel arm (
multicast): the error from the highest-index branch that returnedFailed(branch-index order — results are sorted by branch index before the representative error is selected). This is the legacy LastWins semantics.
The parallel-arm error is a representative error in both siblings. It is not
the causally-first error. The two siblings use different selection orders
(completion vs. branch-index), but both yield a representative, not a
causal, error. A test may assert a specific error identity only when it
controls the selection order through a synchronization primitive
(recipient_list) or fixed branch arrangement (multicast).
Multicast outcome
multicast is governed by the zero-success invariant and the Stopped-wins rule.
When at least one branch returns Stopped, multicast propagates Stopped
(ADR-0025 section 3). When zero branches return Stopped and zero branches
return Completed, multicast reports Failed(last_error).
The partial-success aggregation policy is out of scope for this ADR. The current
multicast returns Failed when any branch fails, even when other branches
succeed. This is inconsistent with recipient_list, which aggregates the
successful results on partial success. The inconsistency is tracked as bd
rc-b41j. A future change that reconciles the two siblings SHALL update this
section.
Governed Segments
This ADR is the compliance authority for: recipient_list, multicast,
cache, do_try, split, streaming-split, load_balance.
The normative test scenarios in the segment-outcome-composition spec
capability cover the first four (delivered by the outcome-aware-segment-composition change). The remaining three (split, streaming-split,
load_balance) inherit compliance from this ADR. They are verified separately.
Migration / existing-code alignment
multicast already complies with the zero-success invariant. Verified in
crates/camel-processor/src/multicast_segment.rs: sequential_multicast and
parallel_multicast track last_error, return Stopped on the first
Stopped branch, and return Failed(last_error) when outputs is empty and
last_error is set.
recipient_list is the non-compliant Segment. It is corrected in
outcome-aware-segment-composition Task 2.1, with this ADR as authority. The
fix changes the zero-success path of RecipientListService::call to return
Err(last_error) instead of Ok(original). The existing Result to
PipelineOutcome adapter then yields Failed, and cache: skips write-back.
stop_on_exception defaults to false in RecipientListConfig. This matches
Apache Camel. The default is unchanged.
Consequences
- A zero-success
recipient_listinside acache:on_miss no longer poisons the cache. The cache retains the previously seeded stale entry. do_trycatches keyed onFailedreceive the error. They do not receive a launderedCompleted(original).- A Segment author can determine, from this ADR alone, whether a new Segment implementation is compliant.
Alternatives considered
- Report
Stopped(original)on zero-success. Rejected.Stoppedis reserved for the intentional Stop EIP (ADR-0025 section 3). Using it for an operational error would hide the failure fromdo_trycatches keyed onFailed. - Change
stop_on_exceptiondefault totrue. Rejected. It would break Apache Camel parity. Partial-multicast routes legitimately continue past a failed branch. - Body-equality invariant ("a Segment must not report
Completedwith an unchanged body when its work failed"). Rejected. It would reject legitimate no-op Segments that returnCompleted(original)after no attempted work. The outcome-based rule keys on "attempted work produced zero successes".
Glossary
- Operational failure: a Segment attempted one or more units of work and zero succeeded.
- Intentional halt: a
Stoppedgoverned by the Stop EIP (ADR-0025 section 3). - Representative error: the parallel-arm
last_error. Forrecipient_list, selection is byJoinSet::join_nextcompletion order; formulticast, by highest branch index (legacy LastWins). Neither is the causally-first error.
API reference
Rust API documentation lives in cargo doc, not in this guide. Read the
rustdoc for canonical type signatures, trait contracts, and method details.
Read the API docs locally
Build and open the rustdoc for the whole workspace from the repository root:
cargo doc --open
For a single crate, target it by name. The --no-deps flag skips dependencies
and builds faster:
cargo doc -p camel-api --no-deps --open
The local build always matches your checkout. Use it when you edit the code or track a branch that is not released yet.
Read the API docs online
Published crates are on docs.rs. docs.rs builds the latest released rustdoc for each crate:
camel-api- core types:Exchange,Message,Body,CamelError,Processor,BoxProcessor,PipelineOutcome, the CQRS bus traits, andCanonicalRouteSpec.camel-core-CamelContext, route lifecycle, hot reload, and the component, language, function, and service registries.camel-builder- the fluentRouteBuilderAPI that constructs aRouteDefinitionby method chaining.camel-dsl- YAML and JSON route parsing intoRouteDefinition.
Each component, language, service, and platform crate publishes its own docs.rs
page from its package metadata. Only camel-bench is unpublished.
Where each concern lives
The workspace is a monorepo of focused crates. Pick the crate that matches the question you are asking.
| You want to read about | Open this crate |
|---|---|
| Core types and contracts | camel-api |
| Route authoring in Rust | camel-builder |
| Runtime, lifecycle, registries | camel-core |
| Declarative routes (YAML, JSON) | camel-dsl |
| EIP pattern implementations | camel-processor |
| Inbound and outbound adapters | camel-component-api and components/* |
| Expression and predicate evaluation | camel-language-api and languages/* |
For the full crate map with the domain vocabulary behind each one, see CONTEXT-MAP.md.
Documentation workflow
Build the book locally, keep code examples honest with the include system, follow the page template, apply the voice rules, and run the linters.
Build the book locally
The guide is an mdBook. Build it from the repository root:
nix shell nixpkgs#mdbook -c mdbook build docs
For a live preview while you edit, run the watcher and server:
nix shell nixpkgs#mdbook -c mdbook serve docs --open
The rendered HTML lands in docs/book. That directory is disposable. The build
output is not committed.
The include system
Code examples come from compiled example crates, not from hand-written
snippets. Each snippet is an mdBook include directive that pulls an anchored
region out of a file under examples/. The directive names the file (by path
relative to the page) and the anchor name.
Anchor a region in Rust with comments:
// ANCHOR: first-route
let route = RouteBuilder::from("timer:tick?period=1000")
.to("log:info?level=info&showBody=true")
.build();
// ANCHOR_END: first-route
In YAML, use # ANCHOR and # ANCHOR_END.
For a working directive, open
concepts/routes-pipelines.md and copy its
include line. It pulls the first-route anchor out of
examples/hello-world/src/main.rs. That one anchor backs several concept and
pattern pages. One source, many readers.
This is a drift contract. The include pulls real code from a compiled example. When a Rust API changes, the example stops compiling. The guide cannot drift from the code while the include resolves. To verify the contract, build the book and check the example crate:
nix shell nixpkgs#mdbook -c mdbook build docs
cargo check -p hello-world -p content-based-router
Never hand-write code that duplicates a compiled example. If no example exists
yet, write minimal inline code and mark the fence rust,no_run or ignore.
Page template
Every Enterprise Integration Pattern page follows the same structure. The full
template lives in docs/AGENTS.md. In short:
- A
# <pattern name>heading. - One sentence naming the pattern and its Hohpe and Woolf category.
- An
{{#include}}directive that pulls the route code from a compiled example. - Two to four paragraphs of prose.
- A reference link to the governing crate
CONTEXT.md. - An ADR citation where the page states architectural rationale.
- A link to the example source on GitHub.
Section hub pages (index.md) are navigation aids. They hold one purpose
sentence and a list of child pages with one-line descriptions. No code, no deep
explanation.
Voice and style
Write like a senior engineer talking to a peer. Short sentences, active voice,
concrete examples over abstractions. The full rules, including banned words and
the em-dash policy, are in VOICE.md. Read it before you write
prose.
Linters
Two structural linters run over the guide. Run them before you commit:
cargo run -p xtask -- lint-adr-cite --deny docs/src/verifies every ADR citation resolves to a file underdocs/adr/.cargo run -p xtask -- lint-glossaryverifies the glossary stays consistent with the Key Terms inCONTEXT-MAP.md.
These are link and structure checks. They do not assess prose quality. Prose
quality depends on the ste-writing skill and human review.
Two-source rule
Every durable claim in the guide cites a source it can defend. The two
acceptable sources are a crate CONTEXT.md (the crate authority) or an ADR in
docs/adr/ (the decision record). A claim with no citation is speculation.
Define each domain term once on its canonical page. Link to it from everywhere else. Do not re-explain. If two pages both explain the Exchange model, one of them is wrong.
Publishing
A GitHub Actions workflow publishes docs/book from main. An administrator
sets the Pages source to GitHub Actions once under Settings, then Pages.
Generated HTML is not committed. The output directory is disposable, so future
release-versioned books can stage without changing the source chapter URLs.