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.29"
camel-core = "0.29"
camel-builder = "0.29"
camel-component-timer = "0.29"
camel-component-log = "0.29"
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.
${env:} placeholders in rest: blocks resolve default-only at generate time. String-typed positions take the concrete default in the emitted document; integer- and boolean-typed positions with a placeholder fail generation. The process environment is never read.
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.
${env:} placeholders in rest: blocks resolve default-only at generate time. A placeholder in a string-typed position takes the concrete default in the emitted document. A placeholder in an integer- or boolean-typed position fails generation; this is tree-walk canon parity with boot, lint, and the LEAN tier. A token without a default fails and names the variable. $${env:X} stays literal. The command never reads the process environment.
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.
Catch-all clause (kind: "*")
The kind: "*" clause matches every error kind. Declare it last because
evaluation is first-match-wins. A specific clause declared before it keeps
precedence. Combining kind: "*" with message_contains narrows the clause
by message. Both conditions must hold.
The pattern kind: "*" with handled: true and retry.handled_by gives the
handler route full ownership of the HTTP response. The handler sets the status
through the CamelHttpResponseCode header, the body, and custom headers.
Custom headers must be string-valued.
routes:
- id: "catch-all"
from: "direct:catch-all"
error_handler:
on_exceptions:
- kind: "*"
handled: true
retry:
max_attempts: 1
handled_by: "direct:shaper"
steps:
- bean:
name: "validate"
method: "process"
- id: "shaper"
from: "direct:shaper"
steps:
- set_body:
value: "shaped"
- set_header:
key: "X-Custom"
value: "yes"
- set_header:
key: "CamelHttpResponseCode"
value: "422"
The do_try catch wildcard (exception: ["*"]) is the segment-scoped
equivalent. It applies within a do_try block instead of the whole route.
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 producer shutdown. It is raised in the producercall()when the channel or semaphore 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. - DivertCopyTo —
InterceptActionvariant that copies the exchange to amock:target with WireTap semantics and then runs the real producer. Testing, ADR-0064. - 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. - InterceptRule — exact-URI rule that maps a send URI to a
SkipToorDivertCopyToaction. Testing, ADR-0064. - 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 path templates. The default JSON binding auto unmarshals requests and marshals responses, with JSON Schema validation. Explicitbinding: rawaccepts non-JSON media, injects no automatic data-format steps, sets the declaredproducesas the response Content-Type, and leaves the request asBody::Stream. 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. - SkipTo —
InterceptActionvariant that replaces the original send and routes the exchange to amock:target. Testing, ADR-0064. - 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: the glossary
tracks Key Terms only.
- 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: [{ kind: "..." }] | Match by error kind. |
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 six 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::Xml | XML document |
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(move |exchange: &camel_api::Exchange| {
let mut routed = routed_clone.lock().unwrap(); // allow-unwrap
let key = exchange.correlation_id().to_string();
if routed.insert(key.clone()) {
let dest = exchange
.input
.header("destination")
.and_then(|v| v.as_str())
.unwrap_or("a");
Some(format!(
"log:routed-{}?showBody=true&showHeaders=true",
dest
))
} else {
// Second call on the same exchange: routing is complete.
routed.remove(&key);
None
}
}))
.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.
The recipient list compiles into a plain processor step, not an outcome-aware segment. A branch error surfaces through the same step-error boundary as any other step: the route error handler decides recovery. A partial dispatch failure leaves the remaining branches' results available to the aggregation strategy.
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. In parallel mode, a branch sub-pipeline that panics counts as a failed branch. The block maps the panic to a Failed outcome with a ProcessorError that names the branch. The zero-success and partial-success rules then apply to that branch as a failure. 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: Text, Json, Bytes, Xml, or Empty. 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. Built-in formats are json, csv, xml, and zip. Protobuf ships as the separate camel-dataformat-protobuf crate and must be registered in the data format registry before a route uses it. 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_on_timeout(Duration) flushes it after a period with no new exchange. complete_on_size_or_timeout(size, timeout) combines both triggers. 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-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.
With coalesce_misses: true, concurrent misses on the same key run the on_miss sub-pipeline once. The first miss becomes the leader. The other misses wait and share the leader's body and error. This prevents a stampede when many exchanges miss the same key at once.
The cache_invalidate step removes a key from the repository. Use it when an upstream event makes a cached entry stale. Set key to remove one entry. Set key_prefix to remove every entry whose key starts with that prefix, a namespace purge. Exactly one of the two is required. On success the step sets the CamelCacheInvalidatedCount exchange property. The value is 1 for an exact key and the removed count for a prefix. A backend without key iteration fails closed on key_prefix. The memory backend has no key iteration.
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.
cache_peek_stale on_miss policy
The cache_peek_stale step accepts an on_miss policy. The value "stop" is the default. On a miss the step sets PipelineOutcome::Stopped and the branch stops. The value "continue" passes the exchange through unchanged. Both values set the CamelCachePeekHit=false and CamelCachePeekStale=false exchange properties on a miss.
On a hit the step sets CamelCachePeekHit=true. It sets CamelCachePeekStale=true when the served entry is past its expires_at. A fresh entry sets CamelCachePeekStale=false. The properties enable a stale-while-revalidate (SWR) route: peek with on_miss: continue, branch on CamelCachePeekHit, fetch and cache on a miss, and serve the peeked body otherwise.
- cache_peek_stale:
key: "${header.cacheKey}"
on_miss: continue
- choice:
when:
- simple: "${exchangeProperty.CamelCachePeekHit} == false"
steps:
- set_body: "fresh-data"
- cache:
key: "${header.cacheKey}"
ttl: "5s"
on_miss:
- log: "SWR miss — fresh body stored"
otherwise:
- log: "Serving cached body: ${body}"
cache_clear and cache_stats
The cache_clear step removes every entry from the repository. It takes an optional repository name and defaults to "memory". The body passes through unchanged.
- cache_clear: {}
- cache_clear:
repository: "persistent"
The cache_stats step replaces the body with a JSON snapshot of the repository statistics. It takes an optional repository name and defaults to "memory".
- cache_stats: {}
The snapshot has these fields: repository, hits, misses, evictions, entries, peek_stale_served, invalidations, and bytes. The bytes field is the total stored payload size when the backend reports it. The redb backend reports a size. The memory backend reports null.
Stale-on-error with a circuit breaker
Compose cache_peek_stale with a route-level circuit_breaker to serve a stale entry when the downstream service fails. The fallback list holds a sub-pipeline. The breaker runs the fallback only while the circuit is open.
circuit_breaker:
failure_threshold: 1
open_duration_ms: 60000
fallback:
- cache_peek_stale:
key: "user-profile-42"
The route body wraps the upstream fetch in a cache step that stores the result under a static key. When the fetch fails failure_threshold times in a row, the circuit opens. While open, the fallback runs cache_peek_stale against the same static key and serves the last cached entry, even when that entry is past its TTL. On a miss (no entry), the default on_miss: stop policy stops the fallback cleanly. The exchange completes without a CircuitOpen error.
The fallback runs on routes with and without an error_handler. A failing fallback step follows the route's error handling. A route with an error_handler routes the failure through the handler. A route without one surfaces the raw error. See Circuit Breaker for the breaker states and Route structure for the fallback field.
The control plane also accepts the five cache steps. A CanonicalRouteSpec sent through RuntimeCommand::RegisterRoute supports cache, cache_invalidate, cache_clear, cache_stats, and cache_peek_stale in the route body and in circuit_breaker.fallback. An unknown step still fails with an error that names the step. See Control Bus and ADR-0016.
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.
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. cache_size is required for the redb backend (for example, cache_size = "256MiB"), bounding the redb page cache; sweep_interval is optional (default 1h).
A shared "redis" repository (redis-backed) is available when [default.cache_repo] backend = "redis" is set. Every process that connects to the same Redis shares one cache, so cached entries work across processes. Redis expires each key at expires_at + stale_retention. The redis backend runs no background sweep task. cache_size and sweep_interval do not apply to it. See ADR-0063 for the design.
[default.cache_repo]
backend = "redis"
url = "redis://127.0.0.1:6379"
stale_retention = "30m"
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 and a redis backend in camel-redis-repo (ADR-0063). The trait stores CacheEntry { bytes, content_type, expires_at } with in-band expiry. The memory and redb 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, a route with an error_handler compiles the breaker 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.
Half-open fallback asymmetry
During the probe-in-flight window, concurrent callers behave differently by route shape. A route with an error_handler compiles the breaker into a CircuitBreakerGate. The gate serves the fallback to concurrent callers while the probe runs. A route without an error_handler compiles the breaker into a Tower CircuitBreakerService. That service rejects concurrent callers with CircuitOpen while the probe runs, even when a fallback is configured.
Both behaviors are sound. The gate keeps a single probe in flight and serves stale fallback data. The service keeps a single probe in flight and rejects. The asymmetry is intentional. See Route structure for the YAML form.
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. Two durable backends survive a restart. "redb" stores keys in an on-disk file. "redis" stores keys in a shared Redis keyspace (ADR-0063). Configure the redis backend with [default.idempotent_repo]:
[default.idempotent_repo]
backend = "redis"
url = "redis://127.0.0.1:6379"
The redis repository registers under the name "redis", so a route selects it with repository = "redis". A redis repository also shares deduplication keys across processes.
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(...) step takes an expression string and compiles it to a predicate through the simple language. The included route uses ${body.contains('<order>')}. The log step after the validator runs only when the predicate holds. 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. Schema-file validation (XSD, JSON Schema) runs through the validator: component endpoint (to: "validator:<path>?type=xml"), not the validate step.
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 predicate 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 |
mcp | both | camel-component-mcp |
exec | producer | camel-component-exec |
validator | producer | camel-validator |
xslt | producer | camel-xslt |
xj | producer | camel-xj |
cxf | both | camel-cxf |
keycloak | both | 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.
- MCP. Model Context Protocol server and client.
- 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.
Driving an HTTP producer
A timer exchange carries no CamelHttpMethod header and a non-empty body. An http: producer therefore defaults to POST for such an exchange. A GET-only upstream rejects the request. Set an explicit method on the destination URI:
- to: "http://wfs.example.org/ows?httpMethod=GET"
The same rule applies to any non-HTTP consumer (cron, file, quartz). The CamelHttpMethod header is an alternative. See HTTP method selection for both forms.
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.
Endpoints declared through a rest: block are http: consumers after lowering, and they are secured the same way: the block-level security_policy is copied onto every lowered route, so its roles or scopes policy runs at the request boundary. See Route structure for the block key and examples/rest-crud for the runnable secured variant.
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.
HTTP method selection
The Producer resolves the outbound method in this order: the httpMethod URI option, then the CamelHttpMethod header, then a body-based fallback. An empty body selects GET. A non-empty body selects POST.
An exchange from a non-HTTP consumer (timer, cron, file, quartz) carries no CamelHttpMethod header and usually a non-empty default body. A timer exchange, for example, holds a tick label. Driving an http: producer from such a source therefore selects POST by default. A GET-only upstream then rejects the request, often with a message that does not name the method as the cause.
Set an explicit method when the source is not HTTP. Append httpMethod=GET to the destination URI, or set the header:
- to: "http://wfs.example.org/ows?httpMethod=GET"
- set_header:
key: CamelHttpMethod
value: GET
- to: "http://wfs.example.org/ows"
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 key is rejected: a URI cannot carry a TLS configuration, so tls=true can never be satisfied and fails closed. tls=false still parses and means explicit plaintext. 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 security through the unified transport auth kernel (ADR-0061 Rule 1):
- Authentication. The interceptor extracts credentials per the route plan's credential sources, authenticates via
kernel_authenticate, and installs the typed principal carrier on a fresh Exchange per request in all four RPC modes.AccessMode::Publicskips extraction entirely; missing or invalid credentials returnStatus::unauthenticated(provider-down maps tounavailable). - Authorization. The route's compiled
RouteSecurityPlandrives the pre-pipeline dispatch check: a non-Public route requires the kernel carrier on the Exchange or the dispatch is denied before the pipeline runs. Route-level policies evaluate in the pipeline layer against the carrier principal.
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 the unified transport auth kernel (ADR-0061; see Authentication and authorization). When a route carries a compiled security plan, the upgrade handler authenticates per the plan's credential sources via kernel_authenticate and stamps the typed principal carrier onto every message Exchange; a non-Public route without the carrier is denied pre-pipeline by the strict dispatch check. Failed authentication returns 401 at the upgrade. 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
failover:// URLs are accepted for Classic (activemq) brokers and rejected for artemis (use a single primary URL or multiple broker entries).
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. A TextMessage carrying an explicit ContentType property (for example application/xml from a non-Camel producer, or from this component's own producer) now keeps that value, so such payloads surface as Body::Bytes instead of Body::Text. 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 still returns ready. The call future waits on the semaphore. 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-16383) |
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. This is the secure-by-default posture. The auto-enable check uses the host from the [components.redis] block. An endpoint that names its own host in the URI follows the global decision when it sets no ssl parameter. The component logs a tracing::warn! when auto-enabling TLS. Two opt-outs keep the connection plaintext:
?ssl=falseon the endpoint URI. The endpoint-level value wins over the global config.tls_mode = falsein the[components.redis]block. This forces TLS off for every endpoint that does not setsslitself.tls_mode = trueforces TLS on for those endpoints, even for loopback hosts. Whentls_modeis unset, auto-enable applies.
Sentinel endpoints select TLS by scheme (redis-sentinel:// or rediss-sentinel://). The global TLS settings do not apply to them.
The tls boolean stays as a legacy force-on flag. Set tls = true to enable TLS on loopback hosts. The boolean cannot force TLS off: a plain tls = false keeps the auto-enable for remote hosts. Use tls_mode = false or ?ssl=false for that.
A build without a TLS feature (tls-rustls-webpki-roots, tls-rustls-native-certs, or tls-native-tls) rejects every TLS endpoint at startup with a Config error. The error names the missing feature. The error is never classified as transient, so no retry loop runs. The camel release binary compiles the redis-tls feature by default, so remote Redis works without extra build steps.
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.
TLS CA trust
tls_ca_cert names a PEM file that holds the CA certificate. The field lives in the global [components.redis] block; it is not a URI parameter. apply_defaults() copies the global value onto each endpoint, and an endpoint-level value wins over the global one.
The component reads the file at endpoint creation, and only for a standalone endpoint that resolved to TLS. A plaintext endpoint and a sentinel endpoint ignore the setting without any filesystem access. Sentinel CA trust is follow-up work (bd rc-hbde6).
The component trusts the PEM as the root for the connection. It passes the bundle to the CA-trusting client constructor, so the server certificate is verified against this root. There is no insecure bypass.
An unreadable file fails closed. Endpoint creation returns a Config error that names the path, before any connect attempt. One caveat: the error message contains the path, so a path that itself contains transient-classifier words (for example readonly) can be misclassified by the retry heuristic (bd rc-ezi0f).
Connection handling
The Producer holds a single multiplexed connection per Endpoint. It opens lazily on the first call, and a reconnect drops the cached connection and re-resolves the master through the topology — that re-resolution is where a sentinel failover is picked up. The producer does not re-resolve on every command.
Each Consumer mode — Pub/Sub and queue — holds ONE persistent connection per session. Every message or popped item is delivered over that same connection; the consumer does not reconnect between messages. A blocking-pop timeout (BLPOP/BRPOP returning nil) keeps the connection. The consumer reconnects only when the Pub/Sub stream ends or a transient transport error strikes, and a Pub/Sub reconnect replays all subscriptions. Reconnects are bounded by the configured NetworkRetryPolicy: when the budget is exhausted the consumer returns an error and Route supervision restarts the Route (ADR-0007).
Connections have a 10-second connect timeout by default (connection_timeout_secs in the config block). 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.
Sentinel / failover
Redis Sentinel gives Redis high availability. Sentinel nodes monitor a master and its replicas. When the master fails, the sentinels elect a replica and promote it to master. Clients must re-discover the new master to keep working.
The component connects to a Sentinel topology with the redis-sentinel:// scheme:
redis-sentinel://sentinel-a:26379,sentinel-b:26379/<master-name>/<db>?command=<cmd>[&key=<key>][&channels=<list>]
The authority holds the comma-separated sentinel node list. The first path segment is the master group name. The second path segment is the optional database number. It defaults to 0. The rediss-sentinel:// scheme is the TLS variant. It enables TLS on the sentinel and the resolved master connections.
The runtime resolves a route URI by scheme. Register RedisSentinelComponent for redis-sentinel:// routes and RedissSentinelComponent for rediss-sentinel:// routes. Register them next to RedisComponent:
ctx.register_component(RedisComponent::new());
ctx.register_component(RedisSentinelComponent::new());
// Only for rediss-sentinel:// (TLS) routes:
ctx.register_component(RedissSentinelComponent::new());
RedisBundle::register_all registers all three schemes from the [components.redis] block.
You can also select Sentinel with the [components.redis.sentinel] config block:
[components.redis.sentinel]
nodes = ["redis://sentinel-a:26379", "redis://sentinel-b:26379"]
master_name = "mymaster"
# Optional sentinel credentials.
# username = "sentinel-user"
# password = "sentinel-pass"
nodes holds the sentinel node URLs. master_name is the master group name. The optional username and password authenticate the sentinel connections. The node password stays in the top-level [components.redis] block. The two credential sets are separate.
// Producer: timer writes a key every 3s through Sentinel.
let producer = RouteBuilder::from("timer:tick?period=3000&repeatCount=3")
.route_id("redis-sentinel-producer")
.set_header("CamelRedis.Key", Value::String("greeting".into()))
.set_header(
"CamelRedis.Value",
Value::String("hello via redis sentinel!".into()),
)
.to("redis-sentinel://127.0.0.1:26379/mymaster/0?command=SET")
.to("log:info?showHeaders=true")
.build()?;
Failover behavior
On a transport error, the producer and consumer reconnect loops re-resolve the current master through the sentinel nodes. The resolved master is never cached. Every reconnect asks the sentinels again. This is bounded transport reconnect, not consumer self-supervision. The retry budget comes from NetworkRetryPolicy. When the budget runs out, the consumer returns Err and Route supervision takes over (ADR-0007).
The health check also re-resolves the master through the sentinel nodes on each check. After a failover, the check reports the new master, not a cached address.
Best-effort Pub/Sub
Pub/Sub delivery is best-effort. When a sentinel-triggered stream ends, the consumer re-subscribes to the new master. Messages published during the failover gap are lost. Duplicates are possible on reconnect. Do not use Pub/Sub for workloads that need durability.
Feature flag
The sentinel cargo feature on camel-component-redis enables Sentinel topology construction. Enable it in your manifest:
camel-component-redis = { workspace = true, features = ["sentinel"] }
Without the feature, the component recognizes redis-sentinel:// and a non-empty [components.redis.sentinel] block and rejects them at startup with a clear error. It fails closed. It does not fall back to a standalone connection.
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 sources: examples/redis-example, examples/redis-sentinel.
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 | — | Named datasource from Camel.toml. Omit to use the inline db_url |
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 query operation gates its dynamic query sources behind allow_dynamic_query (default false, ADR-0032). When the gate is off, the CamelSurrealDbQuery header and the Exchange body are ignored, and the Producer runs the query from the Endpoint configuration. Set allow_dynamic_query=true to accept SurrealQL from the header (first priority) or the body (second priority).
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>][&allow_dynamic_query=<bool>]
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 the header, the body, or the Endpoint config (dynamic sources gated by allow_dynamic_query) |
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 |
allow_dynamic_query | no | false | Accept SurrealQL from the CamelSurrealDbQuery header or the Exchange body (ADR-0032) |
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 resolves SurrealQL with this priority: the CamelSurrealDbQuery header, then the Exchange body, then the query configured on the Endpoint. The header and the body are untrusted Exchange data under ADR-0032. The component gates both dynamic sources behind allow_dynamic_query (default false). When the gate is off, the Producer runs the configured query only. The CamelSurrealDbParams header binds $name placeholders.
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 > endpoint config; requires allow_dynamic_query=true) |
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 query operation gates its dynamic query sources behind allow_dynamic_query (default false), the same default-deny posture as camel-sql's allowDynamicQuery. With the gate off, the CamelSurrealDbQuery header and the body are ignored, and the Producer runs the query from the Endpoint configuration. Set allow_dynamic_query=true on a controlled route to accept query text from Exchange data.
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 |
indexName | yes | — | Target index name. Overrides the index from the URI path. 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 | 10 | Maximum retry attempts |
retryInitialDelayMs | no | 100 | Initial backoff delay |
retryMultiplier | no | 2.0 | Backoff multiplier |
retryMaxDelayMs | no | 30000 | Maximum backoff delay |
retryJitter | no | 0.2 | 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() returns ready unconditionally. The call future waits on the semaphore. 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: examples/opensearch-example.
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>]
| 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 is not a URI parameter. Configure it in the provider section of Camel.toml (or the global LLM config). It sets the activity timeout for streaming mode and the total deadline for materialized mode.
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.
MCP
The MCP component connects routes to Model Context Protocol servers. It has two roles. The Consumer role exposes tools and resources on one shared Streamable-HTTP listener per bind. The Producer role sends mcp:call and mcp:read requests to remote MCP servers. The protocol baseline is 2026-07-28, stateless: no initialize handshake, no sessions.
The mcp: DSL block declares a server catalog. It lowers each tool to an mcp:<server>/tool/<name> consumer route and each resource to an mcp:<server>/resource/<name> consumer route:
mcp:
server:
name: crm
bind: 127.0.0.1:9100
security_policy: { roles: [mcp-client] }
tools:
- name: lookup
input_schema: { type: object, properties: { id: { type: string } }, required: [id] }
resources:
- name: customers
uri: crm://customers
URI
mcp:<server>/tool/<name>?schema=<schema>
mcp:<server>/resource/<name>?uri=<uri>
mcp:call?server=<remote>&tool=<name>
mcp:read?server=<remote>&uri=<uri>
Consumer URIs come from DSL lowering. The schema and uri values travel on the query string, never in Exchange headers or bodies. Producer URIs dispatch exactly one JSON-RPC request per Exchange. The producer never auto-loops an LLM call. The route owns every dispatch decision.
Server (Consumer)
One Streamable-HTTP listener serves each bind address. Every tool and resource route on that server shares it. The first consumer on a bind starts the listener. A later consumer with a conflicting config (tls, allowed_hosts, catalog caps) is rejected. Registration of a name or URI held by a live owner is rejected; a dead owner's entry is replaced on restart (ADR-0068).
Security follows the unified transport auth kernel (ADR-0061): a server
without a security_policy starts Public by default, and the per-bind
exposure gate applies — a non-loopback bind serving any Public route
requires a [binds."<addr>"] allow_public_exposure = true acknowledgement
or start fails naming the bind (an acknowledged exposure warns permanently).
The DSL block's security_policy propagates to every lowered tool and
resource route; the kernel authenticates each request per the route plan's
credential sources (normalized RFC 9110 headers) and installs the typed
principal before the pipeline. Catalog caps (max_tools, max_resources,
default 128 each) reject surplus registrations at start.
Remote hosts that announce a protocol version other than 2026-07-28 get a -32022 rejection and one warn! record per event. The server reads no Mcp-Session-Id header.
Client (Producer)
mcp:call?server=crm-prod&tool=lookup sends the Exchange body as tool arguments. The reply content goes to the Exchange body. The CamelMcpResult header carries {"is_error": <bool>, "content": <content>}. The producer does not act on the flag. The route author decides what to do with a failed call.
Configuration
Server runtime config lives in Camel.toml under [mcp.servers.<name>] (bind, tls, security_policy, max_tools, max_resources, allowed_hosts). Remotes live under [mcp.remotes.<name>] (url, transport). A DSL mcp: block names a server; that name must match a TOML key or the consumer start fails. When the block declares bind/tls/max_tools/max_resources, those values ARE the runtime listener values; TOML and the DSL declaring the same key with different values fails startup naming both sources (ADR-0061 Rule 9 — never silent TOML-wins). TOML-only keys (allowed_hosts) still apply.
Reference: MCP crate CONTEXT, ADR-0060. Example source: examples/mcp-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 | UTC | IANA timezone identifier (e.g. America/New_York) |
includeMetadata | no | true | 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 | — | Not supported. The Component rejects it at Endpoint creation with block is not supported |
exchangePattern | no | — | Not supported. The Component rejects it at Endpoint creation with exchange_pattern is not supported |
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 only the registry. It fails fast when no Consumer is registered and failIfNoConsumers is not false. call acquires the permit before the dispatch timeout starts. It then 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 consumer's start() spawns one forwarder task per unit of concurrentConsumers (one per subscriber queue in Fanout mode). The forwarders share one receiver. Each forwarder awaits send_and_wait for InOut and waitForTaskToComplete=Always exchanges, and the forwarders process exchanges in parallel when concurrentConsumers is greater than 1. InOnly exchanges without a reply channel do not block a forwarder.
concurrentConsumers is reported to the Runtime through ConcurrencyModel::Concurrent. This parallel InOut processing was a defect until 2026-08-09. Finding I1 and bd issue rc-exa2 (audit-fix-misc-correctness) tracked the defect when a single forwarder serialized send_and_wait.
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.
Kubernetes identity
KubernetesPlatformService builds its election identity from the pod it runs on. Production deployments MUST expose POD_NAME through the Kubernetes Downward API. Expose POD_NAMESPACE as well. The platform uses it when the configuration sets no namespace.
The node ID resolves from the first non-empty source in a fixed chain. The chain tries the POD_NAME environment variable, then the HOSTNAME environment variable, then the local hostname. Resolution from a fallback source logs a warning. When no source resolves, platform construction fails with a configuration error.
The Lease holderIdentity has the format <namespace>/<node_id>. This is the value operators see in kubectl get lease. The namespace resolves in this order: the configured namespace, the pod namespace, then default.
An upgrade may leave Leases with a holder in the old format. The first post-upgrade acquisition rewrites each Lease's holder. The format change does not bypass lease expiry or optimistic concurrency.
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 and is REQUIRED — a keycloak or oidc provider without audiences fails at startup (ADR-0061: an unscoped audience binding would accept tokens minted for any client of the issuer). 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 with static credentials and role-based policies. 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_component_timer::TimerComponent;
use camel_core::CamelContext;
let mock = MockComponent::new();
let mock_ref = mock.clone();
let mut ctx = CamelContext::builder().build().await.unwrap(); // allow-unwrap
ctx.register_component(TimerComponent::new());
ctx.register_component(mock);
let route = RouteBuilder::from("timer:tick?period=1000&repeatCount=1")
.route_id("mock-demo")
.set_body(camel_api::Body::Text("hello"))
.map_body(|body: camel_api::Body| {
camel_api::Body::Text(body.as_text().unwrap_or("").to_uppercase())
})
.to("mock:result")
.build()?;
ctx.add_route_definition(route).await?;
ctx.start().await?;
let endpoint = mock_ref.get_endpoint("result").unwrap();
endpoint.await_exchanges(1, std::time::Duration::from_secs(5)).await;
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.
BodyMatcher and HeaderMatcher are the public matcher vocabulary. Use expect_body_matcher(BodyMatcher) and expect_header_matcher(key, HeaderMatcher) to register matcher expectations. They share the ordered slot list with expect_body. Insertion order across expect_body and expect_body_matcher is preserved. Header matcher entries use any-exchange semantics, same as expect_header and expect_header_regex.
BodyMatcher variants: Equals(Body), Regex(String), Contains(String), StartsWith(String), EndsWith(String), Exists, JsonSubset(serde_json::Value). HeaderMatcher variants: Equals(serde_json::Value), Regex(String), Exists. exists takes no argument. jsonSubset takes a JSON object. regex takes a string pattern. An invalid regex is a malformed-pattern error. It never passes and never latches into fail_fast_error.
JsonSubset matches recursively over objects. Arrays compare exactly. A text body that parses as JSON is accepted. A text body that does not parse fails the matcher. A non-object pattern or a non-object received top-level value fails the matcher.
Failures name the matcher kind, its pattern, and the received value. The display forms are equals <json>, regex <pattern>, contains <needle>, startsWith <prefix>, endsWith <suffix>, exists, jsonSubset <json>. When the body is not text, a string-matcher failure adds body is not text. When the body is not JSON, a JsonSubset failure adds body is not JSON or body is not a JSON object. When a header regex sees a non-string value, the failure adds value is not a string. The received value is rendered whole.
expect_header_regex remains available. It coerces non-string header values with to_string() before matching. HeaderMatcher::Regex is strict. It requires a string value and fails with value is not a string otherwise. The divergence is intentional.
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.
String methods mutate in place
Rhai string methods such as replace, trim, and pad mutate the subject in place and return unit (). They do not return a new string. This differs from JavaScript, Python, and Rust.
Call the method as a bare statement. The statement form mutates the body in place:
body.replace(",", "%2C");
Never write body = body.replace(...). The right-hand side is unit, so the assignment silently drops the value. The body stays unchanged. The same applies to headers: headers["k"] = headers["k"].replace(...) writes unit into the map entry, and the value becomes Null. The failure is silent. No error is raised.
The rhai_replace_* characterization tests in the Rhai crate pin this behavior.
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 |
zip | built-in (camel-processor) | Any body → zipped Bytes |
protobuf | camel-dataformat-protobuf | Json ↔ Bytes |
JSON, CSV, XML, and ZIP 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 error_handler and circuit_breaker objects are documented
below. The security_policy object is documented in
Authorization.
| 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 with optional fallback sub-pipeline |
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.
Circuit breaker
Set circuit_breaker to protect a route from a failing downstream service. The
breaker opens after failure_threshold consecutive failures. While open, it
rejects exchanges for open_duration_ms. See Circuit
breaker for the breaker states.
The optional fallback list holds a sub-pipeline. The breaker runs the
sub-pipeline instead of rejecting the exchange while the circuit is open. An
absent or empty fallback keeps the existing behavior: the breaker returns
CircuitOpen.
circuit_breaker:
failure_threshold: 1
open_duration_ms: 60000
fallback:
- cache_peek_stale:
key: "user-profile-42"
The fallback runs on routes with and without an error_handler. A fallback
step that stops cleanly (for example, a cache_peek_stale MISS with the
default on_miss: stop) surfaces Ok(exchange) with the exchange state
intact. No CircuitOpen escapes.
A failing fallback step follows the route's error handling. A route with an
error_handler routes the failure through the handler. A route without one
surfaces the raw error to the caller.
The Cache page shows the stale-on-error composition with
cache_peek_stale.
Half-open fallback asymmetry
During the half-open probe-in-flight window, the fallback behavior differs by route shape. A route with an error_handler compiles the breaker into a CircuitBreakerGate. The gate serves the fallback to concurrent callers while the probe runs. A route without an error_handler compiles the breaker into a Tower CircuitBreakerService. That service rejects concurrent callers with CircuitOpen while the probe runs, even when a fallback is configured.
Both behaviors are sound. The gate keeps a single probe in flight and serves stale fallback data. The service keeps a single probe in flight and rejects. The asymmetry is intentional. See Circuit breaker for the breaker states.
Security policy
Set security_policy to authorize exchanges before the steps run. The object
takes one of five forms: roles, scopes, ref, wasm, or permission.
The full policy model lives in Authorization.
The optional credential_sources list names where the route reads its
credential. When absent, the default is [authorization_header]: the route
reads the Authorization header only. Each entry names one source:
| Form | Meaning |
|---|---|
authorization_header | Bearer token in the Authorization header |
query_param: { param: <name> } | Token in a query parameter |
cookie: { name: <name> } | Token in a cookie |
header: { name: <name> } | API key in a named custom header |
routes:
- id: tile-service
from: "http://0.0.0.0:8090/tiles"
security_policy:
roles: ["tile-user"]
credential_sources:
- cookie: { name: session }
- query_param: { param: token }
- header: { name: X-Api-Key }
- authorization_header
steps:
- set_header:
key: CamelHttpResponseCode
value: 200
- set_header:
key: Content-Type
value: "application/json"
- set_body: '{"layer":"streets","status":"authenticated"}'
Extraction runs in the declared order. The first source that supplies a
credential wins. credential_sources is valid only with the roles or
scopes form. Load-time validation rejects malformed credential_sources
entries: an empty list, an empty parameter or cookie name, and a header name
that is not a valid RFC 9110 token. See Authorization
for the extraction semantics and
ADR-0059.
The optional provider string names the configured authenticator for the
route. The name must match one of the configured providers: keycloak,
oidc, or native. When more than one provider is configured, provider
is required. A route without it fails to load, and the error names the
available providers. An unknown provider name also fails the load. Like
credential_sources, provider is valid only with the roles or scopes
form.
REST block policy
A rest: block accepts the same security_policy object, declared once
on the block. Lowering copies it onto every route the block produces, so
every operation enforces the same policy before its handler runs. The
object takes the same five forms and passes the same load-time validation
as the route-level key, including credential_sources, provider, and
audiences.
rest:
- host: 0.0.0.0
port: 9090
path: /api/users
security_policy:
roles: ["user"]
provider: "native-demo"
operations:
- method: GET
operation_id: listUsers
to: direct:listUsers
produces: application/json
- method: POST
operation_id: createUser
consumes: application/json
produces: application/json
success_status: 201
to: direct:createUser
- method: PUT
path: /{id}
operation_id: updateUser
consumes: application/json
produces: application/json
to: direct:updateUser
- method: DELETE
path: /{id}
operation_id: deleteUser
to: direct:deleteUser
success_status: 204
The source lives at
examples/rest-crud/routes/secured.yaml.
Run the runnable variant with cargo run -p rest-crud --bin secured: a
request without a credential gets 401 before any handler runs, and a
token whose principal holds the required role is granted. A block without
security_policy lowers to public routes.
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 |
coalesce_misses | bool | no | false | Run one on_miss per concurrent miss wave on the same key |
on_miss | list | yes | — | Sub-pipeline to run on cache miss |
- cache:
key: "${header.cacheKey}"
ttl: "5s"
on_miss:
- set_body: "computed"
With coalesce_misses: true, concurrent misses on the same key run the on_miss sub-pipeline once. The first miss leads. The rest wait and share the leader's body and error.
cache_invalidate
Remove a single entry or a namespace from the cache repository. Set key for an exact-key removal or key_prefix for a namespace purge. Exactly one of the two is required.
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
repository | string | no | "memory" | Repository name |
key | string | no | — | Exact cache key expression (mutually exclusive with key_prefix) |
key_prefix | string | no | — | Namespace prefix expression (mutually exclusive with key) |
On success the step sets the CamelCacheInvalidatedCount exchange property: 1 for an exact key, the removed count for a prefix. A backend without key iteration (memory) fails closed on key_prefix.
- cache_invalidate:
key: "${header.cacheKey}"
- cache_invalidate:
key_prefix: "user-profile-"
cache_clear
Remove every entry from the cache repository.
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
repository | string | no | "memory" | Repository name |
- cache_clear: {}
- cache_clear:
repository: "persistent"
cache_stats
Replace the body with a JSON snapshot of the cache repository statistics.
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
repository | string | no | "memory" | Repository name |
- cache_stats: {}
The snapshot holds repository, hits, misses, evictions, entries, peek_stale_served, invalidations, and bytes. The bytes field is the stored payload size when the backend reports it (null for the memory backend).
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 |
on_miss | string | no | On-miss policy: "stop" (default) or "continue" |
on_miss does not have the same meaning as the on_miss field of cache. In cache, the field holds a sub-pipeline. In cache_peek_stale, the field holds a policy word. Do not write a step list under cache_peek_stale.on_miss.
- cache_peek_stale:
key: "${header.cacheKey}"
on_miss: continue
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 |
fallback | list | no | — | Sub-pipeline executed while the circuit is open |
The fallback list holds a sub-pipeline of steps. The breaker runs it instead of
rejecting the exchange while the circuit is open. See
Circuit breaker for the full surface.
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 |
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 |
security_policy | object | no | — | Block authorization copied to every lowered route |
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 |
binding | string | no | json | Binding mode: json or raw |
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 |
Operations bind in one of two modes. The default json mode accepts JSON-essence media types for consumes and produces: bare application/json, parameterized forms such as application/json; charset=utf-8, and +json suffixes such as application/problem+json. It unmarshals requests, marshals responses, and validates declared schemas automatically. Any other media type in json mode fails route load. The raw mode accepts any RFC 9110 type/subtype media type, leaves the request as Body::Stream with no automatic unmarshal or marshal, and sends the trimmed produces value as the response Content-Type. request_schema and response.schema are rejected in raw mode. The default success status is injected in both modes; a raw POST returns 201 with the declared produces type.
- method: post
path: /ingest
binding: raw
consumes: application/octet-stream
produces: text/plain
to: direct:ingest
Raw binding streaming contract
raw operations own the stream semantics of their request and reply bodies.
The contract:
- No pipeline caching. Lowering injects no
unmarshal/marshal, so noStreamCacheService-wrapped processor compiles ahead of the user steps. The requestBody::Streamreaches the first user step unpollied; the injectedContent-Typeand default-status steps never read it. - Single consumption. The request stream is consumed at most once. A
second consumption attempt fails with
AlreadyConsumedand propagates as a route error — never a panic. A reply whose stream was already consumed returns HTTP 500 with an empty body. - Metadata preservation. The HTTP consumer records the request
Content-TypeandContent-Lengthin the stream metadata before the exchange enters the route, and the pipeline never alters them. - Original or new reply stream. A route may reply with the original
request stream (echo) or a newly generated
Body::Stream; both are streamed to the wire under the route-suppliedContent-Type. - Request limits fail closed. A request with
Content-Lengthovermax_request_bodyis rejected 413 before the stream opens. A chunked request over the cap fails with the limit error when consumed. - Response limits cover materialized bytes only.
max_response_bodycaps materialized reply bodies — an over-cap materialized reply is replaced with HTTP 500 (Response body exceeds configured limit). A streamed reply is not byte-capped: capping a stream mid-flight would truncate an already-committed response, so routes that need response caps must materialize the body first. - Client disconnects do not fail the consumer. If a client drops the connection during a streamed reply, the server keeps serving subsequent requests.
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. An include list can also live inside [default] or a named profile section. Profile-scoped lists override top-level lists on key conflicts.
Note:
includeentries are literal paths. A relative entry resolves against the directory that holds the main config file.${env:}expansion runs after the merge, over values only. Placeholders inside anincludelist stay literal, soinclude = ["${env:CAMEL_INCLUDE_CONF}"]fails withincluded file not found.
Switching the cache backend between profiles needs one extra step. Profile merges are additive: keys omitted by a profile survive from [default], and validation rejects any cross-backend cache_repo key. Do not set [default.cache_repo]; define the complete table inside each profile instead:
[dev.cache_repo]
backend = "redb"
path = "/var/lib/rust-camel/dev-cache.redb"
cache_size = "64MiB"
[prod.cache_repo]
backend = "redis"
url = "redis://prod-redis.internal:6379"
A [<profile>.cache_repo] table whose counterpart is absent from [default] inserts whole at merge time, so no redb key survives under the redis profile.
Repository registration names
Both [idempotent_repo] and [cache_repo] accept an optional name string. The name is the registry key that EIP steps (idempotent consumer, cache) resolve repositories by, and for the redis backends it is also a keyspace segment (camel:idem:<name>:*, camel:cache:<name>:*), so two differently-named repositories never share keys. Allowed characters are [A-Za-z0-9:_-]; glob metacharacters are rejected because clear scans by prefix.
When name is omitted, the backend convention applies. The defaults are historical and pinned by existing scenarios; the table shows the full mapping:
| Section | Backend | Default name |
|---|---|---|
[idempotent_repo] | redb | redb |
[idempotent_repo] | redis | redis |
[cache_repo] | memory | memory |
[cache_repo] | redb | persistent |
[cache_repo] | redis | redis |
The cache redb default is persistent, not redb — an asymmetry kept for backward compatibility. Set name explicitly when the distinction matters to your routes.
One caveat on the cache memory backend: the config only replaces the default memory repository when max_capacity is set. A name (or any other cache_repo key) on a memory table without max_capacity registers nothing — the built-in default stays.
One boundary of the cross-repository prefix-collision rule: validation compares declared endpoints, not resolved addresses. A standalone url and a sentinel_nodes topology that both point at the same physical Redis instance and database are treated as distinct databases, because resolving the sentinel topology requires network I/O that validate() does not perform. The repositories stay separated by their distinct default key prefixes and name segments; set explicit key_prefix values when mixing both shapes against one instance.
Environment overrides
After includes and profile merges, the loader overlays a fixed allowlist of CAMEL_* environment variables onto the merged tree. The loader ignores a CAMEL_* variable outside the allowlist and logs a warning. Two exceptions, CAMEL_PROFILE and CAMEL_CONFIG_FILE, select the profile and the config file itself. They do not override config fields and do not warn.
camel run and CamelConfig::from_env_or_default() apply these overrides to the loaded file; CamelConfig::from_file() does not.
Allowlisted variables, by group:
- General:
CAMEL_TIMEOUT_MS,CAMEL_DRAIN_TIMEOUT_MS,CAMEL_WATCH,CAMEL_WATCH_DEBOUNCE_MS,CAMEL_LOG_LEVEL - Runtime journal:
CAMEL_RUNTIME_JOURNAL_PATH,CAMEL_RUNTIME_JOURNAL_DURABILITY,CAMEL_RUNTIME_JOURNAL_COMPACTION_THRESHOLD_EVENTS - Idempotent repo:
CAMEL_IDEMPOTENT_REPO_PATH,CAMEL_IDEMPOTENT_REPO_DURABILITY - Cache repo:
CAMEL_CACHE_REPO_BACKEND,CAMEL_CACHE_REPO_PATH,CAMEL_CACHE_REPO_MAX_CAPACITY,CAMEL_CACHE_REPO_STALE_RETENTION,CAMEL_CACHE_REPO_MAX_ENTRIES,CAMEL_CACHE_REPO_PAYLOAD,CAMEL_CACHE_REPO_PAYLOAD_DIR,CAMEL_CACHE_REPO_CACHE_SIZE,CAMEL_CACHE_REPO_SWEEP_INTERVAL,CAMEL_CACHE_REPO_MASTER_NAME,CAMEL_CACHE_REPO_KEY_PREFIX,CAMEL_CACHE_REPO_DB,CAMEL_CACHE_REPO_SENTINEL_NODES - Supervision:
CAMEL_SUPERVISION_INITIAL_DELAY_MS,CAMEL_SUPERVISION_MAX_ATTEMPTS
CAMEL_CACHE_REPO_SENTINEL_NODES is the only override whose value is a list. Its value is a comma-separated list of host:port entries, for example CAMEL_CACHE_REPO_SENTINEL_NODES=sentinel-1:26379,sentinel-2:26379. The loader splits the value on commas, trims each entry, and drops blank entries. An empty value yields an empty list. The list replaces the file value, and, on the redis backend, an empty node list normalizes to absent. The override clears the field to unset.
The empty-means-unset rule composes with the complete per-profile [<profile>.cache_repo] tables shown above. One image ships one file with a complete table per environment, and env overrides adjust values that differ per deployment. Flipping CAMEL_CACHE_REPO_BACKEND fails validation when the merged table carries keys of the other backend; switch backends through the profile tables instead.
An empty value preserves the file or profile value for these scalar cache repo variables: CAMEL_CACHE_REPO_PAYLOAD, CAMEL_CACHE_REPO_PAYLOAD_DIR, CAMEL_CACHE_REPO_CACHE_SIZE, CAMEL_CACHE_REPO_SWEEP_INTERVAL, CAMEL_CACHE_REPO_MASTER_NAME, CAMEL_CACHE_REPO_KEY_PREFIX, and CAMEL_CACHE_REPO_DB. The loader skips the empty override instead of passing an empty string to typed deserialization.
Note: Connection strings and credentials are outside the allowlist. Set them with
${env:VAR}placeholders inCamel.tomlvalues, never through env overrides. The loader ignores a variable such asCAMEL_CACHE_REPO_URLand logs a warning.
- 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.
[binds."<addr>"]
Per-bind public-exposure acknowledgements (ADR-0061 Rule 4). When any route
on a NON-loopback bind (http://0.0.0.0:8080, wss://0.0.0.0:9000, an MCP
bind, …) compiles to a Public security plan, startup refuses unless the
bind carries an explicit acknowledgement; an acknowledged exposure warns
permanently on every start (ADR-0052 rule 3). Loopback binds
(127.0.0.1, localhost, ::1) need no acknowledgement.
[binds."0.0.0.0:8080"]
allow_public_exposure = true
| Field | Type | Default | Description |
|---|---|---|---|
allow_public_exposure | bool | false | Acknowledge that this bind intentionally serves unauthenticated routes. Required for non-loopback Public binds; refusal names the bind and the routes. |
[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 five optional sub-tables. The [observability.tracer] block configures the built-in tracing layer, and [observability.metrics] gates its metric families. The other three activate optional exporters.
| Field | Type | Default | Description |
|---|---|---|---|
tracer | table | (built-in defaults) | Built-in tracing layer config. |
metrics | table | (built-in defaults) | Metric-family levers. Absent table means all defaults. |
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. |
Span and metric enablement are independent:
[observability.tracer] enabledgates SPAN creation only. With Prometheus (or OTel) active, an explicitenabled = falsestill runs the tracer pipeline so metric families keep flowing; no spans are created.- The pipeline itself is turned off only when neither tracing nor any exporter is active. The
[observability.metrics]levers below can suppress individual non-error families but never disable the pipeline.
[observability.metrics]
Metric-family levers. Unknown keys fail at load, consistent with the sibling observability tables. There is no lever for the error family: camel_errors_total is structurally non-disableable and is exported regardless of any combination of these keys.
| Field | Type | Default | Description |
|---|---|---|---|
enabled | bool | true | Master switch for the non-error metric families. false suppresses exchanges, duration, and component families; errors always flow. |
exchange | bool | true | Opt-out for the camel_exchanges_total family. Only takes effect when enabled is true. |
duration | bool | true | Opt-out for the camel_exchange_duration_seconds family. Only takes effect when enabled is true. |
components | bool | false | Opt-in for the component-operations metric family (camel_component_operations_total). |
[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.
Placeholders in Camel.toml use ${env:VAR} and ${env:VAR:-default}. The :- separator is native: default is used when VAR is unset. An unset variable without a default aborts load, naming the field. The legacy {{...}} syntax is rejected with an error pointing at the ${env:} forms. See Environment variable interpolation.
[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 static credential store. |
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 | (required) | JWKS endpoint. |
audience | array of strings | [] | Required aud claim values. |
client_id | string | null | OAuth2 client ID. |
client_secret | string | null | OAuth2 client secret. Resolves ${env:VAR}; unset variable without default fails load. |
token_endpoint | string | null | Token endpoint for client credentials flows. |
introspection_endpoint | string | null | Token introspection endpoint. |
[security.native]
The native block is a static credential store with no external identity provider. Set at least one credential: a scalar bearer_token or api_key, or one or more [[security.native.credentials]] entries. A config with no credential fails load.
| Field | Type | Default | Description |
|---|---|---|---|
subject | string | (required) | Principal name for the scalar bearer_token and api_key identities. |
issuer | string | "native" | Issuer recorded on synthesized principals. null falls back to "native". |
bearer_token | string | null | Pre-issued bearer token. Resolves ${env:VAR}; unset variable without default fails load. |
api_key | string | null | Pre-shared API key. Resolves ${env:VAR}; unset variable without default fails load. |
roles | array of strings | [] | Roles granted to the scalar identities. |
scopes | array of strings | [] | Scopes granted to the scalar identities. |
credentials | array of tables | [] | Static credentials, each with its own subject, roles, and scopes. |
[[security.native.credentials]] array elements:
| Field | Type | Default | Description |
|---|---|---|---|
subject | string | (required) | Principal name for this credential. Must not be empty. |
secret_env | string | absent | Environment variable holding the secret. Read at startup; fails closed if unset or empty. |
secret | string | absent | Plaintext secret. Logged with a warning at startup; use secret_env in production. |
roles | array of strings | [] | Roles granted to this credential's principal. |
scopes | array of strings | [] | Scopes granted to this credential's principal. |
Each credentials entry must set exactly one of secret_env or secret; setting both or neither fails at load. The block uses deny_unknown_fields, so unknown keys are rejected at load.
[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. Resolves ${env:VAR}; unset variable without default fails load. |
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.
backend selects the store. "redb" is the default. The redb repository registers under the name "redb". The redis repository registers under the name "redis", and route steps select it with repository = "redis". Redis keys survive a process restart and are shared by every process that connects to the same Redis.
| Field | Type | Default | Description |
|---|---|---|---|
backend | string | "redb" | "redb" (persistent on-disk store) or "redis" (persistent, shared across processes). |
name | string | absent | Registration-name override (also the redis keyspace segment). Defaults: "redb"/"redis". Allowed charset [A-Za-z0-9:_-]. |
path | string | (required for redb) | Path to the .redb file. Must not be empty. Redb only. |
durability | string | "immediate" | immediate fsyncs on every key. eventual skips fsync. Redb only. |
url | string | (required for redis) | Standalone endpoint, redis:// or rediss://. Mutually exclusive with sentinel_nodes. Redis only. |
sentinel_nodes | string array | (alternative to url) | Sentinel node addresses. Mutually exclusive with url. Redis only. |
master_name | string | (required with sentinel_nodes) | Master name resolved through the sentinels. Redis only. |
sentinel_username | string | absent | Sentinel AUTH username. Redis only. |
sentinel_password | string | absent | Sentinel AUTH password. Redacted from Debug output. Redis only. |
password | string | absent | Data-node AUTH password. Redacted from Debug output. Rejected in url mode. Redis (sentinel mode) only. |
username | string | absent | Data-node AUTH username. Redacted from Debug output. Rejected in url mode. Redis (sentinel mode) only. |
db | integer | absent | Data-node database index. Valid range 0 to 16383. Defaults to 0 when absent. Rejected in url mode. Redis (sentinel mode) only. |
key_prefix | string | "camel:idem" | Redis key prefix for this repository's keyspace. Allowed charset [A-Za-z0-9:_-]. Redis only. |
In url mode the URI carries the password and the database. The password rides the userinfo and the database rides the ?db=N query parameter, as in redis://:pass@host:port?db=N. A username in the URI is not supported.
A runnable redis configuration lives in examples/redis-repositories:
[default.idempotent_repo]
backend = "redis"
url = "redis://127.0.0.1:6379"
[cache_repo]
Optional cache repository configuration. When unset, only the default "memory" cache repository is registered. With backend = "redb", a persistent "persistent" repository (redb-backed) is registered alongside "memory". With backend = "redis", a shared "redis" repository is registered alongside "memory", and route steps select it with repository = "redis".
| Field | Type | Default | Description |
|---|---|---|---|
backend | string | "memory" | "memory" (moka-backed, size-eviction only), "redb" (persistent, survives restarts), or "redis" (persistent, shared across processes). |
name | string | absent | Registration-name override (also the redis keyspace segment). Defaults: "memory"/"persistent"/"redis" (the redb default is a kept asymmetry). The memory backend only honors it when max_capacity is set. |
path | string | (required for redb) | Path to the .redb file. Created if it does not exist. Must not be empty. Redb only. |
stale_retention | duration string | 7d (wiring fallback) | How long after expiry a stale entry stays readable. Redb: the sweep reclaims the entry after this window. Redis: the key expires at expires_at + stale_retention. Duration strings, for example "168h", "7d", "30m". The value in force at set() time applies; later changes are not retroactive (see ADR-0065). Redb and redis. |
max_entries | integer | 1000000 | Maximum entry count for the redb backend; new-key writes are rejected at the cap. Redb only. |
cache_size | byte-size string | (required for redb) | Bounds the redb page cache, e.g. "384MB", "256MiB", or plain bytes (1073741824). Decimal suffixes are powers of 1000, binary suffixes powers of 1024. Redb only. |
sweep_interval | duration string | 1h | How often the redb background sweep runs. Must be positive. Redb only. |
payload | string | "inline" | Payload storage mode. "inline" keeps payload bytes in the repository entry. "disk" offloads payload bodies to blob files under payload_dir. Rejected on the memory backend. Redb and redis. |
payload_dir | path string | (required when disk) | Directory holding offloaded payload files. Required and non-empty when payload = "disk"; rejected otherwise. No default. Supports ${env:} strict interpolation. Redb and redis. |
payload_sweep_interval | duration string | 1h | How often the offloaded-payload sweep runs when payload = "disk". The interval also widens every blob's death epoch as a grace window. Must be at least one second. Redb and redis. |
payload_max_ttl | duration string | 720h (30d) | Expiry fabricated for entries stored without a TTL when payload = "disk", so index row and blob file share one death timeline. Must be at least one second. Redb and redis. |
max_capacity | integer | 10000 (default memory repo) | Entry cap for the memory backend. Memory only. |
url | string | (required for redis) | Standalone endpoint, redis:// or rediss://. Mutually exclusive with sentinel_nodes. Redis only. |
sentinel_nodes | string array | (alternative to url) | Sentinel node addresses. Mutually exclusive with url. Redis only. |
master_name | string | (required with sentinel_nodes) | Master name resolved through the sentinels. Redis only. |
sentinel_username | string | absent | Sentinel AUTH username. Redis only. |
sentinel_password | string | absent | Sentinel AUTH password. Redacted from Debug output. Redis only. |
password | string | absent | Data-node AUTH password. Redacted from Debug output. Rejected in url mode. Redis (sentinel mode) only. |
username | string | absent | Data-node AUTH username. Redacted from Debug output. Rejected in url mode. Redis (sentinel mode) only. |
db | integer | absent | Data-node database index. Valid range 0 to 16383. Defaults to 0 when absent. Rejected in url mode. Redis (sentinel mode) only. |
key_prefix | string | "camel:cache" | Redis key prefix for this repository's keyspace. Allowed charset [A-Za-z0-9:_-]. Redis only. |
In url mode the URI carries the password and the database. The password rides the userinfo and the database rides the ?db=N query parameter, as in redis://:pass@host:port?db=N. A username in the URI is not supported.
Fields that do not apply to the configured backend are rejected at validation (fail-closed), and a malformed cache_size, sweep_interval, stale_retention, payload_sweep_interval, or payload_max_ttl fails validation with an error naming the field. The same applies to payload fields set while payload is inline or unset.
A runnable redis configuration lives in examples/redis-repositories:
[default.cache_repo]
backend = "redis"
url = "redis://127.0.0.1:6379"
stale_retention = "30m"
[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 that routes reference through the datasource URI parameter (for example sql:SELECT * FROM users?datasource=my-db). 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 |
Cache repo overrides carry a typed contract. String-typed fields receive their raw value verbatim, with no numeric or boolean coercion. Numeric-typed fields parse strictly typed values. Duration fields accept humantime forms with explicit units. A unitless numeric value is rejected at validation with an error naming the required format. An empty value is skipped for the newer scalar variables, so the file or profile value stays effective. Legacy variables receive the raw value, empty or not.
| Variable | Field | Value class | Empty value | Duration rule |
|---|---|---|---|---|
CAMEL_CACHE_REPO_BACKEND | cache_repo.backend | string-verbatim | raw value (not skipped) | — |
CAMEL_CACHE_REPO_PATH | cache_repo.path | string-verbatim | raw value (not skipped) | — |
CAMEL_CACHE_REPO_MAX_CAPACITY | cache_repo.max_capacity | numeric-typed | raw value (not skipped) | — |
CAMEL_CACHE_REPO_STALE_RETENTION | cache_repo.stale_retention | string-verbatim | raw value (not skipped) | humantime units required. Unitless numeric rejected: cache_repo.stale_retention: invalid duration '604800' — use a unit-bearing form such as '7d' or '24h' |
CAMEL_CACHE_REPO_MAX_ENTRIES | cache_repo.max_entries | numeric-typed | raw value (not skipped) | — |
CAMEL_CACHE_REPO_PAYLOAD | cache_repo.payload | string-verbatim | skipped (file/profile value stays effective) | — |
CAMEL_CACHE_REPO_PAYLOAD_DIR | cache_repo.payload_dir | string-verbatim | skipped (file/profile value stays effective) | — |
CAMEL_CACHE_REPO_CACHE_SIZE | cache_repo.cache_size | string-verbatim | skipped (file/profile value stays effective) | — |
CAMEL_CACHE_REPO_SWEEP_INTERVAL | cache_repo.sweep_interval | string-verbatim | skipped (file/profile value stays effective) | humantime units required. Unitless numeric rejected: cache_repo.sweep_interval: invalid duration '3600' — use a unit-bearing form such as '7d' or '24h' |
CAMEL_CACHE_REPO_MASTER_NAME | cache_repo.master_name | string-verbatim | skipped (file/profile value stays effective) | — |
CAMEL_CACHE_REPO_KEY_PREFIX | cache_repo.key_prefix | string-verbatim | skipped (file/profile value stays effective) | — |
CAMEL_CACHE_REPO_DB | cache_repo.db | numeric-typed | skipped (file/profile value stays effective) | — |
CAMEL_CACHE_REPO_SENTINEL_NODES | cache_repo.sentinel_nodes | CSV list | empty list replaces the file value and normalizes to absent on redis | — |
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.
Note: Connection strings and credentials are outside the allowlist. Set them with
${env:VAR}placeholders inCamel.tomlvalues, never through env overrides. The loader ignoresCAMEL_CACHE_REPO_URL,CAMEL_CACHE_REPO_USERNAME,CAMEL_CACHE_REPO_PASSWORD,CAMEL_CACHE_REPO_SENTINEL_USERNAME, andCAMEL_CACHE_REPO_SENTINEL_PASSWORDand logs a warning.
Reference: Config crate
Environment variable interpolation
Substitute environment variables into route files and Camel.toml with
${env:VAR} tokens. The tokens work in endpoint URIs, log messages,
header values, and any other string field.
The expansion point differs by surface. Route files expand in the raw
route source, before YAML parsing. Camel.toml expands every string
leaf of the parsed, merged tree. The tree combines the main file,
include files, and CAMEL_* environment overrides. Expansion runs
before typed deserialization.
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
load. The error names the variable. Set a default or export the variable
to avoid the failure.
The same syntax works in Camel.toml:
[security.keycloak]
client_secret = "${env:KC_SECRET}"
[observability.otel]
endpoint = "${env:OTEL_ENDPOINT:-http://localhost:4317}"
Escapes
| Input | Output |
|---|---|
${env:VAR} | Value of VAR; fails closed if unset and no default |
${env:VAR:-default} | Value of VAR, or default when unset |
$$ | A single $ |
$${env:VAR} | The literal text ${env:VAR} |
The standalone $$ escape works on every surface: route files and all
Camel.toml leaves. The full-form escape $${env:VAR} yields the
literal placeholder text on route files and plain Camel.toml leaves.
Exception — credential leaves: Camel.toml sections that hold
credentials or connection secrets reject the escaped full form. On
security, datasources, idempotent_repo, and cache_repo leaves, a
$${env:VAR} leaves a residual ${env:VAR} marker, which fails load.
There is no legitimate reason for a credential field to hold the literal
text of a placeholder.
Fail-closed
Both surfaces fail closed. An unset variable without a default aborts:
- Route discovery fails with an error naming the variable.
Camel.tomlload aborts with an error naming the field.
Use ${env:VAR:-default} for optional values.
Legacy {{...}} syntax
Camel.toml rejects the legacy {{...}} placeholder syntax. Any {{ in
a string leaf fails load with an actionable message: placeholders use
${env:NAME} or ${env:NAME:-default}. Route files never supported the
{{...}} form.
How it works
The DSL loader (camel_dsl::interpolate_env) scans raw route source
before YAML parsing. camel-config walks the merged Camel.toml tree
after the builder merges the main file, include files, and CAMEL_*
environment overrides (resolve_tree_placeholders). The walk replaces
${env:...} patterns before typed deserialization. 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 retains the legacy
{{...}} API for compatibility. Camel.toml loading does not use it.
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 service binds exactly the address the caller supplies; there is no loopback default. 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),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.
Native auth
The native auth pipeline reproduces a Keycloak-style flow without external dependencies. It validates static credentials against a local store and applies role-based policies. The same pipeline works with a real Keycloak through camel-component-keycloak.
Register static credentials in Camel.toml. Each [[security.native.credentials]] entry binds a subject to a credential, supplied inline (secret) or by environment-variable reference (secret_env). Roles and scopes are optional:
[security.native]
subject = "native-user"
issuer = "native"
[[security.native.credentials]]
subject = "svc-orders"
secret_env = "ORDERS_SECRET"
roles = ["service"]
scopes = ["read:orders", "write:orders"]
The CLI builds a NativeCredentialStore from these entries and wraps it in a StaticTokenAuthenticator. Each entry maps its credential to a Principal with the entry's roles and scopes.
The example defines the bearer values it presents to the authenticator:
let alice_token = "alice-token";
let bob_token = "bob-token";
Validate a bearer value with StaticTokenAuthenticator:
println!("--- Validation ---");
let alice_principal = authenticator.authenticate_bearer(alice_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 = authenticator.authenticate_bearer(bob_token).await;
match &bob_principal {
Ok(p) => println!("Bob: VALID (subject={}, roles={:?})", p.subject, p.roles),
Err(e) => println!("Bob: 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));
let mut alice_exchange = Exchange::default();
alice_exchange.input.headers.insert(
"authorization".to_string(),
Value::String(format!("Bearer {}", alice_token)), // allow-secret
);
let mut bob_exchange = Exchange::default();
bob_exchange.input.headers.insert(
"authorization".to_string(),
Value::String(format!("Bearer {}", bob_token)), // allow-secret
);
let alice_principal = authenticator.authenticate_bearer(alice_token).await?;
let alice_typed = ExamplePrincipal(alice_principal);
let alice_auth = AuthContext {
principal: &alice_typed,
transport: TransportId::Http,
};
let bob_principal = authenticator.authenticate_bearer(bob_token).await?;
let bob_typed = ExamplePrincipal(bob_principal);
let bob_auth = AuthContext {
principal: &bob_typed,
transport: TransportId::Http,
};
let alice_decision = admin_policy
.evaluate(&mut alice_exchange, &alice_auth)
.await;
let bob_decision = admin_policy.evaluate(&mut bob_exchange, &bob_auth).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);
let wrapped = BearerInjectingPolicy::new(alice_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 as its policy form. An optional credential_sources list
declares where the credential comes from (see below). Routes with
security_policy do not support the canonical hot-reload path.
Credential sources
By default, a route reads its credential from the Authorization header
(ADR-0033). A browser cannot set that header on an <img src> request. Map
tiles served to Leaflet, MapLibre, or OpenLayers need another transport. The
credential_sources key names the extraction sources:
routes:
- id: tile-route
from: "http://0.0.0.0:8080/tiles"
security_policy:
roles: [tile-user]
credential_sources:
- cookie: { name: session }
- authorization_header
steps:
- to: "log:info"
Each entry names one source:
| Form | Meaning |
|---|---|
authorization_header | Bearer token in the Authorization header |
query_param: { param: <name> } | Token in a query parameter |
cookie: { name: <name> } | Token in a cookie |
header: { name: <name> } | API key in a named custom header |
Extraction runs in the declared order. The first source that supplies a
credential wins; later sources are fallbacks. When no source supplies a
credential, the request fails with 401 before policy evaluation. Store
lookups run in constant time.
http:// and ws:// consumers support the key. On a ws:// route, a
roles or scopes policy authenticates the token extracted from the
declared sources; the removed trust_upstream_principal flag no longer
exists, and exchange-property principal evidence never authorizes.
Load-time validation rejects malformed declarations: an unknown source form, an empty cookie name, or a header name that is not a valid RFC 9110 token.
Diagnostic records never render a declared credential value. The 401 reply
body carries a generic reason only (ADR-0051). The operator sets
SameSite=Lax (or stricter) and HttpOnly on session cookies where the
cookie is issued. Cookie auth on state-changing verbs still requires CSRF
defense.
See ADR-0059 for the extraction architecture.
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.
// Context is constructed after policy load; observability args pass
// None/false until then.
let wasm_policy = WasmSecurityPolicy::new(
&wasm_path,
WasmConfig::default(),
Arc::new(camel_core::RegistryComponentContext::new(
registry, None, false,
)),
HashMap::new(),
)
.await?;
let policy = AuthenticatedWasmPolicy::new(authenticator, alice_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 Lease holderIdentity has the form <namespace>/<node_id>. The
namespace is resolved once: config namespace, then pod namespace, then
default. The same value scopes the Lease API client. This is the value
an operator sees in kubectl get lease.
The node id resolves from the first non-empty source in the chain
POD_NAME → HOSTNAME → local hostname. POD_NAME comes from the
Downward API. Fallback sources log a warning. When no source resolves,
platform construction fails with a config error. After an upgrade, the
first acquisition rewrites each Lease holder to the new format. This
rewrite does not bypass lease expiry or optimistic concurrency.
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.
Self-fencing
Renewal obeys a renewal budget. The budget is renew_deadline, measured
from the last successful renewal. Each renew attempt gets only the
remaining budget. A hung attempt fails when its budget runs out.
A leader that cannot renew within the budget steps down. It emits
StoppedLeading and stops its delegate. The step-down does not depend
on the Lease state the leader observes.
Failures within the budget keep the leader in place. This covers
transient API failures and optimistic-concurrency conflicts. The loop
retries at the jittered retry_period cadence. The sleep before each
retry is shortened so it never crosses the budget boundary.
Validation enforces renew_deadline < lease_duration. The holder
therefore fences itself before the Lease can legally expire for peers.
This ordering holds modulo Kubernetes clock skew on Lease timestamps.
Reference: crates/platforms/camel-platform-kubernetes/CONTEXT.md
(Self-fencing).
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
Testing
This section describes how to test routes with the lean camel test boot and with route interception. Interception rewrites to: send points at compile time. It supports isolated unit tests without mock: lines in production routes.
Route interception
Use route interception to replace or copy a send without changing the route. Rules run at compile time. Validation happens once, in InterceptRules::new; the compiler consults the frozen rules at each send point.
Two actions exist:
SkipToreplaces the original send. The exchange goes only to themock:target.DivertCopyTocopies the exchange to amock:target and then runs the real producer. The copy uses WireTap semantics: detached when the bound (20) admits it, inlineCallerRunswhen saturated.
Targets must be mock: URIs. InterceptRules::new rejects other targets at build time. The match is exact URI, first-match-wins.
Rules freeze at first successful route registration or at context start. After freeze, set_intercept_rules returns CamelError::Config. Use CamelContextBuilder::with_intercept_rules before freeze.
SkipTo example
use camel_core::intercept::{InterceptAction, InterceptRule, InterceptRules};
use camel_core::{CamelContext, RouteDefinition};
use camel_core::route::BuilderStep;
let rules = InterceptRules::new(vec![InterceptRule {
uri: "seda:out".into(),
action: InterceptAction::SkipTo { uri: "mock:tap".into() },
}])?;
let mut ctx = CamelContext::builder()
.with_intercept_rules(rules)
.build()
.await?;
ctx.add_route_definition(
RouteDefinition::new("direct:in", vec![BuilderStep::To("seda:out".into())])
.with_route_id("send-route"),
)
.await?;
The send to: seda:out never reaches seda:. The exchange goes to mock:tap only. The seda: producer is not resolved.
DivertCopyTo example
use camel_core::intercept::{InterceptAction, InterceptRule, InterceptRules};
use camel_core::{CamelContext, RouteDefinition};
use camel_core::route::BuilderStep;
let rules = InterceptRules::new(vec![InterceptRule {
uri: "kafka:orders".into(),
action: InterceptAction::DivertCopyTo { uri: "mock:orders-copy".into() },
}])?;
let mut ctx = CamelContext::builder()
.with_intercept_rules(rules)
.build()
.await?;
// Consumer route still receives the real message.
ctx.add_route_definition(
RouteDefinition::new("kafka:orders", vec![BuilderStep::To("mock:arrival".into())])
.with_route_id("consumer"),
)
.await?;
ctx.add_route_definition(
RouteDefinition::new("direct:in", vec![BuilderStep::To("kafka:orders".into())])
.with_route_id("send"),
)
.await?;
The exchange goes to mock:orders-copy and to the real kafka:orders producer. A failure in the copy does not change the real outcome.
For processor composition, camel_processor::compose_divert builds the same divert from a WireTapService copy stage and a BoxProcessor real stage. The runtime owns the lifecycle: WireTapLifecycle::start reopens admission with a fresh token and tracker after restart.
Further detail lives in crates/camel-core/CONTEXT.md and crates/camel-processor/CONTEXT.md. The contract is defined in ADR-0064.
Declarative camel test
camel test loads each *.test.yaml document. The document selects route files, injects direct: inputs, and asserts mock: expectations. An optional intercepts block adds route interception without editing production routes. camel run ignores test documents and never parses the intercepts block.
Intercepts
Declare intercepts as a map from source URI to an action object. The object holds exactly one key: skipTo or divertCopyTo. The value must be a mock: URI.
intercepts:
kafka:orders:
skipTo: mock:orders
seda:audit:
divertCopyTo: mock:audit
skipTo replaces the original send before the compiler resolves the source component. The real component does not need to be in the lean set, and the exchange never reaches it. divertCopyTo copies the exchange to the mock: target and then runs the real producer. The real component must be in the lean set, because the compiler still resolves it. Divert uses WireTap semantics: detached when the bound admits it, inline CallerRuns when saturated. A failure in the copy does not change the real outcome.
Target and expectation share the endpoint name. skipTo: mock:orders and expects: {mock:orders: {count: 1}} both resolve to endpoint orders on the mock: component. Use the same name in both places to collect the intercepted exchange.
Matching uses the full URI verbatim. Query parameters are part of the key. kafka:orders does not match kafka:orders?x=1. List the exact URI that the route sends to.
Failure handling stays unchanged. Parse errors in the intercepts map and route-load errors from interception (for example, a divertCopyTo whose source has no registered component) are document errors. camel test reports them on stderr and exits with code 2. No endpoint result counts toward passed or failed in that case.
The contract lives in ADR-0064 and the route-interception spec (openspec/specs/route-interception/spec.md in the repository — outside the rendered book).
camel lint warns R-MOCK-IN-PRODUCTION on inline to: mock: and endpoints: mock: sends in route files. The warning is exempt for tests/fixtures/ paths and *.test.yaml documents. Migrate the send to an intercepts: block in a *.test.yaml document, as described above.
Bean stubs
A beans: block declares stub beans for the bean: steps in the routes. A stub bean is an in-process processor registered in the bean registry before the context boots. The bean: step resolves against it, so the test runs without a real bean implementation. The block maps a bean name to a declaration.
beans:
validator:
kind: echo
enricher:
kind: setBody
config:
body: enriched
Each declaration has a kind and an optional methods list and config map. The kind selects the stub behavior.
| Kind | Config | Behavior |
|---|---|---|
echo | none | Passes the exchange through untouched. |
setBody | body (required) | Replaces the input body with the configured string. |
fail | message (optional) | Fails with the configured message. Without message, it fails with exactly fail bean <name>. |
echo accepts no config keys. setBody requires body and rejects any other key. fail accepts only message. A config key that does not fit the kind is a document error.
The methods list is an allowlist. When omitted, the stub accepts every method the routes invoke on it. When present, the runner cross-validates it against the methods the routes call before boot. A route that calls a method outside the list is a document error and exits with code 2.
A fail stub surfaces as a document error. The runner reports it on stderr and exits with code 2. Settling and evaluation are skipped. The default message fail bean <name> uses the declared bean name.
The stub beans mirror the bean: step. The step looks up a bean by name and calls a method on it. The stub supplies that lookup in the test. See Bean for the step contract. The example pair lives in examples/yaml-dsl/config/beans-demo.yaml and beans-demo.test.yaml.
Endpoint expectations
expects maps a mock: endpoint name to an expectation object. The object may hold count, minCount, maxCount, a bodies list, and a headers map. count is mutually exclusive with minCount and with maxCount. minCount together with maxCount means the inclusive range [minCount, maxCount]; minCount above maxCount is a document error. An explicit maxCount: 0 asserts absence: the endpoint must receive no exchanges during the settle window. Example: expects: {mock: out: {minCount: 1, maxCount: 2}} passes with 1 or 2 exchanges and fails with 3.
bodies uses strict grammar. Each entry is a bare string or a single-key matcher map. A bare string is exact equality (equals). A map with one recognized body-matcher key selects that matcher. Any other form is a document error. camel test exits with code 2 and names the field and the key.
Body matchers in v1: equals, regex, contains, startsWith, endsWith, exists, jsonSubset. exists takes null and takes no argument. jsonSubset takes a JSON object. A regex value must be a valid pattern. The runner rejects an invalid pattern at parse time and exits with code 2.
headers values use dual grammar. Any literal JSON value stays exact structural equality (equals). A map whose sole key is equals, regex, or exists selects that matcher. Any other value stays a literal. jsonSubset on a header is a document error. camel test exits with code 2.
expects:
mock:result:
count: 2
bodies:
- regex: "^order-[0-9]+$"
- jsonSubset: {status: "ok"}
headers:
X-Trace: { regex: "^[a-f0-9]{8}$" }
mode: {batch: 1, predicate: "raw"}
jsonSubset requires a JSON object pattern. The received body may be Body::Json or Body::Text that parses as JSON. A text body that does not parse fails the matcher. The received top-level JSON must be an object. Objects match recursively. Every pattern key must exist with a matching value. Nested objects match by subset. Arrays compare exactly by length, order, and element equality. Extra fields in the received object do not fail the assertion.
A sole predicate key is reserved. camel test rejects it with predicate matchers are not supported and exits with code 2. This applies in every matcher position. A multi-key object that contains predicate stays a literal in dual positions. It does not select a matcher.
Matcher mismatches are assertion failures. camel test prints a FAIL line that names the matcher, its pattern, and the received value. The received value is rendered whole. The document exits with code 1. Parse-time errors (invalid regex, non-object jsonSubset, wrong key count) exit with code 2.
Migration note: only literals whose single key is a matcher key change meaning in dual positions (expectReply.body, expects.headers values, expectReply.headers values). For example expectReply: {body: {equals: "x"}} previously meant literal equality of {"equals": "x"}. With matchers it selects equals "x". Wrap the literal to keep the old meaning: body: {equals: {equals: "x"}}. expects.bodies entries were strings before, so matcher maps there add no migration. A sole predicate key, or a sole jsonSubset key on a header, parsed as a literal before; it now fails at parse with exit 2.
sequence asserts cross-endpoint arrival order. It is a top-level list of mock: endpoint refs in the order the arrivals must have happened. The list needs at least two entries; fewer is a document error and camel test exits with code 2. Duplicates are allowed: the same endpoint may appear for consecutive arrivals. Each entry must carry the mock: scheme; the prefix is stripped to the bare endpoint name exactly as for an expects key, so mock:probe-a addresses the endpoint probe-a.
The assertion projects the arrivals at the listed endpoints in global arrival order and requires the projection to equal the declared list exactly. Arrivals at unlisted endpoints are ignored, so the assertion narrows to the probes that matter while expects handles the rest. A mismatch is an assertion failure: camel test prints a FAIL line naming the first divergence — the position, the expected endpoint, and the actual endpoint — and exits with code 1. Parse errors (fewer than two entries, an entry that is not a mock: URI or names an empty endpoint path) exit with code 2.
Probe pattern
Observe an intermediate route send by diverting a copy to a probe endpoint, then assert the order of those observations with sequence:. divertCopyTo copies the exchange to the probe before the real send continues, so the route is unchanged.
intercepts:
seda:audit: {divertCopyTo: mock:probe-a}
seda:persist: {divertCopyTo: mock:probe-b}
expects:
mock:probe-a: {count: 1}
mock:probe-b: {count: 1}
sequence: [mock:probe-a, mock:probe-b]
Cross-endpoint order is deterministic only between causally-ordered sends — sequential route steps, reply chains. Two concurrent branches that race to different probes produce a happened-order that sequence: faithfully reports but that is nondeterministic between runs. Assert order only over sends the route orders; concurrent branches assert happened-order only.
Reply assertions
An input may declare expectReply to assert against the reply message the direct: producer returns. The block holds two optional keys: body and headers. At least one must be present. An empty expectReply is a document error.
inputs:
- to: "direct:enrich"
body: "plain"
expectReply:
body: "enriched"
expectReply.body uses dual grammar. Every bare scalar (string, number, boolean, null) and every array is literal equals. A string becomes Body::Text. Other scalars and arrays become Body::Json. An object with one recognized body-matcher key selects that matcher. Any other object is literal equals with structural equality. expectReply.headers values use the same dual header grammar as expects.headers. The reply must satisfy every expected header. Extra headers on the reply do not fail the assertion.
The reply message is the route output when the route set one. Otherwise it is the final input message. Nothing in the lean camel test component set sets the output today. The reply pairs with the input by delivery order. Inputs deliver strictly sequentially, so reply[i] matches the i-th input.
Each asserted input produces one result row labeled reply[i] <input.to>. A mismatch is an assertion failure. It surfaces as a FAIL line and counts toward failed. The document exits with code 1. A delivery error is a document error. It exits with code 2 and skips reply evaluation.
A document may omit expects when at least one input declares expectReply. The reply assertions then drive the outcome. A document with neither endpoint expectations nor any expectReply still fails to parse.
The example pair lives in examples/yaml-dsl/config/reply-demo.yaml and reply-demo.test.yaml.
Repository stubs
A repositories: block declares in-memory stubs for the named repositories that cache:, idempotent:, and claimCheck: steps resolve against. The block maps a registry kind to a map of repository name to stub target. The only valid target in v1 is the literal memory.
repositories:
cache:
persistent: memory
idempotent:
dedupe: memory
claimCheck:
store: memory
Three registry kinds exist: cache, idempotent, and claimCheck. Each maps repository names to the stub target. The runner registers a fresh memory backend under each declared name before the routes load. The steps then resolve at compile time. Only the memory target is supported. Any other target is a document error.
The built-in name memory is not stubbable. Registering it would collide with the built-in repository, so the runner rejects it. Blank repository names are rejected too. An undeclared name still fails route load. A stub resolves only its explicitly declared name. A typo hits the same compile-time ComponentNotFound gate as production. An unknown registry kind is a document error that lists the three supported kinds.
Stubs are lossy. The R-REPOSITORY-STUB warning on stderr names each stubbed registry and repository and lists the semantics the memory backend does not exercise: for cache, prefix purge, TTL/stale timing, disk offload, and stats; for idempotent and claimCheck, persistence; for all, backend failure. Cover these in the integration tier.
The example pair lives in examples/yaml-dsl/config/repositories-demo.yaml and repositories-demo.test.yaml.
Env fixtures
An optional env: map declares string fixture values for the unit-tier interpolation seams. Route files, inline routes: sources, and the doc-side identifier fields (repository and bean stub keys, intercept sources and targets, mock: references, inputs[].to) consult the map before their inline :-default. The map is the only resolution source beyond the defaults: the ambient process environment is never read, so runs stay hermetic.
env:
CACHE_REPO_NAME: faststub
repositories:
cache:
"${env:CACHE_REPO_NAME:-persistent}": memory
The placeholder on the stub key resolves to faststub. A route file carrying the matching reference resolves through the same map, so the step asks for the repository the document stubbed:
- cache:
repository: "${env:CACHE_REPO_NAME:-persistent}"
key: k
Both sides name faststub; the stub registers and the route loads. Without the env: map the same placeholder pair would resolve to the default persistent on both sides — the stub key and the step would still agree, but the fixture steers the name without editing either file.
Every env: value must be a string. An integer, boolean, or null value is a document error. Values are data, never re-interpolated: the value text is substituted verbatim, so a value that itself looks like a placeholder stays literal. Typing follows the route-side contract: a substituted leaf keeps string typing, so an integer- or boolean-typed field carrying a placeholder fails the document exactly as camel run rejects it, even when the env: map supplies the value. Numeric-typed knobs are tracked separately.
Identifier interpolation has a fixed grammar and scope. The doc-side identifier fields resolve ${env:NAME:-default} through the same scanner as the route sources. A stub key and its route reference thus always name the same value. Interpolation runs after deserialization and before the other document checks. The scheme, blank-name, and built-in memory guards see the resolved value. A placeholder without a default and without an env: entry fails document validation at exit 2. The message names the variable and the field position. Assertion data and path fields stay literal. Input body and headers values, expectReply blocks, matcher contents, bean methods and config values, repository stub targets, settle, and the routeFiles and routeFilesFromRoot paths are never interpolated. The scope follows the mock-testkit spec requirement "Doc-side identifiers interpolate through the document env layer for name-match parity with route sources" (openspec/specs/mock-testkit/spec.md in the repository, outside the rendered book). The regression that motivated the requirement is tracked as bd rc-4hexo.
CI output and filters
camel test accepts five flags for CI use: --junit, --filter-file, --filter-endpoint, --unit, and --integration.
--junit <FILE> writes a JUnit XML report after the run. The report holds one testsuite per attempted document, named by the document path as displayed in stdout. Each suite carries a <property name="tier"> row with the derived tier (lean or full) when the tier is known. Each assertion row becomes one testcase with the same label as its PASS/FAIL line (endpoint name, reply[i] <to> reply label, <settle>). A failing row carries a <failure> element. A document-level error (unreadable file, parse error, boot failure, route load failure, input delivery failure) becomes one <error> testcase named <document> in that document's suite. An expansion-level error (unreadable directory entry, zero-document directory) becomes one synthetic suite named by the path in the error, with a single <error> testcase named <expansion>. The report is written on exit-0, exit-1, and exit-2 runs alike. It is not written when a filter flag fails validation (see below). A report write failure prints to stderr and exits 2.
--filter-file <GLOB> narrows the expanded document set to documents whose entire displayed-path string matches the glob. The glob follows glob-crate semantics: * does not cross /, and ** does. The match happens before reading, so filtered-out documents are never read or parsed. Directory arguments display the paths as collected: a . argument yields ./-prefixed paths, and an absolute argument yields absolute paths. Patterns must account for the prefix. For example, --filter-file './sub/**' matches the ./sub/-prefixed paths a . argument produces.
--filter-endpoint <NAME> narrows the set to file-admitted documents whose expects map contains the given name. The match is exact against the bare endpoint name (the URI suffix after mock:). Scenario documents declare no expects, so an endpoint filter excludes them. Select scenario documents with --filter-file or by naming them on the command line. A file-admitted document that fails to parse still reports its error and sets exit 2, regardless of the endpoint filter.
--unit and --integration are symmetric tier filters. --unit runs only documents that derive the lean tier. --integration runs only documents that derive the full tier. The tier is content-derived, so the filter applies after parsing and tier derivation. A nonmatching document found through directory expansion is excluded silently. A nonmatching document named explicitly on the command line fails with tier-filter-collision and exits 2. Supplying both flags together is misuse. camel test rejects it before any document is read and exits 2.
Every executed document prints one tier annotation line before its PASS/FAIL rows: [lean] for the unit tier, [full] for the integration tier. CI parsers that consume stdout must account for these lines.
Exit codes follow a fixed contract. Verdict failures exit 1: expectation mismatch, settle timeout, reply assertion failure, scenario receive-timeout, and validation-mismatch. Apparatus failures exit 2: runtime scenario-var-unresolved (an unset variable is an authoring bug, not a product failure), action-transport-failure, partner-startup-failure, shutdown-failure, and infra-unavailable. Document validation failures exit 2: unreadable file, parse error, boot failure, and harness wiring errors. Precedence is 2 over 1 over 0.
Scenario documents run through one of two execution paths. The build selects the path.
| Build | Endpoint schemes | Execution path |
|---|---|---|
| default | fake: only | No-boot smoke path. Any other scheme reports infra-unavailable, names the adapter, and exits 2. |
integration-http | fake:, direct:, http: | Embedded full boot. Real composition root, real wire, harness partner listeners. Any other scheme reports infra-unavailable, names the adapter, and exits 2. |
integration-sql | no scenario endpoint references required; sql: actions | Embedded full boot. An sql:-only document runs without integration-http. |
The default build provides only the in-memory fake: partner adapter. A scenario whose endpoints are all fake: runs the no-boot smoke path. A fake:-only scenario keeps that path in any build.
The integration-http feature is enabled by default in camel-cli
since 2026-09-05. The build boots the real composition root. A scenario whose endpoints are all fake:, direct:, or http: qualifies. Each http: endpoint binds a harness partner listener on 127.0.0.1:0. A direct: send stimulates the booted context through its own producer path. The document runs over the real wire.
The integration-sql feature is independent of integration-http. It is on by default in camel-cli. An sql:-only document boots the same composition root without integration-http. The integration-sql CI job proves this independence: it builds camel-cli with --no-default-features --features integration-sql,itest-e2e and runs the scenario e2e suite.
Filters combine as AND across kinds and OR within repeats of one kind. The tier filter counts as a kind. When at least one filter is given and no document survives, camel test prints a misuse error naming the filters and exits 2. An invalid glob pattern prints to stderr and exits 2 before any document runs.
Split a large suite across CI jobs with --filter-file. Each job runs one shard and writes its own report. Example: a job that runs only the shard-1 documents:
camel test . --junit shard-1.xml --filter-file './src/**/shard-1*'
Annotating pull requests from the report requires the CI platform's JUnit publisher or report-ingest integration. On GitHub Actions, upload the report as an artifact and pass it to a JUnit-annotation action of your choice.
Scenario documents
A scenario: document is the integration-tier contract of ADR-0069. The document declares an action list. The runner executes four actions in order: send, receive, sleep, and validate. A send takes an optional method field, for example method: PUT. The field is uppercased at load. Without the field, a body implies POST and no body implies GET. The README of the camel-integration-test crate is the grammar reference.
A scenario document may declare a partners: section to script the responses a harness partner serves. The section is a map from the declared endpoint string to a sequence of script entries. The same document interpolates ${name} in endpoint strings, body string leaves, and header values. bindVar fills a scenario variable with the partner's bound authority. The crate README documents the partners: shape, the interpolation surface, and the two-layer bindVar rule.
A scenario can script failure paths. A partners: entry may declare delay, fault: close, and times. The harness holds for the delay before it serves the response or commits the fault. The fault: close drops the connection without an HTTP response. The times repeats an entry for a fixed number of matching requests. A route-level error_handler.retry redials after a fault, so a fault-to-healthy sequence tests retry behavior.
A validate action with a partner target asserts the recorded-request count. The count is exact and non-negative. Optional method and path filters narrow it. A deadline polls until the count settles or the deadline passes. The runnable example pair lives in examples/integration-testing/partner-retry-route.test.yaml and partner-retry.routes.yaml.
A partner target's URI must equal a harness endpoint reference the scenario's own send/receive actions declare, or self-declare one. The object target form self-declares: provisioning: harness on an http endpoint plus a partners: entry naming the URI. The validate's own reference then wires the partner exactly as a send/receive reference does: the driver binds the partner and fills the reference's bindVar with the bound authority.
Proxy routes whose query varies per request need this form. The varying query makes each request dial a different wire path, so no literal arrival lane exists for a receive to name; the recorded traffic is the only assertion surface, and the scenario runs with no sacrificial receive. The route step reads the bound authority and appends the query — for example to: ${env:UPSTREAM}/tiles?bbox=1.2:
scenario:
- send: {to: direct:start}
- validate:
target: {partner: {endpoint: http://upstream/tiles, provisioning: harness, bindVar: UPSTREAM}}
expectation: {count: 1}
deadline: 5s
partners:
http://upstream/tiles:
- path: /tiles?bbox=1.2
response: {status: 200, body: tile}
One declared harness endpoint and one bindVar can serve every path a
route dials. The route's to: URIs share the one authority and differ
only in path, for example http://${MOCK}/orders and
http://${MOCK}/billing. The partners: section scripts each path
under the same declared key, one entry per path with its own response
body. The two-key rule governs resolution: the declared endpoint string
is the provisioning key that binds the listener, and an interpolated
reference such as http://${MOCK}/billing is a dynamic reference the
router resolves to the already-bound partner by authority. Arrivals queue
per request path on that single listener.
Do not declare one endpoint per path. The N-bindVar fan-out provisions
one listener per path for what is one logical partner, and the extra
bindings reassign ports spuriously; this caused the 2026-09-06 pilot
incident. Each dynamic-reference receive names its own path, and each
drains its own arrival lane on the single listener: from: http://${MOCK}/orders drains the orders lane, from: http://${MOCK}/billing drains the billing lane. A dynamic receive must
name a path — a bare authority is an apparatus error. The registered
key remains the adapter-lookup key; the path on the wire picks the lane.
In the client role, standalone roundtrip receives drain their own
path's parked roundtrip oldest-first: two receives naming different
paths of one partner never cross-match (bd rc-cr5yf). The runnable
example pair lives in
examples/integration-testing/partner-multi-path.test.yaml and partner-multi-path.routes.yaml.
A migration note for suites that grew one script per assertion: express
each independent assertion chain as its own scenario document.
Documents run independently, and the runner continues past a failing
document, so one camel test run reports every chain. ADR-0069
section 11 keeps the ordered action list as the pin: the actions inside
one document stay ordered.
Back-to-back send: actions with no intervening receive: dispatch genuinely concurrent requests. The crate README documents the burst-send recipe for asserting the concurrent arrivals. A direct: send may declare expectReply to assert the route's synchronous reply; the README's usage/grammar area details the verb. The scenario-tier field takes matcher verbs directly, unlike the unit tier's expectReply block.
A scenario document may declare one document-level logs: block. The harness captures every tracing event emitted while the document runs, and it evaluates the block after the action list completes. Three clauses exist, and every declared clause must hold. contains lists substrings of captured event messages. regex lists unanchored patterns; each pattern matches against the composite camel-log message. noLevelAbove sets a level cap over the whole document window; the cap spans every target, route processors and harness tasks alike. An unknown level, an invalid pattern, or an unknown key fails the load.
Log capture is process-global. Documents that run concurrently in one process attribute events conservatively: the harness files an event in every open window, so a sibling document's WARN can fail this document's noLevelAbove cap. Serialize log-asserting documents, or keep them on the current-thread itest path, when a document needs strict isolation.
Datasource steering
A scenario reads SQL state through the booted context's datasource catalog. The datasource itself lives in Camel.toml, not in the document: the datasource: field of a sql: action and of a validate sql target names a key from the [datasources] table. That table is a strict interpolation surface. Leaf values resolve ${env:NAME} and ${env:NAME:-default}, and a residual marker fails the load instead of passing through (see crates/camel-config/CONTEXT.md).
[datasources.appdb]
provider = "sqlx"
db_url = "${env:APPDB_URL:-sqlite:file:memdb_demo?mode=memory&cache=shared}"
At boot the placeholder resolves through the same layered source as the route files. The source checks harness-provisioned bindVar values first, then document env: values, then variables listed in envPassthrough:, then the inline default (ADR-0069 section 4). A hermetic document pins the value itself:
env:
APPDB_URL: "sqlite:file:memdb_demo?mode=memory&cache=shared"
The shared cache is a requirement, not a preference, for every :memory: datasource. A bare sqlite :memory: database is per-connection. An INSERT on one pooled connection and a SELECT on another can therefore hit different databases, and a validation can pass against state the document never seeded. The boot rejects sqlite::memory: without cache=shared with the sql-memory-not-shared error (crates/camel-integration-test/CONTEXT.md).
The recipe above shows the recommended shape, the named shared-memory URI. A name such as memdb_demo holds one database, and every pool connection shares it, so the author selects max_connections for the workload. sqlite:file: matches no automatic datasource factory prefix, so the datasource pins provider = "sqlx". The bare sqlite::memory:?cache=shared form is still accepted. Its shared name comes from sqlx-internal naming, and with the Any driver each pooled connection can hold a private database. Pin max_connections = 1 with that form.
A document that needs a real database lists the variable in envPassthrough: and keeps the inline default:
envPassthrough:
- APPDB_URL
The CI job or Compose file then supplies APPDB_URL, for example a service-container Postgres URL. The harness never provisions the database. The address arrives through the variable, so the surrounding infrastructure stays the author's concern (ADR-0069 section 9).
Two laws keep the surface orthogonal. The datasource name (appdb) is an identifier path, and interpolation never touches it. The db_url value is an env leaf path, and the layered source always resolves it. The same laws govern every strict-prefix table (crates/camel-config/CONTEXT.md), so this section describes one instance of a general steering pattern, not a datasource-specific rule (bd rc-l7m7t, bd rc-4hexo).
Isolation and teardown
Each scenario boot owns its datasource catalog and its pools. The boot teardown closes those pools after the context stops. An in-memory sqlite database therefore dies with its boot: a later document booting the same [datasources] alias in the same process starts from an empty database. camel test runs documents sequentially in one process, so this per-boot freshness keeps one document's seeded rows out of the next document's validations. The guarantee is a contract of the teardown seam, not an accident of driver internals (crates/camel-integration-test/CONTEXT.md).
The guarantee is load-bearing for named shared-memory URIs. A datasource pinned to sqlite:file:<name>?mode=memory&cache=shared shares one named database across every connection that uses the name, in any boot. A lingering connection from an earlier boot would carry that database's rows into the later boot; the teardown close is what kills it. The adversarial tests pin this shape directly.
The scenario-tier convention for memory fixtures is the named shared-memory URI, for example sqlite:file:memdb_demo?mode=memory&cache=shared. A named memory URI shares one database across every pool connection. The author therefore selects max_connections for the workload, and the named_shared_memory_uri_probe in the sqlx pool factory pins multi-connection sharing. The bare sqlite::memory:?cache=shared form is still accepted, but with the Any driver each pooled connection can hold a private database there, so that form pins max_connections = 1.
Durable datasources are outside the per-boot guarantee. A file-backed sqlite database, or a service-container Postgres behind envPassthrough:, keeps its rows across boots. No harness mechanism cleans it between documents. Isolation for durable datasources is the document author's responsibility — the same law that governs user-provided infrastructure generally (ADR-0069 section 9): the harness provisions hermetic defaults, never cleanup for resources it does not own.
The authoring convention for durable datasources is the prepare-action clean-first idiom: the first sql: prepare statement deletes the state a previous run may have left, before any INSERT re-seeds it.
scenario:
- sql:
datasource: appdb
prepare:
- DELETE FROM orders # clean first: a prior document's rows
- INSERT INTO orders VALUES ('seed-a')
DELETE FROM (whole-table) or a table-recreating statement are the two clean-first shapes; TRUNCATE applies where the engine supports it. A document that skips the clean-first statement works only as long as it runs alone. The adversarial boot-freshness tests in crates/camel-integration-test pin both directions: a second memory-sqlite boot reads zero, a second file-backed boot reads everything.
Known limitation, parallel mode (bd rc-gcf9n): camel test executes documents sequentially today. When parallel document execution lands, two concurrently booted documents that share one memory name could collide on the same shared memory database. The planned remedy is a per-boot unique suffix in the memory name (memdb_{scenario}_{boot}) minted by the harness for hermetic memory datasources. This note records a plan, not a rule for the current runner. Until parallel lands, documents that share a durable datasource must not run concurrently.
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. |
| 0058 | Outcome-aware segment composition contract | Defines how outcome-aware EIP segments compose with PipelineOutcome semantics. |
| 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. |
| 0057 | HTTP header emission policy | Sorts HTTP headers into three RFC-derived buckets that decide what the component emits. |
| 0059 | Auth extraction path divergence | Documents where credential extraction diverges from the shared trust-boundary path and why. |
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. |
| 0056 | Cache Repository port | CacheRepository port in camel-api with a memory-default backend for cache storage. |
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. |
| 0060 | MCP as a first-class component | MCP becomes a first-class component with its own adapter confinement and trust rules. |
Process and Tooling
Workspace policy for tests, builds, and publishing.
| ADR | Title | Summary |
|---|---|---|
| 0054 | #[ignore] test classification policy | Ignored tests must carry a reason and a classification; an xtask lint enforces the policy. |
| 0055 | Publish topology without cyclic dev-dependencies | Publishable crates must not form cyclic dev/build dependency chains in the publish topology. |
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).
Amendment 2026-08-28 — ADR-0066: error family non-disableable; retry and breaker accounting
ADR-0066 amends this ADR in three ways.
- The error family is the only non-disableable metric family.
MetricsCollector::increment_errors(this ADR's signal-replacement API) is always emitted. No[observability.metrics]lever can disable it. The success-path families introduced by dashboard-observability (rc-6s6h) are gateable. - Retry accounting. One
increment_errorsper exhaustedNetworkRetryPolicysequence, executed by the policy helpers (retry_async/retry_async_cancelable). Call sites do not increment on their ownErrarm for attempts the helper retries. Cancellation is not failure. Per-attempt telemetry lives oncamel_retry_attempts_total{scheme,operation}. - Breaker rejections are not errors. Open-breaker fast-fails
(
CamelError::CircuitOpen) count oncamel_circuit_breaker_rejections_total{route}and are excluded fromcamel_errors_totalin the pipeline tracer. The taxonomy categories (a)-(h) and the signal-replacement rules above are unchanged.
See ADR-0066 for the full collector binding and lifetime contract.
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: Amended by ADR-0063 (originally 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. The Redis implementation ships as the camel-redis-repo repository service crate (Amended by: ADR-0063), not inside the component.
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-0063: Redis repository service. The Redis backends form a separate port family from Claim Check. ADR-0063 records why the
StepLifecycleguidance above does not apply to Redis repository connections: they hold no background work, so no connection lifecycle exists to manage —docs/adr/0063-redis-repository-service.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 nine compile-time guardrails: incompatible withrequired,default,secret,name,aliases, and any non-stringkind; empty separator rejected; separator without trailing.rejected; bare.rejected (strips to an empty name). - 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
Amendment (2026-08-20): unsafe impls removed
The unsafe impl Send / unsafe impl Sync for SharedSnapshot described
below no longer exist. BoxProcessor is now
tower::util::BoxCloneSyncService, which is Send + Sync by construction.
CompiledStep therefore shares through the standard Arc auto traits, and
no unsafe impl is needed. The compile-time guard in route_compiler.rs now
asserts both CompiledStep: Send and CompiledStep: Sync (change
fix-pipeline-syncbox-mutex-convoy). The Decision, Consequences, and
Safety sections below stay unchanged as history; they describe the state
before the type change. Send/Sync safety is now proven by the compiler.
The INVARIANT rule (no interior mutability) is not what the compiler
proves. Types with interior mutability such as Mutex are still Sync.
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
Amendment 2026-09-11: Ollama execution tier (resolves bd rc-gfum)
Self-grill question 5 deferred the decision on where and when to run the 8
Ollama slow test: cases. bd rc-gfum resolved it. The slow test:
classification does not change. The amendment adds only the execution tier.
Ruling: hybrid. Two tiers run the suite:
- Primary verification (local, weekly). A
systemd --usertimer on the developer host runs the full 8-test suite against the realqwen3.5:4b(3.4 GB) andembeddinggemma(621 MB) models, which are already present. The timer isPersistent=true, so it fires after a boot if the host was off. Results append one JSON line per run to.opencode/fleet/events.log, which the agent fleet already monitors. A failure files a P1 bd bug linkeddiscovered-from:rc-gfumto raise human attention. - CI wiring smoke (GitHub Actions, weekly + manual). One job pulls only
the 621 MB
embeddinggemmamodel and runs onlyollama_embedwith--ignored. It catches CI-environment rot (adapter construction, socket transport, config round-trip) without paying for CPU-only 4B inference. The model cache key holds 621 MB, which is 6% of the 10 GB repository quota. Job timeout is 15 minutes.
Why not a nightly 4B job on GitHub runners. A 4B model on a 2-4 vCPU,
no-GPU runner generates roughly 2-8 tokens per second. Seven of the eight
tests generate text; two carry tool-schema and multi-turn context that forces
tens of seconds of CPU prompt ingestion before the first token. The realistic
job envelope is 20-40 minutes, the model cache consumes one third of the
shared quota, and ollama_cache_hit asserts a timing ratio
(elapsed2 < elapsed1/2) that is flaky on a contended CPU runner. The local
tier already provides inference-quality coverage green, so the nightly job
would buy noise.
The bd closes once this note lands and the first local timer run appends a
pass line to events.log.
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: Amended by ADR-0063 (originally 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:95-115 (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:95-115 (get
checks expiry), crates/camel-core/src/cache/memory.rs:128-135 (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:128-135 (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.
Amendment (2026-08-18): the 60s default documented above never shipped — the wiring hardcoded 1h.
sweep_intervalnow makes the interval configurable via[default.cache_repo]; the default stays 1h because an O(N) sweep over a large persistent cache costs more than delayed reclamation.
- 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:68-120 (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. The Redis backend ships as the camel-redis-repo repository service
crate (Amended by: ADR-0063), not inside the component. Its reclamation uses
one SET ... EXAT (expires_at + stale_retention) write, so the server deadline
is retention-bounded garbage collection, not expiry enforcement (ADR-0063
Decision 8).
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. The anticipated len()/keys()
extension materialized as the default async method invalidate_prefix (chosen
over a separate trait — single registry lookup, no downcast); CacheStats
grew peek_stale_served/invalidations/bytes (source-breaking for external
struct literals; migrate with ..Default::default()).
Amendment (bd rc-22wj, pre-1.0): the stats method signature was corrected from
sync fn stats(&self) -> CacheStats to async fn stats(&self) -> CacheStats
(default body unchanged, still infallible). A synchronous signature made it
structurally impossible for RedbCacheRepository to offload its payload-sum
byte scan off the tokio worker. Call sites await; no twin sync/async pair was
introduced. Ruled by escalation review (e_gpt) over the rejected twin-method and
redb stored_bytes() alternatives.
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-62.
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-62 | CacheStats struct (not non_exhaustive) |
camel-api/src/cache.rs:68-120 | 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:95-115 | get checks expires_at in-band |
camel-core/src/cache/memory.rs:128-135 | 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).
With stop_on_exception=false, zero success reports Failed(last_error).
The error is the highest-branch-index representative in the parallel arm.
It is the iteration-last error in the sequential arm. Partial success
aggregates successful branch outputs only. multicast reports Completed
with the aggregated outputs. Discarded failures are logged at warn. This
resolves the recipient_list inconsistency tracked as bd rc-b41j.
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 the success set is empty
and last_error is set. Both arms enforce the partial-success guard per bd
rc-b41j. The guard checks outputs.is_empty() in sequential_multicast and
completed.is_empty() in parallel_multicast. Partial success aggregates the
successful branches and logs discarded failures at warn.
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.
ADR-0059: Auth Extraction Path Divergence
Date: 2026-08-15 Status: Accepted References: ADR-0010, ADR-0032, ADR-0033, ADR-0051 Bd: rc-7x1z
Context
A route credential can arrive by more than one channel: an Authorization
header, a query parameter, a cookie, or a named custom header. Two code
paths validate that credential, and they diverge.
The HTTP layer owns the first path. SecurityPolicyLayer runs before the
pipeline (ADR-0010). It calls authenticate() inside policy.evaluate.
Extraction reads the route-declared credential_sources from the Exchange
input. The policy owns extraction.
Components own the second path. WS and gRPC pre-authenticate the request. They
extract the credential before policy.evaluate runs. They store the resulting
principal in the Exchange property camel.auth.principal. The policy then
falls back to that preloaded principal via trust_upstream_principal. The
component owns extraction.
Before this change, the two paths used different extraction code. HTTP used a
hardcoded Authorization prefix strip. WS used extract_token_multi. gRPC
still uses its own hardcoded prefix strip. The split caused two defects:
- A route could not declare a cookie or query source on the HTTP path, because the layer never reached the multi-source extractor.
- The preloaded-principal branch carried a serialization split: the writer
stored a JSON string, but the reader expected a JSON object. Every
trust_upstream_principalgrant on the component path returned 500.
Decision
Extraction is standardized behind extract_token_multi. Every source — the
Authorization header, a query parameter, a cookie, and a named custom
header — flows through the same extraction and the same constant-time store
lookup. A route declares its
sources in credential_sources, in order. First match wins.
The divergence stays. The HTTP layer calls extraction inside authenticate().
Components call extraction before policy.evaluate. A WS roles/scopes
route requires explicit trust_upstream_principal: true. The component gates
evaluation on successful authentication, so the spoof caveat from ADR-0010 does
not apply on that path.
The preloaded-principal branch reads and writes one serialization format. The
canonical store_principal_properties / principal_from_exchange pair stores
the principal as a JSON string in camel.auth.principal. The trust branch now
delegates to principal_from_exchange (tracked in bd rc-7x1z), so the
read matches the write.
Consequences
Unifying HTTP onto extract_bearer_token widened the default-path acceptance.
The auth scheme is now case-insensitive and whitespace-tolerant per RFC 9110
(RFC 7235). A lowercase or uppercase bearer scheme, a leading space, or a
double space now authenticate when the store holds the trimmed token.
Previously rejected malformed credentials now either authenticate (if the store
accepts the token) or are rejected through the normal path. No previously
granted route changes outcome. Fail-closed is preserved. WS behavior is
unchanged — it already used this extraction.
An empty token after the scheme (Bearer with nothing following) is treated
as an absent source: with trust_upstream_principal=false the request stays
unauthenticated; with trust=true it can grant through a preloaded
principal — the same outcome as a request that carries no Authorization header
at all.
The preloaded-principal fix is load-bearing. The store/read pair was
historically split across two serialization formats (store_principal_properties
wrote a JSON string; extract_principal_from_exchange read a JSON object).
Every trust grant on the component path returned 500 as a result. The split was
latent since the WS preloaded-principal path landed. This change unifies both
sides on the canonical string format. The WS roles/scopes + trust
combination now works; before the fix it always failed.
A deferred Phase-1 note: header values rejected by http::HeaderValue::from_str
(non-ASCII bytes, control characters) are treated as absent sources, never
fatal (ADR-0032). On the layer path with trust=true and a preloaded principal
present, such a value flips an error to a grant — the same outcome as a request
without that header.
First-match-wins precedence is deterministic: the first declared source with a present value wins, even when a later source holds a valid token.
gRPC is a fast-follow. Its extract_principal still hardcodes the
Authorization header and the Bearer prefix (camel-component-grpc/src/server.rs).
It does not read credential_sources yet.
ref, wasm, and permission policy variants carry no credential_sources.
They are rejected when the key is present because a registry-resolved
Arc<dyn SecurityPolicy> and the WASM/permission evaluators carry no
authentication-capability metadata, and adding that metadata would violate the
no-new-abstraction constraint.
Alternatives considered
- Keep the two extraction code paths separate. Rejected. The split hid the serialization defect and blocked cookie/query sources on HTTP.
- Resolve all sources at the component layer only. Rejected. HTTP's layer path needs the policy to carry sources; component-internal resolution is not a DSL surface.
- Rewire
trust_upstream_principalinto a verified-principal channel. Deferred. Out of scope for this change.
ADR-0060: MCP as a First-Class Component
Date: 2026-08-16
Status: Accepted
Amends: none
Cross-refs: ADR-0020 (adapter confinement), ADR-0032 (exchange-data trust
boundary), ADR-0033 (fail-closed security defaults), ADR-0038 (per-item DoS
caps), ADR-0052 (diagnostic endpoint posture)
Origin: OpenSpec change add-mcp-component (bd rc-elcd)
Context
MCP (Model Context Protocol) tools are the natural extension surface for the
LLM component (camel-component-llm). Without a transport, a route can only
request a tool call. It cannot dispatch one to an external tool server, nor
expose its own tools to an MCP host.
Two folds were rejected. Folding MCP into camel-component-llm double-binds
the chat/embed-shaped LlmProvider to a tool-transport protocol. Folding it
into camel-http conflates JSON-RPC semantics with HTTP server mechanics.
MCP is its own bounded concern.
MCP is therefore a first-class Component (scheme mcp:), on the same footing
as camel-http or camel-kafka.
Decision
Rule 1: First-class Server + Host component
camel-component-mcp owns both MCP roles. Creation path disambiguates the
role, mirroring the split camel-http uses for HTTP server vs client:
- Consumer (server): exposes tools and resources over Streamable HTTP.
- Producer (client): dispatches tool calls and resource reads to remote MCP servers.
Rule 2: Route-owned tool dispatch
The component never auto-loops. The LLM component emits ChatEvent::ToolCall
intent. The route decides to route to mcp:call. The route reshapes the
result into ChatRole::Tool for the next turn.
The MCP producer is a dispatch target, never a decider. It issues exactly one JSON-RPC request per Exchange.
Rule 3: DSL-lowered catalog (declaration surface, TOML-owned runtime)
A mcp: DSL block declares a named server, its tools, and its resources. The
block lowers each tool to an mcp:<server>/tool/<name> consumer route and
each resource to an mcp:<server>/resource/<name> consumer route.
This is not a full rest: → http: analogue. rest: lowering emits routes
with processing steps (unmarshal, marshal, status injection); the mcp: block
is a step-less catalog declaration — it registers names, schemas, and
resource URIs on the server. Its lowered routes carry no processing steps
(deny_unknown_fields forbids steps inside the block), so a tools/call on a
bare lowered route echoes its arguments through the identity pipeline. Tool
and resource behavior is expressed by explicit routes whose from: is an
mcp: URI and that attach their own steps. Steps passthrough inside the
mcp: block is a tracked limitation (bd rc-23y2), not a design goal.
Runtime server config is TOML-owned. mcp.servers.<name> (McpServerConfig)
is the sole source of runtime server config: bind, tls,
security_policy, max_tools, max_resources, allowed_hosts. The DSL
block's server fields (bind, tls, security_policy, max_tools,
max_resources) are a declaration/validation surface only — they document
declared intent and mirror the TOML keys (declaration parity), but their
values do NOT flow to the runtime. Divergence between a DSL bind and the
TOML bind for the same name is not reconciled: TOML wins.
The DSL server name is the coupling key: it MUST match a
mcp.servers.<name> key or consumer START fails cleanly (McpError::Endpoint
naming the missing server). A DSL block whose server is absent from TOML
config lowers routes that fail at consumer start, never silently.
Rule 4: rmcp confined to src/adapter/
All rmcp imports live in src/adapter/ (ADR-0020). Project-owned traits
(McpClient, the server handler) carry Camel-shaped types across the
boundary. The JSON-RPC envelope dies at the trait boundary. A boundary test
scans for rmcp references outside the allowlist.
McpServerMap (HashMap<String, Arc<dyn McpClient>>) is the client-role
twin of the LLM ProviderMap. It is a per-component map, not a global
registry (ADR-0020).
Rule 5: Streamable HTTP only
The only transport is Streamable HTTP. The transport config key accepts
exactly the string streamable-http; anything else fails at deserialization
(see Implementation decisions). The deprecated HTTP+SSE transport is excluded
at the Cargo level: the rmcp transport-sse-* features are never enabled.
Rule 6: Protocol baseline 2026-07-28, stateless
The component speaks exactly MCP 2026-07-28, the first stateless revision.
No initialize handshake. No protocol sessions. No Mcp-Session-Id.
Per-request protocol version and client capabilities travel in _meta.
The client connects with ClientLifecycleMode::Discover. A remote that does
not speak 2026-07-28 fails fast at producer start as a CamelError. The
server overrides supported_protocol_versions() to the single baseline;
rmcp's inline guard rejects other peers with JSON-RPC -32022.
Rule 7: Exclusions
- Prompts: deferred. Duplicates
camel-template/ MiniJinja (ADR-0047). - stdio transport: rejected. Camel is not a process supervisor.
- Protocol sessions: rejected. The baseline is stateless.
- Legacy transports (SSE): rejected at the Cargo level.
- Resource subscriptions: rejected. v1 serves read-on-demand only.
Rule 8: Security posture
MCP server inputs are adversary-controlled Exchange data. They cross the ADR-0032 trust boundary and drive the data plane. The server endpoint is therefore NOT a diagnostic endpoint: ADR-0052 exempts only non-data-plane inbound. It does not inherit the ADR-0052 unauthenticated convention.
Instead (ADR-0033 Require-Explicit-Choice) the server uses two gates. The
bind gate is presence-only: a mcp.servers.<name>.security_policy key must
be set, or the consumer refuses to start (fail-closed). The enforcement gate
is route-level: the adapter copies the inbound HTTP request headers onto the
Exchange, and each request runs through the route's SecurityPolicy
(camel-api, ADR-0033) before any route step. A mcp: block's server
security_policy propagates to every lowered tool and resource route. The
TOML security_policy stays a presence gate only. Loopback bind is
preferred. A non-loopback bind emits a warn! at startup (ADR-0052 rule 3).
Catalog cardinality is capped. max_tools / max_resources default to 128
each and ride the ADR-0038 per-item config channel: hardened but raisable, no
global disable switch. Breach rejects the (N+1)th route at consumer start
with a clean CamelError. Silent truncation is forbidden (ADR-0038).
Rule 9: No cross-crate types
MCP types stay in the component crate. No MCP-shaped type crosses into
camel-api or camel-component-api. The client Exchange contract is
Body + headers (CamelMcpToolCall). The crate boundary is this ADR's own
rule, following the ADR-0020 confinement precedent.
Implementation decisions (carried from implementation review)
The following decisions surfaced during implementation. They are recorded here as carried reviewer obligations — explicit decisions, not omissions.
Remote config fails fast at deserialization
Remote entries (remotes map) use #[serde(deny_unknown_fields)]. The
transport field is an enum whose deserializer accepts only
streamable-http. A typo or a legacy transport string fails at config load.
Unknown keys fail too.
URL strings are NOT validated at startup. They are validated at first use —
connect time — matching the LlmBundle precedent. Server entries are the
opposite: validate_server_policy runs at consumer start, fail-closed
(security policy present, bind is an IP literal, caps nonzero).
Producer propagates is_error faithfully
The producer receives McpToolResult with a structured is_error: bool. It
carries the flag and the content into the Exchange body and the
CamelMcpResult header. It does NOT act on the remote's error flag.
This is an explicit decision, not an omission. The route author decides how to handle a remote-reported failure. The component must not invent policy.
Same-process both-roles start order
The producer connects at route start() and the discover connect is not
retried. In a same-process deployment that serves both roles, the server
consumer must start before any producer route that targets it. Operators
control this with route definition order or startup_order. A producer that
starts first fails fast and stays not-ready; it does not silently recover.
Shared-listener bind conflicts fail closed
One shared listener serves each bind. A later consumer whose config conflicts
with the live listener — different allowed_hosts, TLS shape, or catalog
caps — is rejected with McpError::Endpoint. Duplicate tool/resource
registration is rejected atomically under the registry lock. Two concurrent
same-name starts cannot silently overwrite the first registration.
Non-object input schemas advertise as {}
A non-object input schema cannot be expressed in rmcp's catalog shape. It
degrades to {} at the catalog, while call-time validation accepts anything
for such a schema. This is a known drift between advertisement and
enforcement. Hardening is tracked in bd rc-ap58.
Consequences
- MCP tool dispatch composes in routes without an LLM auto-loop.
- rmcp churn is confined to
src/adapter/(ADR-0020). - The DSL
mcp:block gives operators a declarative server catalog. - Security posture is fail-closed: no bind without policy, caps enforced.
- The protocol baseline is single-version. Legacy peers are rejected, not negotiated.
- Prompts and subscriptions stay out of v1.
Options considered
Fold MCP into camel-component-llm
Rejected. Double-binds the chat/embed-shaped LlmProvider to a
tool-transport protocol. Re-imports tool execution that ADR-0020 isolates.
Fold MCP into camel-http
Rejected. Conflates JSON-RPC semantics with HTTP server mechanics. Reuse its
reqwest client and ServerRegistry pattern as dependencies; do not host MCP
semantics there.
Generalized tool-dispatch SPI in camel-component-api
Rejected for v1. Follows the LlmProvider precedent: provider traits stay
local while there is a single consumer. Promote to SPI only if a second
consumer appears.
Self-grill record
Questions generated:
- [glossary] Does "MCP Server Consumer" collide with the Components "Consumer" term?
- [sharpen] Does the security posture contradict ADR-0052 (unauthenticated by convention)?
- [cross-ref] Does route-owned dispatch need an ADR at all, or is it plain component behavior?
- [scenario] What happens when a later consumer on the same bind differs in TLS or caps?
Answers:
- [glossary] No. "Consumer" names the runtime role (inbound adapter). "MCP Server Consumer" names the MCP-specific instance of that role. The terms differ at the identifier level; no glossary collision.
- [sharpen] No. ADR-0052 governs diagnostic endpoints — non-data-plane
metadata. An MCP server carries tool invocations from a host; that is data
plane, crossing the ADR-0032 boundary. It must authenticate. The
loopback-preference and the non-loopback
warn!transfer (ADR-0052 rule 3); the unauthenticated-by-convention rule does not. - [cross-ref] No prior ADR governs tool dispatch semantics. The LLM
component never executes tools; nothing in ADR-0020 or the EIP corpus
decides who calls
mcp:call. Recording it prevents a future auto-loop "convenience" from shipping as default behavior. - [scenario] The second consumer is rejected at
get_or_spawnwithMcpError::Endpointnaming the conflict. Fail-closed, never silent.
Outcome: approve as new ADR (0060). MCP as first-class Server+Host
component; route-owned tool dispatch; DSL-lowered catalog; rmcp confined to
src/adapter/ (ADR-0020); Streamable HTTP only; protocol baseline
2026-07-28 stateless. Exclusions: Prompts, stdio, sessions, legacy
transports, subscriptions. Implementation decisions recorded as carried
obligations; the schema-advertisement drift is tracked in bd rc-ap58.
Self-grill mode: manual (4 principles L6: glossary consistency, conflict
with existing ADRs, redundancy with implicit ADRs, numbering — 0060 next free
after 0058).
ADR-0061: Unified Transport Authentication Kernel
Date: 2026-08-20
Status: Accepted
Amends: ADR-0060 (Rule 3 amended, Rule 8 superseded)
Supersedes: ADR-0059 (in part: the retained extraction divergence)
Cross-refs: ADR-0010, ADR-0032, ADR-0033, ADR-0051, ADR-0052, ADR-0059,
ADR-0060
Origin: OpenSpec change unify-transport-auth (bd rc-fzgm)
Amendment (rc-f79u, 2026-08-23): wasm source joins the kernel
The wasm source transport is the fifth kernel transport. TransportId
gains the Wasm variant. The transport converges on the
boundary-authentication shape (Gen B, like ws and grpc). The host
authenticates at the axum host-edge handler, where the raw request
exists. The guest never observes authentication. Denials never reach
accept-http. Accepted requests keep the 202-immediate-ack semantics
unchanged. The WIT contract is untouched.
Boundary authentication
The host-edge handler runs kernel_authenticate before the guest is
woken. A denial renders 401 (unauthenticated) in the transport idiom.
A non-Public plan without kernel wiring denies with 401. Missing wiring
never degrades to Public.
The host edge performs authentication only. Authorization stays
pipeline-owned. Strict dispatch and the policy layers enforce it after
the Exchange exists.
Carrier threading
The Exchange does not exist at the auth point. The minted principal
rides the request metadata as a private HttpMeta field. The host
stashes it into the SourceHostState pending slot. The slot holds one
outstanding request. install_carrier places the principal on the
Exchange at assembly, when the guest submits the request. The
one-outstanding-request invariant prevents a follow-up accept from
poisoning the slot.
Operator-authoritative bind
The operator bind is authoritative. A guest bind that conflicts with
the operator bind fails at consumer startup, before TcpListener::bind.
Neither direction overrides silently. Non-loopback binds run
enforce_bind_exposure_gate with WasmSourceBindAcks. The CLI wires
the acks like the MCP bind acks.
Classification delivery
SecurityContext.policy became Option<Arc<dyn SecurityPolicy>>.
SecurityContext::from_plan builds plan-only contexts. The route
controller delivers classification for every staged server route.
deliver_security_context runs at both the start and the resume
sites. A wasm: source route without a plan is Public pass-through,
subject to the per-bind exposure gate.
The four-transport references below (Rule 8, Consequences) now read five. The wasm source is the fifth.
Context
Server-component authentication lived in two wiring generations. Gen A
(http, mcp) passed headers into the Exchange and let the core
SecurityPolicyLayer extract, authenticate, and authorize. Gen B (ws,
grpc) received an injected SecurityContext and authenticated at the
transport boundary. Both worked. They disagreed on where authentication
happens, how denial is rendered, and what happens when no principal
reaches the authorization layer.
Main had already ratified parts of the target contract (73076dbd,
bd rc-5u38): fail-closed {{env:}} placeholders, a named
security-provider registry with per-route security_policy.provider,
OIDC JWKS prefetch, N native credentials, and gRPC honoring
credential_sources. The framework lacked the one artifact that makes
these coherent. That artifact is a compiled per-route security plan with
explicit access modes and an unforgeable principal type.
This ADR records the decided architecture. Delivery has three phases
(expert-ruled, KB rc-fzgm-e-opus-reruling). Phase 1 (kernel types,
fail-closed layer, compiled plan, per-bind exposure gate) has landed.
Phase 2 (transport convergence) and Phase 3 (audience enforcement) are
planned.
Decision
Rule 1: Transports extract, the kernel authenticates
Transport components never implement authentication. They extract
credentials per the plan and render denial in the transport idiom (HTTP
status, ws close, tonic::Status, JSON-RPC error). The kernel in
camel-auth owns authentication semantics and authorization:
kernel_authenticate verifies credentials against the registered
provider, install_carrier places the typed principal on the Exchange,
and enforce_dispatch checks the route binding before dispatch.
This supersedes the divergence ADR-0059 retained (layer-authenticated
HTTP vs boundary-authenticated components). ADR-0059's extraction
standardization (credential_sources, first-match-wins source order)
carries forward unchanged as the extraction input.
The plan is control plane (ADR-0032). It compiles before listeners bind. Credentials and principals cross the boundary as typed values, never raw tokens.
Rule 2: Kernel types
camel-api (security_policy.rs) owns the plan vocabulary:
TransportId: Http, Ws, Grpc, Mcp.AccessMode:Public,Authenticated,Authorized(Arc<dyn SecurityPolicy>).AuthPrincipal: read trait over a verified principal.AuthContext: carriesTransportIdand the principal intoSecurityPolicy::evaluate.RouteSecurityPlan: compiled per route at staging. Fields: access mode, provider ref (Noneonly forPublic), credential sources (capability-checked per transport), audience binding.AudienceBinding: reserved field. Enforcement lands in Phase 3.
The concrete AuthenticatedPrincipal lives in camel-auth.
camel-api never names it.
Rule 3: Provider selects authentication, access mode decides enforcement
security_policy.provider is the authentication selector. The access
mode is the enforcement predicate. The two axes are orthogonal and
jointly required. A route declaring a policy or a provider is never
downgraded to Public by missing wiring. Classification fails loudly
instead.
The core SecurityPolicyLayer fails closed when the principal is
absent for non-Public routes (ADR-0033 posture extended to principal
absence). Additive role grants never conjure authentication. Explicit
denial overrides grants.
Rule 4: Public by default, gated per bind
Public is the default access mode only for routes that declare no
security at all. The default and its gate ship in the same phase
(expert-ruled additive layering). A non-loopback bind that serves any
Public route requires operator acknowledgment:
allow_public_exposure = true under [binds."<bind-address>"] in
Camel.toml. Every boot emits a warn! naming the bind and the
exposed-route count. The acknowledgment is permanent. It never silences
the warning (ADR-0052 rule 3). It never satisfies misconfigured
siblings: any declaring route that fails classification still blocks
startup. Loopback binds permit Public silently.
Rule 5: Policy-form provider resolution
Policy forms (ref, wasm, permission) are authorization-only today
and reject the provider field. They classify as Authorized. Plan
compilation resolves the provider as follows:
- A named provider must resolve in the registry.
- An unnamed form with exactly one registered provider resolves that sole provider into the plan.
- An unnamed form with zero providers is a compilation error.
- An unnamed form with more than one provider and no name is a compilation error.
The resolution never yields a Public downgrade. Extending these forms
with an optional provider field is in scope for Phase 2 and not
required by this change.
Rule 6: trust_upstream_principal removed
Removed (pre-1.0 breaking). Accepting an Exchange-property principal
contradicts typed-principal unforgeability. No migration path can honor
both the "never authorizes" invariant and the flag's property-trust
behavior. Phase 1 deletes the property-evidence path and the DSL flag.
Stale configs fail at load with an error naming the field
(deny_unknown_fields). Property evidence has no authorization path
from the first Phase-1 commit. The Bearer-token legacy dual-read is
unaffected. It is deleted at the Phase 2 strict-mode task.
Rule 7: Per-provider isolation (decision now, enforcement Phase 3)
Each provider validates independently. Authentication requests and the
authentication cache (AuthnCache, distinct from the permission cache)
are provider-local. The cache key includes route audience, issuer,
transport context, and provider identity. Providers that share
audience, issuer, and transport still hold separate entries.
Substitution tests prove that a token minted for one provider's route
cannot authenticate another's. Fixtures use per-provider signature keys
and the same issuer, so issuer rejection cannot mask missing key
isolation.
Rule 8: The principal seal (honest statement)
AuthenticatedPrincipal construction is same-crate-only (camel-auth
kernel.rs). The type has zero public constructors, zero feature-gated
constructors, and zero test-only constructors. Cargo feature
unification would make a cfg(test) or feature-gated constructor
unsound. Tests mint through the real kernel_authenticate path, never
a shortcut.
The seal guards against accidental construction and property spoofing
from every other crate. Against hostile code inside camel-auth
itself, the seal holds at review level only. This is the documented
in-process trust boundary: the type system prevents accidents. It does
not sandbox in-process code.
Rule 9: ADR-0060 amendments
Rule 3 (TOML-owned runtime), amended. The mcp: DSL block becomes
the runtime owner of listener configuration (bind, TLS, caps), as
rest: owns host/port. TOML mcp.servers.<name> remains the
source for items with no DSL counterpart (allowed_hosts). Overlapping
keys must agree. Divergence is a hard startup error, never silent
TOML-wins. The step-less catalog declaration (Rule 3 lowering
semantics, bd rc-23y2) is unchanged. The rest: block gains an
optional security_policy with the same surface and the same
load-time validation, so both declaration forms classify under the
same kernel.
Rule 8 (mandatory MCP security_policy), superseded. The
presence-only bind gate is replaced by the kernel's per-bind exposure
gate with uniform semantics across all five transports. The
component-local MCP gate is removed when Phase 2 converges the MCP
registry onto the kernel gate. The route-level enforcement gate
(headers copied to the Exchange, route policy before steps) is
preserved as per-route dispatch enforcement.
Consequences
- All five server transports converge on one kernel. Duplicate Gen A / Gen B authentication is deleted after convergence (Phase 2).
- Declared security never silently downgrades. Missing wiring fails classification, and a non-Public route without a principal denies.
- Non-loopback
Publicexposure requires per-bind acknowledgment and warns at every boot, forever. - Stale
trust_upstream_principalconfigs fail at load with an error naming the field. - ADR-0059's divergence decision is superseded. Its extraction standardization carries forward.
- ADR-0060 Rule 3 gains DSL listener ownership with hard conflict errors. ADR-0060 Rule 8's presence gate is gone.
AudienceBindingis reserved in the types. Its enforcement, the provider-local authn cache, and the substitution tests land in Phase 3.- Phase 3 note (2026-08-21): enforcement is live. Per-request
audience and issuer sets are enforced on every provider
(REPLACEMENT semantics); the provider-local authn cache keys
entries by provider, binding, transport, and token hash; and the
cross-transport substitution E2E suite
(
crates/camel-test/tests/audience_substitution_test.rs) pins cross-provider, issuer, transport, and audience isolation over real http and ws routes.
ADR-0062: Reserved Test Suffix and Placement Contract
Date: 2026-08-22
Status: Accepted (Amended 2026-09-11 — two reserved suffixes; see "Amendment")
Origin: OpenSpec change test-placement-contract (bd rc-6760)
Context
camel test runs test documents. A test document names route files, declares
inputs, and states expected outputs. It is not a route.
Before this change, no name separated the two file kinds. camel run
filtered test documents in the CLI layer. An explicit *.test.yaml glob was
honored as a user override. The matched files then parsed as routes and
failed on unknown fields. The rule lived in the wrong crate and the wrong
layer.
Route discovery already had one reserved-behavior gate. A .json route file
loads only under a pattern that explicitly targets .json
(DiscoveryError::JsonRequiresExplicitPattern). Wildcards never load JSON
silently. Test documents needed the same treatment, plus a placement
contract for where they live.
Decision
Rule 1: The suffix is reserved and owned by camel test
A file name ending in .test.yaml or .test.yml names a camel test
document. The suffix is reserved. Only camel test consumes such files.
Test documents are YAML only. A .test.json name is not test-suffixed and
keeps the JSON explicit-pattern gate.
Rule 2: Discovery enforces the suffix, not the CLI
camel_dsl::discovery::is_test_document is the single suffix rule.
Discovery checks it first in the glob-entry loop. The check runs before the
extension gate, before any read, and before interpolation. A reject
therefore never triggers an environment lookup.
- A wildcard pattern skips test documents with no error. The file is never read.
- A literal pattern (no
* ? [ ] { }metacharacters) that names a test document fails withDiscoveryError::ReservedTestSuffix. The error names the file and namescamel testas the owner.
This is the explicit-gate idiom, the same family as
JsonRequiresExplicitPattern. When the operator names a file, ambiguity
fails loudly. When a wildcard merely brushes the file, discovery stays
silent.
camel run passes default globs, Camel.toml routes, and --routes
patterns verbatim to discovery. Watch reload inherits the rule through the
same resolver. camel lint applies the exported predicate before it
invokes the engine and prints one info line. The lint corpus gate consumes
the same predicate. No consumer keeps a private copy.
Rule 3: Colocation is the blessed placement
routes/foo.yaml with a routes/foo.test.yaml sidecar is the blessed
default. The pair stays together in review, in git history, and in the
scaffold.
A separate test directory is first-class. A test document may declare
routeFilesFromRoot. Its entries resolve against the project root. The
root is the nearest ancestor directory that holds a Camel.toml, found
by walking up from the document. Monorepo
semantics: the nearest ancestor wins, so a per-service Camel.toml anchors
that service. This matches cargo workspace discovery. Anchoring never uses
the git or workspace root. A walk that finds no Camel.toml fails with
TestDocError::NoProjectRoot and names the walked path.
Rule 4: No in-string sigils
routeFilesFromRoot entries that start with a sigil never resolve; the
load fails with a file-not-found error.
@/is frontend idiom. It imports meaning from a foreign ecosystem.$collides with${env:}interpolation.~/reads as a home directory.- URI pseudo-schemes muddy the endpoint model, where schemes name components.
A file suffix carries none of these collisions.
Consequences
- The previous explicit
*.test.yamlglob override is removed. This is an accepted breaking change (pre-1.0). A route-shaped file that used the reserved suffix must be renamed. expand_patterns_excluding_test_docsis deleted from camel-cli. Discovery owns the rule, so library consumers and watch reload get the same behavior.- Known cost:
camel run --watchwakes on a test-document save and reloads to a no-op. The suffix skip keeps the reload harmless. - Known cost: a wildcard glob that matches only test documents reports no routes. That report is correct.
camel linton a test document prints one info line and exits 0.
Alternatives considered
- Keep the CLI-layer filter. Rejected. Discovery is the single choke point for route loading. A CLI filter left library consumers and watch reload unprotected.
- Honor an explicit
*.test.yamlglob as an override. Rejected, with the breaking change accepted. The override parsed test documents as routes and failed on unknown fields. A loud reserved-suffix error beats a silent-shape parse failure. - In-string sigils (
@/,$,~/, URI pseudo-schemes). Rejected. Each collides with an existing idiom (Rule 4). - Reserve
.test.jsonas well. Not chosen. Test documents are YAML only in this change, and a second format would double the parser surface.
Amendment (2026-09-11): Two reserved suffixes
Origin: OpenSpec change job-ux-reshape (bd rc-10d50). Jobs left the
test family: a job is an operator tool, not a test, and the job runner
no longer consumes *.test.yaml.
.job.yaml/.job.ymlnames acamel jobdocument. The suffix is reserved under the same contract as the test suffix.- The discovery rule generalises to a reserved-document contract:
is_test_document(test family) andis_job_document(job family) join underis_reserved_document, which is the single gate for the wildcard skip and the literal-name error.ReservedTestSuffixis renamedReservedDocumentSuffix; the error names both families and their runners. - A
*.test.yamldocument that declaresexecute:fails to load undercamel jobwith an error that directs the author to rename the file. There is no alias and no compatibility shim (zero adoption at the time of the rename; accepted breaking change, pre-1.0). - Job documents live under
[jobs].dirinCamel.toml(defaultjobs), resolved against theCamel.tomlroot. This table governs where job documents live, never where a job's routes come from: the explicit route source stays mandatory. - Test placement rules (colocation,
routeFilesFromRootanchoring) are unchanged and apply to the test family only. - ADR-0069 Decision 6 (the section classifies the document) is untouched: section classification still holds within a suffix; the suffix is now the first discriminator between the two families.
ADR-0063: Redis Repository Service
Date: 2026-08-22 Status: Accepted Amends: ADR-0023, ADR-0056 Cross-references: ADR-0028, ADR-0032, ADR-0033, ADR-0051, ADR-0054
Decision
Decision 1: repository service crate at crates/services/camel-redis-repo
The Redis backends ship as one new crate, crates/services/camel-redis-repo. The
crate implements IdempotentRepository and CacheRepository from camel-api.
It does not implement Component, own a URI scheme, or create Endpoints.
The Services family is the correct home. camel-auth already proves the family
holds named, context-scoped infrastructure that is not a Lifecycle
implementation. The repository backends share that shape: contract in
camel-api, implementation outside camel-core, named lookup in
CamelContext, and route steps that consume the lookup.
Repository reads and writes run during Exchange processing. Named registration
happens at context build time through camel-config. The crate differs from a
URI Component in contract and user-facing role, so the Components directory
charter and its component-specific lints must not apply.
Decision 2: both repositories in one change
RedisIdempotentRepository and RedisCacheRepository ship together. They share
connection acquisition, key namespacing, error mapping, and the executor seam.
Splitting them would double the wiring and test overhead for zero isolation
benefit. The traits stay independent, so shipping together creates no coupling.
Decision 3: connection seam through the component, commands as redis::Cmd
The service reuses the connection machinery of camel-component-redis instead
of building a second client. The component widens its surface additively:
MultiplexedExecutor::get_connmoves frompub(crate)topub(crates/components/camel-redis/src/executor.rs:268).- A new
pub async fn refresh(&self)reconnects without&mut self(crates/components/camel-redis/src/executor.rs:313). topology_from_configmoves frompub(crate)topub(crates/components/camel-redis/src/topology.rs).
The service issues repository commands (SET NX, SET ... EXAT, EXISTS,
GET, UNLINK, SCAN) as redis::Cmd values through its own narrow
trait RepoCommandExecutor (crates/services/camel-redis-repo/src/executor.rs:46).
The component's RedisCommand dispatch path stays untouched. No existing
component signature changes.
Decision 4: one executor per repository, no registry
Each repository owns one MultiplexedExecutor built from its own configuration.
Two repositories that target the same server hold two multiplexed connections.
That costs one extra socket. A deduplication registry would need identity
equality over topology, credentials, TLS, and database index. It would also need
lifetime and eviction rules. Two possible repositories do not justify that
machinery. A registry, if ever wanted, is an additive future change with its own
spec.
Decision 5: Sentinel always compiled in the service crate
The service enables camel-component-redis/sentinel unconditionally. Cargo
feature unification compiles the component's Sentinel branch into every binary
that links both the service crate and camel-config. The component's own
sentinel feature stays default-off and fail-closed. That gate still governs
builds whose dependency graph does not link the service crate. The asymmetry is
intentional and documented in the redis-failover spec delta.
Decision 6: explicit config selection, no auto-detection
Sentinel routing is selected by explicit fields: sentinel_nodes plus
master_name. sentinel_nodes and cluster_nodes are mutually exclusive and
validated at startup. master_name missing under sentinel_nodes is rejected
at startup.
Runtime probing for "is this host a Sentinel" would infer topology from
unconfirmed signals. That inference contradicts ADR-0033 (fail closed, validate
at startup) and ADR-0032 (master_name is operator config, never inferred).
The probe also cannot produce a reliable positive signal. A Sentinel answers
SENTINEL get-master-addr-by-name only for a known master name. Port heuristics
are convention, not contract.
Decision 7: SET NX is never re-issued (Contract C1)
Idempotent add issues exactly one SET key 1 NX per trait call. A lost
response leaves the outcome unknown: the first insert may have succeeded on the
server. A retry could observe the key it set itself and return Ok(false) for a
first-seen message. On any transport error, add returns
Err(CamelError::Io) immediately. The Idempotent Consumer treats Err as an
unknown outcome and retries or dead-letters, per ADR-0023 Contract C1.
The repository may call refresh() after the failure so later calls use a
healthy connection. Command re-issue for add is what is forbidden. Retry-safe
operations (GET, EXISTS, UNLINK, SCAN, plain SET) may refresh and
re-issue at most once, because a re-issued command cannot corrupt state.
Decision 8: one atomic SET ... EXAT for cache writes
Cache set writes the serde_json CacheEntry with a single SET. When
expires_at is Some, the command carries EXAT (expires_at + stale_retention)
(crates/services/camel-redis-repo/src/cache_repo.rs:111-123). The server-side
deadline is garbage collection only. It extends past logical expiry so
peek_stale stays satisfiable inside the retention window, per ADR-0056
Decision 5. When expires_at is None, the command sets no Redis deadline: the
entry lives until invalidated.
Plain SET is last-writer-wins. A re-issued identical SET stores the same
bytes and the same EXAT, so a lost response on cache set is safe to retry.
Decision 9: clear() and invalidate_prefix use prefix-scoped SCAN + UNLINK
Keys are namespaced {prefix}:{repo}:{key} by keyspace::namespaced
(crates/services/camel-redis-repo/src/keyspace.rs:9). clear() walks
SCAN MATCH {prefix}:{repo}:* and unlinks in batches. invalidate_prefix
walks {prefix}:{repo}:{step_prefix}* and returns the removed count.
FLUSHDB and FLUSHALL are forbidden. A shared Redis would lose every other
tenant's data. A prefix-scoped walk with batched UNLINK bounds the damage to
one repository namespace.
Every namespace token, including the step prefix from cache_invalidate { key_prefix }, is validated against [A-Za-z0-9:_-] before it enters a SCAN
pattern (crates/services/camel-redis-repo/src/keyspace.rs:17). Glob
metacharacters are rejected as CamelError::Config before any SCAN runs. The
step prefix is a simple-language expression resolved from exchange data, so this
guard is an ADR-0032 trust-boundary obligation.
Decision 10: error mapping is Io for transient, Config for setup
Transient transport failures map to CamelError::Io
(crates/services/camel-redis-repo/src/error.rs:7). Setup and validation
failures map to CamelError::Config. The crate produces no ProcessorError.
classify() therefore keeps reporting "io" for transport failures, and a
failed read stays Err, never "absent", per Contract C1.
Decision 11: cluster rejected; no idempotent TTL
Configuration that requests a cluster topology is rejected at validation with a
Config error. The repositories assume single-key routing. Cluster support, if
a need appears, is a separate change.
Idempotent keys carry no TTL. This matches ADR-0023 and the redb backend. Camel-Java offers optional key expiry, but this project declared idempotent TTL out of scope. A later TTL needs its own explicitly specced change.
Decision 12: no Lifecycle implementation
The repositories implement no Lifecycle and no StepLifecycle. Construction
resolves the topology once and connects eagerly, so an unreachable topology
fails fast at context build. Sentinel failover is detected only through the
explicit refresh on a later error. No Consumer task and no background task
exist. Reclamation is server-side through EXAT. Dropping the repository owns
connection cleanup.
ADR-0028 states that a persistent backend which needs connection lifecycle
management should implement StepLifecycle on the backend client. That rule
does not apply here: the multiplexed connection carries no timers, buckets, or
queues, so no lifecycle exists to manage.
Decision 13: per-command response timeout
Every repository command carries a 30-second response timeout
(DEFAULT_RESPONSE_TIMEOUT, crates/services/camel-redis-repo/src/executor.rs).
connection_timeout_secs guards only the TCP connect. Without an
execution-side bound, a half-open socket or a silent peer could park an
Exchange-processing future indefinitely and defeat refresh-on-error, because
the error never arrives. An elapsed timeout maps to transient
CamelError::Io, so retry-safe operations refresh and re-issue once, add
returns Err without a re-issue (Contract C1), and the next call re-resolves
the topology. The component's connection configuration is untouched: the
timeout wraps query_async inside MultiplexedRepoExecutor::execute in the
service crate, so component consumers keep their existing behavior. The redis
driver also enforces its own default response timeout on multiplexed
connections; the service-crate timeout is the crate's own contract and does
not depend on driver defaults. Plumbing the driver's response timeout
through the component landed (OpenSpec change redis-response-timeout,
bd rc-dq7a). MultiplexedExecutor now accepts
with_response_timeout(Duration), applied in get_conn on the initial
connect and on every refresh/reconnect rebuild. On that branch the
driver's parallel 1 s connect default is disabled through
set_connection_timeout(None), so the component's connection_timeout_secs
wrapper stays the sole connect bound. The repository service crate
constructs its executor with a 35 s driver response timeout (30 s backstop
plus 5 s margin), so the service crate's own 30 s contract governs end to
end and the driver deadline is defense-in-depth only. Component consumers
that do not call the builder keep the driver default. Their behavior does
not change.
Rejected alternatives
Module inside camel-component-redis
ADR-0023 and ADR-0056 anticipated Redis backends inside the Redis component,
following Apache Camel packaging. Rejected: the blessed crate keeps repository
APIs, unconditional Sentinel support, and the repository release surface
separate from URI endpoint behavior. It also avoids pushing the component's
optional sentinel feature onto component users who want no repository.
New crates/adapters/ family
Rejected: it creates a parent taxonomy, workspace globs, and documentation context for exactly one crate. A family is created after the family exists. SQL or Memcached repository backends, if they appear, land in Services under the same rule.
Shared connection registry
Rejected per Decision 4. One multiplexed connection per repository keeps independently configured repositories isolated with a one-sentence ownership rule and no invariants to police.
Sentinel auto-detection
Rejected per Decision 6. Probing infers topology at runtime, adds a new failure mode, and cannot work without the master name it claims to avoid.
Retrying SET NX
Rejected per Decision 7. A retry can convert a first-seen message into a false
duplicate. The unknown outcome must surface as Err.
FLUSH-based clear()
Rejected per Decision 9. On a shared deployment, FLUSHDB is a cross-tenant
data-loss incident.
Context
Problem
ADR-0023 and ADR-0056 declared Redis backends future work. Deployments that share cache and idempotent state across restarts or nodes need them. The Redis component already owns topology resolution, Sentinel failover, and a multiplexed executor. A second, independent Redis client inside the service crate would duplicate that machinery and diverge on failover behavior.
Forces
- Reuse with a narrow seam. The service needs connection acquisition and topology resolution, not the component's command dispatch. Widening three items is the smallest additive surface that serves both.
- Hexagonal boundary. Dependency direction stays
camel-api <- camel-redis <- camel-redis-repo <- camel-config.camel-coreimports nothing new. - Fail-closed culture. Topology selection, charset guards, and cluster rejection all validate at startup (ADR-0033).
- Shared deployments. Redis instances are commonly shared.
clear()andinvalidate_prefixmust never escape the repository namespace. - Unknown-outcome honesty. Contract C1 governs every repository backend. A
lost response to
SET NXmust surface asErr, and transient failures must classify as"io".
Consequences
Registration through camel-config
[default.cache_repo] backend = "redis" and
[default.idempotent_repo] backend = "redis" register the repositories at
context build time (crates/camel-config/src/context_ext.rs). The DSL steps
select repositories by name. No autowiring exists, matching ADR-0023 and
ADR-0056.
Component surface grows by three items
get_conn, refresh, and topology_from_config become pub in
camel-component-redis. Existing consumers are unaffected. The widening is
recorded here so future component audits know the public surface is
intentional.
Test seams live in the service crate
FakeRepoExecutor and FakeStaticTopology
(crates/services/camel-redis-repo/src/executor.rs:164,
crates/services/camel-redis-repo/src/executor.rs:291) drive both repositories
without a live Redis. Live coverage runs in
crates/camel-test/tests/redis_repositories_test.rs under the
integration-tests feature, per ADR-0054.
Credentials stay redacted
Sentinel node credentials and connection URLs follow ADR-0051. Redaction
happens in the component's topology code. The service adds no new
credential-bearing Debug output.
Load-bearing citations
| File:line | Element |
|---|---|
crates/services/camel-redis-repo/src/lib.rs:20-24 | crate exports: RedisCacheRepository, RedisIdempotentRepository |
crates/services/camel-redis-repo/src/executor.rs:46-51 | trait RepoCommandExecutor (execute, refresh) |
crates/services/camel-redis-repo/src/executor.rs:56 | MultiplexedRepoExecutor wrapping the component executor |
crates/services/camel-redis-repo/src/connection.rs | one topology resolution per construction, cluster rejected first |
crates/services/camel-redis-repo/src/keyspace.rs:9 | fn namespaced builds {prefix}:{repo}:{key} |
crates/services/camel-redis-repo/src/keyspace.rs:17 | fn validate_namespace_token enforces [A-Za-z0-9:_-] |
crates/services/camel-redis-repo/src/cache_repo.rs:111-123 | single SET with EXAT (expires_at + stale_retention) |
crates/services/camel-redis-repo/src/idempotent_repo.rs | add issues one non-retried SET NX |
crates/services/camel-redis-repo/src/error.rs:7 | fn to_camel_error maps Redis failures to Io |
crates/components/camel-redis/src/executor.rs:268 | MultiplexedExecutor::get_conn (widened to pub) |
crates/components/camel-redis/src/executor.rs:313 | MultiplexedExecutor::refresh(&self) reconnect primitive |
crates/camel-config/src/context_ext.rs:258-279 | redis cache and idempotent repository registration |
crates/camel-test/tests/redis_repositories_test.rs | live integration suite |
ADR-0064: Two-Tier Testing Contract
Date: 2026-08-23
Status: Accepted
Ratified: e_glm, acting for the maintainer, 2026-08-23
Origin: bd rc-379d (epic rc-7roi)
Amends: none
Cross-refs: ADR-0002 (CQRS RuntimeBus — control-plane ceiling), ADR-0004 (hot-reload atomic pipeline swap), ADR-0024 (PipelineOutcome), ADR-0042 (Arc compiled-steps snapshot), ADR-0045 (camel-core architecture charter), ADR-0046 (Camel is inspiration, not conformance), ADR-0055 (publish topology), ADR-0062 (reserved test suffix), prior rulings: e_opus 2026-08-18 (docs/reviews/2026-08-18-camel-mock-expansion-ruling.md), e_opus 2026-08-23 (this ADR)
Context
rust-camel has one in-process test surface today. camel test boots a lean
CamelContext, loads route documents, delivers direct: inputs, settles
traffic, and evaluates mock expectations (crates/camel-cli/src/commands/test/runner.rs).
The mock-testkit spec (openspec/specs/mock-testkit/spec.md) is its live
contract.
This surface has no name that separates it from the different job of testing a
route against a real adapter with a full runtime. Without that name, feature
requests push the lean boot toward a general runtime. One such request
(rc-5s8c) proposed adding http: to the lean boot; it never landed. That
direction is wrong: it grows the in-process boot into a second, half-formed
runtime, and it hides the growth inside register_component lines that no
reviewer gates.
Two prior facts frame this decision:
- The 2026-08-18 ruling fixed the mock as a sink, not an in-out simulator, and rejected any inspection channel that threads component state into the CQRS read side. That ruling forbids widening the data plane through the control plane (ADR-0002 / ADR-0045). Route interception (AdviceWith) was confirmed out of scope for the component and parked for the test surface.
- The lean boot is real but narrow.
boot_context()registers exactly five components:mock,direct,timer,log,seda(runner.rs:56-67). It registers no bean registry, discards producer replies in the delivery loop (runner.rs:168), and registers no dataformat.
This ADR names the two jobs, pins the boundary between them, and gives the route-interception feature (AdviceWith) a staging frame so it can land without reopening the plane question. It binds the unit tier only. It sketches the integration tier without binding it.
Decision
1. Two tiers, one boundary
rust-camel testing has two tiers. The boundary between them is fixed by two axes: the inbound stimulus that drives the route under test, and the runtime profile that hosts it.
- Unit tier (
camel test): a lean in-process boot. Stimulus isdirect:only. Runtime profile is the closed lean component set below. No external transport, no bundle config, no full runtime. - Integration tier (sketched in section 4, not bound here): a full runtime boot with real adapters and transport-boundary assertions.
The two-tier framing is the load-bearing decision. Every other rule in this ADR follows from it.
2. The closed lean component set
The unit-tier lean boot registers exactly this set:
{ direct, log, mock, seda, timer }
This set is closed and pinned by this ADR. It mirrors the current
boot_context() reality (runner.rs:56-67). An addition to the set requires
an amendment to this ADR. The amendment is the gate. A silent
register_component line in the runner is not a valid way to grow the set,
because it grows the unit-tier runtime profile with no review.
timer: stays in the set as a deterministic in-process source. It carries no
producer, so it is inert to the send-point interception in section 5. A
timer: route that never quiesces is a test-authoring concern (bound it with
repeatCount), not a reason to evict the component.
3. The creep rule (the test that rejects new components)
For any proposed unit-tier capability, apply this test:
Does this capability require an inbound stimulus other than
direct:, or a component outside the closed lean set? If yes, it belongs in the integration tier, not in thecamel testlean boot.
This is the test that rejects http: in the lean boot (rc-5s8c). http: is
both outside the closed set and an external transport stimulus. It fails the
test on both clauses. The rule is orthogonal to set membership: a component in
the set (for example seda:) passes the set clause, and a route driven only
by a direct: producer passes the stimulus clause, even when that route reads
from seda:.
4. Integration tier — SKETCH (non-binding)
This section is a non-binding sketch. It records intended direction so that the boundary in section 1 has a defined other side. It does not ratify vocabulary, assertion shape, or API. The binding integration-tier contract requires a future ADR. Do not cite this section as settled contract.
Intended shape, for orientation only:
- Embedded mode only. The integration tier boots a full runtime in-process
(bundles,
Camel.toml) and drives routes through real adapters. - Standalone mode is out of scope. Testing a separately deployed
camel runprocess over IPC is out of scope until the frozen no-IPC invariant is explicitly amended by a future ADR. The 2026-08-18 ruling rejected inventing an IPC control surface for test assertions; that rejection stands here. - Citrus-inspired vocabulary (send / receive-with-timeout / sleep / validate against logical endpoints), used as inspiration, not conformance (ADR-0046). receive-with-timeout assertions sit at transport boundaries.
- Partner behavior simulation (rc-i2qf, re-parented under epic rc-kk69) is integration-tier work.
5. AdviceWith — staging context (enabled here, not implemented)
This ADR enables route interception (AdviceWith). It does not implement it.
Interception stays a data-plane Tower decoration. It is never routed
through the RuntimeBus or a RuntimeQuery; that path breaks the ADR-0002 /
ADR-0045 control-plane ceiling (the 2026-08-18 ruling, fact 2). The intercept
is baked into the compiled-steps snapshot (ADR-0042 / ADR-0004), so it applies
atomically and is not mutated after context.start().
Stage A — boot-time interception (camel-core). Boot-time InterceptRules
plus a Tower send-point wrapper installed before context.start(). Match is
exact-URI, first-match-wins. Two divert kinds:
- divert (WireTap-style): the intercept copies the exchange to a mock sink
as an outcome-isolated side-effect. A failure in the mock copy MUST NOT
corrupt the real path's
PipelineOutcome(ADR-0024). The copy is a side branch, exactly like WireTap. - skip: full producer replacement. The real producer never runs.
Stage A send-point scope includes seda: send-side (see section 6). It
excludes seda: consumer-side and any post-queue assertion semantics (also
section 6).
Stage B — declarative intercepts. An intercept block in *.test.yaml
(rc-7f0n), building on the reserved test-suffix contract (ADR-0062).
Stage C — deprecation of inline to: mock: in production routes. A
scope-aware lint that warns, then errors, mirroring the ADR-0045 §4 discipline
(rc-car5). Inline mock: stays legitimate in pure test-fixture routes that
camel run never loads. Migration is lazy. There is no flag-day.
No new crate is introduced by any stage (ADR-0055 publish topology).
6. The seda send-side carve-out (pinned, fenced — not revocable)
The maintainer requires seda: send-side interception inside Stage A scope.
This deviates from the original ruling, which excluded seda: until demand
appeared. Demand has appeared (maintainer instruction, 2026-08-23), and the
mechanics are verified safe:
to: seda:xenqueues from the producer'scall():tx.send().awaitunderblockWhenFull=true, elsetx.try_send(crates/components/camel-component-seda/src/lib.rs:761-831). The producer is aService<Exchange>wrapped inBoxProcessor::from_fn(crates/components/camel-component-seda/src/lib.rs:514-528). Wrapping that producer is mechanically identical to wrapping any other producer.
The carve-out is pinned and fenced, not a revocable knob. Fenced OUT of scope, as hard boundaries:
seda:consumer-side interception (from: seda:). Consumer forwarding runs in a separate background task (forward_envelope,lib.rs:727); it is not a producer send point.- Fanout-partial semantics across the subscribers map. Fanout is all-or-nothing at the producer and has no single interception point that represents "one subscriber".
- Post-queue (processed-side) assertion semantics under divert. A divert
records the exchange pre-queue (before
tx.send). A test that asserts processed-side state under divert observes intake, not consumer output. This gap is why the fence exists.
The carve-out is not revocable by convenience. A future change that wants consumer-side, fanout-partial, or post-queue seda interception amends this ADR, the same gate as any lean-set change. Removing the send-side carve-out passes the same gate. Revocability would reintroduce the ambiguity that the closed-list discipline exists to remove.
Consequences
Positive
- The boundary is a named test, not a habit.
camel testcannot silently grow into a second runtime. - AdviceWith has a staging frame that never touches the control plane. Each stage lands on its own review, with divert error-isolated per ADR-0024.
- The seda send-side carve-out is explicit and fenced, so the safe part ships without dragging in the unsafe part.
Negative
- The closed set forces an ADR amendment for any new unit-tier component. This is intended friction. It is the cost of keeping the lean boot lean.
- The integration tier stays a sketch, so integration testing has no bound contract until a future ADR lands. Accepted: binding it now would ratify vocabulary before the design work is done.
Known unit-tier gaps (follow-ups under epic rc-7roi, not fixed here)
These are current lean-boot limits. This ADR names them so they are tracked, and does not specify their solutions:
- rc-07qh: the lean boot registers no bean registry, so routes with
bean:steps fail undercamel test. - rc-66c5: the delivery loop discards producer replies
(
producer.oneshot(),runner.rs:168), so InOut routes cannot assert replies. - rc-24e5: no explicit dataformat registration in the lean boot (investigation).
Amendment (2026-08-24): the rc-07qh (bean registry) gap was closed by bean-test-registry, and the rc-66c5 (reply capture) gap was closed by reply-capture. Stage C's warn-phase lint (rc-car5) completes the §5 warn phase:
camel lintnow warnsR-MOCK-IN-PRODUCTIONon inlinemock:sends in route files, with fixture-path and test-document exemptions.
Alternatives considered
http:in the lean boot (rc-5s8c): rejected by the creep rule. It is outside the closed set and an external transport stimulus.- AdviceWith through the RuntimeBus / RuntimeQuery: rejected. It breaks the ADR-0002 / ADR-0045 control-plane ceiling and forces endpoint state into the projection read model (2026-08-18 ruling, fact 2).
- Porting the Java AdviceWith API shape verbatim: rejected. Camel is inspiration, not conformance (ADR-0046).
- arming / lazy-recording of mock traffic: rejected. It subsidizes the
anti-pattern of inline
mock:in production routes instead of retiring it. - A
record=falseURI parameter: rejected. It is a per-endpoint knob for a route-level concern. - A
[components.mock]config knob: rejected. It is a config surface for a decision that belongs in the route or test document. - Standalone integration mode (test a deployed
camel runover IPC): rejected here. Out of scope until the no-IPC invariant is explicitly amended.
Concession
Bounded-retention hardening in camel-mock is permissible as pure
hardening if a live production memory cost appears. This is a cap on
retained exchanges, nothing more. It MUST NOT introduce record semantics or
any inspection channel. The sink identity from the 2026-08-18 ruling holds.
Self-grill record
Questions generated:
- [glossary] Does "two-tier" or "creep rule" collide with an existing CONTEXT-MAP term or an established ADR concept?
- [sharpen] "Inbound stimulus" is fuzzy. Does adding
seda:to the closed set contradict the creep rule, givenfrom: seda:is itself an inbound stimulus? - [scenario] Under divert, what does a post-queue seda assertion observe, and does that break the mock-sink contract?
- [cross-ref] Does the seda producer actually wrap like any other producer,
and is
timer:truly inert to send-point interception?
Answers (with citations):
- [glossary] No collision. CONTEXT-MAP has no "two-tier" or "creep rule"
entry; the control-plane ceiling it does define (
CONTEXT-MAP.md:132, Synchronous-projection CQRS, ADR-0002 / ADR-0045) is the ceiling this ADR respects, not one it renames. The intercept-as-snapshot claim aligns with PipelineOutcome (CONTEXT-MAP.md:156, ADR-0024). - [sharpen] No contradiction. Set membership and the creep rule are
orthogonal.
seda:is IN the closed set, so it passes the set clause; a route driven by adirect:producer that then sends toseda:uses no stimulus other thandirect:.http:fails both clauses; that is why the rule rejects it. Wording sharpened to "external transport inbound stimulus." - [scenario] A divert records the exchange pre-queue, before
tx.send(crates/components/camel-component-seda/src/lib.rs:761-831). A processed-side assertion under divert would observe intake, not consumer output. This does not break the sink contract; it is fenced OUT explicitly in section 6 so no test relies on it. - [cross-ref] Verified. The seda producer is a
Service<Exchange>returned asBoxProcessor::from_fn(crates/components/camel-component-seda/src/lib.rs:514-528); wrapping it is identical to wrapping any producer.timer:carries no producer (it is afrom:source), so a send-point wrapper never sees it — inert, no scope surprise.
Open question escalated to the human, resolved at ratification (2026-08-23): whether the seda carve-out should be revocable or permanently pinned-and-fenced. Ruling: permanently pinned-and-fenced. Send-side is IN; consumer-side, fanout-partial, and post-queue are OUT. Any change to the fence, in either direction, requires an ADR amendment, the same gate as a lean-set change. A revocable carve-out would create two contract classes inside one ADR, one gated by amendment and one revocable by convenience, and would reintroduce the ambiguity the closed-list discipline removes.
Outcome: confirm (seda carve-out pinned-and-fenced; open question resolved at ratification, see above) Self-grill mode: self-grill-proposals skill
ADR-0065: Cache Payload Offload
Date: 2026-08-24 Status: Accepted Cross-references: ADR-0023, ADR-0033, ADR-0056, ADR-0063
Decision
Decision 1: decorator over the backend, index in backend, blob on disk
DiskOffloadRepository (crates/camel-core/src/cache/disk_offload.rs:62)
wraps any Arc<dyn CacheRepository>. It is a decorator, not a backend. The
wrapped backend stores a small index entry with an emptied bytes field and
a payload_path. The payload bytes live in one blob file under payload_dir.
The payload travels opaquely through the trait. The decorator intercepts
set, get, and peek_stale, and delegates every other method. One
insertion point serves both persistent backends, per the service-seam
reasoning of ADR-0063. CacheEntry gains payload_path: Option<String>
(crates/camel-api/src/cache.rs:23). The field serializes with
#[serde(default)], so JSON stored by older binaries still deserializes.
Context wiring registers the decorator under the bare backend's name, so
route steps select it unchanged.
Decision 2: file-first write order with unique per-attempt tmp names
set writes the blob before it stores the index entry. The write opens a
unique per-attempt tmp name with create_new, calls sync_all, then
renames onto the final name within the same directory. Same-directory
rename is atomic on POSIX. The parent-directory fsync is best effort, warns
once, and is ignored on failure. A crash before rename leaves a .tmp
orphan. It never leaves an index row that points at missing bytes.
Decision 3: self-die filenames and the death epoch
Blob names are
{blake3-128hex(key)}.{death_epoch}.{blake3-128hex(payload || content_type-discriminant)}.blob
(blob_filename, crates/camel-core/src/cache/disk_offload.rs:479). The
content fingerprint hashes the payload bytes followed by the one-byte
discriminant of the ContentType enum, which separates the domains
(content_fingerprint, crates/camel-core/src/cache/disk_offload.rs:471).
The death_epoch is effective_expires_at + stale_retention + payload_sweep_interval as unix seconds. The sweep-interval grace keeps each
file alive at least as long as any inner sweeper or server-side deadline
keeps the index row. Residual tick lag is a documented MISS+WARN degradation.
Entries written without a TTL get expires_at = now + payload_max_ttl
fabricated on the stored entry, so index and file share one death timeline.
The default cap is 720h (30 days).
Two writers that store identical content under the same key produce the same filename, which is coherent. Any difference in content or death time yields a distinct filename. The surviving index row references its own blob. No cross-writer pairing of one writer's bytes with another writer's metadata can occur. A 128-bit collision would yield a complete-but-stale entry or a miss, never a torn entry. Orphaned blobs reclaim themselves at their encoded epoch.
Decision 4: inline fallback on blob-write failure
If the blob write fails (ENOSPC, EIO), the decorator stores the unstripped
entry inline, warns, and returns Ok(()). The cache EIP fails the pipeline
on a set error, so the decorator must not add a new route-failure mode.
Errors from the wrapped backend still propagate.
Decision 5: MISS+WARN for a dead file, Err for failing storage (Contract C1)
The read path holds zero expiry logic. The in-band expiry check stays in the
wrapped backend. When an entry carries payload_path, the decorator
sanitizes the name (sanitize_blob_name,
crates/camel-core/src/cache/disk_offload.rs:499): the path must resolve to
a direct child of payload_dir. Separators and .. are rejected, so a
corrupt or foreign row cannot trigger an arbitrary file read.
A missing blob (sweep lag, NFS skew, crash window) returns Ok(None) with a
WARN. An I/O failure on an existing blob (EIO, EACCES) returns Err, per
Contract C1: a failing disk is a storage failure, not a miss.
Decision 6: standalone sweeper with ENOENT as success
A standalone tokio task sweeps the payload directory
(spawn_sweeper, crates/camel-core/src/cache/disk_offload.rs:619). It
unlinks blobs whose encoded death epoch has passed and reclaims stale .tmp
files (sweep_payload_dir,
crates/camel-core/src/cache/disk_offload.rs:560). Unlink of an absent file
counts as success, so concurrent replicas sweep without coordination. The
task stops on the context shutdown token, and Drop aborts it
(crates/camel-core/src/cache/disk_offload.rs:432). No sweeper exists under
inline mode.
Decision 7: fail-closed config matrix
Four cache_repo fields govern offload: payload ("inline" default,
"disk"), payload_dir, payload_sweep_interval (default 1h), and
payload_max_ttl (default 720h). Validation rejects payload = "disk" on
the memory backend, payload = "disk" without a non-empty payload_dir,
and any payload field set under inline mode or the memory backend
(crates/camel-config/src/config.rs:1806,
crates/camel-config/src/config.rs:1899). Malformed or zero intervals fail
with an error that names the field. payload_dir has no default: the
operator states where the blobs live. ${env:} strict interpolation applies.
Rejected alternatives
Per-backend offload options
Rejected: the same logic would be triplicated across backends that already diverge on scan and delete.
Compact binary codec (bincode, base64)
Rejected as a substitute: a codec fixes the JSON x4 bloat of a Vec<u8> but
keeps the full dataset in backend RAM. It also does not unlock replicas > 1 on the redb single-writer lock. Kept as a separate follow-up.
Trait widening (keys(prefix)) and directory-per-prefix layout
Rejected: self-die filenames make eager file deletion unnecessary. Purge is index purge plus asynchronous reclaim.
payload_min_size threshold
Rejected: every payload in the target workload is about 50 KB. Additive later if a small-entry consumer appears.
Context
Problem
The cache backs the tile proxy for emergency-services WMS, WMTS, and radar
delivery (7 sources, about 8k tiles per source). It is a resilience asset:
when an upstream fails, the stale tile is served. The hard requirement is
having the tile, not latency. No backend satisfies replicas > 1 with a
full dataset and bounded RAM:
- redb is embedded and takes a single-writer file lock. It needs a read-write-once volume, and no RWX or NFS sharing. Each replica re-warms its own copy.
- redis is shared, but the whole dataset lives in RAM.
CacheEntryserializes as JSON, so aVec<u8>payload becomes an integer array. A 50 KB tile occupies about 200 KB of redis RAM. - memory is volatile.
A small, shared, durable index plus payload files on cheap storage resolves the tension.
Forces
- Operator-owned placement. Routes, EIPs, and backend choice stay untouched. The operator only decides where the blob lives.
- Fail-closed culture. The config matrix validates at startup (ADR-0033).
- Unknown-outcome honesty. Contract C1 (ADR-0023) governs the read paths: a dead file is a miss, a failing disk is an error.
- No new failure mode. A full disk must degrade the cache, not break the route.
Consequences
Rollback requires a cache clear
A rollback to a binary without offload reads an offloaded index row and serves empty bytes. Clear or re-seed the cache across a rollback.
NFS caveats
A local volume is preferred. On NFS, fsync durability is mount-dependent,
and rename plus create_new semantics can leave short-lived .tmp files
under load. The sweeper reclaims them.
Stats report index-side accounting only
stats delegates to the wrapped backend. Offloaded entries contribute an
emptied bytes field: redb sums entry bytes, so each offloaded entry
contributes 0, and redis reports None for the sum. Blob bytes never
appear.
Portability
Offloaded entries are unreadable by consumers that do not share
payload_dir. Context build emits one startup WARN that names the resolved
directory (crates/camel-config/src/context_ext.rs:296).
Multi-replica on RWX volumes
With the redis backend, the index is shared and the blobs live on one RWX volume. Concurrent writers to the same key are last-index-wins. The surviving row references its own blob, and the loser reclaims at its death epoch.
Retention and TTL changes apply to future writes only
Every blob's death epoch is computed at set() time with the retention
values in force at that moment, and the sweeper deletes by the filename
epoch alone — runtime config never takes part in deletion. Changing
stale_retention, payload_max_ttl, or payload_sweep_interval therefore
never applies retroactively:
- Lowering
stale_retentiongives no immediate disk relief. Existing blobs die at their baked epoch, so the orphan window after a purge grows temporarily. Relief comes only fromclear()or from waiting out the old cycle. - Raising
stale_retentionopens a redb-only transient window where a row outlives its blob: the row is reclaimed atexpires_at + new_retention(redb recomputes with the runtime value), while the blob dies at its baked epoch. Reads in between are a MISS with WARN. The window is nominally bounded bynew_retention − old_retention − payload_sweep_interval(the grace baked into the blob), plus up to one redb sweep tick before the row goes. It closes by itself when the redb sweep removes the row — cold keys heal without churn. Pair a large raise withclear()when stale-serve continuity matters during the transition. Redis has no such window: the key expires at the immutable EXAT set at write time, which is the same death line the blob already encodes. payload_max_ttlchanges affect only the expiry fabricated for futurettl = Nonewrites; existing blobs keep their baked epochs.payload_sweep_intervalchanges affect only the grace of future writes plus the runtime sweep cadence and.tmpGC age.- Mixed-config replicas sharing one
payload_dirare safe: the sweeper is filename-driven, so different retention settings only shift windows, never correctness.
The sweeper logs one INFO line per pass with live and reclaimed blob counts and bytes, so operators can watch the volume during any of these transitions.
Load-bearing citations
| File:line | Element |
|---|---|
crates/camel-api/src/cache.rs:18 | CacheEntry |
crates/camel-api/src/cache.rs:23 | payload_path: Option<String>, #[serde(default)] |
crates/camel-core/src/cache/disk_offload.rs:62 | DiskOffloadRepository decorator |
crates/camel-core/src/cache/disk_offload.rs:479 | fn blob_filename self-die name format |
crates/camel-core/src/cache/disk_offload.rs:471 | fn content_fingerprint domain-separated blake3-128 |
crates/camel-core/src/cache/disk_offload.rs:490 | fn parse_death_epoch |
crates/camel-core/src/cache/disk_offload.rs:499 | fn sanitize_blob_name direct-child guard |
crates/camel-core/src/cache/disk_offload.rs:560 | sweep_payload_dir: death-epoch unlink, tmp GC |
crates/camel-core/src/cache/disk_offload.rs:619 | spawn_sweeper standalone task |
crates/camel-core/src/cache/disk_offload.rs:432 | impl Drop aborts the sweeper |
crates/camel-config/src/config.rs:709-730 | the four payload config fields |
crates/camel-config/src/config.rs:1806 | memory-backend payload rejection |
crates/camel-config/src/config.rs:1899 | disk-mode fail-closed matrix |
crates/camel-config/src/context_ext.rs:296-330 | wrap-on-disk wiring and portability WARN |
crates/camel-test/tests/cache_payload_offload.rs | live redis offload integration suite |
ADR-0066: Metrics Collector Binding and Lifetime Contract
Date: 2026-08-28 Status: Accepted Origin: bd rc-hrm1.8 (epic rc-hrm1), expert ruling N7 Amends: ADR-0012 Cross-refs: ADR-0012, ADR-0013, ADR-0041, ADR-0044, ADR-0045, ADR-0052
Context
The 2026-08-26 metrics audit (epic rc-hrm1) found two structural defects
in collector wiring. First, with_tracer_config snapshotted a collector
before backend lifecycle registration. A prometheus-only context captured
NoOpMetrics and exported nothing (rc-cizb). Second, the first-registered
OTel collector won the route path. A later Prometheus registration
overwrote a CamelContext.metrics slot that no route or component read.
The fix landed as metrics-handle-late-binding (8c876d54): one
MetricsHandle per context, backed by ArcSwap, resolved after backend
registration. This ADR pins that contract and the lifetime rules that
follow from it. It also records the error-accounting amendments to
ADR-0012 that the dashboard-observability change introduces.
Decision
Decision 1: one late-bound collector slot per context
Each CamelContext owns exactly one MetricsHandle
(crates/camel-api/src/metrics.rs:110). The handle is an
ArcSwap<CollectorSlot> that seeds itself with NoOpMetrics. Consumers
may hold the handle before any real collector exists. Calls before
registration are safe no-ops. The handle is resolved after backend
registration. This supersedes the collector snapshot inside
set_tracer_config that produced rc-cizb.
Decision 2: registration composes; order is irrelevant
MetricsHandle::register composes the new collector over the stored one
via CompositeMetricsCollector (crates/camel-api/src/metrics.rs:132,
:240). Registration never replaces. The same Arc registered twice is
a no-op (Arc::ptr_eq dedupe), so a call site that wires one collector
through two builder paths does not double-count. Composition is
order-independent, so registration order is irrelevant.
Decision 3: multi-backend fan-out composes
OTel and Prometheus registered simultaneously both receive every
emission. No collector silently wins the route path. The composite
delegates every trait method to each member, including the
error-semantics methods added by dashboard-observability
(increment_retry_attempt, increment_circuit_breaker_rejection).
Decision 4: tracer.enabled gates spans ONLY
[observability.tracer] enabled controls span creation only. It does
not gate metric emission. A prometheus-only context (tracer off) still
collects route and component metrics. Prometheus enablement implies the
tracer pipeline, so the non-disableable error family is always exported
(effective_tracer_config, crates/camel-config/src/context_ext.rs:962).
Decision 5: the error family is the only non-disableable family
The ADR-0012 error family (MetricsCollector::increment_errors,
crates/camel-api/src/metrics.rs:10) is always emitted. No
[observability.metrics] lever can disable it. The exchange counter,
duration histogram, and component-operations families are gateable
independently. The pipeline tracer never gates increment_errors
(crates/camel-core/src/shared/observability/adapters/tracer.rs:159).
Decision 6: retry accounting (amends ADR-0012)
One increment_errors per exhausted NetworkRetryPolicy sequence,
executed by the policy helpers themselves (retry_async,
crates/components/camel-component-api/src/network_retry.rs:245;
retry_async_cancelable, :349). Call sites do not increment on their
own Err arm for attempts the helper retries. Cancellation is not
failure: a cancelled sequence emits no error. Per-attempt telemetry
lives on camel_retry_attempts_total{scheme,operation}.
Decision 7: breaker rejections are not errors (amends ADR-0012)
Open-breaker fast-fails (CamelError::CircuitOpen, classified
"circuit_open", crates/camel-api/src/error.rs:174) count on
camel_circuit_breaker_rejections_total{route}. The pipeline tracer
skips increment_errors for error_class == CIRCUIT_OPEN
(crates/camel-core/src/shared/observability/adapters/tracer.rs:178-179).
Callers still receive CamelError::CircuitOpen unchanged. Only metric
routing changes.
Decision 8: rejection-counter unit is readiness probes
The rejection counter counts readiness probes, not logical sends. A
parked caller retries poll_ready on backoff, so one open breaker
produces about one rejection per backoff interval per parked send. The
unit is pinned: camel_circuit_breaker_rejections_total measures probe
pressure, not dropped work. Alerting must scale thresholds by the probe
rate, not by the send rate.
Decision 9: helper-owned exhaustion errors use the operation label
Helper-owned exhaustion errors place the OPERATION in the first-arg
(route) label position of camel_errors_total. The helper has no route
scope, so the shape is increment_errors(operation, "e:{scheme}:{operation}"). Dashboards keying on route must expect
component-operation pseudo-routes there, for example
e:container:events-connect.
Decision 10: registration-order contract for gauges
Identity gauges (build info, uptime) fire pre-registration at build
time. They are re-published on registration, so a late-registered
collector still sees them. Route-state is transition-driven and does NOT
replay to collectors registered after transitions fired. The canonical
path registers pre-start (configure_context) and is e2e-proven.
Post-start registration shows uptime without route inventory until the
next transition. Queue-depth self-heals via 250ms samplers.
Decision 11: sampling cadence
| Series | Cadence |
|---|---|
camel_uptime_seconds | 60s refresh task |
| SEDA queue depth | 250ms sampler |
| Aggregator queue depth | 250ms sampler |
| Aggregator TTL sweep | ttl / 2, minimum 50ms |
| Resequencer queue depth | post-accept only |
The resequencer publishes after each accept. Timeout-release staleness is a known ceiling: a batch released by gap timeout is not re-sampled until the next accept.
Decision 12: double-count contract
Facade failures land on e:{component}:{operation}. Retained
component-specific error labels MUST never equal that string. A true
double-count is one series incremented twice for one failure. Dashboards
summing the whole camel_errors_total family count each component
failure twice (uniform series plus specific series). This is intended
per D5 and stated here so the 4.2 audit table records it.
Decision 13: helper and facade label collision
retry_async with metrics = Some emits e:{scheme}:{operation},
byte-identical to the facade label when scheme == component (the
opensearch shape). Today no boundary wires BOTH the helper
(metrics = Some) AND the facade: the Some helper sites (container
events-connect/logs-connect, sql pool-init) do not use the facade,
and the facade sites (opensearch, and the rest of the Phase-4 sweep)
pass the helper None. A component that adopts the facade at an
operation boundary where retry_async runs with metrics = Some would
same-series double-count. Wiring BOTH at one boundary is FORBIDDEN.
Choose one error owner per boundary. A component that wants per-attempt
telemetry AND facade outcome semantics must use distinct operation
labels — the shared series cannot carry both.
Decision 13a: third-backend registration
Registration composes (D2) and fan-out is order-independent, so a third backend needs no code change: register it and every emission site fans out to it. Delegation cost grows linearly with member count — one dyn dispatch per member per call — which is the accepted price of the multi-backend contract.
Decision 14: vocabulary asymmetry
| Component | Operations |
|---|---|
| kafka | consume, produce |
| redis | command (producer only) |
Kafka emits both consume and produce. Redis emits command (producer) only. The redis consumer stays outside the family vocabulary. D5 names the producer operation only.
Decision 15: recording-double debt
About ten recording doubles exist across crates, growing per trait method. Consolidation is bd-tracked (rc-4dvi). This ADR records the intent: the doubles are known, counted, and scheduled for consolidation, not silent drift.
Rejected alternatives
Per-backend collector slots
Rejected: a slot per backend re-introduces the winner-takes-all bug. One composed slot is the contract.
Snapshot at config time
Rejected: this is the rc-cizb failure mode. Late binding is the fix.
Consequences
Alert thresholds recalibrate
camel_errors_total counts drop by the retry factor and by breaker
rejection rate. Alert thresholds calibrated to inflated counts fire
less. The merge commit states this.
Trait growth is additive
Five new default methods on MetricsCollector keep out-of-tree
implementors compiling. The composite delegates all of them.
Cardinality is bounded
camel_component_operations_total is bounded by closed label sets.
Components opt in with the components lever, default off.
Load-bearing citations
| File:line | Element |
|---|---|
crates/camel-api/src/metrics.rs:110 | MetricsHandle (ArcSwap slot) |
crates/camel-api/src/metrics.rs:132 | register composes |
crates/camel-api/src/metrics.rs:240 | CompositeMetricsCollector |
crates/camel-core/src/context.rs:53 | metrics: Arc<MetricsHandle> |
crates/camel-config/src/context_ext.rs:962 | effective_tracer_config |
crates/camel-core/src/shared/observability/adapters/tracer.rs:178-179 | circuit_open exclusion |
crates/camel-api/src/error.rs:174 | CIRCUIT_OPEN |
crates/components/camel-component-api/src/network_retry.rs:245 | retry_async |
crates/components/camel-component-api/src/network_retry.rs:349 | retry_async_cancelable |
crates/components/camel-container/src/lib.rs:1545-1562 | helper-owned exhaustion |
crates/components/camel-kafka/src/consumer.rs:591 | e:kafka:recv-exhaustion |
crates/components/camel-kafka/src/consumer.rs:386,410 | ("kafka","consume") |
crates/components/camel-kafka/src/producer.rs:282 | ("kafka","produce") |
crates/components/camel-redis/src/producer.rs:21-23 | ("redis","command") |
crates/camel-processor/src/aggregator.rs:371 | TTL sweep ttl / 2, min 50ms |
crates/camel-processor/src/aggregator.rs:31 | 250ms queue-depth sampler |
crates/components/camel-component-seda/src/lib.rs:43 | 250ms queue-depth sampler |
crates/camel-processor/src/resequencer/mod.rs:202-211 | post-accept publish |
Gauge A/B lever cost (bench-era-2)
The request-path cost of keeping the metric families (memory gauges)
ON was measured by an A/B lever study on the http-server cell:
ratio 0.9890, 95% CI [0.9785, 1.0126], UNPAIRED cross-run arms —
cost unresolved from zero, at most about 1% at this resolution.
Canonical benchmark runs therefore keep gauges ON. Full protocol,
quiet-host gates, and interpretation:
docs/benchmarks/history/2026-08-29-benchmark-v4-addendum.md.
ADR-0067: JMS Message-Type Forwarding Policy
Date: 2026-08-29 Status: Accepted Origin: bd rc-41h3 (epic rc-41h3), Phase 3 Cross-refs: ADR-0032
Context
The JMS bridge forwards broker messages to a bytes-only proto. The proto
carries a body and headers. It has no branch for the JMS message types
ObjectMessage, MapMessage, or StreamMessage. Today
JmsConsumer.convertMessage falls through for these types and produces an
empty body. This ADR pins that behavior as policy.
Decision inputs
Input 1: current empty-body behavior
The fall-through path produces an empty body with headers preserved. Under
AUTO_ACKNOWLEDGE, receipt acknowledges the message
(JmsConsumer.java:132). No body accessor is invoked (property accessors still run for headers and content-type).
Input 2: Java-serialization gadget risk
ObjectMessage.getObject() executes attacker-controlled readObject.
Broker messages are exchange data under ADR-0032. They are untrusted and
adversary-controlled. A policy of never deserializing keeps the bridge out
of the deserialization attack surface.
Input 3: MapMessage flattening rejected
Flattening a MapMessage to a JSON body is rejected for now. JMS map
values are constrained to primitives, String, and byte[]. Faithful
flattening still forces decisions the bytes-only proto cannot express
today:
- numeric type preservation: the int vs long vs double distinction is lost in JSON numbers;
byte[]representation: base64 vs hex, and how it is versioned;- null-value semantics: JSON null vs an absent key;
- a canonical media type and versioning for the synthesized body.
With no consumer demand, each decision is an unforced compatibility commitment. Flattening stays revisitable.
Decision
Forward ObjectMessage, MapMessage, and StreamMessage with an empty
body. Preserve headers. Receipt acknowledges under AUTO_ACKNOWLEDGE.
Never invoke the body accessors on these types.
Content producers must use BytesMessage or TextMessage to carry a
body.
Consequences
No deserialization attack surface
The bridge never calls getObject() or any other body accessor. The
Java-serialization gadget risk from Input 2 does not reach the bridge.
Content producers use Bytes or Text
Producers that need a body must use BytesMessage or TextMessage.
Object, Map, and Stream messages arrive empty-bodied.
Flattening is revisitable
MapMessage flattening stays open. A future ADR can revisit it when a consumer demands it and the proto can express the constraints from Input 3.
ADR-0068: MCP Registry Owner-Liveness Entries
Date: 2026-08-29 Status: Accepted Origin: bd rc-apvm Cross-refs: ADR-0007, ADR-0060
Context
The MCP server role registers one tool or resource route per consumer start. Each entry maps a name or URI to a route. The registries are process-global singletons (ADR-0060). A consumer that dies without a stop leaves its entries behind. The entries are dead: their channels never deliver again.
A dead entry blocks a legal takeover. A restart of the same route hits the duplicate guard and fails. The dead entry also holds a catalog cap slot. Listings advertise a tool that can never answer.
Decision inputs
Input 1: lease or heartbeat rejected
A lease or heartbeat renews liveness on a timer. It fixes every crash shape, but needs a renewing task and TTL tuning. A restart faster than the TTL still hits the duplicate guard. Heartbeats are self-liveness machinery. ADR-0007 forbids consumers from self-supervising. Rejected.
Input 2: pid-scoped keys rejected
Pid-scoped keys would key entries by process id. The registry is an in-memory process-global singleton. There is no cross-process path. The reported case is same-process. Rejected as moot.
Input 3: sender.is_closed() rejected
sender.is_closed() is true only when the bridge died. On
drop-without-stop the bridge detaches and parks on rx.recv() forever.
The channel stays open. Rejected as insufficient.
Input 4: Drop-guard rejected
A Drop-guard on the consumer covers drop and abort. The runtime takes
the bridge handle, so Drop cannot abort the detached bridge. It still
needs owner-conditional unregister to avoid deleting a successor's
entry. Redundant once replace-on-conflict exists. Rejected.
Decision
McpConsumer::start() mints an Arc<()> token and stores it in
Running. Each registry entry and route security plan carries a
Weak<()> view of that token. The token dies when the consumer dies:
task abort, unwind, or plain drop.
register() replaces a duplicate entry whose owner token is dead. A
live owner keeps today's rejection. Before cap enforcement, register()
prunes every dead-owner entry, so a dead entry under any name releases
its slot. resolve() and list_ready() skip and remove dead-owner
entries. The unregister used by stop() and failure cleanup is
owner-conditional. unregister_owned removes an entry only when the
caller's token matches the entry's token (Weak::ptr_eq). A late stop
of a dead owner cannot delete a replacement's entry.
McpBindSecurity plans take the same discipline. register_plan is
owner-scoped: a plan held by a live owner is kept, a dead owner's plan
is replaced. The unregister used by stop() and failure cleanup is
owner-conditional. unregister_plan_owned removes a plan only when the
caller's token matches the entry's token (Weak::ptr_eq). plan_for
ignores plans whose owner is dead.
Consequences
Dead entries stop blocking takeover
A restart of a dead route replaces the stale entry. The duplicate guard rejects only live-owner duplicates. A failed duplicate start cannot remove or overwrite the incumbent consumer's plan.
Dead entries release cap slots
The prune-before-cap sweep reclaims slots without waiting for an unrelated list or resolve operation. The sweep is bounded by the cap itself.
Listings stop advertising dead tools
list_ready prunes dead-owner entries before listing. Dead tools and
resources disappear from tools/list and resources/list.
In-flight requests fail cleanly
A request that resolved an entry before its owner died sends into a dead channel. It returns a clean MCP error. The route body never runs.
Plan removal cannot open an auth hole
A missing plan means unauthenticated pass-through. A late stop of a dead owner cannot remove a live replacement's plan. Dispatch stays authenticated.
No new supervision machinery
There are no timers, heartbeats, or new config knobs. ADR-0007 keeps its posture: consumers do not self-supervise.
ADR-0069: Integration-Tier Testing Contract
- Status: Accepted (human-ratified 2026-09-03; e_opus + e_gpt BLESS-WITH-FIXES, fixes applied). Amended 2026-09-06: section 13 added (flake taxonomy and test-design rules R1-R7, bd rc-jwp3; ADR-0070 carries the staged-listener application of R2).
- Date: 2026-09-03
- Supersedes: none. Binds the sketch in ADR-0064 section 4.
- Epic: rc-kk69. Authoring path: human grill + ste-writing (same path as ADR-0064, per rc-379d precedent). Not a conductor-light change.
Context
ADR-0064 fixed the two-tier testing boundary. It named the integration tier as a non-binding sketch: full runtime boot, real adapters, receive-with-timeout assertions at transport boundaries. This ADR converts that sketch into binding contract.
Three inputs shaped the decisions below:
- Two expert consultations (e_opus, e_gpt) over the code, the ADRs, and the bd history. Both returned GO-WITH-CONDITIONS. All rulings were verified against source files.
- A grill session with the human on 2026-09-03. Seven questions, all sealed.
- Verified demand. The HTTP bridge header corruption (rc-eoft, rc-f0cn), the
consumer readiness defect (rc-w1u9, since closed through Explicit startup
and
mark_ready), and the WS reconnect work (rc-cl7, rc-39d6) are real regressions that hand-written tests did not catch.
The pain is concrete. crates/camel-test/tests/ holds roughly 30 hand-written
integration tests. They probe for free ports, sleep fixed intervals, and assert
by hand. The scenario runner replaces that pattern with declarative documents.
Decision
1. One format, derived tier
All test documents are .test.yaml. There is no second schema and no
integration-specific suffix.
A document's tier is a pure function of its content. No field declares the tier. A declaration field can only repeat what the machine computes, or contradict it. The contradiction class is removed by not having the field.
The function is total through a conservative default:
tier(document):
1. The document has a `scenario:` section -> FULL
2. Else, compute the component closure over:
the parsed RouteDefinitions from any route source
(routeFiles, routeFilesFromRoot, or inline routes),
with nested steps traversed recursively,
MINUS endpoints replaced by `intercepts`,
PLUS the schemes named in `inputs` and `expects`:
closure within {direct, log, mock, seda, timer} -> LEAN
any non-lean literal, placeholder-in-scheme,
or dynamic dispatch step -> FULL
Dynamic dispatch steps are recipient_list, routingSlip,
dynamic_router, and toD-style targets computed from the exchange at run
time. Their target scheme is not knowable before run time, so they force FULL.
scenario: forces FULL without condition. A "lean scenario" would need an
action interpreter inside the lean boot. That is runtime-profile creep, which
ADR-0064 fences. Unit documents already have an action vocabulary:
inputs, expects, intercepts.
This conforms to ADR-0064 in spirit. That ADR fixes the boundary by inbound stimulus plus runtime profile. It does not fix the boundary by file name. Content-derived tiering measures the real stimulus. A label only claims it.
The lean registry stays byte-identical to today. Tiering routes documents to a boot. It never grows the lean set. The creep rule and its amendment gate stay in force without change.
2. Mixed vocabulary is forbidden in v1
A document with scenario: must not declare inputs, expects, or
intercepts. The runner rejects such a document at load time.
Reasons: one vocabulary per tier, and per document. intercepts exists to fake
transports in the lean boot. The full boot has the real transport. If a demand
for mixing emerges, reopen this rule with evidence.
The full boot also registers mock, direct, and log. The ban is for
clarity, not for lack of capacity.
camel-lint does not parse test documents today. When it does, it becomes the
static enforcement surface for this rule and for tier derivation. Until then,
the runner enforces at load time.
3. Filters, not modes
camel test --unit runs only lean documents. camel test --integration runs
only full-tier documents. Default camel test runs everything, each document
at its derived tier.
Tier filters are symmetric and exclude by scope:
- A nonmatching document found through directory expansion is excluded from the run.
- A nonmatching document named explicitly on the command line fails with
tier-filter-collision. The explicit name is the assertion. - Supplying both
--unitand--integrationis misuse, exit 2.
A CI job for the fast suite pins the fleet to lean through the filter, without any per-document field.
A name or pattern filter may come later. It will compose with the tier filters
as one filter surface. A --watch mode is filed separately (rc-hi9y) and is
out of scope here.
4. Environment parity and hermeticity
Today camel run resolves ${env:NAME} and ${env:NAME:-default} in
Camel.toml and in route URIs. camel test resolves nothing. The full-boot test
path closes this gap by construction: both commands boot through the same
composition root, so the resolver behaves the same.
Hermeticity is primary. The test document is the source of truth, not the CI machine.
- An optional
env:section in the document fixes fixture values for the scenario. The resolver reads these first. - Ambient environment inheritance is off by default.
- A document may list specific variables that pass through from the ambient environment.
CAMEL_PROFILEis pinned per document. An ambient profile would break hermeticity.- The document
env:section does not couple to theCAMEL_*config-override allowlist. That allowlist governs TOML overrides. It is a different resolver from the${env:}route placeholder path.
The current resolvers read process-global state directly. The harness must
not mutate the process environment: concurrent documents, and a future
parallel, would race on it. Instead, the boot path receives an explicit
layered environment source: document env first, allowlisted ambient second,
defaults third, otherwise unresolved. The pinned profile is passed the same
way. This layered source is an input to the DSL and config loaders, not a
process-global rewrite.
Known limitation, recorded when this ADR was written: resolve_tree_walk
visited only string leaves, so placeholders in int-typed TOML fields did not
resolve. Solved by the typed env probe (rc-45xig, rc-v1sw — landed
2026-09-10): the provenance-tracking interpolation and smallest-first typed
probe coerce clean-integer defaults at integer-typed positions in both the
DSL YAML and camel-config TOML arms. Ports in URIs resolve today.
5. Partner-side assertions are the only normative proof
The harness owns a listener on the other side of the wire. For an outbound
route, the harness binds an HTTP server on 127.0.0.1:0 and validates what
the route producer sends. For an inbound route, a harness client drives the
real consumer and validates the wire response. What arrives there is the
proof: bytes, headers, status, timing.
A proxy route whose upstream query varies per request has no literal
arrival lane a receive can name, so its scenario asserts the harness
record through a validate whose partner URI self-declares the harness
reference in object form (provisioning: harness on an http endpoint plus a partners:
entry naming the URI) rather than matching a send/receive
reference; the sacrificial-receive workaround is forbidden by design.
Transport interception and mock: expectations are secondary diagnostics.
They never produce a green integration result. Mocks and interception are unit
tier tools by ADR-0064 design.
Loopback on 127.0.0.1:0 inside one process does not violate the no-IPC
invariant. The invariant forbids a test-control channel to a deployed
camel run process. Loopback traffic is the subject under test.
The readiness prerequisite is satisfied by rc-w1u9. CamelContext::start()
waits until the HTTP consumer binds or fails: the consumer opts into
ConsumerStartupMode::Explicit and calls mark_ready() after bind. The
current signal does not expose an address selected from port 0. Inbound
scenarios therefore use an explicitly configured loopback port in v1.
OS-selected consumer ports require a separate operator-facing bound-address
API, filed on its own merits. The integration tier consumes only
operator-facing signals.
6. Core purity fences
camel-core is the engine. The testing program does not touch it.
- No crate the testing program introduces may appear in
camel-core[dependencies]or[dev-dependencies]. The ADR-0055 lint machinery enforces this. - No virtual clock in core. Integration deadlines use real monotonic time. Paused Tokio time stays a unit-harness concern.
- No observability tap or test-only event surface in core. Readiness (rc-w1u9) is an operator signal designed on its own merits.
- Tier derivation lives outside core. The function reads DSL and lint surfaces. Core has no concept of tiers.
- Partner readiness polling wants a "consumer bound" event. That want is the
rc-w1u9 temptation. The fix stays an operator signal, not a test callback.
The harness consumes the readiness signal only through the same public
surface an operator uses: health or readiness state, or the bound address
a future boot handle reports (the WebSocket side of that address surface
has since landed:
ServerRegistry::get_or_spawn_with_listener. ADR-0070 generalizes the full staged-listener surface and its rulings). It never subscribes to a core-internal event or a callback added for the test. If partner readiness needs more than the operator surface exposes, the gap is an operator-facing engine feature, filed on its own merits. It is not a harness hook into core. - Bugs the tier exposes are engine bugs. They are filed and fixed in the domain on their own merits.
Any new core API proposed for testability must answer one question: would this API exist without tests? If not, it does not land.
7. Failure taxonomy
Exit codes are inherited from camel test today: 0 all pass, 1 any failure,
2 any parse or misuse error. The split is epistemic:
Exit 1, the scenario ran and the system under test failed it:
receive-timeout: nothing reached the partner before the deadline.validation-mismatch: the message arrived and failed the validator.- (moved to exit 2, see below)
Exit 2, the scenario never got a meaningful answer:
scenario-var-unresolved(moved from exit 1, rc-whof): a referenced variable was never set. An unset variable is an authoring bug — the scenario never got a meaningful answer, whether the defect is caught statically at load or at run time.doc-validation: mixed vocabulary, broken grammar.tier-filter-collision: an explicitly named document did not match the tier filter.partner-bind-failure: the harness could not bind its listener.partner-startup-failure: the partner listener bound but its handler failed to start.action-transport-failure: a send or receive action failed at the transport before any assertion ran.infra-unavailable: an adapter needs a broker or Docker that is absent. The error names the requirement. It never hangs.full-boot-failure: the embedded boot failed.shutdown-failure: teardown of the boot or a partner timed out or erred after the verdict was recorded.
Every adapter operation carries a deadline, not only infra-unavailable. A
cancelled parallel sibling reports as cancelled, not as a verdict failure.
infra-unavailable fails loud by default. With demand-gated adoption, a
silent skip would hide the demand signal itself. A skip mechanism, if ever
needed, is a separate decision.
8. Demand gate and activation order
The tier activates adapters only when a concrete regression justifies them.
Update 2026-09-05: the demand signal arrived, so the camel-cli binary now
enables integration-http by default. The gate itself is unchanged: the
adapter still compiles only behind the Cargo feature, featureless builds
keep the infra-unavailable path, and the e2e suites that spawn the real
binary stay behind the non-default itest-e2e test feature so the default
suite keeps its runtime and composition.
- HTTP, both directions. Outbound bridge and proxy regressions justify it (rc-eoft, rc-f0cn). The rc-w1u9 readiness work is already satisfied.
- SQL, as the
sql:scenario action against named datasources (rc-25lup.1, rc-25lup.2). It landed 2026-09-08. - WS, after the consumer-client role lands (rc-39d6).
- gRPC is a loopback candidate. It needs no Docker. It activates on demand.
- Kafka, JMS, and other broker adapters wait for an adapter-specific regression.
Each adapter is a Cargo feature. There is no all-components feature. CI runs a
dedicated integration-http job with path filters. A dedicated
integration-sql job proves that the sql feature stands alone: it builds
camel-cli with --no-default-features --features integration-sql,itest-e2e
and runs the scenario e2e suite, so the full-boot path runs without
integration-http. Broker scenarios run in an isolated or scheduled job.
Loopback tests carry no #[ignore] marker. That is the ADR-0054 rule, not a
new one. The loopback budget is seconds.
9. Partner provisioning sources
The grammar names three sources for a partner endpoint address. The axis is who owns the lifecycle.
harness: the harness binds an in-process listener on127.0.0.1:0. This is the only source implemented in v1.testcontainer: the harness manages an ephemeral container and destroys it with the scenario. Reserved grammar value. The v1 runner rejects it as unsupported.user-provided: the document receives an address through a variable or passthrough environment value. How that infrastructure exists is not the harness's concern. Docker Compose, a CI service container, or a staging broker are all the same to the grammar. Reserved grammar value in v1. The runner rejects it as unsupported until an adapter activation needs it.
The harness source also backs a validate self-declaration: a partner
validate URI may name no send/receive reference when the object
target form carries provisioning: harness on an http endpoint and a partners: entry
names the URI. Proxy routes whose query varies per request use this
channel, since those routes have no literal arrival lane (section 5) and
a sacrificial receive is forbidden by design.
The system under test is always the embedded boot. The harness never drives a
deployed camel run. A live camel run --watch next to a test run is two
independent processes. They share nothing. Port conflicts are the only
interaction, and section 4 covers them: URI ports resolve through ${env:}.
10. Crates
Two new crates, both depending on core, never the reverse.
camel-bundles owns the component-bundle registration cascade extracted from
camel run, plus the lifecycle handle. The name is the project's own noun:
the crate's sole responsibility is running ComponentBundle::register_all.
camel-config keeps context composition. The extraction does not re-home it.
The shared boot boundary is enumerated, not blanket. camel-bundles owns the
bundle registration and the lifecycle handle with explicit shutdown(). The
handle owns the bridge cleanup and the JMS and CXF pool teardown. The CLI
keeps the watcher, signal handling, the second-Ctrl+C path, the conditional
exec guard, and operator logging. Security setup, bind acknowledgements,
datasources, and startup checks move to the shared boot when, and only when,
both consumers need the same semantics. Until then each path states what it
owns. The handle type is BootHandle, following the ...Handle suffix
precedent.
camel run and the harness both register bundles through this one cascade.
Feature flags for bundles forward from the CLI and from the harness into
camel-bundles.
camel-integration-test owns the scenario model, the parser, the action
executor, validators, partner adapters, and the Rust API. camel-cli depends
on it and provides a thin command adapter. camel-test stays unchanged: it is
the publish-order leaf sink, and ADR-0055 forbids publishable dependencies on
it.
11. Citrus divergences
Citrus is inspiration, not authority. This is the ADR-0046 rule applied to Citrus.
Adopt: ordered action lists. Logical endpoints bound to typed transport
drivers. send, receive with a mandatory deadline, sleep, validate.
Scenario variables with extraction. Exact body, header, and status
validators.
Defer: iterate. Validator registries. A negative expect-timeout action.
Protocol-specific action families. Structured parallel with sibling
cancellation on failure. The cancellation semantics are sealed. The timing is
deferred until a scenario demands it.
Reject, binding:
- Citrus conformance in files, API, or literal semantics. Assertion translation is re-derivation, not porting. This is the ADR-0046 anti-pattern.
- XML, Spring Bean, JUnit, and TestNG formats and runner coupling. This runtime is embedded Rust.
- Standalone mode against a deployed
camel run. The frozen no-IPC invariant stands. - Universal symmetric client-server endpoints. Each adapter declares the roles it supports.
repeat-on-error. It masks non-determinism and subverts the conditional determinism of ADR-0054.
12. bd hygiene
rc-i2qf closes with a reason, not a supersede. Its acceptance criterion is
already satisfied by the recorded rejection: a producer is a write/send sink.
Partner reply behavior belongs to the typed partner adapters in
camel-integration-test. camel-component-mock does not change.
13. Flake taxonomy and test-design rules
Added 2026-09-06 (bd rc-jwp3, epic rc-99d5). Two escalation reviews (e_opus advisory, e_gpt adversarial counter-review) produced the adjudicated text below. The counter-review's corrected rules are the normative wording. The full reviews are local-only working documents. The durable adjudication record is bd rc-99d5. The wording below is self-contained.
13.1 Taxonomy
Seven flake classes. Cite these tags in bd issues:
unbounded-wait: a test awaits externally driven progress without a deadline (loop, retry, receive, lock, JoinHandle).port-toctou: a bind-inspect-close-rebind race around ephemeral ports. Governed by ADR-0070.pooled-race: a raw test server closes a connection while a pooled client reuses it.global-state: statics or environment mutation contaminates a later test in the same process.platform-timing: an OS-dependent race window. A detector class, not a cause.sleep-as-sync: sleep stands in for synchronization. The test assumes state after N milliseconds. Await_untilbarrier on an observable state replaces it. Post-start sleeps are vestigial since the rc-w1u9 explicit handshake.runner-pollution: orphan processes or firewall residue from earlier CI steps.
Honest expectations, from the counter-review's audit. The rules below prohibit named anti-patterns, bound hangs in gating jobs, and removed the two known races (rc-y24l, rc-u3aw). No rule makes any class structurally impossible. A renamed helper or an assembled raw response can still evade a scanner. Retries that fail on flaky, weekly macOS coverage, and job-level ceilings remain necessary layers.
13.2 Rules
R1 no-unbounded-wait. A test operation that waits for externally
driven progress MUST have a deadline at its call site, or use an
audited bounded helper. This covers network I/O, channel receive,
lock acquisition, process exit, readiness polling, and JoinHandle
waits. A long-lived service loop MAY run without an internal deadline
only when the test spawns it, owns its handle, bounds every readiness
assertion, and bounds teardown. A source exception MUST carry
// allow-test-wait: <reason>. Enforcement is a narrow AST lint over
known wait calls (syn, already an xtask dependency). It does not
claim complete proof. R6 remains the mandatory backstop.
R2 no-free-port. Test infrastructure MUST NOT select an address
through bind, inspect, close, and rebind. The component that owns the
bind MUST accept port zero, retain the live listener, and report its
bound address through a production or operator-facing API. A child
process MAY bind port zero and report the address to its parent. A
reservation socket is not an ownership handoff. The counter-review
held this rule BLOCKED until a bound-address API existed. ADR-0070 is
that API. Four applications landed. Every test binary and itest suite
now stages listeners. The named exceptions carry their own bd issues:
rc-1dgvg (in-lib residue), rc-s7dyw (external-process handoff), plus
ADR-0070's reserved-address and oneshot-placeholder exceptions.
R3 no-raw-http-test-server. Tests of outbound HTTP client behavior
MUST use a real loopback server that implements the connection
semantics the scenario requires. Prefer axum, Hyper, or wiremock. Use
oneshot
only for server-handler behavior below the network boundary. Raw TCP
is allowed for malformed-message, framing, disconnect, and other
protocol-fault tests. Such
a server MUST implement either one-response-and-close semantics or a
complete request loop for persistent connections. A literal scanner
flags suspicious fixtures. It cannot enforce protocol correctness.
R4 no-env-mutation-unguarded. Tests MUST inject configuration
directly when the API permits it. A test of process-environment
behavior MUST run in a dedicated child process with an explicit
environment. In-process mutation is a documented legacy exception. It
MUST use one crate-wide RAII guard, one lock, and MUST restore the
prior value. Async or multi-threaded mutation is forbidden unless all
readers and writers are proven to use that same lock. The lint recognizes the
canonical guard type, not variable names.
R5 no-unignored-loopback. No new rule. ADR-0054 and
cargo xtask lint-ignore already enforce the stricter closed
vocabulary with the bidirectional allowlist. That contract stands
unchanged.
R6 per-test-timeout. Per-test ceilings are required. Nextest
provides them and isolates each test in its own process. Adopt nextest
first for --workspace --lib. Container and bridge suites stay on
their current cargo invocations until migration measurements pass.
Job-level timeout-minutes remain mandatory for every job. Note the
semantics: slow-timeout { period = "30s", terminate-after = 3 }
terminates after about 90 seconds.
R7 quarantine. Normal CI MAY retry for diagnosis only when
flaky-result = "fail" keeps a pass-on-retry red. A confirmed flaky
test may enter a checked-in quarantine registry that names its exact
test ID, bd issue, owner, and ISO expiry date. A gating xtask lint
MUST reject missing, malformed, or expired entries. A separate
non-gating job MAY run quarantined tests with retries. Maximum
lifetime is 14 days. #[ignore] and name suffixes are not quarantine
mechanisms.
13.3 Enforcement status and pilot evidence
The rules are normative from this amendment. Their scanners land incrementally under tracked bd issues (structural lint work: rc-3lx2). Partial enforcement is the honest state, per 13.1.
The scenario-tier pilot of 2026-09-05 (bd rc-enbw, timeout.sh
migration eval on 0.40.0) supplies the first empirical support: both
central timeout asserts of timeout.sh replicate cleanly through
client-side send and receive with a deadline. Two cautions travel
with that evidence. First, partner receive does not yet record
responseTimeout-aborted requests (bd rc-kcli), so deadline proofs do
not rest on partner-side abort accounting until it closes. Second,
the pilot also requested a minimum-elapsed not-before-X assertion
(bd rc-1alu). Section 2's vocabulary ban governs format mixing, not
assertion growth. New assertion kinds enter through their own
changes.
Consequences
Positive
- One test document format across the route lifecycle.
- The tier boundary stays machine-checked with no label field to drift.
- Engine defects get an honest detector without engine pollution.
- The env gap between
camel runandcamel testcloses by construction.
Negative
- The tier function must stay correct as the DSL grows. New dynamic-dispatch steps must register as FULL-forcing.
- Content-derived tier removes the file name as tier metadata. CI selects through the tier filters, not a glob. The runner's tier report records each document's tier for audit.
- Two new crates raise the publish surface.
Alternatives considered
- A separate
*.integration.test.yamlschema. Rejected in grill. Citrus separates because it is an external framework against a deployed system. The harness here is first-party and embedded. The separate schema also duplicatedrouteFilesreferences across two files per route. - A declared
tier:field. Rejected. Redundant or contradictory, never informative. The filter flag carries the assertion role. - Growing the lean boot with more components. Rejected by ADR-0064 creep rule. Unchanged here.
- Interception or mock expectations as integration proof. Rejected. rc-w1u9 shows the in-process view lies about readiness. Only the wire is honest.
Self-grill record
- Grill session 2026-09-03, seven questions, all sealed by the human.
- Expert consultations: e_gpt (first round, 8 rulings), e_opus (verdict and P1-P4 adjudication, both code-verified). e_gpt second round did not respond; e_opus arbitration covered the naming deadlock.
- Divergence labels for ADR-0046 bookkeeping: unified format vs Citrus file
separation (
divergence), filter flags vs mode flags (divergence), repeat-on-error reject (divergence), scenario-implies-FULL (pin-invariant).
ADR-0070: Staged Listeners for Port-Deterministic Tests
Status
Accepted (2026-09-06). Generalizes ADR-0069 §6.5. The capability spec
openspec/specs/staged-listener-binding/spec.md is normative for the
contract details.
Context
The epic rc-99d5 taxonomy names the port-toctou flake class. A test
binds port 0, reads the assigned port, and drops the listener. It then
hands the bare port number to a component that binds later. Between the
drop and the real bind, any process can claim the port. The camel-ws
hang (bd rc-y24l) burned a CI runner for six hours through this class.
The original inventory counted 104 probe callsites.
The fix pattern is now landed in every inbound transport. Four applications, each reviewed and merged:
| Component | Surface | Change |
|---|---|---|
| camel-component-grpc | GrpcConsumer::start_with_listener (consumer.rs:434), server-side get_or_spawn_with_listener (server.rs:187) | pre-epic |
| camel-component-ws | ServerRegistry::stage_listener / get_or_spawn_with_listener (lib.rs:132, :295), WsConsumer::start_with_listener (lib.rs:1336) | bd rc-9xsv |
| camel-component-http | ServerRegistry::stage_listener / get_or_spawn_with_listener (lib.rs:886, :853) | bd rc-h0aw |
| camel-test residue (http_static 20 sites staged, ws_security 8 placeholder) | support helpers + constant placeholders | bd rc-yorz9 |
| camel-component-wasm | staged_listener::stage_listener (staged_listener.rs:37), consumed at the source bind site | bd rc-wgba |
The camel-test itest binaries acquire ports through two helpers,
stage_http_listener and stage_ws_listener (bd rc-h0aw). The wasm
test binaries use stage_wasm_source_listener (bd rc-wgba).
Decision
- Test port acquisition SHALL come from a staged bound listener. The
test binds
{host}:0, parks the listener with the component, and reads the actual port from it. Bind-read-drop probes are forbidden. A route that later binds the same port receives the held socket. No window exists in which the port is free. - Every application follows one contract (the capability spec carries
the scenario detail):
- Exact
(host-string, port)key. No DNS or wildcard normalization.0.0.0.0and127.0.0.1are different keys. - One-shot consumption. The consumer removes the entry when it takes the listener.
- Empty by default. The unstaged path is behaviorally compatible with the pre-change path. An added map lookup is the only internal difference.
- Deterministic conflicts. A staged entry on the same port under a
different host string fails the consumer before any bind with
staged listener conflict on port {p}: staged under host {h}, requested {r}. Duplicate staging fails withlistener already staged for {h}:{p}. These strings are contractual. A silent fresh bind would riskEADDRINUSEflakiness, so the failure is explicit instead. - One consumption point. Registries consume inside the one-shot
init winner (the
OnceCellclosure). The wasm source consumer consumes at its single bind site. Both close the two-callers race by construction.
- Exact
- Consumption ordering. A consumer consults the staged map only after config agreement validation and security gates. In wasm the consumption point sits after the operator/guest bind agreement and the ADR-0061 exposure gate. A refused route never consumes a staged slot. Staging never bypasses a gate.
- Production-surface ruling (papal e_opus reviews of rc-h0aw and
rc-wgba): listener injection on a COMPONENT registry is the
bound-address operator surface class. ADR-0069 §6.5 blesses
get_or_spawn_with_listeneras that class.stage_listenerbelongs to the same class. The ADR-0069 §6 fence forbids test-only event surfaces in camel-core. No staged API exists in core, and none may. - The ADR-0069 gate question for new core APIs is "would this API exist without tests?" For component staged surfaces the answer is yes. An operator can pre-bind a socket and hand it to the component. External supervisors do this today through FD inheritance.
- Exception for asserted-unbound addresses. A test that proves an
address has no listener (connect-refused assertion) cannot stage.
A staged listener is bound by definition. Such a test names a fixed
reserved address,
127.0.0.1:1. The canon spec carries this exception in the no-port-probes requirement. 6a. Exception for placeholder ports. The exception applies ONLY where the request is served bytower::ServiceExt::oneshotin process AND the address is never bound nor dialed. Such a test performs no port acquisition, so a constant placeholder replaces the probe (bd rc-yorz9, ws_security_test). No staging applies, because no acquisition happens. A test that binds or dials never qualifies. - No reset API. Staged keys are ephemeral ports that never repeat within a process. Each test binary is a fresh process. Unconsumed slots from refusal-path tests stay inert until process exit. camel-http, camel-ws, and camel-component-wasm all ship without reset, by the same rationale.
- New inbound transports MUST offer the staged surface at design time. The surface is part of the transport's operator contract, not a retrofit.
Consequences
- The
port-toctouclass is structurally eliminated from every test binary and itest suite.grep -rn 'fn free_port' crates/returns zero matches. The final camel-test residue (two local helpers missed by the rc-h0aw inventory) was removed under bd rc-yorz9 during this ADR's review. Two named residues remain under their own bd issues: the camel-http in-lib helpersetup_consumer_on_free_port(crates/components/camel-http/src/lib.rs:7387, 8 sites, bd rc-1dgvg) and the camel-bridge Quarkus env handoff (bd rc-s7dyw, an external process that cannot consume a staged listener). - Process-global staged maps are accepted test-tier state. They are empty in production and hold no task references.
- The CLONE-FIXTURE pattern is the sanctioned way to hold two handles
for one socket in a test: std bind,
try_clone(),set_nonblocking(true),tokio::net::TcpListener::from_std. Tokio has notry_clone. - ADR-0069 §6.5 anticipated "the bound address a future boot handle reports". The staged-listener surface is that address surface. Port acquisition needs no separate boot handle.
- A future transport that binds without a staged surface reintroduces the class. Review must cite this ADR when rejecting such a design.
ADR-0071: HTTP Outbound URL Policy (Query Composition and CamelHttpUri Fence)
Status
Accepted (2026-09-06). Implements the query-composition half of the
http-contract-surface change for the camel-http producer; the capability
spec openspec/changes/http-contract-surface/specs/http-url-resolution/spec.md
is normative for the scenario detail. Companion to ADR-0032 (exchange-data
trust boundary) and ADR-0034 (control-bus authorizedRoutes fence precedent).
Context
Three bd findings name the outbound URL as a contract surface under ADR-0032, which defines exchange headers as untrusted, adversary-controlled data:
- rc-rbfxq — a
CamelHttpUriexchange header replaces the entire outbound URL — scheme, host, path, query — before SSRF validation, with no allowlist tying it to the endpoint's configured host. SSRF default (allow_internal=false) blocks private addresses only, so an attacker who can setCamelHttpUriredirects the producer to any public host. The e_glm counter-report lowered it to p2 (defense-in-depth, not default-path exploitability): reachable only when a route step explicitly copies untrusted data into an exactly-casedCamelHttpUriheader. But the contract inconsistency with ADR-0034 stands — control-bus fenced the same header-driven control-target class withauthorizedRoutes, while camel-http had no fence. - rc-k3pir — default inbound reflection is undocumented: the consumer
installs
CamelHttpPath/CamelHttpQueryfrom the inbound wire request (lib.rs:1821-1825), and a non-bridged producer consumes both by default, sofrom: http://.../in -> to: http://upstream/apiappends the inbound path and query to the upstream URL with zero route steps. Through the rc-69fie else-if, the inbound query silently replaced operator-configuredquery_params. - rc-69fie — a
CamelHttpQueryheader present on an exchange carrying endpoint-configured query params took the header branch and silently discarded the configured params — no merge, no warning. Under ADR-0032 this is worse: the untrusted exchange header fully replaced trusted operator config.
ADR-0032 establishes that no untrusted exchange datum may drive a control
plane, numeric, or resource decision, or an executable/interpretable sink,
without validation, bounding, or a capability check. The outbound URL is a
resource decision. ADR-0034 supplies the fence precedent: control-bus fenced
its header-driven route target with a mandatory authorizedRoutes allowlist
that fails closed when absent.
Decision
-
Outbound query composition (rc-69fie). When the exchange carries a
CamelHttpQueryheader, the producer composes the outbound query from the arm-specific higher-precedence source followed by the header pairs whose keys are absent from that set; the higher-precedence source wins any key collision.- Base arm (no
CamelHttpUri): the higher-precedence source is the endpoint query —raw_query(consumed option keys filtered) plus programmaticquery_params. - Override arm (
CamelHttpUripresent): the higher-precedence source is the override URI's own query. The endpoint base query does NOT ride an override — the override remains untrusted exchange data under ADR-0032, so trusted operator config never rides an untrusted override. CamelHttpPathapplies to the path component before query composition in both arms.- Header pair bytes are carried verbatim; a raw byte forbidden in a query component inside a header value produces a resolve error naming the offending byte, never a re-encoding (Wave-A law, raw-preserving serializer).
- A present-but-empty
CamelHttpQueryis a no-op: the higher-precedence source is emitted unchanged with no additional?marker. - Override-URI merge fix: when an override URI carries its own query
and the exchange also carries
CamelHttpQuery, the two merge at pair level — override pairs first (winning collisions), header pairs appending for absent keys — instead of concatenating a second?marker. - Deliberate divergence from Apache Camel. Apache Camel applies the
CamelHttpQueryheader verbatim, replacing the endpoint query entirely (header-wins-verbatim). This ADR composes instead, so collisions resolve to the higher-precedence (operator) source: the endpoint config in the base arm, the override URI's own pairs in the override arm. The divergence is explicit and test-pinned.
- Base arm (no
-
Default inbound reflection retained (rc-k3pir, Apache Camel parity). Consumer-installed
CamelHttpPath/CamelHttpQueryride the outbound URL by default and compose per rule 1. The plain-proxy shape keeps working; the operator query pair is not replaced by reflected inbound data. -
Opt-in
allowedUriHostsfence (rc-rbfxq). The endpoint URI accepts a comma-separatedallowedUriHostsoption: exact hosts, each optionallyhost:port.- Bracketed IPv6 literals compare in canonical form; DNS names compare
case-insensitively; a host-only entry permits any port; a
host:portentry matches only the override's effective port (explicit port, or the scheme default: 443 https / 80 http). - A malformed entry — a segment carrying a path or userinfo, an entry the
urlcrate rejects, or a declared option that yields zero valid entries after trimming/dropping empty comma segments — fails endpoint creation. - An armed fence fails closed: a
CamelHttpUrioverride resolving to a host not matching any entry, or failing to yield a host, is a resolve error, and the rejected URL is rendered only through the diagnostics redaction path (ADR-0051). - An unarmed endpoint (option absent) keeps the pre-fence override behavior unchanged. The option is consumed: it never appears in the outbound query.
- Bracketed IPv6 literals compare in canonical form; DNS names compare
case-insensitively; a host-only entry permits any port; a
-
Compatibility exception versus ADR-0034. Unlike control-bus's MANDATORY
authorizedRoutesfence (which fails closed when absent), the camel-http fence is opt-in: unarmed endpoints keep the pre-fence override behavior. This is a deliberate compatibility trade-off — defense-in-depth hardening, not incident response — preserving Apache Camel override semantics for operators who did not ask for the fence.
Consequences
- Migration for Apache Camel users relying on header-wins-verbatim:
their
CamelHttpQueryheaders now COMPOSE. Collisions resolve to the higher-precedence source — the endpoint config in the base arm, the override URI's own pairs in the override arm — and only absent keys append. A route that previously overwrote a static operator pair must delete that pair from the endpoint URI or drop the header. - Fence adoption path for routes that copy untrusted inbound data into
CamelHttpUri: declareallowedUriHostswith the exact target hosts. An armed fence turns a redirect-to-any-public-host into a redacted resolve error. The fence is enforced at override resolution time only: redirect hops underfollowRedirects(off by default) are NOT fence-checked and rely on the existing per-hop SSRF validation and cross-origin credential stripping. - Reflection is now documented as the default, next to
bridgeEndpoint, rather than an undocumented side effect. - Wave-A law continues to govern authored and header query bytes: the raw-preserving serializer carries them verbatim, forbidden bytes error naming the byte, and the redaction path protects credentials in any diagnostic rendering of a rejected URL.
- Contract surface updated in
crates/components/camel-http/CONTEXT.md; future bug reports about outbound URL/query behavior check the composition and fence rules there first.
ADR-0072: Test Pyramid v2 (Shared Matcher Algebra)
Status
Proposed (2026-09-06). Supersedes ADR-0069 in part: vocabulary ownership only. ADR-0069's grammar rules, tier derivation, and verdict taxonomy stand unchanged; this ADR does not amend them. Epic rc-8zau7; e_opus consultation ses_f880eca32ffeCbIky4WBYVB71w. The pure-crate carve this ADR ratifies landed in the same change (tasks 1.1-1.2 of shared-matcher-core).
Context
The two test tiers speak different assertion vocabularies with the same
intent (epic rc-8zau7). The scenario tier (ADR-0069) owns a full matcher
grammar after waves A-D: count bounds, path filters, query-subset matching,
value expectations. The unit tier's expects is endpoint-to-count only
(ADR-0064). Capability gravity inverted the test pyramid: authors chose the
scenario tier because the unit tier was mute, not because they wanted real
wire.
That grammar is welded to the scenario harness. Wave D landed the algebra
inside camel-integration-test (document.rs, runner/partner_validate.rs,
runner.rs), surrounded by harness concerns: HttpWireRequest observation,
PartnerRouter, redaction-coupled diagnostics (ADR-0051). Left there, the
algebra cannot serve the unit tier without dragging those concerns into it.
Extracting it while wave D is fresh prevents the weld from hardening.
Two facts frame the placement choice:
- The B2 precedent (rc-6bsf): e_opus ruled that "a new shared crate is over-engineering" and named camel-config as the natural home. That ruling addressed boot sharing, where camel-config already held the dependency edges.
- ADR-0055 publish topology: a crate with zero
camel-*dependencies is topologically free. It publishes first, before every consumer. No cycle risk.
The testing story is also spread across four ADRs — ADR-0064 (contract),
ADR-0069 (crate layout), ADR-0055 (publish leaf), ADR-0070 (staged
listeners) — plus the camel test command and the dual-use lean
components. Coherent, but undocumented in one place. Section 5 closes that
gap.
Decision
1. Placement: a dedicated camel-matchers crate
The shared assertion algebra lives in its own crate,
crates/camel-matchers, in the foundational band below camel-api,
alongside the pure libs. The crate is one named concept with zero ambiguity.
The B2 precedent was weighed and rejected for this case. rc-6bsf applied where a natural home already existed for boot sharing: camel-config was the home of the shared boot edges. Here the natural home IS the vocabulary. Hosting matchers inside a config crate is the semantic accretion that makes a 60+ crate workspace feel disorganized. The precedent does not transfer.
The crate-count cost is acknowledged and accepted. The cost of a crate is semantic confusion, not the number. This crate adds no confusion: its name and charter are one concept. The workspace is 60+ crates; this is one more, justified on its own terms.
Rejected hosts:
camel-core/camel-api: runtime pollution with test vocabulary.camel-test: would invert the dependency direction. The scenario kit (camel-integration-test) would depend on the unit-tier kit.
ADR-0055 topology applies cleanly: zero camel-* dependencies means the
crate publishes first, lint-publish-cycles passes trivially, and no
consumer waits on it.
2. Purity rule: types and pure functions, nothing else
The crate is types plus pure functions. It carries zero camel-*
dependencies.
Allowed foundational third-party dependencies: regex, serde_json,
form_urlencoded. Nothing else.
The crate has:
- No harness types.
- No wire types (
HttpWireRequesthas no home here). - No redaction (the ADR-0051 law stays in the tier kits).
- No async runtime (no tokio).
- No Cargo features.
This buys a crate that camel-test (unit tier), camel-integration-test
(scenario tier), and camel-cli can consume without dragging harness or
wire concerns into one another.
3. One algebra, per-tier grammar, per-tier observation
"Same verbs, different subjects." Both tiers' assertion vocabularies deserialize to, and call into, the same core types. The algebra is one; the subjects are per-tier.
Per-tier grammar. Document formats stay per-tier. The ADR-0069 §2 mixing
ban stands: a scenario: document declares no inputs/expects/
intercepts, and the runner rejects the mix at load time. Grammars are
never unified.
Per-tier observation. What gets matched stays per-tier. The scenario
tier matches recorded wire: HttpWireRequest, lane keys, wire fidelity. The
unit tier matches in-process Exchange projections. These do not unify: wire
fidelity, lane keys, and redaction have no Exchange analog.
Parameterize the algebra, not the observation. Never introduce one
observation trait. The carve in this change is the pattern: matching_count
takes an iterator of projected (method, path_and_query) tuples. Each tier
projects its own observation into those tuples at the call site. The
scenario tier maps its wire records; a future unit tier maps its Exchange
projections.
4. Staged direction (future changes, not this one)
This ADR records the approved sequence. None of the steps below is this change; this change is the pure carve only.
- Step 2: unit-tier
expectsgrowth. The unit tier grows body and header matchers incamel-test, consuming the crate. - Step 3: observational probes. Step identity arrives through
to: mock:probe-Nprobes, registry-only. These are observational and legal today. - Mutating weaving stays gated. Skip and replace processors land only as a lean-set change per the ADR-0064 §5 AdviceWith Stage A/B frame. Observation is free; mutation is gated.
- Wire timeouts are never virtualized. ADR-0069 §6 stands: no virtual clock in core. Paused Tokio time stays a unit-harness concern.
recipient_listand dynamic-dispatch force-FULL stays static. A runtime-verified closure would destroy pre-boot tier selection. The tier function remains a pure function of document content.
5. Testing-surface map
One map of the test surfaces. Each row names the surface, its role in the pyramid, and the ADR that governs it.
| Surface | Role in the pyramid | Governing ADR |
|---|---|---|
camel-test | Unit-tier kit: CamelTestContext, mock access, Tokio time control | ADR-0064 (unit tier), ADR-0055 (publish leaf), ADR-0070 (staged-listener helpers) |
camel-integration-test | Scenario-tier kit: scenario model and parser, partner adapters, embedded FULL boot | ADR-0069 |
camel-matchers | Shared assertion algebra | This ADR (0072) |
camel-cli test command | Runner, tier derivation, tier filters | ADR-0069 §1 and §3, ADR-0064 |
camel-bundles | Shared boot installers: bundle registration cascade, BootHandle | ADR-0069 §10 |
mock, direct, seda, timer, log | Dual-use lean runtime components: the lean boot registers them; they are runtime components, not test-only crates | ADR-0064 §2 (closed lean set, creep-rule amendment gate), ADR-0055 |
This map closes the documentation gap. The story previously sat in
ADR-0064, ADR-0069, ADR-0055, and ADR-0070, plus the camel-cli command and
the component crates. This section is the single reference.
Consequences
Positive
- Two tiers share one semantic core without sharing grammar or observation.
- The unit tier can grow its
expectsvocabulary from the crate (step 2) without touching the scenario tier. - The crate publishes first under ADR-0055 topology; no consumer waits on it.
- The test surfaces have one documented map.
Negative
- One more crate in a 60+ workspace. Accepted and recorded in section 1.
- Grammar and observation duplication between tiers is deliberate and stays. Each tier keeps its own document formats and its own subjects.
- This ADR is Proposed. Section 4 records direction, not landed contract. Each step lands as its own change.
Alternatives considered
- camel-config as host (rc-6bsf B2 precedent). Rejected in section 1. The precedent addressed boot sharing; the vocabulary has no natural home in a config crate.
- camel-core / camel-api as host. Rejected: runtime pollution with test vocabulary.
- camel-test as host. Rejected: inverts the dependency direction.
- One observation trait over both tiers. Rejected in section 3.
HttpWireRequestand Exchange projections do not unify. - Unified grammar across tiers. Rejected: ADR-0069 §2 stands.
Amendment 1 — 2026-09-07 — shared-algebra consumption
The Context statement that the unit tier's expects is endpoint-to-count
only overstated the gap. camel-mock already carried the full seven-key
matcher vocabulary over Body: its grammar mirrored the mock-testkit
rules, and the scenario tier's wave-D grammar mirrored the same keys. The
two tiers spoke one vocabulary all along.
The real defects were the duplicated ad-hoc algebra and the missing upper bounds. Two copies of the matching logic lived in two crates, and neither supported a bounded count. The gap was not vocabulary; it was a shared core and a count bound.
Step 2 (change expects-matcher-growth) is the worked example of per-tier
observation this
ADR prescribes. camel-mock delegates its string and json verbs to the
shared core through the text_only and json_value projections. The unit
tier projects its own observation into the core at the call site instead
of unifying it with the scenario tier's wire records.
The same step completes the count bounds. CountBound carries the state,
maxCount joins the grammar, and an explicit maxCount: 0 asserts absence
over the post-settle snapshot. The count vocabulary that the original
Context credited only to the scenario tier now lands in the unit tier too.
One programmatic note: the mock's expectation state now carries a single
count bound per endpoint. Programmatic setters keep that rule — a later
expect_bound replaces an earlier one (the document grammar always
rejected setting two bounds together).
The Decision sections stand unchanged. This amendment corrects the Context's framing; it does not revise the placement, purity, or staged- direction decisions.
Amendment 2 — 2026-09-07 — step 3 delivery shape
Step 3 of the staged direction delivered the observational probes this ADR
prescribed. The observational weave itself predated the ADR: intercepts
with divertCopyTo landed 2026-08-23 (the declarative-intercepts change),
so a probe endpoint was reachable from any route send before this ADR
recorded the step. The genuine gap was the cross-endpoint arrival-order
assertion.
Step 3 lands as divert-copy probes plus the arrival-sequence assertion.
camel-mock stamps a component-wide, strictly-increasing arrival index on
every recorded exchange. sequence: evaluates a filtered complete
interleaving over the listed endpoints: the arrivals at those endpoints,
projected in global arrival order, must equal the declared list exactly,
while arrivals at unlisted endpoints are ignored. Retention is bounded and
the arrival indices truncate in lockstep with the retained exchanges.
The ADR-0064 section 5 gate comes to this in substance: skipTo exists only as a
test-document construct and never in the production route DSL. Observation
is free; mutation stays gated. The Decision sections stand otherwise
unchanged.
ADR: Default-strict REST content negotiation (415/406)
-
Status: Accepted — spec + plan blessed in the conductor-light flow (2026-09-11); archived with change add-rest-strict-negotiation.
-
Ruling source: e_opus sealed ruling, amended by the user fact ZERO ADOPTION — no retrocompat constraints. Do not re-derive compat.
-
bd: rc-hlb1q (REST DSL v2 L2).
Context
The REST DSL already declares per-operation media contracts:
consumes / produces on RouteDslRestOperation, validated at
lowering time (crates/camel-dsl/src/rest.rs, lower_operation:
binding json must declare JSON-family media via
is_json_media_type, binding raw may declare any valid
type/subtype via is_valid_media_declaration; both helpers are
private in rest.rs:461-476). Nothing enforces these declarations at
runtime:
- The HTTP consumer routes by method+path only:
crates/components/camel-http/src/registry.rs:51(rest_endpoints: Vec<RestEndpoint<T>>,rest_match.rs:30pub struct RestEndpoint<T>carries method and path only). Media is invisible to routing. - Request
Content-Typelands in the requestStreamMetadata(camel-http/src/lib.rs, axum handler ~1560) and in the exchange headers the consumer receive loop installs;Accepttravels as a plain exchange header (see the forwarding pin test atcamel-http/src/lib.rs~4120). - Errors from the route pipeline flow back through the finalizer
pipeline_error_to_reply(camel-http/src/lib.rs:3334), which maps typedCamelErrorvariants to statuses (401/403/400/400/503, else 500).
So a text/plain POST against an application/json operation today
fails late (unmarshal 400) after the body stream may already have been
partially consumed, and Accept is ignored entirely.
Decision
D1 — Enforcement point: header-gate processor, injected by lowering
A ContentNegotiationProcessor in crates/camel-processor/src/ content_negotiation.rs implements the standard Tower service shape
(BoxProcessor = tower::util::BoxCloneSyncService<Exchange, Exchange, CamelError>, crates/camel-api/src/processor.rs:51). REST lowering
(crates/camel-dsl/src/rest.rs, lower_operation) injects it as the
FIRST lowered step — before the UnmarshalStep that request binding
pushes today (rest.rs ~338) and before every other step.
Cycle-free construction (dependency fact). camel-dsl depends on
camel-processor (Cargo.toml; compile.rs already references
camel_processor:: types), so the processor MUST NOT call back into
camel-dsl — that would be a crate cycle. The split:
- The processor in camel-processor is a header-reading shell. It
extracts
Content-TypeandAcceptfrom exchange headers (case-insensitive lookup) and delegates the decision to a check closure injected at construction: `Arc<dyn Fn(Option<&str>, Option<&str>) -> Result<(), CamelError>- Send + Sync>
(content-type, accept). It NEVER polls, wraps, replaces, or caches the body (L3 pin: noStreamCacheService, no consumption). OnOkit passes the exchange through untouched; onErr` it fails the exchange with the typed error.
- Send + Sync>
camel-dsl/src/compile.rsconstructs that closure over the media.rs matcher and aMediaContract— the declaredconsumes/producesparsed ONCE at compile time, not per request. All media semantics (parsing, matching, essence rule, q-factors) stay incrates/camel-dsl/src/media.rs(D3), one home shared with the lowering-time declaration validation.
Wiring uses the existing lowering-only step pattern
(SetHeaderIfAbsent precedent: #[serde(skip_deserializing)] on the
RouteDslStep arm, route_ast.rs:515): the lowering pushes the step,
and the DSL→declarative conversion — centralized in
yaml.rs::route_dsl_to_declarative_route, which the JSON authoring
path reuses (json.rs imports it; JSON is a full-DSL authoring format
per ADR-0026) — maps it to a DeclarativeStep (model.rs). compile.rs
then constructs the processor and emits
BuilderStep::Processor(camel_api::OpaqueProcessor(..)) (construction
precedents in compile.rs ~1005-1292). camel-core needs NO change —
BuilderStep::Processor already accepts pre-built Tower services
(crates/camel-core/src/lifecycle/application/route_definition.rs:62).
The camel-http registry and rest_match stay MEDIA-BLIND. The only
camel-http change is the finalizer mapping (D2).
D2 — Typed errors, mapped by the HTTP finalizer
crates/camel-api/src/error.rs (CamelError is #[non_exhaustive],
line 78) gains:
UnsupportedMediaType { consumed: String, declared: String }→ HTTP 415NotAcceptable { accept: String, produced: String }→ HTTP 406
classify() (line 177) gains categories unsupported_media_type and
not_acceptable; variant_name() (line 214) gains the arms (the
defining-crate exhaustive match makes omission a compile error).
pipeline_error_to_reply (camel-http/src/lib.rs:3334) maps the two
variants to 415/406 with JSON error bodies, mirroring the existing
TypeConversionFailed → 400 pattern. This is the ONLY camel-http
change.
D3 — In-tree parser, no new dependency
crates/camel-dsl/src/media.rs (new module) hosts the RFC 7231/9110
subset. The private helpers split_media_base,
is_valid_media_declaration, is_json_media_type MOVE here from
rest.rs (keeping their current call sites via pub(crate)), and the
module adds the negotiation parser, budget ~150-250 LoC:
- Media type:
type/subtypewith optional+suffix;tcharvalidation for token characters; case-insensitive comparison. - Parameters: parse and skip all except
q(Accept side);qdefaults to 1.0 when absent;q=0means explicit reject. - Wildcards
*/*andtype/*: valid ONLY as Accept entries. A wildcardContent-Typeis malformed (D5). - Charset and every other parameter are ignored for matching.
media.rs also hosts the match entry the injected closure calls:
check_request(content_type: Option<&str>, accept: Option<&str>, contract: &MediaContract) -> Result<(), CamelError>, where
MediaContract is the parsed declaration pair built once at compile
time. The shell in camel-processor holds no media knowledge of its
own — only the closure and the header extraction (D1).
MediaContract sides are Option<MediaRange>: a declaration that is
not a CONCRETE type (raw-mode lowering's tchar-only check can admit
*/*, which is token-valid) yields None for that side — meaning
permissive, exactly the ruling's "undeclared = naturally permissive".
This keeps parse_contract infallible (no unwrap under lint
pressure) and gives degenerate declarations the only semantics they
can honestly carry. The contract also retains the trimmed ORIGINAL
declaration strings (consumes_declared/produces_declared) because
the error payloads echo them and parsed ranges are lowercased and
param-stripped.
Body-lessness travels on the step: ContentNegotiationStep carries
check_content_type: bool, set by lowering from verb_has_body
(rest.rs ~456), because the compile layer sees only the step — no
verb context (compile.rs ~990). The compiled closure maps the
Content-Type argument to None when the flag is false; the
Accept-side gate always runs.
Escape hatch: adopt the mediatype crate ONLY if the matcher exceeds
~350 LoC with residual bugs; that path requires a workspace review
(MSRV, cargo audit, q-factor matching must be exposed).
D4 — Default-strict: no opt-in surface
No flag, no strict field, no permissive matrix. Enforcement exists
exactly where declarations exist; undeclared media naturally means
permissive. REST operations always carry consumes/produces after
lowering defaults (both default to application/json), so lowered
REST operations are strict by default. Non-REST routes (direct,
component consumers) never receive the step and are unaffected.
v1 compatibility is BEHAVIORAL, not byte identity of the lowered
sequence: the delta renames the canon requirement to "v1 behavioral
compatibility for omitted binding" and modifies the JSON/raw pipeline
requirements to carry the negotiation prefix. For requests whose
Content-Type satisfies the declared consumes and whose Accept
admits the declared produces, observable behavior (statuses,
response bytes) is unchanged; mismatched requests move from late
400/200 to early 415/406 — the L2 contract. The v1 pin suites remain
the internal regression net, with their lowered-sequence assertions
updated to include the prefix (and nothing else).
D5 — Malformed-header direction (PINNED)
- Malformed
Accept→ treat as*/*(permissive). If any entry of the comma-separated list fails to parse, the whole header is treated as*/*. - Malformed
Content-Typeon an operation with declaredconsumes(strict side) → 415. A wildcard inContent-Typeis malformed.
Asymmetry is deliberate: Accept failures must not lock clients out
of representations they can consume; an unverifiable Content-Type
against a declared contract must fail closed.
D6 — Matching semantics
One function, used by both sides, media_satisfies(candidate, declared):
- Normalize case; compare
type/subtypeequality; ignore parameters exceptq. - Essence rule (L1): if BOTH candidate and declared are JSON-family
(
application/jsonor any+jsonsuffix — the existingis_json_media_typetest), they satisfy each other. This makesapplication/vnd.api+jsonsatisfyconsumes: application/jsonand symmetrically satisfyAccept: application/jsonagainstproduces: application/json. - Accept-side wildcards:
*/*satisfies any declared;type/*satisfies any declared with the same type. - Media-range precedence (RFC 7231 §5.3.2): among Accept entries that
satisfy the declaration, the most specific governs — exact
type/subtypeover JSON-essence match overtype/*over*/*— and the request is acceptable only when the governing entry hasq > 0. ThusAccept: application/json;q=0, */*;q=1againstproduces: application/jsonis an explicit reject (406): the exact entry outranks the accepting wildcard. When several entries tie at the governing specificity (application/json;q=0, application/json;q=1), the LOWEST quality governs — fail closed, deterministic. - Content-Type side: no wildcards (malformed per D5).
Gate semantics (fail fast, first step):
- 415: request carries a
Content-Typethat does not satisfy declaredconsumes— only for verbs with a body (verb_has_body,rest.rs:456); body-less verbs (GET/DELETE) skip the request check. AbsentContent-Typeis permissive (v1 unmarshal path decides). - 406: NO Accept entry with
q > 0satisfies declaredproduces. AbsentAcceptis permissive (RFC default).q=0on the only matching entry is an explicit reject → 406. - No server-driven selection: the gate accepts or rejects; the single
declared
producesis what the route emits.
Alternatives considered
- Enforce in camel-http routing (media-aware registry): rejected —
bloats the media-blind
RestEndpoint<T>matching surface and only sees media AFTER route match; the ruling pins registry media- blindness. - Enforce in the finalizer by inspecting exchange state: rejected — too late (body may be consumed), and couples transport to DSL declarations it cannot see.
- Parser in camel-api (below both crates): rejected — would split
media knowledge across crates: declaration validation stays in
camel-dsl lowering while runtime matching lived in camel-api,
duplicating the essence rule in two homes. The injected-closure
split keeps every media decision in
media.rs. mediatypecrate from the start: rejected — the subset fits ~150-250 LoC in-tree; a new dependency needs workspace review. Escape hatch documented in D3.- Opt-in
strictflag: rejected — ZERO ADOPTION; a default-off flag enforces nothing and a default-on flag IS default-strict with extra surface.
Consequences
- Lowered REST pipelines gain one leading step; v1 pin tests are updated to assert the prefix (sequence assertions only) and stay green as the internal regression net; well-formed requests keep byte-identical responses.
- L1 (
binding: raw) and L3 (streaming) pin batteries stay green: the gate is header-only and never touchesBody::Stream, metadata, orStreamCacheServiceinjection. variant_name()/classify()updates are compile-enforced in camel-api (variant_name_tests).- doTry catch-by-variant users can catch the two new variant names; no existing matcher changes (additive arms only).
Test strategy
media.rsunit table: tokens, suffixes, params, q, wildcards, case, malformed inputs (both pinned directions).- Processor unit tests (Tower
oneshot): 415/406 emission, pass- through identity, zero body polls (poll-recorder stream, L3 style). - Lowering tests: step order (negotiation first, before unmarshal), both bindings, body-less verbs.
- Compile tests:
BuilderStep::Processoremission with expected declarations. - camel-http e2e: 415/406 status + JSON error body through the axum handler; wildcard/multi-accept/q=0 matrices; malformed directions; v1/L1/L3 pin suites stay green.
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 docs/AGENTS.md. Read it before you write
prose.
Structural checks
The mdBook build is the structural check for the guide. Run it before you commit:
nix shell nixpkgs#mdbook -c mdbook build docs
The build verifies every include directive, link, and page. It does not assess prose quality. Prose quality depends on the ste-writing skill and human review. ADR citation validity and glossary consistency with the Key Terms in CONTEXT-MAP.md are also review-enforced; no xtask lint covers them.
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.