Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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.

  1. Installation
  2. First route in Rust or First route in YAML
  3. CLI usage: run, scaffold, and inspect routes from the terminal.

Next, see Core concepts for the Exchange, Message, and CamelContext model.

Installation

Install rust-camel to build integration routes in Rust. Two paths: embed the crates in a Rust application, or run YAML routes with the CLI.

Prerequisites

Install the Rust toolchain with rustup. rust-camel requires Rust 1.89 or newer. The workspace pins edition 2024, and the Tokio async runtime needs a current stable release.

Confirm your toolchain:

rustc --version

Path 1: Embed in a Rust project

Add the core crates to an existing Cargo project:

cargo add camel-core camel-builder camel-api

Then add only the components your routes reference. A timer-to-log route needs two endpoint crates:

cargo add camel-component-timer camel-component-log

These commands resolve the latest published release from crates.io. The resulting [dependencies] block looks like:

[dependencies]
camel-api = "0.26"
camel-core = "0.26"
camel-builder = "0.26"
camel-component-timer = "0.26"
camel-component-log = "0.26"
tokio = { version = "1", features = ["full"] }
tracing-subscriber = "0.3"

camel-core provides CamelContext, the runtime that starts and stops routes. camel-builder provides the fluent RouteBuilder API. camel-api provides the Exchange, Message, and Value types. Each component crate is optional. Add only what a route references.

Write your first route next: First route in Rust.

Path 2: Run YAML routes with the CLI

The camel CLI parses YAML route files and starts them without compiling Rust. Install the binary from crates.io:

cargo install camel-cli

This places camel on your PATH. Confirm the install:

camel --version

Scaffold a project and run it:

camel new my-integration
cd my-integration
camel run

camel new creates a Camel.toml config file and a routes/ directory with a starter route. camel run starts the Camel context from that config.

Write your first YAML route next: First route in YAML.

Run the examples from source

The repository ships compiled examples. Clone it and run one to verify your toolchain:

git clone https://github.com/kennycallado/rust-camel.git
cd rust-camel
cargo run -p hello-world

The hello-world example fires a timer five times and logs each tick. You should see five log lines, then the process waits for Ctrl+C.

Troubleshooting

SymptomFix
rustc 1.xx is unsupportedRun rustup update stable. rust-camel needs 1.89 or newer.
camel: command not foundcargo install puts binaries in ~/.cargo/bin. Add it to your PATH.
error[E0658] on edition 2024 featuresYour rustc is too old. See the first row.

Next steps

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

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

CommandPurposeExample
runStart routes from a config filecamel run
newScaffold a new projectcamel new my-integration
journal inspectRead events from a journal filecamel journal inspect runtime.db
plugin newScaffold a WASM plugincamel plugin new my-plugin
plugin buildCompile and install a WASM plugincamel plugin build
openapi generateEmit an OpenAPI document from REST routescamel openapi generate routes.yaml

camel run

Start a Camel context from YAML route files.

Trust model. camel run executes 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

FlagDescription
--routes <GLOB>Override the route file pattern from Camel.toml
--config <FILE>Path to Camel.toml (default: Camel.toml)
--watchEnable file-watcher hot-reload
--no-watchDisable file-watcher hot-reload
--otelEnable 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
FlagDescription
<name> (positional)Project name (letters, digits, hyphens, underscores)
--template <NAME>Template to use (default: basic)
--profile-layout <LAYOUT>simple or env (default: env)
--forceOverwrite 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
FlagDescription
<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
FlagDescription
<name> (positional)Plugin name (letters, digits, hyphens, underscores)
--type <TYPE>processor (default), bean, or authorization-policy
--forceOverwrite 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
FlagDescription
<path> (positional, optional)Plugin directory (default: current directory)
--debugBuild 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
FlagDescription
<file> (positional)Path to the route file (.yaml, .yml, or .json)
--title <TITLE>API title for the info section (default: Generated API)
--version <VER>API version for the info section (default: 1.0.0)

Flag definitions live in crates/camel-cli/src/commands/openapi.rs.

Expected output

The command prints a pretty JSON document to stdout. The top-level openapi field is 3.0.3. Each rest: block becomes a path entry. Each operation becomes a verb under that path.

{
  "openapi": "3.0.3",
  "info": {
    "title": "Generated API",
    "version": "1.0.0"
  },
  "paths": {
    "/api/users": {
      "get": { "operationId": "listUsers" }
    }
  }
}

If a file has no rest: blocks, the command exits with an error. Validation warnings print to stderr.

Next steps

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

CommandPurposeExample
openapi generateEmit an OpenAPI 3.0.3 document from rest: blockscamel openapi generate routes.yaml
plugin newScaffold a WASM plugin project from a templatecamel plugin new my-plugin
plugin buildCompile a WASM plugin and install the artifactcamel 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

FlagDescription
<file> (positional)Path to the route file (.yaml, .yml, or .json)
--title <TITLE>API title for the info section (default: Generated API)
--version <VER>API version for the info section (default: 1.0.0)

Flag definitions live in crates/camel-cli/src/commands/openapi.rs.

Expected output

The command prints a pretty JSON document. The top-level openapi field is 3.0.3. Each rest: block becomes a path entry. Each operation becomes a verb under that path. The success response carries a description and a content schema under produces. Body verbs carry a requestBody with a content schema under consumes.

{
  "openapi": "3.0.3",
  "info": {
    "title": "Generated API",
    "version": "1.0.0"
  },
  "paths": {
    "/api/users": {
      "get": {
        "operationId": "listUsers",
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": { "schema": { "type": "object" } }
            }
          }
        }
      },
      "post": {
        "operationId": "createUser",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": { "name": { "type": "string" } },
                "required": ["name"]
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Created",
            "content": {
              "application/json": { "schema": { "type": "object" } }
            }
          }
        }
      }
    }
  }
}

How generation works

The CLI selects a parser by file extension. .yaml and .yml files go through extract_rest_blocks. .json files go through serde_json. Unknown extensions fall back to YAML parsing.

Generation fails fast on three conditions. A file with no rest: blocks exits with an error. Lowering errors fail before generation runs. Duplicate route ids fail before generation runs. Validation warnings print to stderr and do not abort the command.

A body verb with no request_schema produces a warning and a weak stub. A non-204 verb with no response schema does the same. Regenerate the document whenever the rest: blocks change. The document mirrors the runtime contract.

camel plugin new

Create a WASM plugin project from a template. The CLI writes a Cargo workspace member with the right target and the right dependencies. It also writes a sample lib.rs for the chosen plugin type.

camel plugin new my-plugin
FlagDescription
<name> (positional)Plugin name (letters, digits, hyphens, underscores)
--type <TYPE>processor (default), bean, or authorization-policy
--forceOverwrite files if the directory already exists

Flag definitions live in crates/camel-cli/src/commands/plugin.rs.

Plugin types

TypePurpose
processorCustom pipeline step. The plugin runs inside a Route as a Service<Exchange>.
beanNamed function called by name from a route.
authorization-policySecurity 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
FlagDescription
<path> (positional, optional)Plugin directory (default: current directory)
--debugBuild 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

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:

  1. Exchange and Message - the envelope that carries the body, headers, properties, and error state. Every pattern mutates it.
  2. Routes and pipelines - how a source and an ordered step list form a Tower service chain. This is where you compose patterns.
  3. Components and endpoints - how a Component turns a URI into the consumers and producers that do real I/O.
  4. Error handling - how faults propagate through a route, and how handlers control recovery.
  5. Data plane vs control plane - why message flow and route lifecycle run on separate trait hierarchies.
  6. 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:

FieldTypePurpose
inputMessageIncoming message. Always present.
outputOption<Message>Response message. Set for InOut exchanges.
propertiesHashMap<String, Value>Exchange-scoped key-value map. Cross-step scratch space.
extensionsHashMap<String, Arc<dyn Any + Send + Sync>>Non-serializable values like channel senders.
errorOption<CamelError>Failure state. Controls pipeline resolution.
patternExchangePatternInOnly (fire-and-forget) or InOut (request-reply).
correlation_idStringUUID v4 for tracing across steps.
otel_contextopentelemetry::ContextActive 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 are serde_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:

VariantHolds
EmptyNo 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:

VariantMeansReply channel sees
Completed(Exchange)The pipeline ran to the endOk(ex)
Stopped(Exchange)A step ended the route early with Stop. Successful control flow, not an errorOk(ex)
Failed(CamelError)A step returned an error and no handler absorbed itErr(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:

  1. Extract the scheme. This is the part before the first :. For timer:tick?period=1000, the scheme is timer.
  2. Look up the registered Component for that scheme.
  3. Call Component::create_endpoint(uri) to build the Endpoint.
  4. The Endpoint creates the Consumer for a from: URI, or the Producer for a to: 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.

VariantCause
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.
ConsumerStoppingThe 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.

DispositionEffect on the exchangeEffect on the pipeline
PropagateKeep the error.Abort. The outcome is Failed.
HandledClear the error.Stop. The outcome is Completed.
ContinuedClear 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:

  1. Retry the failed step with a redelivery policy.
  2. Route the exchange to a dead-letter endpoint or a custom handled_by endpoint.
  3. Apply the disposition from the matched onException clause.

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_delay sets the wait before the first retry.
  • multiplier scales the wait after each attempt.
  • max_delay caps the wait.
  • jitter_factor randomizes the wait to avoid thundering-herd retries.

Defaults are a 100 ms initial delay, a 2x multiplier, a 10 s cap, and no jitter. See crates/camel-api/src/error_handler.rs. A retry that recovers the exchange clears the error and the pipeline advances. A retry that exhausts falls through to the DLC and then to the disposition.

OnException clauses

onException matches errors by variant or predicate. The first matching clause wins. Each clause can carry its own retry, its own handled_by endpoint, and its own disposition. Put broad clauses before specific clauses. A broad clause first shadows the rest.

Global error handler

Set a default handler on CamelContext. Routes without a per-route handler use it.

ctx.set_error_handler(ErrorHandlerConfig::dead_letter_channel(
    "log:global-dlc",
))
.await;

This is Rust API only. YAML routes compile to the same RouteDefinition but cannot express registration logic. A global handler has no YAML field. Set it on CamelContext in Rust.

Continued disposition in practice

The continued example shows the two mechanisms composing. The onException clause sets the disposition. The error handler clears the error and the pipeline advances to the next step. The DLC still receives the exchange for auditing.

    let eh_config = ErrorHandlerConfig::dead_letter_channel(
        "log:route10-dlc?showHeaders=true&showBody=true&showCorrelationId=true",
    )
    .on_exception(|e| matches!(e, CamelError::ProcessorError(_)))
    .continued(true) // ← clear error, pipeline continues to next step
    .retry(1)
    .build();

    let route10 = RouteBuilder::from("timer:route10?period=2000&repeatCount=1")
        .route_id("continued-disposition")
        .set_header("example", Value::String("continued-disposition".into()))
        .process_fn(always_fail("route10: permanent failure (continued=true)"))
        .error_handler(eh_config)
        .to("log:route10-continued?showHeaders=true&showBody=true&showCorrelationId=true")
        .build()?;
YAML equivalent
routes:
  - id: "continued-disposition"
    from: "timer:route10?period=2000&repeatCount=1"
    error_handler:
      dead_letter_channel: "log:route10-dlc?showHeaders=true&showBody=true&showCorrelationId=true"
      on_exceptions:
        - kind: "ProcessorError"
          continued: true
          retry:
            max_attempts: 1
    steps:
      - set_header:
          key: "example"
          value: "continued-disposition"
      # The always_fail Rust closure maps to a registered bean in YAML.
      - bean:
          name: "always-fail"
          method: "process"
      - to: "log:route10-continued?showHeaders=true&showBody=true&showCorrelationId=true"

Use Continued when a step is non-critical and the route should keep moving after it fails.

doTry blocks

do_try is a local error-handling scope. It wraps a group of steps in a try block with catch clauses and an optional finally clause. A handled catch does not trigger the route-level error handler. The block stays a local island. Unhandled errors bubble up to the route.

    let route1 = RouteBuilder::from("direct:catch-by-variant")
        .route_id("catch-by-variant")
        .do_try()
        .process(always_fail("boom-from-route-1"))
        .do_catch_exception(&["ProcessorError"])
        .handled()
        .process(log_marker("log:caught-by-variant"))
        .end_do_catch()
        .end_do_try()
        .build()?;
YAML equivalent
routes:
  - id: "catch-by-variant"
    from: "direct:catch-by-variant"
    steps:
      - do_try:
          steps:
            # The always_fail Rust closure maps to a registered bean in YAML.
            - bean:
                name: "always-fail"
                method: "process"
          catch:
            - exception:
                - "ProcessorError"
              disposition: "handled"
              steps:
                - bean:
                    name: "log-marker"
                    method: "process"

See the Do Try pattern page for catch-by-variant, catch-by-predicate, and finally clauses. The processor contract is in crates/camel-processor/CONTEXT.md.

doTry vs route-level error_handler

SituationUse
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:

  1. Cancel consumer intake.
  2. Force-complete aggregator buckets.
  3. Cancel the pipeline token.
  4. Join the consumer and pipeline tasks.
  5. Call shutdown(RouteStop) on each stateful step.
  6. 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 the SupervisingRouteController with 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 through send_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.
  • ConsumerStoppingCamelError variant for producer poll_ready shutdown. It signals that the producer channel is closing. Distinct from Stop EIP. Error handling, ADR-0024.
  • Credential redaction boundary — types that hold passwords, tokens, keys, or credential bytes must not expose those values through Debug or general-purpose Serialize. 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. Unhealthy returns HTTP 503 and marks the pod NotReady. Health.
  • EnrichmentStrategy — strategy that merges the original Exchange with the polled or enriched Exchange in the enrich and pollEnrich verbs. Distinct from the EIP-22 AggregateStrategyDef family. Content enricher, ADR-0015.
  • ErrorHandler — DSL declaration (ErrorHandler, OnException) that compiles into ErrorHandlerConfig and ExceptionPolicy at 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 replaces handled: bool. Propagate returns the error upstream. Handled ends the route normally. Continued clears the error and advances. Error handling, ADR-0019.
  • ForcedHealthFailure — when a Consumer crashes, HealthCheckRegistry pins the route's health entry to Unhealthy through force_unhealthy_for_route() until a ConsumerRestart replaces it with a live probe. Health.
  • Handler-contract boundary — conceptual line between an error emitter and the route element that owns the failure's operational signal. Emitters inside the boundary log at warn! or below. Error handling, ADR-0012.
  • LlmProvider — trait abstraction over LLM backends (OpenAI, Ollama, Mock). Camel-shaped, not siumai-shaped. All siumai imports stay in the adapter. Components, ADR-0020.
  • Message — body and headers container inside an Exchange. exchange.input is the incoming Message. exchange.output is 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 generationrest: AST compiled into an OpenAPI 3.0.3 document via camel openapi generate or camel_dsl::openapi::generate_openapi(). YAML DSL.
  • OutcomePipeline — internal trait one layer above Tower for structural EIP sub-pipelines. It returns PipelineOutcome directly so Stopped(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 of CompiledStep::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 pollEnrich and the WASM camel_poll host function. Poll enrich, ADR-0015.
  • ProviderMapHashMap<String, Arc<dyn LlmProvider>> owned by LlmComponent and resolved by name from config. Not a global registry. Components, ADR-0020.
  • REST DSL — declarative rest: YAML/JSON blocks that lower to http: consumer routes with JSON binding, path templates, and optional schema validation. YAML DSL.
  • RetryableStep — object-safe trait that unifies BoxProcessor and OutcomeSegment for RouteErrorHandler::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 an errorHandler is 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 Unauthorized into 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 SQL onConsume post-processing. No route-level handler runs for it. The emitter owns the signal. Error handling, ADR-0012.
  • Starting Route — externally observable Route lifecycle state between accepted start intent and confirmed Consumer or Pipeline side effect. Operators can see Starting in RouteStatusProjection. Routes & pipelines, ADR-0018.
  • StopSegment — outcome-aware analog of CompiledStep::Stop for structural EIP sub-pipelines. It always returns PipelineOutcome::Stopped(ex). Stop, ADR-0024.
  • Supervision — route-level crash recovery. A Consumer task failure sends a CrashNotification. The RuntimeBus records the route as Failed. 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) plus async reload()). Components register it lazily in TlsReloadRegistry::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/adapters layout, not a shared technical layer. Architecture, ADR-0045.
  • WASM sandbox capability posture — per-world grant model across Camel host functions and WASI interfaces. Camel calls use explicit scheme allowlists. WASI uses selective registration, not full-linker registration with runtime denial. Extending, ADR-0050.

Foundational primitives

Crate-local building blocks. The owning crate's CONTEXT.md is the canonical definition. These terms are intentionally not bold, so they stay out of the lint-glossary vocabulary check.

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 Camelrust-camelNotes
CamelContextCamelContextSame name. Built with CamelContext::builder(). No Spring or CDI.
RouteRouteDefinitionBuilt with RouteBuilder (Rust) or parsed from YAML.
RouteBuilder (Java DSL)RouteBuilder (Rust)Same fluent style: .from().to().build().
XML DSLYAML DSLNo XML DSL. Declarative routes use YAML.
ProcessorService<Exchange>Every processor is a Tower Service. No Java interface to implement.
ExchangeExchangeSame concept. Carries input Message, optional output Message, headers, properties.
MessageMessageBody + headers container inside Exchange.
BodyBodyEnum: Text, Json, Bytes, Stream, Empty. Not Object.
EndpointEndpointResolved from a URI scheme (e.g. timer:tick, log:info).
ComponentComponentRegistered 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.
BeanRegistryBeanRegistryNamed 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 YAMLrust-camel Rust
choicechoice.choice()
whenwhen (under choice).when(predicate)
otherwiseotherwise (under choice).otherwise()
filterfilter.filter(predicate)
splitsplit.split(config)
aggregateaggregate.aggregate(config)
multicastmulticast.multicast()
wireTapwire_tap.wire_tap(uri)
loadBalanceload_balance.load_balance()
recipientListrecipient_list.recipient_list(expr)
routingSliprouting_slip.routing_slip(expr)
throttlethrottle.throttle(n, duration)
delaydelay.delay(duration)
looploop.loop_count(n)
marshalmarshal.marshal(format)
unmarshalunmarshal.unmarshal(format)
enrichenrich.enrich(uri)
pollEnrichpoll_enrich.poll_enrich(uri, timeout)
validatevalidate.validate(predicate)
doTrydo_try.do_try()
doCatchcatch (under do_try).do_catch_exception(&[...])
doFinallyfinally (under do_try).do_finally()
circuitBreakercircuit_breaker (route-level).circuit_breaker(config)
transformtransform.transform(body) (alias for set_body)
setBodyset_body.set_body(value)
setHeaderset_header.set_header(key, value)
scriptscript.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, or Failed (see ADR-0024). Stopped is not an error.

Error handling

Apache Camelrust-camelNotes
onException(Exception.class)on_exceptions: [{ exception: [...] }]Match by variant name.
errorHandler(deadLetterChannel)error_handler: { dead_letter_channel: ... }Route-level config.
handled(true)disposition: HandledAbsorbs the error. Route terminates normally.
continued(true)disposition: ContinuedClears the error. Advances to the next step.
maximumRedeliveriesretry(max).with_backoff(...)Retry on the builder.

See ADR-0019 for the exception disposition contract.

What rust-camel does not have

Apache Camel featureStatusAlternative
Spring XML DSLNot plannedYAML DSL
CDI / Spring DINot plannedRust trait system, manual registration
JMXNot plannedOpenTelemetry, metrics endpoints
Bean annotation scanningNot plannedExplicit bean registration
Normalizer EIPNot implementedCompose from Convert Body + Content-Based Router
Content Filter EIPNot implementedUse Script or process closure to strip fields
Detour EIPNot implementedCompose from filter + to
Transaction ClientNot implementedFuture work

Body types

Apache Camel uses Object as the body type and relies on type converters. rust-camel uses a Body enum with five variants:

VariantHolds
Body::TextUTF-8 string
Body::JsonParsed JSON value
Body::BytesRaw byte buffer
Body::StreamAsync stream (materialized by Stream Cache)
Body::EmptyNo 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:

PageCovers
Getting startedInstall and run your first route
Core conceptsExchange, routes, components
EIP patternsThe pattern catalogue
YAML DSL route structureDeclarative route syntax

EIP patterns

rust-camel implements the Enterprise Integration Patterns catalog (Hohpe and Woolf) as Tower middleware. Every pattern is a Service<Exchange>. You add it to a route builder next to a source and a sink.

The patterns group into four families:

  • Routing: decide where an exchange goes next
  • Transformation: change the content, format, or type of the exchange body
  • Messaging: split, aggregate, reorder, and sample exchanges
  • Resilience and control: protect a route from failure, limit throughput, and scope error handling

The vocabulary is shared with Apache Camel for familiarity, not compatibility (ADR-0046).

For route steps that are not EIPs (Stream Cache, Bean, Stop), see Processing steps. For the route structure that hosts these patterns, see Routes and pipelines.

Most patterns take a predicate or expression. For the available languages, see Expression languages.

Routing

Routing patterns decide where an exchange goes next. They pick a destination, branch on content, fan out to several endpoints, or distribute load. They correspond to the Message Routing category in Hohpe and Woolf.

  • Message Filter — pass or drop an exchange by predicate
  • Content-Based Router — route an exchange to one of several destinations by predicate
  • Dynamic Router — compute the destination at runtime from exchange content
  • Recipient List — broadcast an exchange to a list of endpoints computed at runtime
  • Routing Slip — attach a sequence of endpoints and route through each in order
  • Scatter-Gather — broadcast to a fixed list of endpoints and collect the responses
  • Wire Tap — send a copy of the exchange to a side endpoint without blocking the main flow
  • Multicast — send the exchange to several destinations in parallel
  • Load Balancer — distribute exchanges across destination endpoints by strategy

For the route structure that hosts these patterns, see Routes and pipelines.

Message Filter

The Message Filter is a Message Router from Hohpe and Woolf. It drops exchanges that fail a predicate. A filter step evaluates a predicate on each exchange and runs its inner steps only when the predicate holds.

    let route = RouteBuilder::from("timer:tick?period=1000&repeatCount=10")
        .route_id("content-based-routing-demo")
        .process(move |mut exchange: camel_api::Exchange| {
            let c = Arc::clone(&counter_clone);
            Box::pin(async move {
                let n = c.fetch_add(1, Ordering::SeqCst);
                if n.is_multiple_of(2) {
                    exchange.input.body = Body::Text("important".into());
                } else {
                    exchange.input.body = Body::Text("routine".into());
                }
                Ok(exchange)
            })
        })
        .filter(|ex| ex.input.body.as_text() == Some("important"))
        .to("log:filtered?showBody=true&showCorrelationId=true")
        .end_filter()
        .error_handler(
            ErrorHandlerConfig::log_only()
                .on_exception(|_| true)
                .retry(1)
                .build(),
        )
        .build()?;
YAML equivalent
- id: content-based-routing-demo
  from: timer:tick?period=1000&repeatCount=10
  error_handler:
    retry:
      max_attempts: 1
  steps:
    - filter:
        simple: "${body} == 'important'"
        steps:
          - to: log:filtered?showBody=true&showCorrelationId=true

The .filter(predicate) call takes a closure of type Fn(&Exchange) -> bool. In the included route the predicate reads ex.input.body.as_text() and keeps only exchanges whose body is the string important. The .to(...) call inside the filter scope is the inner step. It runs for exchanges the predicate accepts. The .end_filter() call closes the scope. The error_handler that follows then attaches to the route, not to the filter.

When the predicate returns false, the filter does not raise an error. It returns PipelineOutcome::Completed with the original exchange, and the inner step is skipped. The exchange then continues to any step after .end_filter(). In this route the filter is the last step before the error handler, so a filtered exchange simply ends with no log line written.

This is the rule that separates filter from choice. A filter has one branch that runs or skips. A Content-Based Router selects one branch from several. Use filter to gate a single sub-route on a yes-or-no condition. Use Content-Based Router when you must dispatch to one of many destinations.

Per ADR-0025, the filter compiles into a FilterSegment that operates on PipelineOutcome directly. The processor contract for the filter is documented in camel-processor/CONTEXT.md.

The example source is at examples/content-based-routing.

Content-Based Router

The Content-Based Router is a Message Router from Hohpe and Woolf. It inspects the exchange and routes it to one destination from a fixed set of branches.

    let route = RouteBuilder::from("timer:tick?period=1000&repeatCount=6")
        .route_id("content-based-router-demo")
        .process(move |mut exchange: camel_api::Exchange| {
            let c = Arc::clone(&counter_clone);
            Box::pin(async move {
                let n = c.fetch_add(1, Ordering::SeqCst);
                let priority = match n % 3 {
                    0 => "high",
                    1 => "medium",
                    _ => "low",
                };
                exchange.input.body = Body::Text(priority.into());
                Ok(exchange)
            })
        })
        .choice()
        .when(|ex| ex.input.body.as_text() == Some("high"))
        .to("log:high-priority?showBody=true&showCorrelationId=true")
        .end_when()
        .when(|ex| ex.input.body.as_text() == Some("medium"))
        .to("log:medium-priority?showBody=true&showCorrelationId=true")
        .end_when()
        .otherwise()
        .to("log:low-priority?showBody=true&showCorrelationId=true")
        .end_otherwise()
        .end_choice()
        .error_handler(
            ErrorHandlerConfig::log_only()
                .on_exception(|_| true)
                .retry(1)
                .build(),
        )
        .build()?;
YAML equivalent
- id: content-based-router-demo
  from: timer:tick?period=1000&repeatCount=6
  error_handler:
    retry:
      max_attempts: 1
  steps:
    - choice:
        when:
          - simple: "${body} == 'high'"
            steps:
              - to: log:high-priority?showBody=true&showCorrelationId=true
          - simple: "${body} == 'medium'"
            steps:
              - to: log:medium-priority?showBody=true&showCorrelationId=true
        otherwise:
          - to: log:low-priority?showBody=true&showCorrelationId=true

The included route fires a timer that assigns one of three priority strings to the body. The .choice() call opens a routing block. Each .when(predicate).to(endpoint).end_when() chain defines one branch. The .otherwise().to(endpoint).end_otherwise() chain defines the fallback. The .end_choice() call closes the block so the error_handler attaches to the route, not to the choice.

The router evaluates the when predicates in order and runs the first branch that matches. It short-circuits on the first match, so it skips the remaining branches. The order of your when clauses matters when predicates overlap. Put the most specific predicate first. Each predicate is a closure of type Fn(&Exchange) -> bool. It can read the body, headers, and properties. The example reads ex.input.body.as_text() to dispatch on the string the previous process step produced.

If no predicate matches and you omit otherwise, the exchange passes through unchanged. It does not stop, and it raises no error. The route continues to the next step. Add an otherwise branch when an unmatched exchange must not proceed silently. The choice block composes with other steps. A process step inside a branch mutates the body, and the to endpoint receives that mutated body. An error handler attached after end_choice() catches errors raised inside the chosen branch.

Use the Content-Based Router when the set of destinations is fixed at build time. Use the Dynamic Router when the destination is itself data. A header value, a registry lookup, or a computation decides where the exchange goes next. CBR chooses among branches you wrote. The Dynamic Router computes an endpoint at runtime.

Per ADR-0025, the choice compiles into a ChoiceSegment that returns PipelineOutcome directly, so Stop propagates with exchange state intact. Per ADR-0001, that segment runs as a Service<Exchange> in the Tower middleware pipeline. The processor contract is documented in camel-processor/CONTEXT.md.

For how a filter feeds a choice inside the same route, see the Message Filter page.

The example source is at examples/content-based-router.

Dynamic Router

The Dynamic Router is a Message Router from Hohpe and Woolf. It computes the destination endpoint at runtime from exchange data, instead of selecting from a fixed set of branches.

    let route = RouteBuilder::from("timer:tick?period=1000&repeatCount=10")
        .route_id("dynamic-router-demo")
        // Set a rotating destination header
        .process(move |mut exchange: camel_api::Exchange| {
            let dests = dest_clone.clone();
            Box::pin(async move {
                let mut d = dests.lock().unwrap(); // allow-unwrap
                let dest = d.next().unwrap(); // allow-unwrap
                exchange
                    .input
                    .set_header("destination", Value::String(dest.to_string()));
                exchange.input.body = Body::Text(format!("routed to {}", dest));
                Ok(exchange)
            })
        })
        // Dynamic router: read header and route to correct endpoint
        .dynamic_router(Arc::new(|exchange: &camel_api::Exchange| {
            let dest = exchange
                .input
                .header("destination")
                .and_then(|v| v.as_str())
                .unwrap_or("a");
            Some(format!(
                "log:routed-{}?showBody=true&showHeaders=true",
                dest
            ))
        }))
        .build()?;
YAML equivalent
- id: dynamic-router-demo
  from: timer:tick?period=1000&repeatCount=10
  steps:
    - set_header:
        key: destination
        value: a
    - dynamic_router:
        simple: "log:routed-${header.destination}?showBody=true&showHeaders=true"

The Rust example rotates the destination header through a, b, c. YAML set_header sets one fixed value, so the YAML form routes every exchange to log:routed-a. The Simple expression log:routed-${header.destination} mirrors the Rust closure.

The .dynamic_router(Arc::new(|exchange| ...)) step takes a closure of type Fn(&Exchange) -> Option<String>. The router calls the closure and forwards the exchange to the endpoint it returns. Then it calls the closure again on the result. The loop ends when the closure returns None. Each hop receives the exchange as the previous endpoint left it. That endpoint can mutate a header or the body. The next closure call then reads the changed value and either returns a new destination or None to stop.

The example reads the destination header that an upstream process step sets. It returns the matching log:routed-{dest} endpoint. A single Some value may also carry several endpoints separated by the uri_delimiter (default ,). The router visits all of them within one iteration before it calls the closure again.

Two safeguards stop a runaway loop. The closure must not return the same endpoint on consecutive iterations. A hop that leaves the routing data unchanged trips this guard. The router then raises an error instead of spinning. An iteration cap (max_iterations, default 1000) and an optional timeout bound the loop. To end routing after a single hop, have that hop clear the value the closure reads, or return None.

The Dynamic Router differs from the Recipient List. The Dynamic Router re-evaluates its expression after every hop, so each endpoint can steer the exchange to the next. The Recipient List evaluates its expression once and sends the exchange to every endpoint on that list. Use the Dynamic Router when each hop can change where the exchange goes next. Use the Recipient List for one-shot fan-out to a known set.

The closure runs synchronously on the route channel. Any I/O the closure performs blocks the next step until the closure returns. If you need async work, resolve the destination in an upstream process step and store it in a header. Let the dynamic router read that header. This keeps the router a small, fast step.

Per ADR-0001, the dynamic router compiles into a DynamicRouterService that runs as a Service<Exchange> in the Tower middleware pipeline. The processor contract is documented in camel-processor/CONTEXT.md.

The example source is at examples/dynamic-router.

Recipient List

The Recipient List is a Message Router from Hohpe and Woolf. It evaluates an expression once to resolve a list of endpoints, then sends a copy of the exchange to each one.

    let route = RouteBuilder::from("timer:tick?period=2000&repeatCount=3")
        .route_id("recipientlist-demo")
        .set_header(
            "destinations",
            Value::String("log:channel-a?showBody=true,log:channel-b?showBody=true,log:channel-c?showBody=true".into()),
        )
        .recipient_list_with_config(
            RecipientListConfig::new(expression)
                .parallel(true),
        )
        .to("log:summary?showBody=true")
        .build()?;
YAML equivalent
- id: recipientlist-demo
  from: timer:tick?period=2000&repeatCount=3
  steps:
    - set_header:
        key: destinations
        value: "log:channel-a?showBody=true,log:channel-b?showBody=true,log:channel-c?showBody=true"
    - recipient_list:
        simple: "${header.destinations}"
        parallel: true
    - to: log:summary?showBody=true

The route stores three log endpoint URIs in the destinations header. The .recipient_list_with_config(...) call takes a closure of type RecipientListExpression. That closure reads the header and returns the comma-separated string. The processor splits the string, resolves each URI, and dispatches a clone of the exchange to each endpoint. The .parallel(true) flag runs the three dispatches concurrently instead of one after the other.

The exchange that reaches the next step depends on the aggregation strategy. The default LastWins strategy forwards one branch's result to the step that follows. Set MulticastStrategy::Original to pass the input exchange through unchanged and discard every branch output. Set CollectAll to gather each branch body into a JSON array. The example keeps the default, so log:summary receives the result of one resolved branch.

Use the Recipient List when the destinations are data. A header, a registry lookup, or a computation decides the targets at runtime. Use Multicast when the targets are fixed in the route. A Content-Based Router picks one branch from a set. The Recipient List fans out to all of them. A guard caps the resolved list at max_recipients (default 1000) before any endpoint resolves. An expression that returns millions of URIs cannot exhaust memory.

Per ADR-0025, the recipient list is an outcome-aware structural EIP. A branch that returns PipelineOutcome::Stopped records a partial failure. The aggregation skips the stopped branch, and the route error handler sees the partial outcome through the same boundary that step errors flow through.

Per ADR-0001, the recipient list compiles into a Service<Exchange> step in the Tower middleware pipeline. The processor contract is documented in camel-processor/CONTEXT.md.

The example source is at examples/recipientlist.

Routing Slip

The Routing Slip is a Message Router from Hohpe and Woolf. It reads a header that holds a comma-separated list of endpoints. It routes the exchange through each one in sequence.

    let route = RouteBuilder::from("timer:tick?period=1000&repeatCount=10")
        .route_id("routing-slip-demo")
        .process(move |mut exchange: camel_api::Exchange| {
            let c = counter_clone.clone();
            Box::pin(async move {
                let n = c.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
                let slip = if n.is_multiple_of(2) {
                    "log:step-a?showBody=true,log:step-b?showBody=true"
                } else {
                    "log:step-c?showBody=true,log:step-d?showBody=true"
                };
                exchange
                    .input
                    .set_header("slip", Value::String(slip.to_string()));
                exchange.input.body = Body::Text(format!("message #{}", n));
                Ok(exchange)
            })
        })
        .routing_slip(Arc::new(|exchange: &camel_api::Exchange| {
            exchange
                .input
                .header("slip")
                .and_then(|v| v.as_str().map(|s| s.to_string()))
        }))
        .build()?;
YAML equivalent
- id: routing-slip-demo
  from: timer:tick?period=1000&repeatCount=10
  steps:
    - set_header:
        key: slip
        value: "log:step-a?showBody=true,log:step-b?showBody=true"
    - routing_slip:
        simple: "${header.slip}"

The Rust example uses a process closure to alternate the slip header between two endpoint lists. YAML set_header sets one fixed value, so the YAML form routes every exchange through log:step-a then log:step-b.

The route sets a slip header that holds one of two endpoint lists, then calls .routing_slip(...). The closure takes a &Exchange and returns an Option<String>. It reads the slip header and returns the string. The processor splits the string on the uri_delimiter (default ,), resolves each URI, and calls each endpoint in turn. The exchange that one endpoint returns feeds into the next. If the closure returns None, the exchange passes through unchanged.

Each step sees the mutations the previous step made. A log endpoint prints the body. An endpoint can append a header or transform the body. Any of these changes what the next endpoint receives. In the example the body carries the identifier message #N, and that identifier propagates from the first step to the last.

The slip is computed once. The closure runs on the initial exchange, and the resulting list drives the whole sequence. Each endpoint mutates the exchange, but the list of endpoints itself is fixed. To change the path between exchanges, vary the header value upstream of the slip, as the example does with its counter.

The Routing Slip is sequential. The Recipient List also evaluates its expression once. It sends a copy to every endpoint and aggregates the results, instead of threading one exchange through a chain. Use the Routing Slip when the exchange must visit a sequence of endpoints in order, each one building on the last. Use the Recipient List for one-shot fan-out to an independent set. The Dynamic Router re-evaluates its destination after every hop, so it suits a path that each endpoint steers.

Per ADR-0001, the routing slip compiles into a RoutingSlipService that runs as a Service<Exchange> in the Tower middleware pipeline. The processor contract is documented in camel-processor/CONTEXT.md.

The example source is at examples/routing-slip.

Scatter-Gather

The Scatter-Gather is a Message Router from Hohpe and Woolf. It broadcasts a copy of the exchange to a fixed list of endpoints in parallel and gathers the responses into one aggregate exchange.

- id: scatter-gather-demo
  from: timer:tick?period=1000&repeatCount=3
  steps:
    - scatter_gather:
        endpoints:
          - direct:pricing
          - direct:inventory
          - direct:reviews
        aggregation: collect_all
    - to: log:aggregated?showBody=true

The scatter_gather step sends the same exchange to every endpoint listed under endpoints. All dispatches run in parallel. The aggregation field picks the strategy that merges the responses. The default is last_wins, which keeps the body of the last branch to complete. Set aggregation: collect_all to assemble every branch body into a JSON array. The step after scatter_gather receives the merged result.

Scatter-Gather is DSL sugar over Multicast. The YAML parser lowers the step to a multicast block with parallel: true and the chosen aggregation. No new processor or Rust builder method exists for it. Rust code calls .multicast() directly. Use Scatter-Gather when the broadcast-and-collect shape is the point. Use a raw multicast block when you need the extra knobs it exposes: parallel_limit, stop_on_exception, or timeout_ms.

The endpoints are fixed in the route definition. A route that must compute destinations at runtime uses a Recipient List. The gather is also stateless. No correlation key or completion condition exists. The step collects all parallel responses in one pass and moves on. Stateful accumulation across many exchanges is the Aggregator EIP.

Per ADR-0025, the lowered multicast compiles into a MulticastSegment that operates on PipelineOutcome. Per ADR-0001, the segment runs as a Service<Exchange> step in the Tower pipeline. The processor contract is documented in camel-processor/CONTEXT.md.

Wire Tap

The Wire Tap is a Message Router from Hohpe and Woolf. It sends a copy of the exchange to a tap endpoint for inspection or monitoring while the original exchange continues down the route unchanged.

    let route = RouteBuilder::from("timer:tick?period=1000&repeatCount=5")
        .route_id("wiretap-demo")
        // Tap: send a clone to monitoring (fire-and-forget)
        .wire_tap("log:monitor?showBody=true&showCorrelationId=true")
        // Main pipeline continues normally
        .to("log:main?showBody=true&showCorrelationId=true")
        .build()?;
YAML equivalent
- id: wiretap-demo
  from: timer:tick?period=1000&repeatCount=5
  steps:
    - wire_tap: log:monitor?showBody=true&showCorrelationId=true
    - to: log:main?showBody=true&showCorrelationId=true

The included route fires a timer and calls .wire_tap("log:monitor?..."). The tap clones the exchange and dispatches the clone to log:monitor, which logs the body and correlation id. The main pipeline then runs the original exchange through .to("log:main?..."), which logs the same body. The tap runs as fire-and-forget. An error on the tap endpoint does not stop the main flow, and the main flow does not wait for the tap to finish.

The Wire Tap differs from Multicast in scope. A wire tap is one side channel. It does not return a value into the main pipeline. The exchange that continues down the route is the same exchange the tap saw before the clone. Multicast is the main flow: it fans out to every branch and aggregates the results back into the pipeline. Use a wire tap to observe an exchange without changing it. Put a consumer on the main pipeline when it must affect the result.

The clone duplicates the body. A wire tap on an exchange with a multi-megabyte body copies that body for the tap endpoint, which costs memory and CPU. For large bodies, pair the route with a Claim Check step that stashes the body and passes a reference id. The tap then reads the reference id without copying the body.

Per ADR-0001, the wire tap compiles into a Service<Exchange> step in the Tower middleware pipeline. The processor contract is documented in camel-processor/CONTEXT.md.

The example source is at examples/wiretap.

Multicast

The Multicast is a Message Router from Hohpe and Woolf. It sends a copy of the exchange to every endpoint in a fixed list and merges the responses back into one exchange.

    let route = RouteBuilder::from("timer:tick?period=2000&repeatCount=3")
        .route_id("multicast-demo")
        // Give the exchange a meaningful body before multicasting
        .process(|mut exchange: camel_api::Exchange| {
            Box::pin(async move {
                exchange.input.body = Body::Text("hello from multicast".to_string());
                // Add broadcast-id header with UUID
                exchange
                    .input
                    .set_header("broadcast-id", Value::String(Uuid::new_v4().to_string()));
                Ok(exchange)
            })
        })
        // Multicast: same message goes to all three log endpoints in parallel.
        // CollectAll aggregation gathers each response body into a JSON array.
        .multicast()
        .parallel(true)
        .aggregation(MulticastStrategy::CollectAll)
        .to("log:channel-a?showBody=true&showCorrelationId=true")
        .to("log:channel-b?showBody=true&showCorrelationId=true")
        .to("log:channel-c?showBody=true&showCorrelationId=true")
        .end_multicast()
        // After multicast: the body is a JSON array of all three responses
        .to("log:summary?showBody=true&showCorrelationId=true")
        .error_handler(
            ErrorHandlerConfig::log_only()
                .on_exception(|_| true)
                .retry(1)
                .build(),
        )
        .build()?;
YAML equivalent
- id: multicast-demo
  from: timer:tick?period=2000&repeatCount=3
  error_handler:
    retry:
      max_attempts: 1
  steps:
    - set_body:
        value: hello from multicast
    - multicast:
        parallel: true
        aggregation: collect_all
        steps:
          - to: log:channel-a?showBody=true&showCorrelationId=true
          - to: log:channel-b?showBody=true&showCorrelationId=true
          - to: log:channel-c?showBody=true&showCorrelationId=true
    - to: log:summary?showBody=true&showCorrelationId=true

The example sets a text body and a broadcast-id header, then opens a multicast block over three log endpoints. The parallel: true flag dispatches all three branches at once instead of one after another. Each branch receives its own clone of the exchange, so a branch that mutates the body or headers does not affect its siblings. The clone also carries a CamelMulticastIndex property and a CamelMulticastComplete flag. A branch can read these to learn its position in the fan-out. The collect_all strategy gathers each branch response body into a JSON array, and the step that follows the block, log:summary, receives that array as its body.

The aggregation strategy decides what the post-multicast exchange carries. LastWins (the default) keeps the body of the last branch to complete and discards the rest. CollectAll assembles every branch body into a JSON array. Original passes the input exchange through unchanged and drops all branch output. A Custom variant takes a function of type MulticastAggregationFn for merge logic the built-ins do not cover. Three other knobs live on the block. parallel_limit caps how many branches run at once when parallel is on. stop_on_exception fails the whole block on the first branch error. timeout bounds how long the block waits for slow branches.

Pick Multicast when the destinations are fixed in the route and you want every one to run. Pick the Recipient List when a header or expression must compute the destinations at runtime. Scatter-Gather is YAML sugar over Multicast with parallel dispatch and collect_all. Use it when the broadcast-and-collect shape is the whole point. A Wire Tap sends one copy to a side channel and never merges a result back.

Per ADR-0025, multicast is an outcome-aware structural EIP. A branch that returns PipelineOutcome::Stopped propagates that stop out of the block. Aggregation does not run on stopped output. The route error handler sees any partial outcome through the same boundary that step errors use. Per ADR-0001, the block compiles into a Service<Exchange> step in the Tower pipeline, with each branch a child step on the route channel. The processor contract is documented in camel-processor/CONTEXT.md.

The example source is at examples/multicast.

Load Balancer

The Load Balancer is a Message Router from Hohpe and Woolf. For each exchange it picks one endpoint from a fixed list and sends the exchange there.

    let route = RouteBuilder::from("timer:tick?period=1000&repeatCount=10")
        .route_id("load-balancer-demo")
        // Give the exchange a meaningful body before load balancing
        .process(|mut exchange: camel_api::Exchange| {
            Box::pin(async move {
                exchange.input.body = Body::Text("hello from load balancer".to_string());
                Ok(exchange)
            })
        })
        // Load Balance: RoundRobin distributes messages across endpoints
        .load_balance()
        .to("log:server-a?showBody=true")
        .to("log:server-b?showBody=true")
        .to("log:server-c?showBody=true")
        .end_load_balance()
        .build()?;
YAML equivalent
- id: load-balancer-demo
  from: timer:tick?period=1000&repeatCount=10
  steps:
    - set_body:
        value: hello from load balancer
    - load_balance:
        strategy: round_robin
        steps:
          - to: log:server-a?showBody=true
          - to: log:server-b?showBody=true
          - to: log:server-c?showBody=true

The example sets a text body, then opens a load-balance block over three log endpoints with the default round_robin strategy. For each exchange, the strategy picks one endpoint and dispatches the exchange there. The other two endpoints see nothing. With ten timer ticks over three endpoints, round-robin hands ticks out in order. The first goes to log:server-a, the second to log:server-b, the third to log:server-c. The fourth cycles back to server-a.

The strategy decides which endpoint a given exchange hits. RoundRobin (the default) cycles the endpoints in order with a shared counter. Random picks an index at random on each call. Weighted takes a list of (name, weight) pairs and draws an endpoint in proportion to its weight. A heavier endpoint gets more traffic. Failover tries the endpoints in order. On error it moves to the next, and keeps going until one succeeds or the list is exhausted. Pick round-robin for uniform endpoints of equal capacity. Pick weighted when endpoints differ in throughput. Pick failover when one primary endpoint should serve and the rest stand by as backups.

The Load Balancer sends each exchange to one endpoint. Multicast sends a copy to all of them. A Content-Based Router picks a branch by predicate. The Load Balancer picks by strategy, not by message content. The selection is stateless across exchanges except for the round-robin counter. Under Random or Weighted, two consecutive exchanges may land on the same endpoint.

Per ADR-0025, the load balancer is an outcome-aware structural EIP. A selected branch that returns PipelineOutcome::Stopped propagates that stop out of the block. Under Failover, a branch that returns Failed moves the dispatch to the next endpoint instead of propagating the failure. Per ADR-0001, the block compiles into a Service<Exchange> step in the Tower pipeline, with each to arm a child step on the route channel. The processor contract is documented in camel-processor/CONTEXT.md.

The example source is at examples/load-balancer.

Transformation

Transformation patterns change the content, format, or type of the exchange body. They convert between wire formats, enrich the body with external data, or set the body from an expression. They correspond to the Message Transformation category in Hohpe and Woolf.

  • Convert Body — convert the exchange body to a new type in the pipeline
  • Marshal and Unmarshal — serialize and deserialize the body to or from a named format
  • Transform — set the body from a Simple expression or a literal value
  • Script — run an inline script that can modify the exchange
  • Poll Enrich — poll a resource and replace the body with the result
  • Content Enricher — call a resource and replace the body with the enriched result

For the data types these steps operate on, see Exchange and Message.

Convert Body

The Convert Body step is a Message Translator (Hohpe & Woolf). It converts the exchange body to a target BodyType variant so downstream steps read the same data under a different type.

    let route_text_to_json = RouteBuilder::from("timer:text-to-json?period=2000&repeatCount=3")
        .route_id("text-to-json")
        .set_body(r#"{"message": "hello from text", "count": 42}"#)
        .log(
            "Route 1: Starting with Text body containing JSON string",
            LogLevel::Info,
        )
        .convert_body_to(BodyType::Json)
        .log(
            "Route 1: Converted Text → Json successfully!",
            LogLevel::Info,
        )
        .to("log:info?showBody=true")
        .error_handler(ErrorHandlerConfig::log_only())
        .build()?;
YAML equivalent
- id: text-to-json
  from: timer:text-to-json?period=2000&repeatCount=3
  error_handler:
    retry:
      max_attempts: 0
  steps:
    - set_body:
        value: '{"message": "hello from text", "count": 42}'
    - convert_body_to: json
    - to: log:info?showBody=true

The .convert_body_to(BodyType::Json) call picks a target variant from the BodyType enum: Json, Bytes, or Text. The step reads the current body, re-encodes it to that variant, and stores the result on the exchange. Steps after the conversion then read the body under its new type.

Use Convert Body when the data is correct but the type is wrong. A source that emits bytes and a sink that expects JSON sit at a type boundary. The step changes the type and keeps the content. Transform does a different job. It replaces the body from an expression or a literal value. Convert Body re-types the existing data. Transform overwrites it.

Convert Body works only within the built-in BodyType variants. Marshal and Unmarshal handle named wire formats such as CSV or Protobuf. Those steps translate between a structured type and a serialized representation, not between body types.

Per ADR-0001, the step compiles into a Service<Exchange> in the Tower middleware pipeline. The processor contract and BodyType definition are documented in camel-processor/CONTEXT.md.

The example source is at examples/convert-body-to.

Marshal and Unmarshal

Marshal and Unmarshal are the Message Translator pair (Hohpe & Woolf). Marshal serializes the body to a named wire format. Unmarshal deserializes a wire-format body back into a structured type. Together they cross the boundary between the pipeline and external systems.

    let route_csv_marshal = RouteBuilder::from("timer:csv-marshal?period=2000&repeatCount=3")
        .route_id("csv-marshal")
        .set_body(r#"[{"name":"Carol","age":28},{"name":"Dave","age":35}]"#)
        .unmarshal("json")?
        .log("Route 2: starting CSV marshal from Json", LogLevel::Info)
        .marshal("csv")?
        .log("Route 2: marshalled Json -> CSV Text", LogLevel::Info)
        .to("log:info?showBody=true")
        .error_handler(ErrorHandlerConfig::log_only())
        .build()?;
    let route_json_roundtrip = RouteBuilder::from("timer:json-roundtrip?period=2000&repeatCount=3")
        .route_id("json-roundtrip")
        .set_body(r#"{"message": "hello", "count": 42}"#)
        .log(
            "Route 1: Starting with Text body containing JSON string",
            LogLevel::Info,
        )
        .unmarshal("json")?
        .log("Route 1: Unmarshalled Text -> Json", LogLevel::Info)
        .marshal("json")?
        .log(
            "Route 1: Marshalled Json -> Text (round-trip complete!)",
            LogLevel::Info,
        )
        .to("log:info?showBody=true")
        .error_handler(ErrorHandlerConfig::log_only())
        .build()?;
YAML equivalent
- id: json-roundtrip
  from: timer:json-roundtrip?period=2000&repeatCount=3
  error_handler:
    retry:
      max_attempts: 0
  steps:
    - set_body:
        value: '{"message": "hello", "count": 42}'
    - unmarshal: json
    - marshal: json
    - to: log:info?showBody=true

- id: csv-marshal
  from: timer:csv-marshal?period=2000&repeatCount=3
  error_handler:
    retry:
      max_attempts: 0
  steps:
    - set_body:
        value: '[{"name":"Carol","age":28},{"name":"Dave","age":35}]'
    - unmarshal: json
    - marshal: csv
    - to: log:info?showBody=true

The .marshal("csv") call names the wire format as a string. The processor looks up that name in the data format registry and applies the format to the current body. Marshal stores the serialized result on the exchange. Unmarshal reverses the flow. It parses the body through the named format and stores the parsed structure on the exchange.

The string parameter keeps the route declaration format-agnostic. Common formats include json, csv, xml, and protobuf. Each format owns its body-type mapping and its configuration. See Data Formats for the format catalog, the DataFormat trait, and per-format options. This page covers only the route-level step.

A route that crosses a system boundary pairs the two steps. Marshal prepares the body for the wire on the outgoing side. Unmarshal restores a structured type on the incoming side. Each step in between then reads the body shape it expects.

Per ADR-0001, both steps compile into Service<Exchange> services in the Tower middleware pipeline. The data format registry and the marshal/unmarshal hooks are documented in camel-processor/CONTEXT.md.

The example sources are at examples/marshal-csv and examples/marshal-unmarshal.

Transform

The Transform step is a Message Translator from Hohpe and Woolf. It sets the exchange body from a literal value, a Simple expression, or a Rhai expression. In the Rust builder API, transform is an alias for set_body. Both compile to the same SetBody processor.

    let route = RouteBuilder::from("timer:transform?period=1000&repeatCount=3")
        .route_id("transform-pipeline")
        .process(|mut exchange| async move {
            exchange.input.body = Body::Text("hello world".to_string());
            Ok(exchange)
        })
        // .transform() is an alias for .set_body() — sets body to a static value
        .transform(Value::String("hello world (via transform)".into()))
        .map_body(|body: Body| {
            if let Some(text) = body.as_text() {
                Body::Text(text.to_uppercase())
            } else {
                body
            }
        })
        .set_header("transformed", Value::Bool(true))
        .to("log:info?showHeaders=true&showBody=true&showCorrelationId=true")
        .error_handler(
            ErrorHandlerConfig::log_only()
                .on_exception(|_| true)
                .retry(1)
                .build(),
        )
        .build()?;
YAML equivalent
- id: transform-demo
  from: timer:tick?period=1000
  steps:
    - set_header:
        key: prefix
        value: hello
    - transform:
        simple: "${header.prefix} world"
    - to: log:transformed?showBody=true

The Rust .set_body(value) call accepts any type that implements Into<Body>. Strings, JSON values, and byte vectors all qualify. The step discards the old body and stores the new value on the exchange. When the new body depends on the current exchange, call .set_body_fn(closure) instead. The closure receives an &Exchange and returns a Body, so it can read headers, properties, and the inbound body to compute the replacement. The .transform(value) method forwards to set_body and exists for parity with the Apache Camel route DSL.

The YAML transform: step accepts three shapes that map to the SetBodyConfig fields in the DSL layer. A literal (transform: "hello") stores the value as-is. A Simple expression (transform: { simple: "${body.upper()}" }) evaluates the expression and stores the result. A Rhai expression (transform: { rhai: "body + '_processed'" }) does the same through the Rhai engine. The Rust API splits the static and dynamic cases across set_body and set_body_fn. The YAML form unifies them under one step.

Transform overwrites the body. Convert Body re-types the existing body without changing its content. A route that needs new content uses Transform. A route that needs the same content under a different type (Text to Json) uses Convert Body.

Transform evaluates one expression and writes one result. Script runs a full Rhai block that can mutate headers, properties, and body in the same step. A route that needs a single replacement uses Transform. A route that needs branching logic or several mutations uses Script.

Per ADR-0001, the step compiles into a Service<Exchange> in the Tower middleware pipeline. The processor contract is documented in camel-processor/CONTEXT.md.

The example source is at examples/transform-pipeline.

Script

The Script step is a Message Translator from Hohpe and Woolf. It runs an inline Rhai script against the exchange so the route can mutate headers, properties, and body in one step. rust-camel registers Rhai as the scripting language.

        // Step 4: .script() — mutating Rhai expression tags the order and
        //         appends a status suffix. Changes propagate back to the Exchange.
        .script(
            "rhai",
            r#"
            headers["processed"] = true;
            let status = if headers["priority"] == "high" { "PRIORITY" } else { "STANDARD" };
            body = body + " [" + status + "]";
            "#,
        )
        // Step 5: log every message (after enrichment)
        .to("log:all-orders?showBody=true&showHeaders=true")
YAML equivalent
- id: script-demo
  from: timer:tick?period=1000
  steps:
    - script:
        language: rhai
        source: |
          headers["tenant"] = "acme"
          body = body + "_processed"
    - to: log:scripted?showBody=true&showHeaders=true

The .script("rhai", source) call hands the script text to the Rhai engine. The engine exposes three variables on the exchange: headers and properties as mutable maps, and body as the current body string. The script reads and writes all three. Assignments to headers["key"] and body propagate to the next step in the pipeline.

Script handles logic that one expression cannot express. A Rhai block runs conditionals, loops, and several mutations in a single step. Transform evaluates one expression and writes one result. A route that needs a single body replacement uses Transform. A route that needs branching logic or several field changes uses Script.

Script interprets Rhai at runtime, so it runs slower than a native .process(|ex| ...) closure. The closure compiles to Rust and reads the full Exchange type. A route that needs maximum throughput uses process. A route that needs logic it can change without a rebuild uses Script.

Per ADR-0001, the step compiles into a Service<Exchange> in the Tower middleware pipeline. The Rhai integration is documented in camel-language-api/CONTEXT.md.

The example source is at examples/language-rhai.

Poll Enrich

The Poll Enrich is a Content Enricher variant from Hohpe and Woolf. It polls a passive resource and feeds the result into the exchange.

    let route = RouteBuilder::from("timer:tick?period=1000&repeatCount=3")
        .route_id("pollenrich-demo")
        .poll_enrich(
            format!("file:{config_dir_str}?fileName=config.json&noop=true"),
            5000,
        )
        .stream_cache_default()
        .to("log:enriched?showBody=true&showHeaders=true&showCorrelationId=true")
        .build()?;
YAML equivalent
- id: pollenrich-demo
  from: timer:tick?period=1000&repeatCount=3
  steps:
    - poll_enrich:
        uri: "file:/tmp/rust-camel-pollenrich?fileName=config.json&noop=true"
        timeout: 5000
    - stream_cache: true
    - to: log:enriched?showBody=true&showHeaders=true&showCorrelationId=true

The .poll_enrich(uri, timeout) step reads from a polling consumer. It waits up to timeout milliseconds for a message. A file or database is a passive resource. It stores data but does not push it. Poll Enrich pulls that data on demand. The example reads a config file each time the timer fires.

The EnrichmentStrategy trait decides how the polled exchange combines with the original. The default UseEnrichedBody strategy replaces the original body with the polled body. It keeps the original headers and properties. It discards the headers from the polled exchange. Write a custom strategy when you need to merge both bodies or preserve the polled headers.

When the poll returns no message within the timeout, the step calls on_no_poll. The default behavior passes the original exchange through unchanged. ThrowOnNoPoll wraps a base strategy and errors the exchange instead. Use it when missing data is a failure, not a normal condition.

Choose Poll Enrich when the source is a passive consumer. Choose the Content Enricher when the source is an active endpoint that receives a request and returns a response. Poll Enrich reads. Content Enricher calls.

Per ADR-0001, the step compiles into a Service<Exchange> in the Tower pipeline. The strategy contract and the PollEnrichService implementation are documented in camel-processor/CONTEXT.md.

Reference: Processor crate · Example source

Content Enricher

The Content Enricher is a Message Translator from Hohpe and Woolf. It calls a producer endpoint and feeds the response into the exchange.

    let route = RouteBuilder::from("timer:tick?period=1000&repeatCount=3")
        .route_id("content-enricher-demo")
        .enrich("direct:enrich-data")
        .to("log:enriched?showBody=true&showCorrelationId=true")
        .build()?;
YAML equivalent
- id: content-enricher-demo
  from: timer:tick?period=1000&repeatCount=3
  steps:
    - enrich: "direct:enrich-data"
    - to: log:enriched?showBody=true&showCorrelationId=true

- id: enrichment-source
  from: direct:enrich-data
  steps:
    - set_body:
        value: "enriched-value"

The .enrich(uri) step sends the exchange to a producer endpoint and waits for a response. The example routes the call to direct:enrich-data. A second route consumes from that endpoint and supplies the enrichment payload. The two-route pattern keeps the enrichment source out of the main route. Multiple consumers can share the same source.

The EnrichmentStrategy trait decides how the response combines with the original exchange. The default UseEnrichedBody strategy replaces the original body with the response body. It keeps the original headers and properties. It discards the response headers. Write a custom strategy when you need to merge both bodies or preserve the response headers.

The difference from Poll Enrich is the data source. Content Enricher calls an active endpoint that receives a request and returns a response. Poll Enrich reads from a polling consumer that holds passive data. Use Content Enricher to pull data from a service. Use Poll Enrich to read from a file or database.

Per ADR-0001, the step compiles into a Service<Exchange> in the Tower pipeline. The EnrichService calls the producer inside the step and merges the response before the step returns. The processor contract is documented in camel-processor/CONTEXT.md.

Reference: Processor crate · Example source

Messaging

Messaging patterns change the cardinality or order of exchanges. They split one message into many, gather many into one, reorder a sequence, or sample one in every N. Several compose into split-process-aggregate flows.

  • Aggregator — collect exchanges by correlation key and emit a batch
  • Splitter — break a composite message into one exchange per fragment
  • Streaming Splitter — split a byte-stream body into fragment exchanges with backpressure
  • Zip Splitter — split a ZIP archive into one exchange per entry
  • Resequencer — reorder exchanges by sequence number in batches or streams
  • Sort — sort an array body by a comparator expression
  • Sampling — pass through one out of every N exchanges
  • Claim Check — stash a large payload and replace it with a claim ticket
  • Cache — cache a computed body by key with TTL and stale-read fallback

For the processor contract that implements these patterns, see camel-processor/CONTEXT.md.

Aggregator

The Aggregator is a Message Routing pattern from Hohpe and Woolf. It collects related exchanges into a bucket and emits one combined exchange when a completion condition holds.

    let route = RouteBuilder::from("timer:orders?period=200&repeatCount=12")
        .route_id("aggregator-demo")
        .process(move |mut ex: camel_api::Exchange| {
            let c = Arc::clone(&counter_clone);
            Box::pin(async move {
                let n = c.fetch_add(1, Ordering::SeqCst);
                let order_id = ["A", "B", "C"][(n % 3) as usize];
                ex.input
                    .headers
                    .insert("orderId".to_string(), serde_json::json!(order_id));
                ex.input.body = Body::Text(format!("order-item-{n}"));
                println!("[timer] #{n} orderId={order_id}");
                Ok(ex)
            })
        })
        .aggregate(
            AggregatorConfig::correlate_by("orderId")
                .complete_when_size(3)
                .max_buckets(100)
                .bucket_ttl(std::time::Duration::from_secs(60))
                .build()
                .unwrap(), // allow-unwrap
        )
        // Pending exchanges (Body::Empty, CamelAggregatorPending=true) still flow
        // through the pipeline — log only completed batches.
        .process(|ex: camel_api::Exchange| {
            Box::pin(async move {
                if ex.property("CamelAggregatorPending").is_some() {
                    return Ok(ex);
                }
                let key = ex
                    .property("CamelAggregatedKey")
                    .map(|v: &serde_json::Value| v.to_string())
                    .unwrap_or_default();
                let size = ex
                    .property("CamelAggregatedSize")
                    .map(|v: &serde_json::Value| v.to_string())
                    .unwrap_or_default();
                println!(
                    "[batch] orderId={} size={} body={:?}",
                    key, size, ex.input.body
                );
                Ok(ex)
            })
        })
        .to("log:batch?showBody=true&showCorrelationId=true")
        .error_handler(
            ErrorHandlerConfig::log_only()
                .on_exception(|_| true)
                .retry(1)
                .build(),
        )
        .build()?;
YAML equivalent
- id: aggregator-demo
  from: timer:orders?period=200&repeatCount=12
  error_handler:
    retry:
      max_attempts: 1
  steps:
    - aggregate:
        header: "orderId"
        completion_size: 3
        max_buckets: 100
        bucket_ttl_ms: 60000
    - to: log:batch?showBody=true&showCorrelationId=true

Each incoming exchange carries a correlation key in a header. The correlate_by("orderId") call names that header. Exchanges that share the key land in the same bucket. An AggregationFn folds each new exchange into the bucket seed to build the emitted batch body. In the included route, the process step rotates the orderId header through "A", "B", and "C", so three buckets fill in parallel.

A bucket completes when it reaches its size limit or when its inactivity timeout fires. complete_when_size(3) flushes the bucket after three exchanges arrive for that key. complete_when_timeout(Duration) flushes it after a period with no new exchange. The bucket_ttl setting caps how long an incomplete bucket can live before the background sweep evicts it. The config validator rejects any setup with no memory bound, so set max_buckets, a timeout, or bucket_ttl.

Exchanges that arrive before a bucket completes still pass through the pipeline. They carry the CamelAggregatorPending property and an empty body. The process step after the aggregator checks that property and skips them. Only completed batches carry the CamelAggregatedKey and CamelAggregatedSize properties.

Use the Aggregator when many correlated messages arrive over time and you want one combined output. Multicast does the opposite job. It sends one message to many endpoints at once. The Aggregator collects. Multicast fans out.

Per ADR-0001, the aggregator compiles into a Service<Exchange> step in the Tower middleware pipeline. The processor contract and the divergences from Apache Camel are documented in camel-processor/CONTEXT.md.

The example source is at examples/aggregator.

Splitter

The Splitter is a Message Routing pattern from Hohpe and Woolf. It takes one composite message and produces one exchange per fragment.

    let route = RouteBuilder::from("timer:batch?period=2000&repeatCount=3")
        .route_id("splitter-demo")
        // Simulate incoming CSV data
        .process(|mut exchange: camel_api::Exchange| {
            Box::pin(async move {
                exchange.input.body = Body::Text("alice,100\nbob,200\ncharlie,300".to_string());
                Ok(exchange)
            })
        })
        // Split by lines, aggregate all fragment bodies into a JSON array
        .split(SplitterConfig::new(split_body_lines()).aggregation(AggregationStrategy::CollectAll))
        // Transform each CSV line into a JSON object
        .map_body(|body: Body| {
            let text = body.as_text().unwrap_or("");
            let parts: Vec<&str> = text.splitn(2, ',').collect();
            let (name, amount) = match parts.as_slice() {
                [n, a] => (*n, a.parse::<u64>().unwrap_or(0)),
                _ => (text, 0),
            };
            Body::Json(serde_json::json!({
                "name": name,
                "amount": amount,
            }))
        })
        .to("log:fragment?showBody=true&showCorrelationId=true")
        .end_split()
        // After split: aggregated JSON array of all fragments
        .to("log:aggregated?showBody=true&showCorrelationId=true")
        .error_handler(
            ErrorHandlerConfig::log_only()
                .on_exception(|_| true)
                .retry(1)
                .build(),
        )
        .build()?;
YAML equivalent
- id: splitter-demo
  from: timer:batch?period=2000&repeatCount=3
  error_handler:
    retry:
      max_attempts: 1
  steps:
    - set_body:
        value: "alice,100\nbob,200\ncharlie,300"
    - split:
        expression: body_lines
        aggregation: collect_all
        steps:
          - to: log:fragment?showBody=true&showCorrelationId=true
    - to: log:aggregated?showBody=true&showCorrelationId=true

A split expression decides how to divide the body. The included route uses split_body_lines(), which returns one fragment per line. Each fragment becomes its own exchange that flows through the sub-pipeline. A fragment inherits the parent headers, properties, message pattern, and OpenTelemetry context, so its span is a child of the parent span. The CamelSplitIndex, CamelSplitSize, and CamelSplitComplete properties mark where each fragment sits in the batch. The .map_body(...) step inside the split scope turns each line into a JSON object, and .end_split() closes the scope.

When the split scope closes, an aggregation strategy combines the fragment outputs. CollectAll gathers every fragment body into a JSON array. That array then flows to the step after .end_split(). Per the aggregation contract, failed fragments reach the strategy as Err(e) entries in the vector, not as exchanges with an attached exception.

The Splitter differs from Multicast in how it produces children. Multicast sends the same exchange to several endpoints. The Splitter derives one exchange per fragment from a single input. It also pairs with the Aggregator in a split-process-aggregate flow. The Splitter divides one message into many. The Aggregator collects many correlated messages back into one.

Per ADR-0001, the splitter compiles into a Service<Exchange> step in the Tower middleware pipeline. The per-fragment sub-pipeline compiles into child steps on the same route channel. The processor contract is documented in camel-processor/CONTEXT.md.

Per ADR-0025, the split block is an outcome-aware structural EIP. A Stopped outcome from the sub-pipeline returns Stopped(fragment_ex) and skips aggregation. The parent pipeline never sees a partial batch. The per-fragment boundary keeps the fragment mutations made before the Stop visible to the outer pipeline.

The example source is at examples/splitter.

Streaming Splitter

The Streaming Splitter is a Message Routing pattern from Hohpe and Woolf. It splits a streaming body into individual exchanges one fragment at a time, without first buffering the full body in memory.

    let mut splitter = StreamingSplitterService::new(
        expression,
        sub_pipeline,
        AggregationStrategy::CollectAll,
        true,
    );

    let ndjson = vec![
        r#"{"user":"alice","action":"login"}"#,
        r#"{"user":"bob","action":"purchase"}"#,
        r#"{"user":"charlie","action":"logout"}"#,
    ];
    let exchange = Exchange::new(Message::new(make_ndjson_stream(ndjson)));

    println!("Streaming Split example");
    println!("Input: Body::Stream with 3 NDJSON lines");
    println!();

    let _ = splitter.poll_ready(&mut std::task::Context::from_waker(
        &futures::task::noop_waker(),
    ));
    let result = splitter.call(exchange).await?;

    println!();
    println!("Aggregated result body:");
    println!("  {:?}", result.input.body);
YAML equivalent
- id: streaming-split-demo
  from: file:data.ndjson
  steps:
    - split:
        streaming: true
        stream:
          format: ndjson
        aggregation: collect_all
        steps:
          - to: log:fragment?showBody=true&showCorrelationId=true
    - to: log:aggregated?showBody=true&showCorrelationId=true

The included example builds a Body::Stream that holds three NDJSON chunks. A StreamingSplitterService reads the stream through a StreamSplitCodec, which resolves the format from the content type. For application/x-ndjson, the codec parses each line into a separate fragment exchange. The sub-pipeline logs each fragment. When the split scope closes, the aggregation strategy combines the fragment outputs into the result body.

The streaming variant is the memory-efficient alternative to the Splitter. The Splitter materializes every fragment before it processes the first one. The Streaming Splitter pulls one fragment from the source, runs the sub-pipeline, then pulls the next. A multi-gigabyte NDJSON file or a long-running log stream fits in constant memory. The codec reads bytes lazily, so the source produces data only as fast as the sub-pipeline accepts it.

Backpressure flows through the segment boundary. When the sub-pipeline pauses, the segment stops pulling from the stream, and the source stops producing. A Stopped outcome drops the underlying stream and returns the fragment exchange to the outer pipeline. The outer pipeline sees the same outcome shape it would from the eager Splitter.

Per ADR-0025, streaming split is an outcome-aware structural EIP. Stop and Failed outcomes flow through the CompiledStep::Segment boundary with the fragment exchange intact. The processor contract is documented in camel-processor/CONTEXT.md.

The example source is at examples/streaming-split.

ZIP Splitter

The ZIP Splitter is a specialized Splitter from Hohpe and Woolf. It decomposes a multi-entry ZIP archive into one exchange per entry. Each entry flows through the route as its own message.

    let route = camel_builder::RouteBuilder::from("timer:zip-marshal?period=3000&repeatCount=2")
        .route_id("zip-marshal-route")
        .set_body("payload for ZIP compression")
        .log("Route 3: Original body", LogLevel::Info)
        .marshal("zip")?
        .log("Route 3: After marshal to ZIP", LogLevel::Info)
        .unmarshal("zip")?
        .log(
            "Route 3: After unmarshal from ZIP (round-trip!)",
            LogLevel::Info,
        )
        .to("log:zip-result?showBody=true")
        .error_handler(ErrorHandlerConfig::log_only())
        .build()?;
YAML equivalent
- id: zip-marshal-route
  from: timer:zip-marshal?period=3000&repeatCount=2
  error_handler: {}
  steps:
    - set_body: "payload for ZIP compression"
    - log: "Route 3: Original body"
    - marshal: zip
    - log: "Route 3: After marshal to ZIP"
    - unmarshal: zip
    - log: "Route 3: After unmarshal from ZIP (round-trip!)"
    - to: log:zip-result?showBody=true

The included route runs a timer that fires twice. The .marshal("zip") step compresses the body into a single-entry archive. The .unmarshal("zip") step decompresses it back. The route logs the byte sizes at each step. A reader can watch the body shrink during compression and grow during decompression.

The actual split happens through the zip_splitter expression and the StreamingSplitterService. The expression reads the ZIP central directory and emits one exchange per entry. Each exchange carries the entry body plus headers: CAMEL_ZIP_ENTRY_NAME, CAMEL_ZIP_ENTRY_PATH, CAMEL_ZIP_ENTRY_SIZE, and CAMEL_ZIP_ENTRY_INDEX. The ZipSplitConfig struct caps the entry count, the per-entry size, and the total decompressed size.

The ZIP Splitter is format-specific. It knows how to parse the ZIP archive structure. The Streaming Splitter is the generic alternative. It splits any byte stream through a codec that the content type selects. Use the ZIP Splitter when the input is a ZIP archive and you need per-entry headers. Use the Streaming Splitter for NDJSON, log lines, or raw byte chunks.

Per ADR-0001, the splitter compiles into a Service<Exchange> step in the Tower middleware pipeline. The per-entry sub-pipeline compiles into child steps on the same route channel. The processor contract is documented in camel-processor/CONTEXT.md.

The example source is at examples/zip-splitter.

Sort

The Sort is a Message Translator from Hohpe and Woolf. It orders the elements of a body array by a sort key expression. The route downstream sees the array in the chosen order.

      - set_body:
          value: [3, 1, 4, 1, 5, 9, 2, 6]
      - sort:
          expression: "${body}"
      - to: "log:sorted?showBody=true&showCorrelationId=true"

The included route fires a timer every second for three ticks. The set_body step sets the body to a JSON array of eight numbers. The sort step orders the array by the ${body} expression, which uses each element itself as the sort key. The to step logs the sorted array. The sort defaults to ascending order. A reverse: true flag produces descending order.

The Sort step reads the body as a JSON array. A non-array body fails with a processor error. The upstream set_body step guarantees that contract for this example. The sort key expression extracts a comparable value from each element. The expression ${body} uses the element itself. The expression ${body.field} extracts a nested field for sorting objects by a property.

The Sort differs from the Splitter. The Sort keeps the body as a single array and reorders it in place. The Splitter decomposes the body into many exchanges. A route that needs both patterns sorts first and then splits per element. The Sort also pairs with the Aggregator. A split-sort-aggregate flow splits a collection, sorts the fragments, then aggregates the results back into one message.

Per ADR-0001, the sort step compiles into a Service<Exchange> step in the Tower middleware pipeline. The sort runs inside the step and the ordered body flows out before the next step runs. The processor contract is documented in camel-processor/CONTEXT.md.

The example source is at examples/sort.

Sampling

The Sampling is a Message Router from Hohpe and Woolf. It passes one exchange out of every N and drops the rest. The route downstream sees a subsample of the upstream traffic.

      - sampling: 3
      - to: "log:passed?showBody=true&showCorrelationId=true"

The sample period is an integer. sampling: 3 accepts one exchange out of every three and drops the other two. The counter starts at zero. The step increments it before the modulo check. Exchange number three is the first one that passes, then six, then nine. A period of one passes every exchange. The step rejects a period of zero at build time.

The step sets the CamelStop header on a dropped exchange. The surrounding pipeline translates that header into PipelineOutcome::Stopped. The steps after the sampling step do not run for that exchange. The included route fires a timer nine times. The log step runs three times.

Use the Sampling when the downstream rate is more than the route needs. Load shedding drops traffic before it reaches expensive steps. A monitoring or debugging route can sample a feed so the logs stay readable under high volume. A period-N sampler reduces the rate by an integer factor without the state of a rate limiter.

The Sampling differs from the Throttler. The Sampling is stateless and uses a counter. The Throttler paces the rate over a sliding time window and queues excess exchanges. A route that needs both patterns samples first, then throttles.

Per ADR-0001, the sampling step compiles into a Service<Exchange> step in the Tower middleware pipeline. The processor contract is documented in camel-processor/CONTEXT.md.

The example source is at examples/sampling.

Resequencer

The Resequencer is a Message Router from Hohpe and Woolf. It reorders a stream of exchanges into a defined sequence. The route downstream sees the exchanges in order even when the upstream side delivers them out of order.

      - set_header:
          key: "seq"
          simple: "${header.CamelTimerCounter}"
      - set_header:
          key: "region"
          value: "default"
      - resequence:
          batch:
            correlation: "${header.region}"
            sort: "${header.seq}"
            completion:
              size_or_timeout: [3, 2000]
      - to: "log:sorted?showBody=true&showCorrelationId=true"

The batch policy buffers exchanges per correlation key. Each exchange enters the bucket that matches its correlation header. The bucket completes when it reaches its size limit or when the timeout window elapses. At completion, the policy sorts the buffered exchanges by the sort expression. It emits the sorted exchanges as an ordered burst. The included route copies the timer counter into a seq header and tags each exchange with a region correlation key. The resequence.batch step releases three sorted exchanges at a time.

The sort expression extracts a comparable value from each exchange. The route uses ${header.seq}, so the release order matches the sequence numbers. Exchanges that arrive out of order wait in the buffer until the window completes. The policy then sorts them into the correct sequence before release.

The Resequencer differs from the Sort. The Sort orders the elements of one body array in place. The Resequencer buffers separate out-of-order exchanges across many messages, then releases them in sequence. Use Sort when one exchange carries a collection. Use Resequencer when many exchanges arrive out of sequence. The Resequencer also differs from the Aggregator. The Aggregator fuses many exchanges into one. The Resequencer keeps one exchange per output and only changes the order. A route that needs both patterns places a Resequencer before an Aggregator.

Per ADR-0029, the resequencer is a continuation boundary. The main pipeline ends at the resequencer. A post-driver task runs the steps after the resequencer on each sorted emission. Per ADR-0025, the batch and stream policies propagate PipelineOutcome::Stopped through the post-continuation. Per ADR-0001, the pre-steps compile into the main Service<Exchange> pipeline and the post-steps compile into a continuation processor. The processor contract is documented in camel-processor/CONTEXT.md.

The example source is at examples/resequencer.

Claim Check

The Claim Check is a Message Translator from Hohpe and Woolf. It stores the full message payload in a repository and replaces the body with a lightweight claim key. The route carries only the key until a later step retrieves the original body.

      - claim_check:
          repository: vault
          operation: set
          key: "${header.claimKey}"
      - log: "After SET body is the claim key: ${body}"
      - claim_check:
          repository: vault
          operation: get
          key: "${header.claimKey}"
      - log: "After GET body restored: ${body}"

The claim_check step delegates to a ClaimCheckRepository registered on the context. The set operation stashes the current body under the given key and replaces the body with that key. The get operation reads the key, retrieves the stashed body from the repository, and restores it as the body. The included route sets a placeholder payload, pins the claimKey header, then calls set to stash the body. The log step after set shows the claim key as the new body. The second claim_check step calls get with the same key, and the final log step shows the restored body.

Use the Claim Check when the payload is large and the route does not need the full body at every step. Store-and-forward flows stash the payload at the ingress. The route carries only the lightweight key through the pipeline. A later step retrieves the payload at the egress before delivery. This keeps the in-memory footprint low across long routes. The repository also supports get_and_remove, push, and pop for stack and queue access patterns.

The Claim Check differs from the Idempotent Consumer. Both patterns use a repository trait for their state. The Idempotent Consumer stores only the deduplication key. The Claim Check stores the full payload. A route that needs to deduplicate and stash uses both patterns. Claim Check alone keeps a heavy payload out of the pipeline.

Per ADR-0001, the claim check step compiles into a Service<Exchange> step in the Tower middleware pipeline. The repository trait is defined in ADR-0028. The processor contract is documented in camel-processor/CONTEXT.md.

The example source is at examples/claim-check.

Cache

The Cache pattern stores the result of an expensive computation and serves it on subsequent requests with the same key. When the key matches, the route returns the cached body and skips the computation. When the key does not match, the route runs the on_miss sub-pipeline, stores the result, and returns it.

      - cache:
          key: "${header.cacheKey}"
          ttl: "5s"
          on_miss:
            - set_body: "computed-fresh-data"
            - log: "Cache MISS — body computed and stored"
      - log: "After cache body is: ${body}"

The cache step evaluates a key expression against each exchange. If the repository holds a live entry for that key, the step replaces the body with the cached bytes and returns Completed. If the key is absent or expired, the step runs the on_miss sub-pipeline, writes the resulting body to the repository with the given ttl, and returns the body to the pipeline. A key expression that evaluates to None bypasses the cache. The route runs the on_miss sub-pipeline without a lookup or a write-back.

The cache_invalidate step removes a single key from the repository. Use it when an upstream event makes a cached entry stale. The cache_peek_stale step reads a cached entry and ignores its in-band expiry. This serves a post-expiry entry as a fallback when the source is unavailable.

Use the Cache pattern when a route computes the same result more than once. API responses, database lookups, and transform-heavy pipelines benefit from caching. Pair cache_peek_stale with a Circuit Breaker to serve stale data when the downstream service is open.

The default repository is "memory" (moka-backed, size-eviction only). A persistent "persistent" repository (redb-backed) is available when [default.cache_repo] backend = "redb" is set. The redb backend survives process restarts. Its sweep task reclaims entries whose expires_at + stale_retention has passed. The memory backend does not run a sweep. Expired entries stay in memory until size pressure evicts them.

The Cache differs from the Claim Check and the Idempotent Consumer. All three use a repository trait. The Cache stores the full computed body with a TTL. The Claim Check stores the original payload without a TTL. The Idempotent Consumer stores only the deduplication key. A route that needs all three can chain them.

Per ADR-0056, the CacheRepository trait lives in camel-api, with memory and redb backends in camel-core. The trait stores CacheEntry { bytes, content_type, expires_at } with in-band expiry. Both backends do size-eviction only. The expires_at field drives get() misses and peek_stale() reads. Per ADR-0001, each cache step compiles into a Service<Exchange> step in the Tower middleware pipeline. The processor contract is documented in camel-processor/CONTEXT.md.

The example source is at examples/cache-example.

Resilience and control

Resilience and control patterns protect a route from failure, limit throughput, and scope error handling. They map to the System Management family in Hohpe and Woolf, adapted to the Tower middleware pipeline.

  • Circuit Breaker — trip after repeated failures and recover after a cool-down
  • Do Try — scoped catch and finally blocks around a group of steps
  • Throttler — cap the rate of exchanges through a route
  • Idempotent Consumer — reject duplicates by correlation key
  • Delayer — hold an exchange for a fixed duration
  • Loop — repeat a sub-route a fixed number of times
  • Validator — check exchange content against a schema

For the error-handling contract that governs catch and finally behavior, see error handling.

Circuit breaker

The Circuit Breaker is a System Management pattern from Hohpe and Woolf. It trips after repeated failures against a downstream service, then short-circuits further calls for a cool-down so the dependency can recover.

    let main_route = RouteBuilder::from("timer:cb-test?period=1000&repeatCount=15")
        .route_id("circuit-breaker-demo")
        .process(|mut exchange| async move {
            let n = std::time::SystemTime::now()
                .duration_since(std::time::UNIX_EPOCH)
                .map(|d| d.as_secs())
                .unwrap_or(0);
            exchange.input.body = Body::Text(format!("request-{}", n));
            Ok(exchange)
        })
        .to("direct:failing-service")
        .circuit_breaker(
            CircuitBreakerConfig::new()
                .failure_threshold(3)
                .open_duration(Duration::from_secs(3)),
        )
        .to("log:cb-success?showBody=true&showCorrelationId=true")
        .error_handler(
            ErrorHandlerConfig::dead_letter_channel(
                "log:cb-fallback?showBody=true&showHeaders=true&showCorrelationId=true",
            )
            .on_exception(|_| true)
            .build(),
        )
        .build()?;
YAML equivalent
- id: circuit-breaker-demo
  from: timer:cb-test?period=1000&repeatCount=15
  circuit_breaker:
    failure_threshold: 3
    open_duration_ms: 3000
  error_handler:
    dead_letter_channel: log:cb-fallback?showBody=true&showHeaders=true&showCorrelationId=true
  steps:
    - to: direct:failing-service
    - to: log:cb-success?showBody=true&showCorrelationId=true

The breaker cycles through three states. In Closed, traffic flows. Each failed call increments a consecutive-failure counter. A successful call resets that counter to zero. When the counter reaches failure_threshold, the breaker trips into Open. In Open, the breaker rejects every call with CamelError::CircuitOpen. The route never touches the downstream service. The breaker holds Open for open_duration, then enters HalfOpen.

HalfOpen admits a single probe call. The breaker rejects concurrent callers in that window so the probe runs in isolation. A probe that succeeds closes the breaker and resets the counter. A probe that fails reopens the breaker for another full cool-down. This single-probe design stops a backlog of traffic from stampeding the service. The dependency gets one probe, not a flood, at the first sign of recovery.

Use the circuit breaker to protect a downstream service. Do not use it to repair a transient blip. Retry sends more traffic at a failing call. The circuit breaker sends none. Pair them when a flaky dependency needs a retry for transient errors but a hard stop during a sustained outage. Per ADR-0019, the breaker compiles into a gate on RouteChannelService rather than a pipeline step. Boundary rejections flow through RouteErrorHandler::handle_boundary. The route's error_handler can then route CircuitOpen to a dead-letter sink, as the example does with log:cb-fallback.

The example source is at examples/circuit-breaker.

Do Try

The Do Try is an Error Handling pattern from Hohpe and Woolf. It wraps a group of steps in a local scope with one or more catch clauses and an optional finally clause. A route can repair or clean up after a failing step without triggering the route-level error handler.

    let route1 = RouteBuilder::from("direct:catch-by-variant")
        .route_id("catch-by-variant")
        .do_try()
        .process(always_fail("boom-from-route-1"))
        .do_catch_exception(&["ProcessorError"])
        .handled()
        .process(log_marker("log:caught-by-variant"))
        .end_do_catch()
        .end_do_try()
        .build()?;
YAML equivalent
- id: catch-by-variant
  from: direct:catch-by-variant
  steps:
    - do_try:
        steps:
          - to: direct:failing-op
        catch:
          - exception:
              - ProcessorError
            steps:
              - to: log:caught-by-variant

A do_try() block opens the scope. Steps inside the try body run in sequence. When a step returns Err, the block walks its catch clauses in order and runs the body of the first match. do_catch_exception(&["ProcessorError"]) matches by CamelError variant name. do_catch_when(predicate) matches by a FilterPredicate over the exchange when the variant alone is not specific enough. The example shows both shapes. List specific clauses before broad ones. The first match wins.

do_finally() adds a body that runs exactly once whether the try body succeeded, threw, or was caught. Use it for cleanup that must always run. The example's third route pairs a .propagate() catch with a do_finally counter. The catch logs the failure and lets the error escape. The finally block still runs and bumps the counter.

Each catch clause ends with a disposition. The disposition decides what happens to the exchange after the catch body runs. Handled clears the error and stops the block. propagate() keeps the error live so it escapes after the catch body finishes. The full disposition model, with all values and their effects on the pipeline, lives in error handling. The block is a local error-handling island. A Handled catch never reaches the route's error_handler. Only unhandled errors and errors from a propagate() catch escape to the route level.

Use do_try when the repair is scoped to one step or a small group of steps. Use the route-level error_handler when every step in the route shares one retry, dead-letter, or disposition policy. The two compose. A Handled catch repairs a step locally. Unhandled failures still fall through to the route-level safety net.

Per ADR-0001, the do-try compiles into a Service<Exchange> step in the Tower middleware pipeline. The try body and each catch branch compile as child steps on the same route channel. The processor contract is documented in camel-processor/CONTEXT.md.

The example source is at examples/do-try.

Throttler

The Throttler is a System Management pattern from Hohpe and Woolf. It caps how many exchanges a route processes in a fixed time window. Downstream services never receive traffic faster than they can handle.

    let route = RouteBuilder::from("timer:tick?period=100&repeatCount=20")
        .route_id("throttler-demo")
        // Throttle: limit to 2 requests per second
        // Default strategy is Delay - queues messages until capacity available
        .throttle(2, Duration::from_secs(1))
        .to("log:throttled?showBody=true")
        .end_throttle()
        .build()?;
YAML equivalent
- id: throttler-demo
  from: timer:tick?period=100&repeatCount=20
  steps:
    - throttle:
        max_requests: 2
        period_secs: 1
        strategy: delay
        steps:
          - to: log:throttled?showBody=true

The .throttle(2, Duration::from_secs(1)) call sets the rate limit. At most two exchanges pass per second. The included route fires a timer 20 times at 100-millisecond intervals, which yields ten exchanges per second. The throttler holds the excess in an internal queue and releases those exchanges as the one-second window refills. Each released exchange flows into the .to("log:throttled?showBody=true") step inside the throttle scope. The .end_throttle() call closes the scope.

The default Delay strategy queues excess exchanges instead of dropping them. The throttler never discards an exchange on its own. This protects a downstream service from bursts without losing messages. A route that must reject instead of queue composes a filter on a backpressure signal.

Use the Throttler when a downstream service or external API imposes a rate limit. Database writers, third-party HTTP endpoints, and metered SaaS APIs reject or fail when traffic exceeds their quota. The Throttler smooths the source rate to fit that contract.

Per ADR-0001, the throttler compiles into a Service<Exchange> step in the Tower middleware pipeline. The rate-limit window lives inside the service. The processor contract is documented in camel-processor/CONTEXT.md.

The example source is at examples/throttler.

Idempotent Consumer

The Idempotent Consumer is a System Management pattern from Hohpe and Woolf. It detects duplicate exchanges by a message key and skips them. The route processes only the first delivery of each key.

      - idempotent_consumer:
          repository: dedupe
          expression: "${header.messageId}"
          steps:
            - log: "Processing unique message ${header.messageId}"
            - to: "log:processed?showBody=true&showHeaders=true"

The idempotent_consumer step computes a key from each exchange with a MessageIdExpression. In the included route, the expression reads ${header.messageId}. The step then asks its repository whether it has seen the key before. A new key runs the child steps, and the step records that key in the repository. A repeated key skips the child steps entirely. The route pins messageId to a fixed value across five timer ticks. Only the first tick runs the log and to steps. The other four are duplicates.

A duplicate does not raise an error. The segment returns Completed and the parent pipeline continues. This matters when a source retries delivery on failure. Without deduplication, each retry re-runs the child steps and produces duplicate side effects. With the Idempotent Consumer, the second and later deliveries of the same key return Completed and leave the recorded result intact.

Use the Idempotent Consumer when a route must tolerate redelivery without repeating work. Payment processing, order creation, and any at-least-once message source benefit from a deduplication gate. The example registers a memory-backed repository. A memory-backed repository loses its keys when the process exits, so choose a durable backing store when a route must survive a restart.

The Idempotent Consumer differs from the Claim Check. Both use a repository trait. The Idempotent Consumer stores only the key. The Claim Check stores the full payload. Per ADR-0025, the consumer is an outcome-aware segment. Per ADR-0001, it compiles into a Service<Exchange> step in the Tower middleware pipeline. The processor contract is documented in camel-processor/CONTEXT.md.

The example source is at examples/idempotent-consumer.

Delayer

The Delayer is a System Management pattern from Hohpe and Woolf. It holds an exchange in the pipeline for a fixed or dynamic duration. The exchange then moves to the next step.

    let fixed_route = RouteBuilder::from("timer:tick?period=2000&repeatCount=5")
        .route_id("delayer-fixed")
        .delay(Duration::from_millis(500))
        .to("log:delayed?showBody=true")
        .build()?;
YAML equivalent
- id: delayer-fixed
  from: timer:tick?period=2000&repeatCount=5
  steps:
    - delay:
        delay_ms: 500
    - to: log:delayed?showBody=true

The included route fires a timer every two seconds. The .delay(Duration::from_millis(500)) step suspends the exchange for a fixed half-second. The log:delayed step receives the exchange only after the timer elapses. In the YAML DSL the same fixed pause is the delay_ms field. Use a fixed delay to space messages apart by a known interval. Common cases are rate-limited APIs and scheduled batches.

For a per-message pause, set the dynamic_header option to a header name. Write the delay in milliseconds to that header on each exchange. The service reads the header value and clamps it to max_delay_ms (default 3,600,000 ms, one hour). A missing or non-numeric header falls back to delay_ms. The builder equivalent is delay_with_header. The example registers it on a second route that sets CamelDelayMs to 1000. A dynamic delay suits routes where the producer supplies a retry-after or backoff value.

The Delayer differs from the Throttler. The Delayer pauses every exchange by the configured amount. The Throttler paces the rate of accepted exchanges over a window. A route that needs both can place a Delayer after a Throttler.

Per ADR-0001, the delayer compiles into a Service<Exchange> step. Its call method awaits a tokio::time::sleep and returns the exchange when the timer elapses. The processor contract is documented in camel-processor/CONTEXT.md.

The example source is at examples/delayer.

Loop

The Loop is a System Management pattern from Hohpe and Woolf. It repeats a block of steps a fixed number of times for each incoming exchange. The body or state can change one iteration at a time.

    let loop_route = RouteBuilder::from("timer:tick?period=3000&repeatCount=3")
        .route_id("loop-count-demo")
        .set_body("hello")
        .loop_count(3)
        .process(|mut ex: camel_api::Exchange| async move {
            let body = ex.input.body.as_text().unwrap_or("").to_string();
            ex.input.body = camel_api::body::Body::Text(format!("{body}!"));
            Ok(ex)
        })
        .end_loop()
        .to("log:loop-result?level=info&showBody=true")
        .build()?;
YAML equivalent
- id: loop-count-demo
  from: timer:tick?period=3000&repeatCount=3
  steps:
    - set_body:
        value: hello
    - loop:
        count: 3
        steps:
          - to: log:loop-iteration
    - to: log:loop-result?level=info&showBody=true

The included route fires a timer three times. The .set_body("hello") step sets the body to a short string. The .loop_count(3) step opens the loop and .end_loop() closes it. The .process(...) step inside the loop appends one ! to the body on each pass. After three iterations the body becomes hello!!!. The log:loop-result step after the loop logs the final body. In the YAML DSL the loop count is the count field under loop. The count is fixed before the route starts.

Each iteration runs the wrapped sub-pipeline against the same exchange. A mutating step builds on the result of the previous pass. The loop acts as a fold over a fixed range. Use a loop for retry-with-backoff sequences, batch enrichment, or pagination that fetches one page per iteration. The Loop differs from the Splitter. The Loop produces a single output exchange with accumulated state. The Splitter produces many output exchanges, one per fragment.

Per ADR-0025, the loop is an outcome-aware structural EIP. A Stopped outcome from the inner sub-pipeline returns Stopped(ex) and skips the remaining iterations. The parent pipeline never sees a half-applied loop body. Per ADR-0001, the loop compiles into a Service<Exchange> step. The per-iteration sub-pipeline compiles into child steps on the same route channel. The processor contract is documented in camel-processor/CONTEXT.md.

The example source is at examples/loop.

Validator

The Validator is a Message Transformation pattern from Hohpe and Woolf. It checks an exchange body against a schema or predicate. It rejects the exchange when the check fails, so the rest of the route only sees valid input.

    let route_xsd = RouteBuilder::from("timer:xsd-valid?period=3000&repeatCount=2")
        .route_id("xsd-valid")
        .set_body("<order><id>A1</id><amount>5</amount></order>")
        .log("Route 1: Validating XML order against XSD", LogLevel::Info)
        .validate(&xsd)
        .log("Route 1: XML is valid!", LogLevel::Info)
        .to("log:info?showBody=true")
        .build()?;
YAML equivalent
- id: xsd-valid
  from: timer:xsd-valid?period=3000&repeatCount=2
  steps:
    - set_body:
        value: "<order><id>A1</id><amount>5</amount></order>"
    - validate: "${body.contains('<order>')}"
    - to: log:info?showBody=true

The included route fires a timer twice. The .set_body(...) step sets the body to an XML order. The .validate(&xsd) step loads the XSD schema from the configured path and checks the body against it. The log step after the validator runs only when the body is valid. A second route in the example sends an invalid order. It pairs the validator with an error_handler, so the validation error is logged instead of crashing the process.

On a mismatch the validator returns an error. The error flows back through the same RouteErrorHandler boundary as any other step error. A route-level error_handler or a do-try catch block can recover it. Wrap the validator in a do-try block to keep the exchange in the pipeline after a failure. Use the bare validator to fail fast on bad input. The YAML validate step also accepts a predicate expression like ${body.contains('<order>')} for inline checks without a schema file.

The Validator differs from the Filter. The Validator stops the route on a failed check and surfaces the error. The Filter silently drops the exchange. Use the Validator for input validation and contract enforcement at a trust boundary. Use the Filter when an exchange is well-formed but not relevant.

Per ADR-0001, the validator compiles into a Service<Exchange> step. The schema engine runs inside the step. The processor contract is documented in camel-processor/CONTEXT.md.

The example source is at examples/validator.

Processing steps

Route steps that are not Enterprise Integration Patterns. They handle body materialization, method invocation, and flow control. These are route-building utilities, not named patterns from Hohpe and Woolf.

  • Stream Cache — materialize a stream body into bytes so later steps can re-read it
  • Bean — call a registered Rust function as a route step
  • Stop — halt the pipeline without raising an error

For the route structure that hosts these steps, see Routes and pipelines.

Stream Cache

The Stream Cache step converts a Body::Stream into Body::Bytes up to a threshold. A stream drains on the first read. Steps that must read the body more than once cannot do so against a stream. Stream Cache reads the stream once, stores the bytes, and replaces the body. Every later step sees the same bytes.

RouteBuilder::from("timer:tick?period=1000")
    .route_id("stream-cache-demo")
    .stream_cache_default()
    .to("log:cached?showBody=true")
    .build()?;
YAML equivalent
- id: stream-cache-demo
  from: timer:tick?period=1000
  steps:
    - stream_cache: true
    - to: log:cached?showBody=true

The .stream_cache(threshold) call sets the maximum byte count the step stores in memory. Bodies smaller than the threshold become Body::Bytes. Bodies larger than the threshold stay as Body::Stream. The default threshold is 128 KB (DEFAULT_STREAM_CACHE_THRESHOLD). Call .stream_cache_default() to use this default.

In YAML, stream_cache: true enables caching with the default threshold. The form stream_cache: { threshold: N } sets a custom threshold in bytes.

Stream Cache is not an EIP. It is a pipeline utility that makes stream bodies safe for multi-read steps. Place a Stream Cache step before a Splitter that reads the body line by line. Place it before a Content Enricher that sends the original body and then inspects the response.

Per ADR-0001, the step compiles into a Service<Exchange> in the Tower pipeline. The stream body type is documented in camel-api/CONTEXT.md.

Bean

The Bean step calls a method on a registered bean. A bean is a named Rust object that lives in the bean registry. The step looks up the bean by name, calls the named method, and passes exchange data to it.

The #[bean_impl] and #[handler] macros turn a Rust impl block into a bean. Each #[handler] method becomes a callable entry point. The handler signature controls how the framework extracts data from the exchange. A parameter of type Order receives the deserialized body. The macro generates the binding code at compile time, so the binding is type-checked before the route runs.

RouteBuilder::from("timer:tick?period=1000")
    .route_id("bean-demo")
    .bean("orderProcessor", "handle")
    .to("log:processed?showBody=true")
    .build()?;
YAML equivalent
- id: bean-demo
  from: timer:tick?period=1000
  steps:
    - bean:
        name: orderProcessor
        method: handle
    - to: log:processed?showBody=true

The .bean("name", "method") call takes the bean name and method name as strings. The registry resolves the name to an instance. The method receives the exchange data and returns its result into the exchange body.

The Bean step is not an EIP. It is a Message Endpoint utility from the Hohpe and Woolf vocabulary. In Rust, the .process() closure serves the same role with a direct function reference. The Bean step exists primarily for the YAML DSL, where a closure is not available. A route that needs custom logic in YAML defines a bean, registers it, and calls it by name.

The Bean differs from Script. Script runs inline code declared in the route. Bean calls a pre-registered instance. A change to bean behavior needs a rebuild and re-registration. A change to script behavior needs only a route file edit.

Per ADR-0001, the step compiles into a Service<Exchange> in the Tower pipeline. The bean registry contract is documented in camel-bean/CONTEXT.md.

Stop

The Stop step halts the pipeline. No further steps run after a Stop. The exchange does not raise an error. The pipeline reports the outcome as PipelineOutcome::Stopped. This is a successful termination, not a failure.

RouteBuilder::from("timer:tick?period=1000")
    .route_id("stop-demo")
    .filter(|ex| ex.input.body.as_text().map(|t| t.contains("urgent")).unwrap_or(false))
    .to("log:urgent?showBody=true")
    .end_filter()
    .stop()
    .build()?;
YAML equivalent
- id: stop-demo
  from: timer:tick?period=1000
  steps:
    - filter:
        simple: "${body} contains 'urgent'"
        steps:
          - to: log:urgent?showBody=true
    - stop: true

The .stop() call adds a terminal step. When the exchange reaches this step, the pipeline returns PipelineOutcome::Stopped and the route stops processing that exchange. The exchange state is preserved as-is. The filter in the example shows the common pattern. Process exchanges that match a condition, then stop. Exchanges that fail the filter skip the filter block, reach the Stop step, and halt.

The Stop step is not an EIP. It is a flow control utility. It differs from a Message Filter. The Filter drops exchanges that fail the predicate. Stop halts every exchange that reaches it. A route that needs to stop conditionally places a filter or choice block before the Stop.

Stop is modeled as PipelineOutcome::Stopped, not as CamelError. This distinction matters for error handling. A stopped exchange does not trigger the route error handler. Place any log or retry step before the Stop step, not in an error handler.

Per ADR-0024, Stop is a CompiledStep::Stop variant. The executor converts it into PipelineOutcome::Stopped without invoking a Tower service. The outcome layer sits above Tower so the runtime can distinguish a stop from a failure.

Components

Components connect routes to external systems. Each Component owns a URI scheme and creates Endpoints that produce Consumers, Producers, or both. The vocabulary for Component, Endpoint, Consumer, and Producer lives in crates/components/CONTEXT.md.

Catalog

SchemeDirectionAuthority
timerconsumerparent
logproducercamel-log
directbothcamel-direct
sedabothcamel-component-seda
controlbusproducercamel-controlbus
mockbothparent
filebothcamel-file
http, http-staticbothcamel-http
ws, wssbothcamel-ws
grpc, grpcsbothcamel-component-grpc
cronconsumercamel-cron
kafkabothcamel-kafka
jmsbothcamel-jms
mqttbothcamel-mqtt
redis, redissbothcamel-redis
sqlbothcamel-sql
surrealdbbothcamel-component-surrealdb
opensearch, opensearchsproducercamel-opensearch
masterconsumerparent
containerbothcamel-container
llmproducercamel-component-llm
execproducercamel-component-exec
validatorproducercamel-validator
xsltproducercamel-xslt
xjproducercamel-xj
cxfbothcamel-cxf
keycloakconsumercamel-component-keycloak
wasmbothcamel-component-wasm
templateproducerparent

The table covers every crate under crates/components/. The contract crate camel-component-api defines the Component SPI and the Consumer, Producer, and Endpoint traits. It registers no URI scheme.

Direction

consumer marks an inbound Component. It starts a Consumer that submits Exchanges into the Route. producer marks an outbound Component. It creates a Producer that sends Exchanges to an external system. both means the Component supports either direction, one per Endpoint.

master wraps a delegate Consumer in a leadership gate. The bridge exposes inbound traffic only while this node holds the leadership lock (ADR-0035).

Narrative pages

  • Timer and log. The smallest working route.
  • File. Directory poller and disk writer.
  • HTTP. Server Consumer and response handling.
  • gRPC. Service consumer and producer.
  • WebSocket and SOAP. Bidirectional WebSocket traffic and SOAP calls through the Java bridge.
  • Kafka. Broker producer and consumer.
  • JMS. Java bridge consumer and producer.
  • MQTT. MQTT 3.1.1 broker producer and consumer.
  • Redis. Datastore and pub/sub.
  • Database. SQL access.
  • SurrealDB. Multi-model database.
  • OpenSearch. Search and indexing.
  • LLM. Chat completions and embeddings.
  • WASM. Sandboxed plugins with capability model.
  • Cron. Scheduled message generation.
  • Direct. Synchronous in-process routing.
  • SEDA. Asynchronous staging between routes.
  • ControlBus. Runtime control messages.
  • Master. Leader-only route execution.
  • Template. External template rendering.
  • Validator. Schema validation.
  • Exec. External process execution.
  • Keycloak. OIDC auth and JWKS validation.
  • XML transform. XSLT and JSON-XML conversion.
  • Mock. Testing assertions.

Timer and log

The timer and log components form the smallest working route. Timer is a pure source. It fires Exchanges on a schedule and reads from no external system. Log is a pure sink. It formats Exchange state and writes the result through tracing. Together they exercise the source-to-sink path with no network and no extra dependencies.

The hello-world example wires both components and produces one Exchange per tick:

#[tokio::main]
async fn main() -> Result<(), CamelError> {
    tracing_subscriber::fmt()
        .with_target(false) // Cleaner output
        .init();

    let mut ctx = CamelContext::builder().build().await.unwrap(); // allow-unwrap
    ctx.register_component(TimerComponent::new());
    ctx.register_component(LogComponent::new());

    let route = RouteBuilder::from("timer:tick?period=1000&repeatCount=5")
        .route_id("hello-world") // Named routes
        .set_header("source", Value::String("timer".into()))
        .to("log:info?showHeaders=true&showCorrelationId=true") // Correlation ID
        .build()?;

    ctx.add_route_definition(route).await?;
    ctx.start().await?;

    println!("Hello World example running. Press Ctrl+C to stop.");

    tokio::signal::ctrl_c().await.ok();
    ctx.stop().await?;
    Ok(())
}
YAML equivalent
routes:
  - id: hello-world
    from: "timer:tick?period=1000&repeatCount=5"
    steps:
      - set_header:
          key: source
          value: "timer"
      - to: "log:info?showHeaders=true&showCorrelationId=true"

Timer

timer:tick?period=1000&repeatCount=5 fires an Exchange every period milliseconds. The first tick fires immediately. The Consumer stops after repeatCount fires. Omit repeatCount to fire until the Route stops. Set repeatCount=0 and the timer fires never.

ParameterDefaultDescription
period1000Interval between ticks in milliseconds
delay0Wait before the first tick in milliseconds
repeatCountomittedTick limit. Omit for infinite. 0 fires never
fixedRatefalsetrue skips missed ticks. false fires all missed ticks at once
includeMetadatatrueAttach CamelTimer* headers to each Exchange

Timer is a consumer-only Component. Its Consumer submits one Exchange per tick. The Exchange body holds a short label. When includeMetadata=true, the Consumer sets four Exchange headers. They carry the timer name, the tick counter, the ISO-8601 fire time, and the epoch timestamp. Timer does not poll a directory and does not implement PollingConsumer. It is event-driven and runs inside the Route lifecycle.

Log

log:info?showHeaders=true&showCorrelationId=true writes the Exchange to the configured tracing level. The query parameters select what the Producer prints. showHeaders=true adds the Exchange header map. showCorrelationId=true prefixes the line with the correlation id, so several Routes can share one log output.

Log is a producer-only Component. Its Producer formats the Exchange body, headers, and correlation id, then returns the Exchange unchanged. The Producer composes with every other pipeline step (ADR-0001). Calling log: from a from: position returns an error at Endpoint creation.

The Producer exposes five levels: trace, debug, info, warn, error. Each level selects a tracing macro. Routes that handle sensitive data should set logMask=true. The option replaces the body and matching header values with a redaction marker.

Putting it together

The example registers both components before the Route starts. The Route flows from: timer:tick to to: log:info. Each Exchange passes through one Timer Consumer, one .set_header step, and one Log Producer. The result is one log line per tick for five ticks.

This pair is the recommended starting template. Once a route reads a header, transforms the body, or routes to more than one sink, replace log with a real sink and timer with a real source. The registration shape and the Endpoint URIs stay the same.

The timer URI grammar lives in the parent Components authority. The log contract surface lives in the camel-log CONTEXT. The example source is at examples/hello-world.

File

The file component is a directory poller for source routes and a disk writer for sink routes. The same crate covers both directions. The Consumer watches a directory for new or changed files. The Producer writes the Exchange body to disk under a chosen file name.

The file-pipeline example shows both directions with an upper-case transform and a dead-letter channel:

    let route = RouteBuilder::from(&format!(
        "file:{}?delete=true&initialDelay=0&delay=500&readTimeout=5000",
        input_path
    ))
    .route_id("file-pipeline-demo")
    .process(|mut exchange: camel_api::Exchange| {
        Box::pin(async move {
            if let Body::Text(text) = &exchange.input.body {
                let original_len = text.len();
                exchange.input.body = Body::Text(text.to_uppercase());
                exchange
                    .input
                    .set_header("original-length", Value::Number(original_len.into()));
            }
            Ok(exchange)
        })
    })
    .to(format!(
        "file:{}?fileExist=Override&writeTimeout=5000",
        output_path
    ))
    .to("log:pipeline?showHeaders=true&showBody=true&showCorrelationId=true")
    .error_handler(ErrorHandlerConfig::dead_letter_channel(
        "log:dead-letter?showBody=true&showHeaders=true&showCorrelationId=true",
    ))
    .build()?;
YAML equivalent
routes:
  - id: file-pipeline-demo
    from: "file:/tmp/rust-camel-pipeline/input?delete=true&initialDelay=0&delay=500&readTimeout=5000"
    error_handler:
      dead_letter_channel: "log:dead-letter?showBody=true&showHeaders=true&showCorrelationId=true"
    steps:
      - bean:
          name: uppercase-transform
          method: apply
      - to: "file:/tmp/rust-camel-pipeline/output?fileExist=Override&writeTimeout=5000"
      - to: "log:pipeline?showHeaders=true&showBody=true&showCorrelationId=true"

The .process() closure has no YAML step. Register the upper-case transform as a bean and call it with a bean: step. The Rust example writes to temp directories. Substitute real paths in the from and to URIs.

Source

file:{input}?delete=true&initialDelay=0&delay=500&readTimeout=5000 polls the input directory every delay milliseconds after an initialDelay wait. The Consumer submits one Exchange per detected file. The file body becomes the Exchange body. readTimeout=5000 bounds how long a poll waits for new content before it yields an empty result.

delete=true removes each file after a successful read. The deletion happens after the Route processes the Exchange. A route failure leaves the file in place, so the next poll retries it. Omit delete=true to keep every file after consumption. This fits audit pipelines and idempotent replay.

The Consumer is event-driven. It polls the directory on a schedule and pushes each file as an Exchange. It does not implement the on-demand PollingConsumer SPI. The consumer task starts with the Route and runs until the Route stops.

Sink

file:{output}?fileExist=Override&writeTimeout=5000 writes the Exchange body to the output directory. The fileExist parameter controls what happens when the target file already exists:

ValueBehavior
OverrideReplace the target file. Default
AppendAdd the body to the end of the target file
FailReject the write if the target exists
IgnoreSkip the write if the target exists
TryRenameWrite through a temp file, then rename. Requires tempPrefix

The Override and TryRename strategies route through one private atomic-write helper. The helper writes a temp file, then renames it over the target. The Fail strategy uses the OS-level atomic create_new(true) directly. The TryRename strategy requires an explicit tempPrefix. The Producer rejects path-traversal segments in fileName before any file operation. It also rejects a cross-filesystem rename (EXDEV) rather than falling back to a non-atomic copy.

fileName resolves from the CamelFileName Exchange header. A route can set this header in a process step to control the output name. The Producer creates missing parent directories on the path. Validation runs at Endpoint creation and at the Producer boundary. It fails closed (ADR-0033).

Set durable=true for crash safety. The Producer then fsyncs the temp file, performs the rename, then fsyncs the parent directory, in that order. This is crash-safe but slower. Leave durable=false for speed when crash safety is not required.

Pipeline shape

The example chains the source to a process step, then a sink, then log, then a dead-letter error handler. The from: Endpoint and the to: Endpoint share one CamelContext and resolve at Route start.

Use the file component when a directory on disk is the source or the sink. Use a different source when the data lives on a remote system that exposes a pull interface, such as SFTP. The file component polls a local path only. Use a different sink when the destination must be transactional, replicated, or ordered. File writes succeed per file with no cross-file consistency.

The atomic-write contract and the accepted fileExist values live in the camel-file CONTEXT. The example source is at examples/file-pipeline.

HTTP

The HTTP component covers both directions in one crate. The server side binds a TCP listener and consumes inbound requests. The client side produces outbound requests and forwards the response into the Route. The same http: scheme serves both. The direction follows the Endpoint position: from: for a server, to: for a client.

The http-server example shows the server direction with a process step that returns a JSON body:

    RouteBuilder::from("http://0.0.0.0:8080/health")
        .route_id("health-check")
        .to("log:health?showHeaders=true&showBody=true&showCorrelationId=true")
        .process(move |mut exchange| {
            let storage = Arc::clone(&storage);
            let rc = Arc::clone(&request_count);

            async move {
                let uptime = std::time::SystemTime::now()
                    .duration_since(std::time::UNIX_EPOCH)
                    .map(|d| d.as_secs())
                    .unwrap_or(0);

                exchange.input.body = Body::Json(serde_json::json!({
                    "status": "UP",
                    "service": "rust-camel-api",
                    "version": "1.0.0",
                    "uptime_seconds": uptime,
                    "requests_processed": rc.load(Ordering::Relaxed),
                    "users_count": storage.count(),
                    "timestamp": current_timestamp(),
                    "correlation_id": exchange.correlation_id,
                }));
                Ok(exchange)
            }
        })
        .build()
}
YAML equivalent
routes:
  - id: health-check
    from: "http://0.0.0.0:8080/health"
    steps:
      - to: "log:health?showHeaders=true&showBody=true&showCorrelationId=true"
      - bean:
          name: health-handler
          method: build_status

The .process() closure reads runtime state. It reads the system clock, a request counter, and an in-memory store. Register that logic as a bean and call it with a bean: step.

The http-client example shows the client direction. A timer drives a GET request to a remote service, and a log step records the response:

    let route = RouteBuilder::from("timer:http-poll?period=5000&repeatCount=3")
        .route_id("http-client")
        .process(|mut exchange| {
            Box::pin(async move {
                exchange.input.body = Body::Empty;
                exchange
                    .input
                    .set_header("X-Request-Id", Value::String(uuid_header()));
                Ok(exchange)
            })
        })
        .to("https://httpbin.org/get?source=rust-camel&allowInternal=false")
        .process(|exchange| {
            Box::pin(async move {
                let body_str = match &exchange.input.body {
                    Body::Text(s) => Some(s.as_str()),
                    Body::Bytes(b) => std::str::from_utf8(b).ok(),
                    _ => None,
                };
                if let Some(text) = body_str
                    && let Ok(json) = serde_json::from_str::<serde_json::Value>(text)
                {
                    println!(
                        "Response status: {:?}",
                        exchange.input.header("CamelHttpResponseCode")
                    );
                    if let Some(url) = json.get("url") {
                        println!("Response from: {}", url);
                    }
                }
                Ok(exchange)
            })
        })
        .to("log:http-response?showHeaders=true&showBody=true&showCorrelationId=true")
        .error_handler(
            ErrorHandlerConfig::dead_letter_channel("log:http-dlc?showBody=true")
                .on_exception(|_| true)
                .retry(2)
                .build(),
        )
        .build()?;
YAML equivalent
routes:
  - id: http-client
    from: "timer:http-poll?period=5000&repeatCount=3"
    error_handler:
      dead_letter_channel: "log:http-dlc?showBody=true"
      retry:
        max_attempts: 2
    steps:
      - bean:
          name: request-id-setter
          method: apply
      - to: "https://httpbin.org/get?source=rust-camel&allowInternal=false"
      - bean:
          name: response-printer
          method: apply
      - to: "log:http-response?showHeaders=true&showBody=true&showCorrelationId=true"

Both .process() closures have no YAML step. The first sets a UUID request header. The second parses and prints the response. Register each as a bean and call them with bean: steps. The Rust on_exception(|_| true) matches every exception. The YAML retry.max_attempts mirrors the Rust .retry(2).

Server

http://0.0.0.0:8080/health is a Consumer Endpoint. The Runtime binds a TCP listener on the address and port from the URI, then dispatches each inbound request to the Route. The Consumer submits one Exchange per request. The Exchange body carries the request payload. The Exchange headers carry the request headers, the path, the query string, and the request metadata.

The example binds 0.0.0.0 to listen on every interface. Use 127.0.0.1 when the server must stay on the loopback. The component rejects a partial server TLS configuration that supplies only a certificate or only a key. Operators who terminate TLS at a separate proxy need no server TLS in the URI.

The Consumer bounds resource use. It enforces a 2 MiB default request-body limit, a read timeout, and an in-flight request semaphore. The reply finaliser maps the Exchange body shape to a response. A Body::Json value serializes to JSON. A Body::Stream value streams the response. The CamelHttpResponseCode header sets the HTTP status. A missing header produces 200 OK.

Client

to: http://example.org/api/data is a Producer Endpoint. The Producer builds an outbound request, sends it through the configured transport, and returns the response as the new Exchange body. The Producer follows redirects, applies the configured NetworkRetryPolicy, and surfaces transport failures as CamelError.

The component validates each outbound URL and each redirect hop. allow_internal=false rejects internal addresses by default. The Producer pins DNS resolution to validated addresses to prevent DNS rebinding. Cross-origin redirects drop the Authorization and Cookie headers. The Producer has a 10 MiB default response-body limit.

TlsConfig verifies peer certificates by default. An operator can opt out with insecure=true or verify_peer=false. The opt-out emits a warning. The component forbids cleartext HTTP to public addresses even with allow_internal=true.

Direction choice

A route that serves an API uses HTTP as a source. A route that calls another service uses HTTP as a sink. A route that does both needs two Endpoints, one in each position. The second Endpoint belongs to a second Route. The component supports both directions because the same crate owns the connection plumbing. Each Endpoint still picks one direction at creation time.

The inbound and outbound contract surface lives in the camel-http CONTEXT. The diagnostic endpoints (/healthz, /readyz, /metrics) live in the Operations authority. The example sources are at examples/http-server and examples/http-client.

gRPC

The gRPC component produces and consumes gRPC with runtime proto resolution. No compile-time code generation is required. The component resolves .proto files at runtime through camel-proto-compiler and prost-reflect. It supports unary, server-streaming, client-streaming, and bidirectional streaming. The mode is auto-detected from the proto method descriptor.

The grpc-example wires a consumer on port 50051 and a timer-driven producer:

    let consumer_route = RouteBuilder::from(&format!(
        "grpc://0.0.0.0:50051/helloworld.Greeter/SayHello?protoFile={}",
        proto_path
    ))
    .set_body(Body::Json(
        serde_json::json!({"message": "Hello from consumer!"}),
    ))
    .to("log:grpc-consumer?showBody=true")
    .build()?;
YAML equivalent
routes:
  - id: grpc-consumer
    from: "grpc://0.0.0.0:50051/helloworld.Greeter/SayHello?protoFile=helloworld.proto&transport=plaintext"
    steps:
      - set_body:
          value:
            message: Hello from consumer!
      - to: "log:grpc-consumer?showBody=true"

The Rust example builds protoFile from CARGO_MANIFEST_DIR. Substitute the real path to your .proto file. The transport=plaintext parameter is required (ADR-0033).

    let producer_route = RouteBuilder::from("timer:grpc-tick?period=3000&repeatCount=3")
        .set_body(Body::Json(serde_json::json!({"name": "World"})))
        .to(format!(
            "grpc://127.0.0.1:50051/helloworld.Greeter/SayHello?protoFile={}",
            proto_path
        ))
        .to("log:grpc-response?showBody=true")
        .build()?;
YAML equivalent
routes:
  - id: grpc-producer
    from: "timer:grpc-tick?period=3000&repeatCount=3"
    steps:
      - set_body:
          value:
            name: World
      - to: "grpc://127.0.0.1:50051/helloworld.Greeter/SayHello?protoFile=helloworld.proto&transport=plaintext"
      - to: "log:grpc-response?showBody=true"

The Rust example builds protoFile from CARGO_MANIFEST_DIR. Substitute the real path to your .proto file.

URI

grpc://<host>:<port>/<package>.<Service>/<Method>?protoFile=<path>&transport=<mode>
ParameterRequiredDefaultDescription
protoFileyesPath to the .proto file for runtime descriptor resolution
transportyesplaintext or tls (ADR-0033)
serverCertPathconsumer (tls)Path to the server TLS certificate
serverKeyPathconsumer (tls)Path to the server TLS key
clientCaPathconsumer (mtls)Path to the client CA certificate for mTLS
clientCertPathproducer (mtls)Path to the client TLS certificate
clientKeyPathproducer (mtls)Path to the client TLS key

Consumer

grpc://0.0.0.0:50051/helloworld.Greeter/SayHello?protoFile=helloworld.proto&transport=plaintext binds an HTTP/2 listener. The Consumer dispatches each inbound gRPC request to the Route. The Exchange body carries the decoded protobuf message as JSON. The Exchange headers carry gRPC metadata.

Multiple GrpcConsumers on the same (host, port) share one HTTP/2 server. Each consumer registers dispatch by URI path. The shared-server registry refuses to mix TLS and plaintext on one listener.

The Consumer supports four RPC modes. It auto-detects the mode from the proto method descriptor. The same Consumer handles unary, server-streaming, client-streaming, and bidirectional calls without configuration changes.

Producer

grpc://127.0.0.1:50051/helloworld.Greeter/SayHello?protoFile=helloworld.proto&transport=plaintext sends the Exchange body as a gRPC call. The Producer holds a lazy pool of connections. It reports endpoint health through RuntimeObservability.

The Producer requires transport=plaintext or transport=tls in the URI (ADR-0033). The legacy tls=true and tls=false keys are rejected. Under tls, the endpoint URL is rewritten to https://. The insecure_skip_verify=true option hard-errors. The component fails closed on an incomplete mTLS identity.

Security

The component enforces two security layers (ADR-0010, ADR-0032):

  1. Authentication. The server handler parses Authorization: Bearer <token> and calls TokenAuthenticator::authenticate_bearer. Missing or invalid credentials return Status::unauthenticated.
  2. Authorization. The Consumer calls SecurityPolicy::evaluate before pipeline dispatch in all four RPC modes. Denied returns Status::permission_denied. Unknown future decisions fail closed.

Transport setup also fails closed (ADR-0033). Every Endpoint declares transport=plaintext or transport=tls. The component rejects insecure_skip_verify=true, an incomplete mTLS identity, and a TLS/plaintext mismatch on a shared listener.

Streaming

Routes that send streaming responses use GrpcStreamObserver. The observer exposes three methods: on_next, on_error, and on_completed. The route calls these methods to push response messages onto the gRPC stream.

Reference: gRPC crate CONTEXT. Example source: examples/grpc-example.

WebSocket and SOAP (CXF)

The WebSocket component (ws, wss) serves and connects to real-time bidirectional endpoints. The CXF component (cxf) calls and hosts SOAP services through a supervised Java bridge. Both speak plain URI syntax. No code generation. No SOAP engine in the Rust data plane.

The ws-server example wires a chat broadcast and a per-connection echo against the same port:

use camel_api::{CamelError, Value};
use camel_builder::{RouteBuilder, StepAccumulator};
use camel_component_ws::WsComponent;
use camel_core::context::CamelContext;

#[tokio::main]
async fn main() -> Result<(), CamelError> {
    let mut ctx = CamelContext::builder().build().await.unwrap();
    ctx.register_component(WsComponent::new());

    // Echo: CamelWsConnectionKey arrives on the inbound message; the producer
    // targets the same key on the same path, so the reply reaches the sender.
    let echo_route = RouteBuilder::from("ws://0.0.0.0:9000/echo")
        .route_id("ws-echo")
        .to("ws://0.0.0.0:9000/echo")
        .build()?;

    // Chat: CamelWsSendToAll=true tells the producer to fan out to every
    // local client connected to /chat.
    let chat_route = RouteBuilder::from("ws://0.0.0.0:9000/chat")
        .route_id("ws-chat")
        .set_header("CamelWsSendToAll", Value::Bool(true))
        .to("ws://0.0.0.0:9000/chat")
        .build()?;

    ctx.add_route_definition(echo_route).await?;
    ctx.add_route_definition(chat_route).await?;
    ctx.start().await?;
    Ok(())
}
YAML equivalent
routes:
  - id: ws-echo
    from: "ws://0.0.0.0:9000/echo"
    steps:
      - to: "ws://0.0.0.0:9000/echo"
  - id: ws-chat
    from: "ws://0.0.0.0:9000/chat"
    steps:
      - set_header:
          name: "CamelWsSendToAll"
          value: "true"
      - to: "ws://0.0.0.0:9000/chat"

The echo route replies to the sender. The chat route broadcasts to every local client on /chat. The two routes share one port because ServerRegistry keys servers by port and paths register independently.

WebSocket URI

ws://host:port/path[?options]
wss://host:port/path[?options]
ParameterDefaultDescription
maxConnections100Maximum concurrent connections for the path
maxMessageSize65536Inbound message size limit in bytes
heartbeatIntervalMs0Ping interval in ms; 0 disables heartbeat
idleTimeoutMs0Idle close timeout in ms; 0 disables it
connectTimeoutMs10000Client connect timeout in ms
responseTimeoutMs30000Client response timeout in ms
allowOrigin*Allowed Origin header for upgrade requests
tlsCertrequired for wssPath to TLS certificate
tlsKeyrequired for wssPath 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:

HeaderValue
CamelWsConnectionKeyUUID that identifies the connection for targeted replies
CamelWsPathURL path that received the frame
CamelWsRemoteAddressPeer socket address as a string

A ServerRegistry keys servers by port. The first Consumer to register on a port fixes the host and TLS mode. A later registration with the other TLS mode fails (Server on port N already running with different TLS mode). Multiple Consumers on the same port share one server and register independent paths. Consumer shutdown removes the path, the security policy, and the connection registry. The server stays alive until the process exits.

The upgrade handler (dispatch_handler) checks Origin against allowOrigin before any auth step. When a SecurityContext is attached, the handler fails closed on missing credentials, denied policy decisions, and future AuthorizationDecision variants. Query-token values are redacted before logging (ADR-0051). TLS certificate and key paths in WsEndpointConfig::fmt are redacted as well. The crate applies a stricter diagnostic policy than ADR-0051 because the cert and key paths sit on the same boundary.

WebSocket producer

The Producer has two modes. The mode is selected by the inbound Exchange and the URI:

  • Outbound client mode when no local Consumer matches the URI. The Producer opens a Tokio-tungstenite connection, sends the body, and reads the first reply.
  • Server-send mode when a local Consumer matches the URI. The Producer never opens a new socket. It writes to the local connection registry.

The mode picker inspects three signals: the CamelWsSendToAll header, the CamelWsConnectionKey header, and the presence of a local Consumer on (host, port, path). Any one of them selects server-send mode. The producer then targets the keys in CamelWsConnectionKey (comma-separated) or, with CamelWsSendToAll=true, every active connection on the path.

The body becomes a Text frame by default. Set CamelWsMessageType to binary for a Binary frame. The URI parameter binaryPayload=true makes binary the default for that endpoint.

The client mode reconnects on transient failures with a shared NetworkRetryPolicy. The producer also surfaces backpressure: when a server-send channel is full, it sets CamelWsDeliveryDropped and returns Err on the next poll_ready call.

WebSocket security

Auth rides on the per-route SecurityContext (see Authentication and authorization). When a path has a context, the upgrade handler authenticates the bearer token and evaluates the route's SecurityPolicy before completing the upgrade. Failed auth returns 401. Failed policy returns 403. Policy evaluation errors return 500 and increment the e:ws:policy-eval metric (ADR-0012 class e). The health pin becomes the operator signal for bind failures (ADR-0012 class g).

CXF URI

cxf://http://host:port/path?wsdl=file.wsdl&service={ns}Name&port={ns}Port[&operation=opName][&profile=profile_name][&timeout_ms=N][&mtom_enabled=true|false]
ParameterRequiredDescription
wsdlyesPath to the WSDL file
serviceyesService name in {namespace}Name form
portyesPort name in {namespace}Name form
operationnoSOAP operation; falls back to the CamelCxfOperation header
profileyes (resolver)Profile name from Camel.toml
timeout_msnoPer-request timeout in ms
mtom_enablednotrue sets multipart/related and SOAPAction headers; MTOM encoding is partial ([CXF-014])
address (path)yesProducer: 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:

HeaderValue
CxfRequestIdBridge-generated correlation ID
CxfOperationOperation name from the SOAP body
CxfSoapActionValue of the SOAPAction header
CxfSecurityProfileProfile 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>]
ParameterRequiredDefaultDescription
brokersyeslocalhost:9092Comma-separated host:port broker addresses
groupIdconsumercamelConsumer group ID for coordinated consumption
autoOffsetResetconsumerlatestOffset reset policy: earliest, latest, or none
partitionAssignmentStrategyconsumerrangerange, roundRobin, or cooperativeSticky
acksproducerallDurability level: all, 1, or 0
securityProtocolnoPLAINTEXTPLAINTEXT, 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 ssl or ssl-vendored cargo feature.
  • SASL_PLAINTEXT. SASL authentication without encryption. Gated behind the sasl feature.
  • SASL_SSL. SASL authentication with TLS. Requires both the sasl and ssl features.

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:

SchemeShorthandLocks broker type
jmsrejected (ambiguous)no
activemqactivemq:orders → queueyes
artemisartemis:orders → queueyes

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
ParameterDefaultDescription
brokerconfigured default_brokerNamed broker from the [components.jms.brokers] table
acknowledgementModeAutoAuto, Client, DupsOk, or Transacted
messageSelectorSQL-92 selector expression for filtering inbound messages
concurrentConsumers1Number of parallel consumer tasks for this endpoint
transactionModeNoneNone or Session (Session is not yet implemented)
timeToLiveMessage time-to-live in milliseconds
priorityMessage priority 0-9 (9 is highest)
persistentDeliverytruePERSISTENT or NON_PERSISTENT delivery mode
mapJmsHeaderstrueMap JMS headers and properties to Exchange headers
exchangePatternInOnlyInOnly or InOut (InOut is not yet implemented)

Credentials and the broker URL are not URI parameters. They live in the [components.jms.brokers.<name>] table.

Broker configuration

Brokers are declared in Camel.toml. The component creates one Java bridge process per broker. The bridge pool admits at most max_bridges (default 8) bridges concurrently.

[default.components.jms]
default_broker = "main"

[default.components.jms.brokers.main]
broker_url  = "tcp://localhost:61616"
broker_type = "activemq"   # "activemq" | "artemis"
username    = "admin"      # optional
password    = "admin"      # optional

The bridge binary downloads on first use. It comes from a configured release URL and is cached at ~/.cache/rust-camel/jms-bridge/. The download is SHA256-verified. Set CAMEL_JMS_BRIDGE_BINARY_PATH to point at a local build for development.

Consumer

activemq:queue:orders subscribes to a destination. The Consumer submits one Exchange per inbound JMS message. The Exchange body carries the message payload. With mapJmsHeaders=true (the default), the headers carry JMSMessageID, JMSCorrelationID, JMSTimestamp, JMSDestination, and JMSPriority.

The body is typed from the JMS content_type. text/* becomes Body::Text. application/json becomes Body::Json when valid JSON. Binary content becomes Body::Bytes. The consumer pre-flights the bridge slot before starting. A missing or degraded bridge fails fast with JMS bridge not available.

The concurrentConsumers parameter spawns N parallel consumer tasks on the same destination. Each task subscribes independently and submits Exchanges into the shared route pipeline. messageSelector filters messages at the broker with a SQL-92 expression.

Producer

activemq:queue:orders sends the Exchange body to a destination. The content type is inferred from the body. Body::Text sends text/plain. Body::Json sends application/json. Body::Xml sends text/xml. An explicit Content-Type header wins over inference.

The Producer uses a semaphore for backpressure. The default concurrency limit is 128 in-flight sends. When the limit is reached, poll_ready returns Pending. ADR-0024 classifies a closed semaphore as ConsumerStopping.

A successful send returns the Exchange unchanged. The JMSMessageID header carries the broker-assigned message ID. A send failure on a gRPC transport error refreshes the channel. The original send is not retried. A retry on a non-idempotent write would cause duplicates. The caller decides whether to retry.

Java bridge

The component does not speak JMS. A native Java bridge process handles the JMS protocol and the TCP connection. The component talks to the bridge over gRPC. The bridge binary is a jlink image. The host does not need a Java runtime.

The bridge pool assigns one bridge per broker. The first send or consume starts the bridge. The bridge's ephemeral gRPC port comes from its stdout. A health monitor pings the bridge every healthCheckIntervalMs (default 5s). A failed health check moves the slot to Degraded, then Restarting. Restarts use exponential backoff capped at 120 seconds. After 10 failed restart attempts the slot stays Degraded.

The Consumer observes bridge state through a watch channel. A pending bridge returns Pending from poll_ready. A degraded bridge returns Err with the reason. The producer waits for the bridge to become Ready before sending.

Trust boundary

Per ADR-0032, incoming JMS headers, bodies, correlation IDs, and destinations enter exchange.input without validation. The route is responsible for validation when data crosses into a control action, a resource decision, or an executable sink.

Credentials cross the gRPC boundary in plain text for the username and through a Redacted wrapper for the password. BrokerConfig redacts the password in Debug output. BridgeSlot omits its credentials field. redact_url strips user information from URLs before logging. The audit command rg '#\[derive.*Debug' crates/components/camel-jms/src/ checks that no type holding a password derives Debug.

Error handling

ADR-0007 governs Consumer shutdown. JmsConsumer::stop cancels the CancellationToken and waits up to 5 seconds for the consumer tasks to finish. Tasks that do not exit in that window are aborted. An in-flight ConsumerContext::send completes before the loop checks cancellation. The Consumer does not restart itself. Route supervision owns restart.

ADR-0012 classifies log sites as outside-contract or system-broken. A consumer-side ctx.send failure increments the b-prime:jms:consumer-send metric and logs at error!. A bridge restart that exhausts the attempt cap logs at error! and leaves the slot in Degraded.

Limitations

  • The bridge uses AUTO_ACKNOWLEDGE. Messages are acknowledged on delivery, not after processing. A failed route cannot request redelivery.
  • Durable topic subscribers are not supported.
  • IBM MQ is not supported.
  • The InOut exchange pattern and Session transaction 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.

ParameterDefaultDescription
qos1Quality of Service: 0 (AtMostOnce), 1 (AtLeastOnce), or 2 (ExactlyOnce)
ackModeautoauto acks on delivery. manual acks after the pipeline succeeds
cleanSessiontrueMust be false when ackMode=manual and QoS 1 or 2
retainfalseRetain published messages on the broker
keepAliveSecs60MQTT keep-alive interval in seconds
maxPayloadBytes262144Incoming payload limit (256 KB)
clientIdautoOverride the auto-generated client ID
topicspathComma-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>]
ParameterRequiredDefaultDescription
commandnoSETRedis command to execute
keyper-commandnoneRedis key for the operation
channelsPub/SubemptyComma-separated channel names
timeoutblocking1Blocking timeout in seconds
passwordnononeRedis password
dbno0Redis database number (0-255)
sslnoautoForce 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.

GroupCommands
StringSET, GET, GETSET, SETNX, SETEX, MGET, MSET, INCR, INCRBY, DECR, DECRBY, APPEND, STRLEN
KeyEXISTS, DEL, EXPIRE, EXPIREAT, PEXPIRE, PEXPIREAT, TTL, KEYS, RENAME, RENAMENX, TYPE, PERSIST, MOVE, SORT
ListLPUSH, RPUSH, LPUSHX, RPUSHX, LPOP, RPOP, BLPOP, BRPOP, LLEN, LRANGE, LINDEX, LINSERT, LSET, LREM, LTRIM, RPOPLPUSH
HashHSET, HGET, HSETNX, HMSET, HMGET, HDEL, HEXISTS, HLEN, HKEYS, HVALS, HGETALL, HINCRBY
SetSADD, SREM, SMEMBERS, SCARD, SISMEMBER, SPOP, SMOVE, SINTER, SUNION, SDIFF, SINTERSTORE, SUNIONSTORE, SDIFFSTORE, SRANDMEMBER
Sorted setZADD, ZREM, ZRANGE, ZREVRANGE, ZRANK, ZREVRANK, ZSCORE, ZCARD, ZINCRBY, ZCOUNT, ZRANGEBYSCORE, ZREVRANGEBYSCORE, ZREMRANGEBYRANK, ZREMRANGEBYSCORE, ZUNIONSTORE, ZINTERSTORE
Pub/SubPUBLISH, SUBSCRIBE, PSUBSCRIBE
OtherPING, ECHO

Producer

redis://host:port?command=GET sends the Exchange body to Redis. The Producer holds a single multiplexed connection per Endpoint. The connection opens lazily on the first call and stays open for the route lifetime.

The command parameter picks one of the 80+ commands listed above. The Exchange body, headers, and the URI parameters supply the command arguments. Different commands read different headers. For example, HSET reads CamelRedis.Key and CamelRedis.Value. LRANGE reads CamelRedis.Start and CamelRedis.End. Missing required headers return CamelError. A send failure returns Err to the route ErrorHandler.

The Producer is a Tower Service<Exchange>. It composes with any pipeline step and reports per-route metrics.

Consumer

redis://host:port?command=SUBSCRIBE&channels=foo,bar subscribes to one or more Pub/Sub channels. The Consumer submits one Exchange per published message. The CamelRedis.Channel header carries the channel name. CamelRedis.Pattern carries the matched pattern for PSUBSCRIBE.

redis://host:port?command=BLPOP&key=jobs&timeout=5 blocks on a list key and submits one Exchange per popped item. The CamelRedis.Key header carries the list key. The timeout parameter is the block duration in seconds. Use BLPOP for left pop and BRPOP for right pop.

The Consumer's mode comes from the URI command. SUBSCRIBE and PSUBSCRIBE use Pub/Sub mode. BLPOP and BRPOP use queue mode. A command that fits neither returns an error at consumer creation. The component does not silently fall back to BLPOP (REDIS-003).

Security

The component supports TLS through the rediss:// URI scheme or the ssl=true parameter. The two are equivalent. TLS auto-enables for non-loopback hosts when the redis crate is compiled with a TLS feature (tls-rustls-webpki-roots, tls-rustls-native-certs, or tls-native-tls). The component logs a tracing::warn! when auto-enabling TLS. A missing feature gate stops startup with the required cargo add command.

The component redacts passwords in Debug output. Passwords with special characters (@, :, /) are percent-encoded in the connection URL. The safe_endpoint() helper returns a credential-free identifier for tracing.

Connection handling

The Producer holds a single multiplexed connection per Endpoint. The Consumer holds one connection for Pub/Sub mode and one for queue mode. Each connection has a 10-second timeout by default. Transient transport errors trigger reconnect with the configured NetworkRetryPolicy. The route ErrorHandler owns the operational signal for non-transient errors.

The component registers an async health check that sends a PING command. The probe is healthy when Redis responds with PONG and degraded when PING fails or times out.

Error handling

The Consumer logs at error! for channel-closed conditions on Pub/Sub and BLPOP send paths and for retry-exhaustion. Each site reports a typed metric before the log line. The Producer logs send failures at warn!. Per-message non-transient Redis errors log at error! with a typed metric. The route handler owns the operational signal for transient producer errors.

Reference: Redis crate CONTEXT. Example source: examples/redis-example.

Database

The database components connect routes to SQL and SurrealDB. Two crates cover the two database families. The SQL component uses sqlx for PostgreSQL, MySQL, and SQLite. The SurrealDB component uses the SurrealDB SDK for document, graph, vector, and live-query operations.

SQL

The SQL component executes queries against relational databases through sqlx. It covers both directions. A Consumer polls query results. A Producer executes statements.

URI

sql:<query>[?outputType=<mode>&dataSource=<name>&allowDynamicQuery=<bool>&bridgeErrorHandler=<bool>]
ParameterRequiredDefaultDescription
queryyesSQL query or statement. Can also come from a file
outputTypenoSelectListSelectList (JSON array) or StreamList (NDJSON stream)
dataSourcenodefaultNamed datasource from Camel.toml
allowDynamicQuerynofalseAllow query from Exchange header or body (ADR-0032)
bridgeErrorHandlernofalseRoute 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 uses ExchangeLookupPath.
  • PostgreSQL ::cast. :#id::text resolves the placeholder and leaves ::text as 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:

OperationDescription
queryExecute raw SurrealQL
selectSelect records by ID or table
createCreate a new record
updateUpdate an existing record
upsertInsert or update a record
deleteDelete a record
patchApply a JSON Patch to a record
relateCreate a graph edge between records
vectorVector search operation
searchFull-text search
runRun a SurrealDB function
liveSubscribe to live query notifications

Consumer

surrealdb:live?dataSource=my-db subscribes to live query notifications. The Consumer submits one Exchange per live event. Live queries require a WebSocket connection (ws or wss). HTTP connections are rejected for live operations.

Producer

surrealdb:query?dataSource=my-db executes SurrealQL. The query comes from the CamelSurrealDbQuery header, the Exchange body, or the Endpoint configuration. Results materialize as Body::Json. The output=stream option is rejected.

Security

Credentials belong in the named datasource, not in the Endpoint URI. The redact_db_url function removes user information before a URL enters a log or error message. Identifier validation rejects whitespace, quotes, semicolons, and backslashes in interpolated table, edge, and vector-field names.

The raw-query trust boundary is an open hardening gap. The query operation accepts SurrealQL from the CamelSurrealDbQuery header or the body before it falls back to Endpoint configuration. Route authors must filter these sources before the Producer. The SurrealDB crate CONTEXT documents the gap.

Datasource configuration

Both components use named datasources from Camel.toml:

[components.datasources.my-db]
db_url = "postgres://user:pass@localhost/mydb"

The SQL component also supports inline connection parameters in the URI. The SurrealDB component requires credentials in the named datasource.

Reference: SQL crate CONTEXT, SurrealDB crate CONTEXT.

SurrealDB

The SurrealDB component connects routes to a SurrealDB multi-model database. One scheme covers twelve operations: document CRUD, graph edges, vector search, function calls, and live-query change data capture. Both directions share the same surrealdb: scheme. The Consumer is push-based and limited to live; the Producer handles the other eleven operations.

A producer route writes a user and selects the table:

use camel_builder::RouteBuilder;

let create = RouteBuilder::from("timer:tick?period=2000&repeatCount=1")
    .set_body(serde_json::json!({"name": "Alice", "age": 30}))
    .to("surrealdb:create?datasource=demo&table=users")
    .build()?;

let list = RouteBuilder::from("timer:tick?period=2000&repeatCount=1&delay=500")
    .to("surrealdb:select?datasource=demo&table=users")
    .build()?;
YAML equivalent
routes:
  - id: create-user
    from: "timer:tick?period=2000&repeatCount=1"
    steps:
      - set_body: '{"name":"Alice","age":30}'
      - to: "surrealdb:create?datasource=demo&table=users"

  - id: list-users
    from: "timer:tick?period=2000&repeatCount=1&delay=500"
    steps:
      - to: "surrealdb:select?datasource=demo&table=users"

A live-query route turns table changes into a stream of Exchanges:

let live = RouteBuilder::from("surrealdb:live?datasource=demo&table=events")
    .to("log:info?showBody=true")
    .build()?;
YAML equivalent
routes:
  - id: events-cdc
    from: "surrealdb:live?datasource=demo&table=events"
    steps:
      - to: "log:info?showBody=true"

URI

surrealdb:<operation>?datasource=<name>[&table=<t>][&id=<r>][&edge=<e>][&from=<r>][&to=<r>][&function=<f>][&top_k=<n>][&metric=<m>][&vector_field=<f>][&query=<surrealql>]

The path segment is the operation. The component rejects the URI if datasource is missing, the operation is unknown, or output=stream is set. Streaming output is not supported. All results materialize as Body::Json(Vec<Value>).

OperationDirectionRequired URI paramsDescription
queryproducer / pollingRun raw SurrealQL from URI, header, or body
selectproducer / pollingtableSelect all rows or one row by id
createproducertableInsert a new record from a JSON body
updateproducertable, idMerge JSON body fields into an existing record
upsertproducertable, idReplace record content (id present or new)
deleteproducertable, idDelete a record by id
patchproducertable, idApply RFC 6902 JSON Patch operations
relateproducertable, edge, from, toCreate a graph edge between two records
vectorproducertableStore a vector field on a record
searchproducertable, top_kKNN vector similarity search
runproducerfunctionRun a SurrealDB function (fn::, math::, string::)
liveconsumertableSubscribe to live-query notifications (push model)

Common parameters

ParameterRequiredDefaultDescription
datasourceyesNamed datasource from Camel.toml
tableper operationTarget table for the operation
idper operationRecord id in table:key form
fromrelateSource RecordId, full table:key form
torelateTarget RecordId, full table:key form
edgerelateEdge table name (the relationship)
top_ksearchNumber of nearest neighbors to return (must be > 0)
metricnocosinecosine, euclidean, or manhattan (case-insensitive)
vector_fieldnoembeddingField that holds the vector
functionrunFunction name, validated as ASCII identifier with ::
querynoInline SurrealQL for the query operation

Retry policy parameters (retryEnabled, retryMaxAttempts, retryInitialDelayMs, retryMultiplier, retryMaxDelayMs, retryJitter) match camel-sql by name and default. They configure pool-establishment retry today. ADR-0013 owns the retry semantics; producer operations do not retry to avoid duplicating non-idempotent writes.

Producer

The Producer executes the eleven non-live operations. The body shape the Producer requires depends on the operation. create, update, upsert, patch, relate, vector, and run require a Body::Json body. query, select, search, and delete accept input through URI parameters or headers, so an empty body is valid.

select and query also expose a PollingConsumer. They integrate with the Poll Enrich EIP for on-demand reads. Other operations do not poll.

Result bodies land as Body::Json. The Producer sets CamelSurrealDbRecordId on writes when the id can be derived from the URI or the response. The id is the edge record for relate, not the source node.

The query operation reads SurrealQL from the URI, then the CamelSurrealDbQuery header, then the Exchange body. The CamelSurrealDbParams header binds $name placeholders. ADR-0032 classifies Exchange data as untrusted. The component has no allow_dynamic_query switch. The route is responsible for sanitizing SurrealQL from external sources before it reaches the Producer.

Consumer

surrealdb:live?datasource=<name>&table=<t> subscribes to a SurrealDB live query. SurrealDB pushes one notification per CREATE, UPDATE, or DELETE on the table. The Consumer submits one Exchange per notification. The body holds the affected record. The Consumer sets CamelSurrealDbAction to the action type and CamelSurrealDbTable to the table name.

Live queries require a WebSocket transport (ws:// or wss://). The component rejects http:// and https:// datasources for the live operation at endpoint creation.

The Consumer is event-driven. It does not implement PollingConsumer. The live operation exposes no receive() entry point.

Datasource

The datasource URI parameter names a Camel.toml entry. Credentials belong in the datasource, not the URI. The component redacts user information from db_url before any URL enters a log line or error message.

[default.datasources.demo]
db_url = "ws://localhost:8000"
provider = "surrealdb"

[default.datasources.demo.extra]
namespace = "test"
database = "test"
username = "root"
password = "root"

The extra map carries namespace, database, username, and password. The transport schemes are ws://, wss://, http://, and https://. The live operation requires ws or wss. See the Database page for the shared datasource contract.

Headers

HeaderDirectionOperationsDescription
CamelSurrealDbQueryinputquerySurrealQL text (priority: header > body > URI)
CamelSurrealDbParamsinputqueryJSON object map of $name → value bindings
CamelSurrealDbVectorinputsearchQuery vector as JSON array of f32 (alternative to body)
CamelSurrealDbRecordIdoutputall writesResolved table:key id when one can be determined
CamelSurrealDbActionoutputliveCREATE, UPDATE, or DELETE
CamelSurrealDbTableoutputliveTable that triggered the notification

Security

Identifier validation runs on table, edge, and vector_field. The validator accepts ASCII letters, digits, and _. The first character must be a letter or _. Whitespace, quotes, semicolons, and backslashes are rejected. Record keys (id, from, to) go through SDK binding and accept the broader charset SurrealDB requires for numeric, UUID, and string keys.

from and to for relate must be full RecordIds in table:key form. Bare keys are rejected at endpoint creation. This prevents the edge from silently targeting the wrong record when a route author writes from=1 instead of from=user:1.

The raw-query trust boundary is the documented hardening gap for this component. The query operation accepts SurrealQL from the body and the CamelSurrealDbQuery header. The route must filter these sources before the Producer. The component exposes no default-deny switch equivalent to camel-sql's allowDynamicQuery=false.

The component follows the ADR-0012 log-level convention. It never emits error! in production code. Recoverable and handler-owned conditions use warn!. Errors that terminate an operation return CamelError or SurrealDbError. The route error handler or supervision owns the final signal. ADR-0020 classifies the SurrealDB SDK as a stable database-driver class. The crate does not wrap the SDK in a project-owned adapter trait.

Reference: SurrealDB crate CONTEXT. Example source: examples/surrealdb-example.

OpenSearch

The OpenSearch component runs document, index, and search operations against an OpenSearch cluster. The OpenSearchProducer is a Tower Service<Exchange> that owns a shared client and a 128-permit semaphore. The MULTISEARCH operation is reserved but not yet implemented. The component is producer-only. The Endpoint rejects create_consumer() with EndpointCreationFailed.

Index a document

The Producer sends the Exchange body as the document. The optional CamelOpenSearch.Id header sets the document ID. Without it, OpenSearch assigns a server-generated ID.

use camel_api::Exchange;
use camel_builder::RouteBuilder;

let route = RouteBuilder::from("direct:index-user")
    .set_header("CamelOpenSearch.Id", serde_json::json!("user-42"))
    .set_body(serde_json::json!({
        "name": "Alice",
        "age": 30
    }))
    .to("opensearch://localhost:9200/users?operation=INDEX")
    .build()?;
YAML equivalent
routes:
  - id: index-user
    from: "direct:index-user"
    steps:
      - set_header:
          CamelOpenSearch.Id: "user-42"
      - set_body:
          name: "Alice"
          age: 30
      - to: "opensearch://localhost:9200/users?operation=INDEX"

Search documents

The Producer sends the body as the search query. The size and from URI params fill in pagination when the body does not set them.

use camel_builder::RouteBuilder;

let route = RouteBuilder::from("direct:search-users")
    .set_body(serde_json::json!({
        "query": {
            "match": { "name": "Alice" }
        }
    }))
    .to("opensearch://localhost:9200/users?operation=SEARCH&size=25&from=0")
    .build()?;
YAML equivalent
routes:
  - id: search-users
    from: "direct:search-users"
    steps:
      - set_body:
          query:
            match:
              name: "Alice"
      - to: "opensearch://localhost:9200/users?operation=SEARCH&size=25&from=0"

Bulk operations

The body is a JSON array of action-and-document pairs. The Producer serializes each element to a line and rejects the call when the total payload exceeds max_bulk_bytes.

use camel_builder::RouteBuilder;

let route = RouteBuilder::from("direct:bulk")
    .set_body(serde_json::json!([
        { "index": { "_id": "1" } },
        { "name": "Alice", "age": 30 },
        { "index": { "_id": "2" } },
        { "name": "Bob", "age": 25 }
    ]))
    .to("opensearch://localhost:9200/users?operation=BULK&max_bulk_bytes=1048576")
    .build()?;
YAML equivalent
routes:
  - id: bulk
    from: "direct:bulk"
    steps:
      - set_body:
          - index:
              _id: "1"
          - name: "Alice"
            age: 30
          - index:
              _id: "2"
          - name: "Bob"
            age: 25
      - to: "opensearch://localhost:9200/users?operation=BULK&max_bulk_bytes=1048576"

URI

opensearch://<host>:<port>/<index>?operation=<op>[&option=value...]
opensearchs://<host>:<port>/<index>?operation=<op>[&option=value...]  (TLS)
ParameterRequiredDefaultDescription
hostnolocalhostOpenSearch hostname. Falls back to the global config
portno9200OpenSearch port. Falls back to the global config
indexyesTarget index name. Lowercase letters, digits, hyphens, underscores. Max 255 bytes
operationnoSEARCHOne of the operations in the table below
usernamenoBasic auth username. Falls back to the global config
passwordnoBasic auth password. Falls back to the global config
timeout_msno30000Per-request timeout in milliseconds
sizenoSearch result page size. Filled into the body when absent
fromnoSearch result offset. Filled into the body when absent
max_bulk_bytesnoMaximum serialized bulk payload size. BULK rejects larger payloads
retryEnablednotrueEnable retry on transient failures
retryMaxAttemptsno3Maximum retry attempts
retryInitialDelayMsno1000Initial backoff delay
retryMultiplierno2.0Backoff multiplier
retryMaxDelayMsno60000Maximum backoff delay
retryJitterno0.0Jitter 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.

OperationBodyRequired headersDescription
INDEXdocumentoptional CamelOpenSearch.IdIndex a document. Creates or replaces
GETCamelOpenSearch.IdRetrieve a document by ID
DELETECamelOpenSearch.IdDelete a document by ID
UPDATEpartial docCamelOpenSearch.IdApply a partial update
EXISTSCamelOpenSearch.IdCheck whether a document exists
SEARCHqueryRun a search query
BULKaction+doc arrayRun multiple actions in one request
MULTIGETid listRetrieve multiple documents
DELETE_INDEXDelete the entire index
PINGProbe the cluster
MULTISEARCHReserved. Returns a permanent not-implemented error

Headers

HeaderDirectionDescription
CamelOpenSearch.IdinputDocument ID for INDEX, GET, DELETE, UPDATE, EXISTS
CamelOpenSearch.OperationinputOperation name. Overrides the URI operation param

The Producer reads the index name from operator endpoint configuration. URI parsing validates the index name against OpenSearch naming rules and fails closed on null bytes, traversal segments, and invalid characters. The CamelOpenSearch.Id header is untrusted Exchange data. Current typed request builders pass it to _doc/{id} paths without component-side validation. This gap is tracked as audit finding I1.

TLS

Use the opensearchs scheme to enable HTTPS. The base URL builder selects https:// from the scheme. Basic auth credentials apply to both schemes.

routes:
  - id: secure-search
    from: "direct:search-secure"
    steps:
      - to: "opensearchs://opensearch.example.com:9200/users?operation=SEARCH&username=admin&password=secret"

Global configuration

Set defaults in Camel.toml under the opensearch key. URI params always override global values. Unset URI fields fall back to the global config after URI parsing.

[default.components.opensearch]
host = "opensearch.internal"
port = 9200
username = "app-user"
password = "${OPENSEARCH_PASSWORD}"
default_operation = "SEARCH"
index_name = "events"
timeout_ms = 30000

The OpenSearchConfig::Debug implementation redacts the password to <redacted>. The same redaction applies to OpenSearchEndpointConfig::Debug.

Concurrency and retry

The Producer holds a Semaphore that caps in-flight calls at 128. poll_ready() reserves a permit before the call. A closed semaphore maps to CamelError::ConsumerStopping (ADR-0019, ADR-0024). The shared client initializes lazily on the first call. Arc<Mutex<Option<OpenSearch>>> serializes initialization, then each call clones and reuses the client.

Retry uses NetworkRetryPolicy. The retry loop classifies failures as transient or permanent:

  • Transient: HTTP 5xx, network failures, timeouts. Retried up to max_attempts
  • Permanent: HTTP 4xx, missing headers, parse failures. Surfaced immediately

All operations except MULTISEARCH and UNKNOWN use typed request builders from the opensearch crate. The component never interpolates Exchange data into query text. Bodies become structured JSON values that serde supplies to the request builder.

Error handling

Operation failures log at warn! with the index name and error. Client initialization failures log at error! because the producer cannot function without a client. ADR-0012 classifies initialization failures as system-broken. The Producer surfaces transient retry exhaustion and permanent failures as CamelError::ProcessorError. The route ErrorHandler owns the operational signal.

Reference: camel-opensearch CONTEXT. Example source: not yet published.

LLM

The LLM component sends chat, embedding, and tool-calling requests to language model providers. It streams tokens or materializes full responses. Three providers ship: OpenAI, Ollama, and Mock. The component is producer-only. It has no Consumer in the current release.

The llm-example registers a Mock provider and runs three routes. The materialized route below sends a prompt, collects the response, and logs it with headers:

let route = RouteBuilder::from("timer:tick?period=3000")
    .route_id("llm-materialized")
    .set_body("Explain Tower middleware in one sentence.")
    .to("llm:chat?provider=openai-prod&model=gpt-4o&stream=false")
    .to("log:info?showBody=true&showHeaders=true")
    .build()?;
ctx.add_route_definition(route).await?;
YAML equivalent
routes:
  - id: llm-materialized
    from: "timer:tick?period=3000"
    steps:
      - set_body: "Explain Tower middleware in one sentence."
      - to: "llm:chat?provider=openai-prod&model=gpt-4o&stream=false"
      - to: "log:info?showBody=true&showHeaders=true"

URI

llm:<operation>?provider=<name>[&model=<model>][&temperature=<n>][&max_tokens=<n>][&stream=<bool>][&system_prompt=<text>][&timeout_secs=<n>]
ParameterDefaultDescription
providerglobal defaultProvider name from Camel.toml
modelprovider defaultOverride model
temperatureprovider defaultSampling temperature
max_tokensprovider defaultMax output tokens
streamtruetrue streams, false materializes
system_promptSystem prompt override
timeout_secsprovider defaultActivity timeout (streaming) or total deadline (materialized)

Operations

llm:chat runs a chat completion. llm:embed generates a vector embedding. The operation picks which LlmProvider method the producer calls.

Producer

llm:chat?provider=openai-prod&model=gpt-4o sends the Exchange body as a user message. The stream parameter selects streaming or materialized mode.

Streaming mode is the default. The producer returns Body::Stream(StreamBody). Each ChatEvent::Delta carries a token or text fragment. Usage metadata goes to tracing::info!. Usage metadata does not go to Exchange headers. The header CamelLlmUsageAvailable is false at Exchange return time.

Materialized mode (stream=false) collects every ChatEvent into a single Body::Text. The producer writes token counts to CamelLlmTokensIn and CamelLlmTokensOut. It writes the finish reason to CamelLlmFinishReason. The CamelLlmUsageAvailable header is true.

Tool calls flow through CamelLlmTools (input) and CamelLlmToolCalls (output). The component emits tool-call intent through ChatEvent::ToolCall. The component never executes tools. The route owns dispatch.

Cost and cache

Cost observability tracks spending per request. A config-driven PricingTable maps input and output tokens to USD rates. The producer computes cost from final usage. It writes cost to CamelLlmEstimatedCostUsd in materialized mode. It logs cost at info! in both modes. Missing pricing means no cost header and no failure.

The response cache deduplicates materialized requests. It sits at the producer level, not the provider level. A single-flight mechanism built on dashmap and tokio::sync::watch collapses concurrent lookups. Entries expire by TTL and evict by LRU. The cache stores usage, not cost. The cache lookup runs before the semaphore, retry, and timeout layers.

Providers

The Mock provider ships in the default build. OpenAI and Ollama require cargo features (--features openai and --features ollama). The SiumaiProvider adapter isolates the siumai SDK. If siumai breaks, only provider/siumai_adapter.rs changes (ADR-0020).

Retry and timeout

The manual retry loop honors provider retry_after over exponential backoff (ADR-0021). The loop only runs in materialized mode. Once content starts streaming, the loop stops.

The streaming timeout_secs covers each stream.next() call. A deadline lapse yields a Timeout error. The materialized total deadline covers the whole request.

Error handling

LlmError maps to three CamelError variants: Unauthenticated, Io, and ProcessorError. The mapping is lossy. Downstream handlers that need typed LLM errors should use CamelError::ProcessorErrorWithSource. The component has no Consumer. It rejects create_consumer with CamelError::InvalidUri.

The producer logs provider errors at warn!. The route ErrorHandler owns the error! level. Stream finish and tool dispatch log at info!. Cache hit and miss log at debug!.

Reference: LLM crate CONTEXT, ADR-0020, ADR-0021. Example source: examples/llm-example.

WASM

The WASM component runs operator-installed WebAssembly plugins in a Wasmtime sandbox. One crate covers the plugin, bean, authorization-policy, and source worlds. Each world receives a different capability grant. The sandbox limits damage from guest defects. The sandbox is not a security boundary for intentionally malicious plugins.

The wasm-example wires a timer-driven producer that calls an echo plugin, then logs the result:

    let route = RouteBuilder::from("timer:tick?period=1000&repeatCount=3")
        .route_id("wasm-example")
        .to("wasm:echo.wasm?timeout=5&max-memory=10485760")
        .to("log:info")
        .build()?;
YAML equivalent
routes:
  - id: wasm-example
    from: "timer:tick?period=1000&repeatCount=3"
    steps:
      - to: "wasm:echo.wasm?timeout=5&max-memory=10485760"
      - to: "log:info"

The Rust example resolves echo.wasm from the example fixtures/ directory. The YAML form takes the plugin path from the URI. Substitute your own base directory at component registration. The same wasm: scheme serves a source Endpoint that runs an inbound guest loop driven by an http-listener capability. The examples/wasm-source-webhook/ example shows that direction.

URI

wasm:<path/to/module.wasm>[?<param>=<value>...]

The path must be relative. Absolute paths and .. segments are rejected. The path resolves against the base directory passed to WasmComponent::new.

ParameterTypeDefaultDescription
timeoutseconds30Per-call wall-clock deadline. Enforced by epoch interruption.
max-memorybytes52428800Maximum linear memory the guest can allocate.
max-concurrent-callsinteger4Maximum concurrent call_process executions per producer.
max-wasm-sizebytes10485760Reject modules larger than this at load time.
allow-callstringemptyComma-separated URI schemes the guest may call via camel_call or camel_poll.
max-stream-bytesbytesmaterializer defaultPer-stream byte cap for the streaming body bridge.
max-instancesinteger10000Maximum core instances per store.
max-tablesinteger10000Maximum tables per store.
max-table-elementsintegerunlimitedMaximum table elements when set.
bindaddress0.0.0.0:8080Source world only. Bind address for the granted HTTP listener.
pathstringall pathsSource 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.

WorldHost callsCapability sourceCamel host functions
pluginprocess() per Exchangefrom_scheme_list()camel_call, camel_poll, host_store, host_load, get_property, set_property
beaninvoke() for a chosen methodfrom_scheme_list()Same as plugin
authorization-policyevaluate(exchange) before pipelinedenied()get_property, set_property only
sourceguest owns run(listener) loopnone (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.

FieldMeaning
call_schemesURI schemes the guest may call. Empty set denies all schemes.
host_kvWhether 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
FieldDefaultDescription
timeout-secs30Per-call wall-clock deadline.
max-memory52428800Maximum linear memory.
max-concurrent-calls4Maximum concurrent calls.
max-wasm-size10485760Maximum module size.
allow-call-schemesemptyComma-separated schemes for camel_call.
max-stream-bytesmaterializer defaultPer-stream byte cap.
max-instances10000Maximum core instances per store.
max-tables10000Maximum tables per store.
max-table-elementsunlimitedMaximum 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).

VariantWhen raised
WasmError::TimeoutEpoch deadline exceeded.
WasmError::OutOfMemoryGuest exceeded memory limit.
WasmError::TrapGuest hit unreachable, stack overflow, or other trap.
WasmError::GuestPanicGuest panicked with a message.
WasmError::UnhealthyPlugin 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 CONTEXTADR-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]
ParameterRequiredDefaultDescription
scheduleyesUnix 5-field cron expression. Use + as the space separator
timeZonenosystemIANA timezone identifier (e.g. America/New_York)
includeMetadatanofalseAttach 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 from scheduled_at under 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]
ParameterRequiredDefaultDescription
timeout_msno30000Producer call() timeout in milliseconds
failIfNoConsumersnotrueReject the call when no Consumer is registered for the name
blocknotrueReserved for non-blocking send (TODO(DIR-001))
exchangePatternno(none)Reserved for pattern override (TODO(DIR-005))

The endpoint name must not be empty and must not contain whitespace. The Component rejects both at Endpoint creation.

Consumer

from: "direct:name" registers a DirectConsumer in the shared registry. The Consumer starts a background loop that receives Exchanges from the in-memory channel. It submits each Exchange to the Route's Pipeline through send_and_wait. The reply carries the transformed Exchange or the failure.

One Consumer per name. A second Consumer on the same name returns CamelError::EndpointCreationFailed because the registry already holds an open channel. Routes that want many workers must use a different name, or pick a Component that supports fanout (SEDA with multipleConsumers=true).

The Consumer removes its registry entry on stop() or when the cancellation token fires. The next Producer call fails with EndpointCreationFailed until a new route registers.

Producer

to: "direct:name" builds a DirectProducer. The Producer holds one in-flight call at a time through a bounded semaphore. poll_ready checks the registry and acquires a permit. call hands the Exchange to the Consumer's channel and awaits the reply.

failIfNoConsumers=true (default) rejects the call when no Consumer is registered. Set it to false to let the Producer race against late registration. The Producer still waits for the Consumer to receive the Exchange, so false does not give a fire-and-forget guarantee. Use SEDA for that.

The default timeout_ms is 30 000. A timeout returns CamelError::ProcessorError with a timed out message. The error propagates through the route's error handler.

Request-Reply

Direct is the natural request-reply Component for in-process calls. The Producer blocks until the Consumer's Pipeline finishes. Steps that follow to: "direct:name" see the transformed Exchange. Steps that set the body inside the Consumer's Route reach the caller. A timer that sends to a Direct endpoint and logs the result is a synchronous in-process function call.

Error handling

The DirectConsumer reports unhandled pipeline failures through the b-prime:direct:send-and-wait metric and logs at error! (ADR-0012 category b'). Producer send failures (no Consumer, channel closed, reply dropped) are category (a) handler-owned. The Producer logs them at warn!. The route's error handler owns the operational signal.

Reference: Direct crate CONTEXT. Example source: examples/multi-route-direct.

SEDA

The SEDA (Staged Event-Driven Architecture) component stages exchanges in memory between routes that share one CamelContext. A producer sends to seda:name and returns. A consumer on the same name pulls from a bounded queue and processes asynchronously.

seda: is the asynchronous counterpart to direct:. Reach for it to decouple route lifetimes, smooth traffic bursts, or fan out to multiple subscribers.

The seda-demo wires a timer-driven producer against an asynchronous consumer that uppercases the body:

use camel_api::body::Body;
use camel_builder::RouteBuilder;
use camel_component_log::LogComponent;
use camel_component_seda::SedaComponent;
use camel_component_timer::TimerComponent;
use camel_core::context::CamelContext;

let mut ctx = CamelContext::builder().build().await.unwrap(); // allow-unwrap
ctx.register_component(TimerComponent::new());
ctx.register_component(LogComponent::new());
ctx.register_component(SedaComponent::new());

let route_a = RouteBuilder::from("timer:tick?period=1000&repeatCount=5")
    .route_id("producer-route")
    .to("seda:processing")
    .build()?;

let route_b = RouteBuilder::from("seda:processing?concurrentConsumers=2")
    .route_id("consumer-route")
    .map_body(|body: Body| {
        if let Some(text) = body.as_text() {
            Body::Text(text.to_uppercase())
        } else {
            body
        }
    })
    .to("log:output?showBody=true&showHeaders=true")
    .build()?;

ctx.add_route_definition(route_a).await?;
ctx.add_route_definition(route_b).await?;
ctx.start().await?;
YAML equivalent
routes:
  - id: producer-route
    from: "timer:tick?period=1000&repeatCount=5"
    steps:
      - to: "seda:processing"
  - id: consumer-route
    from: "seda:processing?concurrentConsumers=2"
    steps:
      - to: "log:output?showBody=true&showHeaders=true"

Both interfaces compile to the same RouteDefinition. The example source is at examples/seda-demo.

URI

seda:<name>[?size=<n>][&concurrentConsumers=<n>][&multipleConsumers=<bool>][&blockWhenFull=<bool>][&discardIfNoConsumers=<bool>][&timeout=<ms>][&waitForTaskToComplete=<mode>][&exchangePattern=<pattern>]
ParameterDefaultDescription
size1000Bounded queue capacity. Must be greater than 0
concurrentConsumers1Concurrency hint. Clamped to 1 minimum. 0 becomes 1 with a warning
multipleConsumersfalseFanout mode. One queue per subscriber. All-or-nothing delivery
blockWhenFullfalseBlock the producer up to timeout when the queue is full. Default fails fast
discardIfNoConsumersfalseDrop silently when no consumer is active. Default returns an error
timeout30000Timeout in milliseconds for enqueue and reply wait
waitForTaskToCompleteIfReplyExpectedNever, IfReplyExpected, or Always
exchangePatternInOnlyInOnly (fire-and-forget) or InOut (request-reply)

Endpoints that share a name must agree on size, multipleConsumers, exchangePattern, and concurrentConsumers. The component rejects mismatched shared options with an EndpointCreationFailed error.

Consumer

seda:processing?concurrentConsumers=2 registers a consumer that pulls from the endpoint's bounded queue. The Runtime starts one queue forwarder task per consumer. That forwarder awaits send_and_wait for InOut and waitForTaskToComplete=Always exchanges, so those exchanges remain serial even when concurrentConsumers is greater than 1. InOnly exchanges without a reply channel do not block the forwarder.

concurrentConsumers is reported to the Runtime through ConcurrencyModel::Concurrent. Finding I1 and bd issue rc-exa2 track the limitation that blocks true concurrent InOut processing.

The consumer transfers the primary forwarder handle to the Runtime through background_task_handle(). On shutdown, the Runtime aborts that handle, then calls stop(). stop() cancels the private token, aborts retained forwarders, and clears the active consumer registration.

Producer

seda:processing creates a producer that enqueues the exchange. The producer returns immediately for fire-and-forget patterns. Behavior depends on waitForTaskToComplete:

  • Never returns after enqueue. No reply channel is attached.
  • IfReplyExpected waits only when exchangePattern=InOut.
  • Always waits 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:
SynchronySynchronous. Producer blocks until consumer finishesAsynchronous. Producer returns after enqueue
QueueNoneBounded (size)
Multiple consumersNoYes with multipleConsumers=true
Reply semanticsReply is the consumer's pipeline resultReply is optional. Controlled by waitForTaskToComplete
Failure propagationSynchronous to the producerSurfaces as EndpointCreationFailed or ChannelClosed after enqueue
Use caseModular route linking with strict orderingDecoupling, 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>
ParameterRequiredDescription
routeIdyesTarget Route ID. Must differ from the calling Route
actionyesLifecycle command. One of start, stop, suspend, resume, restart, or status
authorizedRoutesyesComma-separated allowlist. Endpoint fails closed when absent

Actions

ActionRuntime commandResponse body
startStartRouteempty
stopStopRouteempty
suspendSuspendRouteempty
resumeResumeRouteempty
restartReloadRouteempty
statusGetRouteStatusBody::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):

  1. The URI declares the target routeId.
  2. authorizedRoutes exists and contains that target.
  3. 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

FailureResult
Missing or unauthorized targetCamelError::Unauthorized
Unknown action or unexpected status responseCamelError::ProcessorError
RuntimeHandle errorpasses 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>
SegmentRequiredDescription
locknameyesLeadership lock name. Nodes with the same lock name compete for one leader.
delegate-uriyesFull URI for any consumer Component (timer:, kafka:, http:, etc.). The Master wraps the Consumer this URI creates.

The Master has no URI query parameters of its own. Query parameters belong to the delegate Component.

Consumer

master:<lockname>:<delegate-uri> gates a delegate Consumer on leadership. When the route starts, the node joins leader election under the lock name. The delegate Consumer does not start until the node wins. On leadership loss, the delegate stops and drains within drain_timeout_ms. If the node wins again, the delegate restarts with the same delegate URI.

Each ExchangeEnvelope the delegate emits carries an x-camel-leader-epoch Exchange property. The Master stamps the property with a monotonic fencing token at bridge start. A stale bridge retains the epoch from its own spawn, not the live epoch. Downstream sinks that need split-brain safety compare the property against the current leader's epoch and reject older envelopes.

Producer

to("master:mylock:http://api.example.com") passes through to the delegate Producer without leader gating. The Master's job is to gate Consumers, not Producers. A write that needs exclusive access should serialize through a queue that the leader Consumer drains.

Leadership backends

The leader election backend comes from the configured PlatformService. The default NoopPlatformService always elects the local node, so a single-node deployment works without external infrastructure. A Kubernetes deployment uses KubernetesPlatformService and Lease objects for distributed election across pods.

Every acquired term increments the leader epoch. The bridge stamps the new epoch on each envelope. A node that loses leadership stops the delegate within drain_timeout_ms and steps down. The route stays alive; only delegate intake pauses until the node wins again.

Configuration

The Master reads from [components.master] in Camel.toml:

KeyDefaultDescription
drain_timeout_ms5000Max time to wait for the delegate Consumer to shut down on leadership loss
delegate_retry_max_attemptsunlimitedBackward-compat alias for reconnect.max_attempts. 0 means unlimited
reconnect.max_attempts0Bounded retry attempts on delegate start failure. 0 means unlimited
reconnect.enabledtrueEnable 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
LayerFieldDefaultBounds
acquisitionmax-total-source-bytes16 MiBtotal source bytes across the dependency closure
acquisitionmax-include-count64included or imported templates per closure
acquisitionmax-include-depth16nested include/extends depth
acquisitionmax-template-size1 MiBsingle template file in bytes
acquisitionreload-timeout-ms5000wall-clock budget for a full reload build
rendermax-context-size64 KiBserialized context bytes per render
rendermax-output-size1 MiBrendered output bytes per render
renderfuel100000per-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>]
OptionDefaultDescription
typefrom extensionSchema type: xml, json, or yaml. rng and schematron are parsed but rejected at endpoint creation.
failOnNullBodytrueReject exchanges with empty bodies when true
headerNamenoneValidate this header's value instead of the body
failOnNullHeadertrueReject exchanges where the named header is missing
maxPayloadBytesnoneReject bodies larger than this many bytes before validation
schemaCacheMaxEntries256Maximum 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.

AspectBehavior
Directionproducer only
Profile selectionendpoint URI, not headers or body
Shellrejected unless allow_shell = true
Default args policydeny 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.

FieldDefaultDescription
workspace_root"."Base for working_dir confinement
default_timeout_secs30Per-profile timeout when the profile omits one
default_concurrency1Producer 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:

FieldDefaultDescription
nameReferenced as exec:{name}
executableBinary name (PATH-resolved at startup) or absolute path
argsexact with empty valuesArgPolicy mode: any, exact { values }, or prefix { values }
deny_flags[]Prefix-matched denylist applied before allow. Always wins
allow_shellfalsePermit 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_secsglobal defaultProcess timeout. Force-kills the process group on Unix
accepted_exit_codes[0]Exit codes treated as success
concurrencyglobal defaultPer-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

ModeWhat passes
anyEvery 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
omittedDeny 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.

HeaderDirectionTypeDescription
CamelExecArgsinputJSON array of stringsArgument list passed to the binary
CamelExecProfileoutputstringEffective profile name
CamelExecExitCodeoutputintegerProcess exit code (omitted on timeout)
CamelExecExitAcceptedoutputbooltrue if exit_code is in accepted_exit_codes
CamelExecTimedOutoutputbooltrue if the timeout fired
CamelExecStderroutputstringLossy-UTF8 stderr, for route predicates
CamelExecStdoutTruncatedoutputbooltrue if stdout exceeded stdout_max_bytes
CamelExecStderrTruncatedoutputbooltrue 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:

MetricTypeLabelsFires when
exec_policy_denials_totalcounterreason, routeArg policy or shell rejection denies a call
exec_timeouts_totalcounterrouteTimeout kills the process
exec_exit_codecountercode, routeProcess exits with a code
exec_stdout_truncated_totalcounter(none)Stdout exceeds the cap
exec_duration_secshistogramrouteEvery 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>
kindDescriptionReference
adminAdmin REST API Produceroperations table
eventsEvents Consumer (user and admin events)events table
admin-eventsAlias of events with eventType=admin-events presetevents 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)

OperationHTTPRequires userIdPath
createUserPOSTno/admin/realms/{realm}/users
deleteUserDELETEyes/admin/realms/{realm}/users/{userId}
getUserGETyes/admin/realms/{realm}/users/{userId}
createRolePOSTno/admin/realms/{realm}/roles
assignRolePOSTyes/admin/realms/{realm}/users/{userId}/role-mappings/realm
createClientPOSTno/admin/realms/{realm}/clients
createRealmPOSTno/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)

ParameterRequiredDefaultDescription
realmyesRealm to poll
eventTypeyesevents or admin-events
pollDelayno5000Milliseconds between polls
maxResultsno100Max events per poll
lookbackWindowno300000Initial lookback in ms (5 min)
dedupCapacityno10000Max tracked event IDs
maxAuthErrorsno3Consecutive auth errors before stop
typenoFilter by event type string
clientnoFilter by client ID
operationTypesnoFilter admin events by operation (comma-separated)
resourcePathnoFilter 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).

HeaderType
CamelKeycloakEventIdboth
CamelKeycloakEventTimeboth
CamelKeycloakRealmIdboth
CamelKeycloakEventTypeboth
CamelKeycloakClientIduser
CamelKeycloakUserIduser
CamelKeycloakSessionIduser
CamelKeycloakIpAddressuser
CamelKeycloakResourceTypeadmin
CamelKeycloakResourcePathadmin
CamelKeycloakAuthUserIdadmin
CamelKeycloakAuthClientIdadmin

both means both events and admin-events. user is set on events. admin is set on admin-events.

Claim mapping

keycloak_claim_paths(client_id) returns the ClaimPaths struct for the realm. The subject is /sub. The role locations are /realm_access/roles and /resource_access/{client}/roles. The scope is /scope. The component RFC 6901 escapes the client_id segment (/ becomes ~1, ~ becomes ~0) before it is substituted into the path. An empty client_id produces /resource_access//roles. The caller must validate the client ID.

KeycloakRealmConfig::introspection_authenticator() builds an IntrospectionAuthenticator that wraps a CachingTokenIntrospector against the realm's /protocol/openid-connect/token/introspect endpoint. The introspector caches positive responses for default_ttl_secs and negative responses for negative_ttl_secs. The HTTP client is DNS-pinned to the introspection host and uses the SSRF policy from [security.keycloak].

JWKS and validation

The realm's /protocol/openid-connect/certs endpoint is the JWKS source. The JWKS cache holds keys for cache_ttl_secs and refreshes refresh_skew_secs before expiry. The component DNS-pins the HTTP client to the JWKS host to close the TOCTOU window between SSRF validation and the first request.

[security.keycloak.validation] controls local JWT validation. method = "local" performs signature and claim checks against the cached JWKS. audience lists the accepted aud claims. clock_skew_secs tolerates the difference between the local clock and the issuer clock.

UMA permission evaluation

KeycloakRealmConfig::uma_evaluator() returns a PermissionEvaluator that uses Keycloak's UMA ticket flow. The evaluator obtains a service-account token, then POSTs grant_type=urn:ietf:params:oauth:grant-type:uma-ticket to the realm's token endpoint. The claim_token form field carries the requesting principal's claims as a base64-encoded JSON string. A 200 response grants permission. A 403 response returns Denied with the Keycloak error_description. A 401 response signals rejected client credentials and surfaces as ProviderUnavailable.

[security.keycloak.uma]
provider = "keycloak"

[security.keycloak.uma.cache]
positive_ttl_secs = 30
negative_ttl_secs = 5
max_entries = 10000

The UMA evaluator pins its DNS to the realm's token endpoint. The connect timeout is 5 seconds. The request timeout is 30 seconds. The evaluator fails closed on transport errors and non-200, non-403, non-401 responses.

Transport hardening

The Keycloak HTTP client is hardened. It follows no redirects. A 302 or 303 response is treated as a misconfiguration or attack signal. Connect timeout is 10 seconds. Request timeout is 30 seconds. validate_server_url rejects non-HTTP schemes and rejects hosts that resolve to blocked IP ranges. The component redacts client_secret to REDACTED in Debug output and skips the secret in Serde serialization.

Error handling

The Admin Producer returns Err on HTTP failure. The route's ErrorHandler owns the operational signal. ADR-0012 classifies these as category a. The Event Consumer logs transient HTTP errors at warn! and increments no metric. Auth retries log at warn! and increment the e:keycloak:auth-material metric. Channel-closed send failures inside the consumer increment the b-prime:keycloak:response-body metric and log at error! as outside-contract (ADR-0012 category b'). The max-auth-errors arm stops the consumer with error! as system-broken (ADR-0012 category c). The component redacts request and response bodies that contain secrets.

Reference: Keycloak component CONTEXT. Example: examples/security-keycloak shows the native auth pipeline that consumes Keycloak-issued JWTs. ADRs: 0010 Security policy pre-pipeline authz, 0012 Log-level convention, 0033 Security defaults fail-closed startup validation.

XSLT and XJ

XSLT transforms XML with a stylesheet. XJ converts between XML and JSON. Both components are producer-only and both delegate to a Java/Saxon xml-bridge sidecar over gRPC. The Rust side never parses XML or executes XSLT.

XJ sits on top of XSLT. The same bridge compiles every stylesheet, whether you supply your own or use the bundled identity pair.

XSLT

xslt:<stylesheet> reads an XSLT stylesheet from disk and applies it to the Exchange body. The body must be XML. The Producer replaces the body with the transformation result.

let route = RouteBuilder::from("direct:in")
    .to("xslt:/etc/transforms/order.xslt?output=xml&param.locale=en")
    .build()?;
YAML equivalent
routes:
  - id: xslt-transform
    from: "direct:in"
    steps:
      - to: "xslt:/etc/transforms/order.xslt?output=xml&param.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>][&param.<name>=<value>][&transformerCacheSize=<n>][&failOnNullBody=<true|false>][&maxPayloadBytes=<n>]
ParameterRequiredDefaultDescription
stylesheetyesPath to the XSLT file. Accepts file:// prefix or a plain path.
outputnostylesheet defaultOutput method: xml, html, or text.
param.<name>noXSLT parameter. Forwarded as a string. Becomes an <xsl:param> value inside the stylesheet.
transformerCacheSizenounlimitedMaximum compiled stylesheets the bridge keeps in cache. 0 disables caching.
failOnNullBodynofalseReturn an error when the body is empty. When false, the empty body is forwarded.
maxPayloadBytesno10 MiBReject 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>][&param.<name>=<value>]
ParameterRequiredDefaultDescription
stylesheetyesclasspath:identity for the bundled pair, or a custom file:// path.
directionyesxml2json or json2xml.
maxPayloadBytesno10 MiBReject Exchange bodies larger than this before sending to the bridge.
retryCountno3Retries on transient transport failure.
retryDelayMsno500Delay between retries.
param.<name>noXSLT parameter forwarded to the bridge.

The parser also accepts transformDirection and resourceUri, but Endpoint creation does not pass them to the Producer. Both options are silently ignored. Do not depend on them.

Bridge model

Both components share the xml-bridge sidecar. Rust does not parse XML or execute XSLT. It reads the stylesheet bytes at Endpoint creation, bounds the Exchange body, and forwards both as bytes through proto/xml_bridge.proto. The sidecar owns stylesheet compilation, transformation, XML parsing, DTD handling, and entity resolution.

The bridge process starts on first use. It exits when the Camel context stops. On transport failure, the runtime restarts the bridge and recompiles every cached stylesheet. Transient transport errors trigger retries with the configured backoff before the route sees a failure.

Trust model

ADR-0032 classifies endpoint configuration as trusted operator input and the Exchange body as untrusted exchange data. The stylesheet is read from disk during Endpoint creation. The Exchange body is bounded and forwarded without Rust-side XML parsing. The sidecar is the security location for XSLT secure processing, document() restrictions, XXE controls, entity-expansion limits, and XML-bomb protection. BridgeError.Kind.SECURITY_VIOLATION reports a policy rejection. The presence of that contract does not prove every defense is enabled. Audit those controls in bridges/xml/.

Error handling

The Producer logs stylesheet compilation failures and transform failures at warn!. The route ErrorHandler owns the resulting error (ADR-0012 category a). The bridge client logs reseed failures at error! with a matching metric. That category is outside-contract and signals transient recovery, not a handler call.

Reference: camel-xslt CONTEXT, camel-xj CONTEXT.

Mock

The Mock component is a producer-only testing utility. It records every Exchange a Route sends to it and exposes assertions you call from your test code.

use camel_builder::RouteBuilder;
use camel_component_mock::MockComponent;
use camel_core::CamelContext;
use tower::ServiceExt;

let mock = MockComponent::new();
let mock_ref = mock.clone();

let mut ctx = CamelContext::new();
ctx.register_component("mock", Box::new(mock));

let route = RouteBuilder::from("direct:input")
    .map_body(|body: camel_api::Body| {
        camel_api::Body::Text(body.as_text().unwrap_or("").to_uppercase())
    })
    .to("mock:result")
    .build()?;

ctx.add_route(route).await?;
ctx.start().await?;

let producer = ctx.create_producer("direct:input").await?;
producer.oneshot(camel_api::Exchange::new(camel_api::Message::new("hello"))).await?;

let endpoint = mock_ref.get_endpoint("result").unwrap();
endpoint.assert_exchange_count(1).await;
endpoint.exchange(0)
    .assert_body_text("HELLO")
    .assert_no_error();
YAML equivalent
routes:
  - id: mock-route
    from: "direct:input"
    steps:
      - to: "mock:result"

The Mock endpoint is the same in both APIs. The Rust builder and the YAML DSL compile to the same RouteDefinition. A YAML-defined route produces Exchanges that the Rust test code can still assert on through the cloned MockComponent handle.

URI

mock:<name>
SegmentRequiredDescription
nameyesLogical name for this endpoint. The same name creates the same recorded-exchange store, so two routes that send to mock:result share one buffer.

The Mock has no query parameters. Every behavior is controlled through the Rust API on MockComponent and MockEndpointInner.

Why producer-only

The Mock only supports producer mode. The contract crate declares supports_producer: true and leaves consumer support disabled. A Mock Consumer would have no source to pull from and no peer to broadcast to. The value is the recording side, not a stub peer.

You use Mock as a to: target inside a Route. Your test code holds a clone of the MockComponent and reads back what arrived. Clone the MockComponent before you register it, because registration moves the value into the CamelContext.

Recording model

Each MockEndpointInner keeps a VecDeque<Exchange> behind a tokio::Mutex and a tokio::sync::Notify for wake-ups. The producer appends every Exchange it processes, optionally deep-cloning the body if MockConfig::copy_on_exchange is true. The default cap is 10 000 retained exchanges. Older entries drop when the cap is exceeded.

Multiple MockEndpoint instances with the same name share one MockEndpointInner through Arc. Two routes that both send to mock:result write to the same buffer. A multicast test can assert on every leg from one handle.

The recording is in-memory only. The component persists nothing to disk, supports no replay, and exposes no remote inspection. Stop the CamelContext and the recorded Exchanges disappear.

Assertions

The Rust API offers three assertion styles. Pick by what your test needs to express.

assert_exchange_count(n) is the first check in most tests. It panics with a descriptive message if the count does not match. Call it before any deeper inspection so a missing exchange fails the test at the count line, not inside a body assertion.

exchange(idx) returns an ExchangeAssert for fluent checks. Chain .assert_body_text("HELLO"), .assert_body_json(value), .assert_body_bytes(&[1, 2, 3]), .assert_header("x-source", json!("timer")), .assert_header_exists("trace-id"), .assert_has_error(), or .assert_no_error(). Every method panics with a message that names the endpoint, the exchange index, the expected value, and the actual value. Test output stays self-explanatory without extra assert! calls.

expect_body, expect_header, and expect_header_regex register a batch of expectations up front. Call assert_satisfied() after the exchanges have arrived. Batch mode matches in strict order by default. Set MockConfig::any_order = true to match each expected body against any received exchange exactly once.

await_exchanges(n, timeout) blocks until n exchanges arrive or the timeout elapses. It uses Notify, not polling, so it returns the instant the producer appends. Call it before exchange(idx) to avoid an out-of-bounds panic. The method needs a multi-threaded Tokio runtime; #[tokio::test(flavor = "multi_thread")] is the right shape.

MockConfig

FieldDefaultEffect
max_retained10000Drop oldest exchanges past this cap.
copy_on_exchangefalseDeep-clone the body on insert. Set true when the caller mutates the Exchange after sending.
fail_fastfalseStop processing after the first failing assertion and record the error.
assert_period_ms0Default timeout for await_exchanges_with_timeout. 0 means use the explicit fallback.
any_orderfalseMatch 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

LanguageCrateUse case
Simplecamel-language-simpleHeader, body, and property access with ${...} syntax
JSONPathcamel-language-jsonpathQuery JSON bodies with $. syntax
XPathcamel-language-xpathQuery XML message bodies
JavaScriptcamel-language-jsFull JS expressions via embedded engine
Rhaicamel-language-rhaiRust-native embedded scripting
MiniJinjacamel-language-minijinjaJinja2-compatible templating

Reference: Languages overview · Language SPI

Simple

A lightweight expression and predicate language for header, body, property, and exception access. It uses ${...} interpolation syntax and supports compound predicates with && and ||.


    let lang = SimpleLanguage::new();

    let order_pred = lang
        .create_predicate("${header.type} == 'order'")
        .expect("valid predicate"); // allow-unwrap

    let high_priority_order_pred = lang
        .create_predicate("${header.type} == 'order' && ${header.priority} == 'high'")
        .expect("valid compound predicate"); // allow-unwrap

    let body_present_pred = lang
        .create_predicate("${body} != null")
        .expect("valid body presence predicate"); // allow-unwrap

    let approved_pred = lang
        .create_predicate("${header.approved} == true")
        .expect("valid boolean predicate"); // allow-unwrap

    let type_expr = lang
        .create_expression("${header.type}")
        .expect("valid expression"); // allow-unwrap

    let counter = Arc::new(AtomicU64::new(0));
    let counter_clone = Arc::clone(&counter);

    let types = ["order", "invoice", "order", "shipment"];
    let priorities = ["high", "low", "high", "low"];

    let handle = tokio::runtime::Handle::current();

    let route = RouteBuilder::from("timer:tick?period=800&repeatCount=8")
        .route_id("language-simple-demo")
        // Step 1: assign rotating headers and alternate empty/non-empty body
        .process(move |mut exchange: camel_api::Exchange| {
            let c = Arc::clone(&counter_clone);
            Box::pin(async move {
                let n = c.fetch_add(1, Ordering::SeqCst) as usize;
                let msg_type = types[n % types.len()];
                let priority = priorities[n % priorities.len()];
                let approved = n.is_multiple_of(2);
                exchange
                    .input
                    .set_header("type", camel_api::Value::String(msg_type.to_string()));
                exchange
                    .input
                    .set_header("priority", camel_api::Value::String(priority.to_string()));
                exchange
                    .input
                    .set_header("approved", camel_api::Value::Bool(approved));

                exchange.input.body = if n.is_multiple_of(3) {
                    Body::Empty
                } else {
                    Body::Text(format!("message #{}", n + 1))
                };
                Ok(exchange)
            })
        })
        // Step 2: demonstrate body null check
        .filter({
            let pred = Arc::clone(&body_present_pred);
            let h = handle.clone();
            move |ex: &camel_api::Exchange| h.block_on(pred.matches(ex)).unwrap_or(false)
        })
        .to("log:body-present?showBody=true&showHeaders=true")
        .end_filter()
        // Step 3: use Simple Language expression to append type info to body
        .process({
            let type_expr = Arc::clone(&type_expr);
            move |mut exchange: camel_api::Exchange| {
                let expr = Arc::clone(&type_expr);
                Box::pin(async move {
                    if let Ok(camel_api::Value::String(t)) = expr.evaluate(&exchange).await {
                        let current = exchange.input.body.as_text().unwrap_or("").to_string();
                        exchange.input.body = Body::Text(format!("{current} [type={t}]"));
                    }
                    Ok(exchange)
                })
            }
        })
        // Step 4: filter — only 'order' messages pass
        .filter({
            let pred = Arc::clone(&order_pred);
            let h = handle.clone();
            move |ex: &camel_api::Exchange| h.block_on(pred.matches(ex)).unwrap_or(false)
        })
        .to("log:orders?showBody=true&showHeaders=true")
        .end_filter()
        // Step 5: compound predicate with && for high-priority orders
        .filter({
            let pred = Arc::clone(&high_priority_order_pred);
            let h = handle.clone();
            move |ex: &camel_api::Exchange| h.block_on(pred.matches(ex)).unwrap_or(false)
        })
        .to("log:high-priority-orders?showBody=true&showHeaders=true")
        .end_filter()
        // Step 6: boolean comparison against true literal
        .filter({
            let pred = Arc::clone(&approved_pred);
            let h = handle.clone();
            move |ex: &camel_api::Exchange| h.block_on(pred.matches(ex)).unwrap_or(false)
        })
        .to("log:approved-orders?showBody=true&showHeaders=true")
        .end_filter()
        .build()?;
YAML equivalent
- id: language-simple-demo
  from: timer:tick?period=800&repeatCount=8
  steps:
    - set_header:
        key: type
        value: order
    - set_header:
        key: priority
        value: high
    - set_header:
        key: approved
        value: "true"
    - set_body:
        simple: "${header.type}"
    - filter:
        simple: "${body} != null"
        steps:
          - to: log:body-present?showBody=true&showHeaders=true
    - filter:
        simple: "${header.type} == 'order'"
        steps:
          - to: log:orders?showBody=true&showHeaders=true
    - filter:
        simple: "${header.type} == 'order' && ${header.priority} == 'high'"
        steps:
          - to: log:high-priority-orders?showBody=true&showHeaders=true
    - filter:
        simple: "${header.approved} == true"
        steps:
          - to: log:approved-orders?showBody=true&showHeaders=true

Simple parses its source once into an AST and reuses it for each Exchange. You build predicates and expressions up front with create_predicate and create_expression, then move them into route closures. Predicates return a boolean for filter and choice steps. Expressions return a Value for enrichment and transformation. The included example constructs four predicates and one expression before the route.

The language evaluates ${header.x}, ${body}, and ${exchangeProperty.y} against the Exchange. Missing headers, properties, and exception messages evaluate to Value::Null. At the predicate boundary, null is false. All other values, including an empty string, are true. Within && and ||, null and empty strings are false. Simple also supports language delegation through ${lang:expr}, which resolves the target language at evaluation time.

Simple is the most common language for predicates in EIP patterns. Use it for flat header and body access in filter, choice, and enrichment steps. It requires no external engine. For structured JSON queries, use JSONPath.

Reference: Language SPI · Simple crate

JSONPath

An RFC 9535 JSONPath expression and predicate language over jsonpath-rust. It evaluates $. queries against an Exchange JSON body.

    ctx.register_language("jsonpath", Box::new(JsonPathLanguage::new()))
        .expect("jsonpath not yet registered"); // allow-unwrap

    let lang = JsonPathLanguage::new();

    let customer_expr = lang
        .create_expression("$.customer")
        .expect("valid expression"); // allow-unwrap

    let active_pred = lang.create_predicate("$.active").expect("valid predicate"); // allow-unwrap

    let customer_expr = Arc::new(customer_expr);
    let active_pred = Arc::new(active_pred);
    let route = RouteBuilder::from("timer:tick?period=800&repeatCount=6")
        .route_id("language-jsonpath-demo")
        .process(move |mut exchange: camel_api::Exchange| {
            let c = Arc::clone(&counter_clone);
            Box::pin(async move {
                let n = c.fetch_add(1, Ordering::SeqCst) as usize;
                let active = (n as u64).is_multiple_of(2);
                let customer = customers[n % customers.len()];
                let body = json!({
                    "active": active,
                    "customer": customer,
                    "order": { "id": n + 1 }
                });
                exchange.input.body = Body::Json(body);
                Ok(exchange)
            })
        })
        .process({
            let expr = Arc::clone(&customer_expr);
            move |mut exchange: camel_api::Exchange| {
                let expr = Arc::clone(&expr);
                Box::pin(async move {
                    if let Ok(value) = expr.evaluate(&exchange).await
                        && let Some(name) = value.as_str()
                    {
                        exchange
                            .input
                            .set_header("customer", camel_api::Value::String(name.to_string()));
                    }
                    Ok(exchange)
                })
            }
        })
        .to("log:all-orders?showBody=true&showHeaders=true")
        .filter({
            let pred = Arc::clone(&active_pred);
            let handle = tokio::runtime::Handle::current();
            move |ex: &camel_api::Exchange| handle.block_on(pred.matches(ex)).unwrap_or(false)
        })
        .to("log:active-orders?showBody=true&showHeaders=true")
        .end_filter()
        .build()?;
YAML equivalent
- id: language-jsonpath-demo
  from: timer:tick?period=800&repeatCount=6
  steps:
    - set_body:
        value: '{"customer": "Alice", "active": true}'
    - set_header:
        key: customer
        jsonpath: "$.customer"
    - to: log:all-orders?showBody=true&showHeaders=true
    - filter:
        jsonpath: "$.active"
        steps:
          - to: log:active-orders?showBody=true&showHeaders=true

JsonPathLanguage validates the query prefix and syntax when it creates an Expression or Predicate. You register the language into CamelContext by name, then build expressions and predicates up front. The included example registers jsonpath and constructs one expression ($.customer) and one predicate ($.active) before the route. Expressions extract values from the JSON body. Predicates gate filter steps.

The query is trusted operator configuration. The JSON body is untrusted, adversary-controlled data under ADR-0032. Exchange data never enters the query string. The implementation stores the operator query and passes body content separately to jsonpath-rust. Resource bounds protect against large or deeply nested input. max_input_bytes bounds a text body before JSON parsing. The default is 16 MiB. max_depth bounds JSON nesting. The default is 64 levels.

Use JSONPath when the body contains structured JSON and you need to extract nested fields or test conditions. For flat header and body access, Simple is lighter and needs no JSON parsing.

Reference: Language SPI · JSONPath crate

XPath

An XPath 1.0 expression and predicate language over sxd-document and sxd-xpath. It evaluates //node and /path queries against an Exchange XML body.

    ctx.register_language("xpath", Box::new(XPathLanguage::new()))
        .expect("xpath not yet registered"); // allow-unwrap

    let lang = XPathLanguage::new();

    let title_expr = lang
        .create_expression("/catalog/book[1]/title")
        .expect("valid expression"); // allow-unwrap

    let in_stock_pred = lang
        .create_predicate("/catalog/book[@in-stock='true']")
        .expect("valid predicate"); // allow-unwrap

    let title_expr = Arc::new(title_expr);
    let in_stock_pred = Arc::new(in_stock_pred);
    let route = RouteBuilder::from("timer:tick?period=800&repeatCount=6")
        .route_id("language-xpath-demo")
        .process(move |mut exchange: camel_api::Exchange| {
            let c = Arc::clone(&counter_clone);
            Box::pin(async move {
                let n = c.fetch_add(1, Ordering::SeqCst) as usize;
                let (title, in_stock) = books[n % books.len()];
                let xml = format!(
                    "<catalog><book id=\"{}\" in-stock=\"{}\"><title>{}</title></book></catalog>",
                    n + 1,
                    in_stock,
                    title
                );
                exchange.input.body = Body::Xml(xml);
                Ok(exchange)
            })
        })
        .process({
            let expr = Arc::clone(&title_expr);
            move |mut exchange: camel_api::Exchange| {
                let expr = Arc::clone(&expr);
                Box::pin(async move {
                    if let Ok(value) = expr.evaluate(&exchange).await
                        && let Some(name) = value.as_str()
                    {
                        exchange
                            .input
                            .set_header("book-title", camel_api::Value::String(name.to_string()));
                    }
                    Ok(exchange)
                })
            }
        })
        .to("log:all-books?showBody=true&showHeaders=true")
        .filter({
            let pred = Arc::clone(&in_stock_pred);
            let handle = tokio::runtime::Handle::current();
            move |ex: &camel_api::Exchange| handle.block_on(pred.matches(ex)).unwrap_or(false)
        })
        .to("log:in-stock?showBody=true&showHeaders=true")
        .end_filter()
        .build()?;
YAML equivalent
- id: language-xpath-demo
  from: timer:tick?period=800&repeatCount=6
  steps:
    - set_body:
        value: "<catalog><book in-stock='true'><title>The Rust Book</title></book></catalog>"
    - set_header:
        key: title
        xpath: "/catalog/book[1]/title"
    - to: log:all-books?showBody=true&showHeaders=true
    - filter:
        xpath: "/catalog/book[@in-stock='true']"
        steps:
          - to: log:in-stock?showBody=true&showHeaders=true

You register xpath into CamelContext by name, then build expressions and predicates up front. The included example constructs one expression (/catalog/book[1]/title) and one predicate (/catalog/book[@in-stock='true']) before the route. Expressions extract values from the XML body. Predicates gate filter steps.

The XPath query is trusted operator configuration. The XML body is untrusted, adversary-controlled data under ADR-0032. Exchange data never enters the query string. max_input_bytes bounds the raw XML body before parsing. The default is 1 MiB. Both sxd-document and sxd-xpath are pure Rust and register no filesystem or network resolver. The parser has no <!ENTITY> declaration handler. Recursive entity expansion, including a billion-laughs payload, is structurally unavailable. External entity declarations cannot trigger a file or network fetch.

Known limitations apply. Namespace prefixes are unsupported because the evaluation context has no prefix-to-URI map. Evaluation has no wall-clock timeout. The query is trusted, and the untrusted XML input has a byte bound. The sxd-xpath library is unmaintained. A replacement must preserve the security posture above.

Use XPath when the body carries XML and you need to select elements or test attributes. For structured JSON bodies, use JSONPath.

Reference: Language SPI · XPath crate

JavaScript

A Boa-backed JavaScript implementation of the Language SPI. It provides Expression, Predicate, and MutatingExpression for the synchronous script: path defined by ADR-0006.

use std::time::Duration;

use camel_api::CamelError;
use camel_builder::{RouteBuilder, StepAccumulator};
use camel_component_log::LogComponent;
use camel_component_timer::TimerComponent;
use camel_core::context::CamelContext;

#[tokio::main]
async fn main() -> Result<(), CamelError> {
    tracing_subscriber::fmt().with_target(false).init();

    let mut ctx = CamelContext::builder().build().await.unwrap(); // allow-unwrap
    ctx.register_component(TimerComponent::new());
    ctx.register_component(LogComponent::new());

    let route = RouteBuilder::from("timer:tick?period=1000&repeatCount=3")
        .route_id("language-js-demo")
        .script(
            "js",
            r#"
            camel.headers.set("greeting", "Hello from JS");
            camel.headers.set("processedBy", "language-js-example");
            camel.body = "JS was here";
            "done";
            "#,
        )
        .to("log:js-output?showBody=true&showHeaders=true")
        .build()?;

    ctx.add_route_definition(route).await?;
    ctx.start().await?;

    println!("JS Language example running for ~3 ticks...");

    tokio::time::sleep(Duration::from_millis(3500)).await;

    ctx.stop().await?;
    println!("Stopped.");

    Ok(())
}
YAML equivalent
- id: language-js-demo
  from: timer:tick?period=1000&repeatCount=3
  steps:
    - script:
        language: js
        source: |
          camel.headers.set("greeting", "Hello from JS");
          camel.headers.set("processedBy", "language-js-example");
          camel.body = "JS was here";
          "done";
    - to: log:js-output?showBody=true&showHeaders=true

The route above uses a .script("js", ...) step, which runs as a MutatingExpression. The script reads and writes exchange data through the camel global. camel.headers and camel.properties are map-like views with get, set, has, remove, and keys. camel.body holds the body value. After successful evaluation, the implementation writes body, header, and property changes back to the Exchange. An error leaves the Exchange unchanged.

Each evaluation creates a fresh Boa Context. State does not leak between evaluations. Boa receives no filesystem, network, environment, stdio, or WASI capability. The only host bindings are the exchange snapshot under camel and a tracing-backed console. Script source is trusted operator configuration. Exchange data is untrusted under ADR-0032. The implementation converts exchange data to JavaScript values. It never concatenates exchange data into script source or evaluates it as code. Untrusted JavaScript must run out-of-process through the function: path from ADR-0005.

Resource limits bound runaway scripts. The default execution timeout is 5,000 ms, enforced by tokio::time::timeout around spawn_blocking. The timeout returns control to the route but cannot cancel a running blocking task. Boa runtime limits eventually stop it. Boa caps loop iterations at 100,000, recursion depth at 512, and stack slots at 10,240. Source size is bounded at 1 MiB. Boa 0.21 has no heap-size limit. A small script can still allocate a large object graph. Do not run untrusted JavaScript in-process.

Reference: Language SPI · JS crate

Rhai

A Rhai implementation of the Language SPI. It provides Expression, Predicate, and MutatingExpression with an unconditional in-process sandbox and Rust-native type safety.


    let lang = RhaiLanguage::new();

    // Expression 1: prepend "[URGENT] " when priority header is "high"
    let enrich_expr = lang
        .create_expression(
            r#"
            if header("priority") == "high" {
                "[URGENT] " + body
            } else {
                body
            }
            "#,
        )
        .expect("valid expression"); // allow-unwrap

    // Expression 2: in-script mutation — compute a tax string using
    // set_header to store an intermediate value, then read it back.
    let tax_expr = lang
        .create_expression(
            r#"
            let tax = header("amount") * 0.1;
            set_header("tax", tax);
            "tax=" + header("tax")
            "#,
        )
        .expect("valid expression"); // allow-unwrap

    // Predicate: only let through messages where amount > 100
    let high_value_pred = lang
        .create_predicate(r#"header("amount") > 100"#)
        .expect("valid predicate"); // allow-unwrap

    let enrich_expr = Arc::new(enrich_expr);
    let tax_expr = Arc::new(tax_expr);
    let high_value_pred = Arc::new(high_value_pred);
    let route = RouteBuilder::from("timer:tick?period=900&repeatCount=6")
        .route_id("language-rhai-demo")
        // Step 1: assign headers and body
        .process(move |mut exchange: camel_api::Exchange| {
            let c = Arc::clone(&counter_clone);
            Box::pin(async move {
                let n = c.fetch_add(1, Ordering::SeqCst) as usize;
                let priority = priorities[n % priorities.len()];
                let amount = amounts[n % amounts.len()];
                exchange
                    .input
                    .set_header("priority", camel_api::Value::String(priority.to_string()));
                exchange
                    .input
                    .set_header("amount", camel_api::Value::from(amount));
                exchange.input.body = Body::Text(format!("order #{}", n + 1));
                Ok(exchange)
            })
        })
        // Step 2: Rhai expression enriches the body based on priority
        .process({
            let expr = Arc::clone(&enrich_expr);
            move |mut exchange: camel_api::Exchange| {
                let expr = Arc::clone(&expr);
                Box::pin(async move {
                    if let Ok(camel_api::Value::String(enriched)) = expr.evaluate(&exchange).await {
                        exchange.input.body = Body::Text(enriched);
                    }
                    Ok(exchange)
                })
            }
        })
        .process({
            let expr = Arc::clone(&tax_expr);
            move |mut exchange: camel_api::Exchange| {
                let expr = Arc::clone(&expr);
                Box::pin(async move {
                    if let Ok(camel_api::Value::String(tax_info)) = expr.evaluate(&exchange).await {
                        let body = exchange.input.body.as_text().unwrap_or("").to_string();
                        exchange.input.body = Body::Text(format!("{body} ({tax_info})"));
                    }
                    Ok(exchange)
                })
            }
        })
        // Step 4: .script() — mutating Rhai expression tags the order and
        //         appends a status suffix. Changes propagate back to the Exchange.
        .script(
            "rhai",
            r#"
            headers["processed"] = true;
            let status = if headers["priority"] == "high" { "PRIORITY" } else { "STANDARD" };
            body = body + " [" + status + "]";
            "#,
        )
        // Step 5: log every message (after enrichment)
        .to("log:all-orders?showBody=true&showHeaders=true")
        // Step 6: filter — only high-value orders (amount > 100) to alert log
        .filter({
            let pred = Arc::clone(&high_value_pred);
            let handle = tokio::runtime::Handle::current();
            move |ex: &camel_api::Exchange| handle.block_on(pred.matches(ex)).unwrap_or(false)
        })
        .to("log:high-value-alert?showBody=true")
        .end_filter()
        .build()?;
YAML equivalent
- id: language-rhai-demo
  from: timer:tick?period=900&repeatCount=6
  steps:
    - set_header:
        key: priority
        value: high
    - set_header:
        key: amount
        value: 200
    - set_body:
        value: "order #1"
    - script:
        language: rhai
        source: |
          headers["processed"] = true;
          let status = if headers["priority"] == "high" { "PRIORITY" } else { "STANDARD" };
          body = body + " [" + status + "]";
    - to: log:all-orders?showBody=true&showHeaders=true
    - filter:
        rhai: 'header("amount") > 100'
        steps:
          - to: log:high-value-alert?showBody=true

You register rhai into CamelContext by name, then build expressions and predicates up front. The included example constructs two expressions and one predicate before the route. Read-only expressions and predicates expose body and headers variables plus header(), set_header(), property(), and set_property() host functions. Their writes affect only the current evaluation. A MutatingExpression exposes body, headers, and properties as mutable scope variables. The implementation writes all three back to the Exchange only after successful evaluation.

Rhai integrates with Rust types without a foreign-function boundary. Exchange values bind directly as Rhai values, and the engine has no external runtime dependency. The sandbox closes filesystem, module, and network access through independent layers. The workspace enables Rhai's no_module feature, each evaluation uses Engine::new_raw(), and disable_symbol blocks eval and import. The sandbox has no configuration opt-out. Rhai source is trusted operator configuration. Exchange data is untrusted under ADR-0032 and never evaluated as source code.

Resource limits bound CPU and memory use. Defaults are 100,000 max operations, 1 MiB max string size, 10,000 max array elements, 10,000 max map entries, 64 max expression depth, and a 5,000 ms execution timeout. The timeout wraps synchronous evaluation in spawn_blocking. It returns control to the route after five seconds but cannot cancel the blocking task. The operation limit eventually stops a CPU-bound script.

Use Rhai for complex logic in pipeline steps: branching, computation, and multi-step mutation. For flat header and body access, Simple is lighter and needs no engine. For JavaScript-syntax scripting, use JavaScript.

Reference: Language SPI · Rhai crate

MiniJinja

A MiniJinja (Jinja2-compatible) template rendering implementation of the Language SPI. It renders structured output such as HTML, JSON, or prompts from Exchange data.

use camel_language_api::Language;
use camel_language_minijinja::MinijinjaLanguage;

let lang = MinijinjaLanguage::default();
let expr = lang.create_expression(
    r#"{% autoescape "html" %}
<h1>Hello, {{ headers.name }}!</h1>
{% endautoescape %}"#,
)?;
YAML equivalent
- id: language-minijinja-demo
  from: timer:tick?period=1000&repeatCount=3
  steps:
    - set_header:
        key: name
        value: World
    - script:
        language: minijinja
        source: |
          {% autoescape "html" %}
          <h1>Hello, {{ headers.name }}!</h1>
          {% endautoescape %}
    - to: log:rendered?showBody=true&showHeaders=true

Each MinijinjaExpression owns an Arc<minijinja::Environment<'static>>. Templates are added once during construction and compiled immediately. Subsequent evaluations look up templates by name with no recompilation. At evaluation time, the expression renders the template against the exchange context and returns the output as the expression value. Exchange headers are available as headers.name inside the template.

Every template source must wrap in exactly one top-level {% autoescape "html"|"json"|"none" %}...{% endautoescape %} block. A lexical validator enforces this at compile time and rejects malformed templates immediately. This gives render output a declared escape strategy before any data interpolation occurs. Synchronous MiniJinja rendering runs on a Tokio blocking thread via spawn_blocking. The route future wraps the join handle in tokio::time::timeout for the configured render deadline. MiniJinja fuel provides an instruction budget that stops runaway templates, infinite loops, and algorithmic-complexity attacks.

Use MiniJinja for template-driven body generation: HTML pages, JSON payloads, and prompt strings assembled from multiple fields. For a body derived from a single expression, the transform step is simpler. Phase 1 covers inline templates only. External file loading, {% include %}, template inheritance, and hot-reload belong to Phase 2.

Reference: Language SPI · MiniJinja crate

Data Formats

A data format converts a message body between a wire representation and a structured type. Each format implements the DataFormat trait from camel-api. The trait defines a marshal operation (body to wire) and an unmarshal operation (wire to body). Per ADR-0030, the trait also exposes Exchange-aware hooks for formats that read or write Exchange metadata.

The Marshal and Unmarshal EIP page covers route-level usage.

Available formats

FormatCrateBody mapping
jsonbuilt-in (camel-processor)Text ↔ Json
csvbuilt-in (camel-processor)Text ↔ Json
xmlbuilt-in (camel-processor)Text ↔ Json
protobufcamel-dataformat-protobufJson ↔ Bytes

JSON, CSV, and XML are registered by default. Protobuf ships as a separate crate.

Reference: DataFormat trait

Protobuf

The protobuf data format converts between JSON and binary protobuf wire format. It uses prost-reflect for dynamic message descriptors that the format compiles at runtime. The format requires no compile-time code generation. It ships as a separate crate, camel-dataformat-protobuf.

Marshal converts Body::Json to Body::Bytes. Unmarshal reverses the conversion and returns Body::Json. The round trip preserves field values through the JSON bridge.

When to use protobuf

Choose protobuf when the contract is a gRPC service or when the schema must evolve without breaking older clients. Protobuf carries typed fields, forward and backward compatibility, and a compact binary encoding. Choose JSON instead when the consumer is a browser, a REST API, or any system that reads text. JSON is readable, universal, and cheaper to debug. See Data Formats for the full format catalog.

Construction

ProtobufDataFormat takes a proto file path and a fully-qualified message name:

use camel_dataformat_protobuf::ProtobufDataFormat;

let df = ProtobufDataFormat::new("protos/helloworld.proto", "helloworld.HelloRequest")?;

The constructor compiles the proto file at runtime through camel-proto-compiler. Pass a shared ProtoCache to new_with_cache to reuse the compiled descriptor pool across formats.

The protobuf format is not built-in. Register it before the route starts:

ctx.data_format_registry()
    .register("protobuf", std::sync::Arc::new(df));

Body type support

Body typeMarshalUnmarshal
Body::JsonEncodes to protobuf bytesPasses through
Body::TextParses as JSON, then encodesRejected
Body::BytesValidates and passes throughDecodes to JSON
Body::Empty, Body::Stream, Body::XmlRejectedRejected

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

A YAML route file defines one or more routes. Each route has an identifier, a source endpoint, and an ordered list of processing steps.

Minimal route

The smallest useful route reads from a source and writes to a destination.

routes:
  - id: "hello-timer"
    from: "timer:tick?period=2000&repeatCount=3"
    steps:
      - log: "Hello from config-loaded route!"
      - to: "log:info"

The file opens with a routes list. Each list entry is one route object. The route above, hello-timer, reads from a timer endpoint, logs a greeting, and forwards the exchange to the log:info endpoint.

Route fields

Each route object accepts these top-level fields. Only id and from are required. The nested config objects (error_handler, circuit_breaker, security_policy) are documented in the step verbs reference.

FieldTypeRequiredDefaultDescription
idstringyesUnique route identifier
fromstringyesSource endpoint URI
stepslistno[]Ordered step verbs
auto_startupboolnotrueStart the route when the context starts
startup_orderintegerno1000Ascending start order; shutdown reverses it
sequentialboolnofalseProcess exchanges one at a time
concurrentintegernoMaximum concurrent exchanges
error_handlerobjectnoPer-route error handler
circuit_breakerobjectnoRoute-level circuit breaker
security_policyobjectnoRoute-level authorization
on_completestringnoProducer URI for the success hook
on_failurestringnoProducer URI for the failure hook

auto_startup: false registers the route but does not start its consumer. Start it later through the route controller or control bus. concurrent caps how many exchanges the pipeline processes in parallel; omit it to let the runtime decide. on_complete and on_failure fire when an exchange exits the pipeline, on success or on error respectively.

Error handling

Set error_handler to retry failed exchanges and send them to a dead letter channel when retries run out. The handler holds a redelivery policy and optional per-exception clauses. The full field set lives on the step verbs reference.

List-form variant

The hot-reload subsystem consumes a flatter form. When a file holds only routes and omits the top-level routes wrapper, the parser reads the file as a bare list of route objects:

- from: timer:hot-reload?period=1000
  route_id: hot-reload-route
  steps:
    - to: log:info?showHeaders=true

Each entry uses route_id instead of id and carries from and steps. This list form maps to the canonical route contract the runtime exchanges over the control bus, not the full authoring model.

Next

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.

FieldTypeRequiredDescription
tostringyesTarget endpoint URI
- to: "log:info"

log

Log the exchange state.

FormSyntax
Shortlog: "message"
Fulllog: { 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.

FieldTypeRequiredDescription
keystringyesHeader name
valueanynoLiteral value
simplestringnoSimple expression
rhaistringnoRhai expression
jsonpathstringnoJSONPath expression
xpathstringnoXPath expression
languagestringnoNamed expression language
sourcestringnoExpression 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.

FieldTypeRequiredDescription
keystringyesHeader name to remove
- remove_header:
    key: "CamelHttpPath"

set_property

Set an exchange property. Same expression fields as set_header but keyed by name.

FieldTypeRequiredDescription
namestringyesProperty name
valueanynoLiteral value
simplestringnoSimple expression
rhaistringnoRhai expression
jsonpathstringnoJSONPath expression
xpathstringnoXPath expression
languagestringnoNamed expression language
sourcestringnoExpression source for language
- set_property:
    name: "MyProperty"
    value: 42

set_body

Set the exchange body.

FormSyntax
Literalset_body: "value"
Configset_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.

FieldTypeRequiredDescription
simplestringnoSimple predicate
rhaistringnoRhai predicate
jsonpathstringnoJSONPath predicate
xpathstringnoXPath predicate
languagestringnoNamed expression language
sourcestringnoExpression source for language
stepslistnoChild 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.

FieldTypeRequiredDescription
whenlistnoPredicate blocks (expression fields + steps)
otherwiselistnoFallback 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.

FieldTypeRequiredDescription
stepslistyesProtected steps
catchlistnoCatch clauses
finallyobjectnoFinally 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.

FormSyntax
Shortdelay: 500 (milliseconds)
Fulldelay: { delay_ms: 500, dynamic_header: "X-Delay" }
- delay: 500
- delay:
    delay_ms: 200
    dynamic_header: "X-Delay"

loop

Repeat child steps.

FormSyntax
Countloop: 3
Fullloop: { count: 3, steps: [...] }
Whileloop: { while: { simple: "..." }, steps: [...] }

Full-form fields:

FieldTypeRequiredDescription
countintegernoFixed iteration count (exclusive with while)
whileobjectnoPredicate block; loops while it holds
stepslistnoChild steps per iteration
max_iterationsintegernoSafety 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.

FieldTypeRequiredDefaultDescription
expressionstring/objectnoSplit expression (string or language block)
aggregationstringnolast_winsAggregation strategy
parallelboolnofalseProcess fragments in parallel
parallel_limitintegernoMax parallel fragments
stop_on_exceptionboolnotrueStop on first error
streamingboolnofalseStream the split
streamobjectnoStream config (format, max_record_bytes, batch_size, chunk_size)
stepslistno[]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.

FieldTypeRequiredDefaultDescription
headerstringyesHeader used as the correlation key
correlation_keystringnoAlternative correlation expression
completion_sizeintegernoComplete after N exchanges
completion_timeout_msintegernoComplete after timeout
completion_predicateobjectnoPredicate-block completion trigger
strategystringnocollect_allAggregation strategy
max_bucketsintegernoMax concurrent buckets
bucket_ttl_msintegernoBucket time-to-live
force_completion_on_stopboolnoEmit pending buckets on route stop
discard_on_timeoutboolnoDrop buckets that time out
- aggregate:
    header: "CorrelationId"
    completion_size: 10

marshal

Serialize the body to a data format.

FieldTypeRequiredDescription
marshalstringyesFormat name (json, protobuf, ...)
configobjectnoFormat-specific config
- marshal: "json"

unmarshal

Parse the body from a data format. An optional schema validates the parsed JSON and rejects mismatches.

FieldTypeRequiredDescription
unmarshalstringyesFormat name
schemaobjectnoJSON Schema for validation
configobjectnoFormat-specific config
- unmarshal: "json"

convert_body_to

Convert the body type.

FieldTypeRequiredDescription
convert_body_tostringyesTarget type (json, ...)
- convert_body_to: json

bean

Invoke a registered bean method.

FieldTypeRequiredDescription
namestringyesBean name
methodstringyesMethod name
- bean:
    name: "myBean"
    method: "handle"

script

Run a script inline.

FieldTypeRequiredDescription
languagestringyesScript language (rhai, ...)
sourcestringyesScript source
- script:
    language: "rhai"
    source: "1 + 1"

function

Run a function in an external runtime.

FieldTypeRequiredDescription
runtimestringyesRuntime name (deno, ...)
sourcestringyesFunction source
timeout_msintegernoExecution 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.

FormSyntax
Boolstream_cache: true
Configstream_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.

FieldTypeRequiredDefaultDescription
parallelboolnofalseSend in parallel
parallel_limitintegernoMax parallel sends
stop_on_exceptionboolnofalseStop on first error
timeout_msintegernoPer-endpoint timeout
aggregationstringnolast_winsAggregation strategy
stepslistno[]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.

FieldTypeRequiredDefaultDescription
endpointslistno[]Target endpoint URIs
aggregationstringnolast_winsAggregation strategy
- scatter_gather:
    endpoints:
      - "log:a"
      - "log:b"

recipient_list

Resolve recipients from an expression and send to each.

FieldTypeRequiredDefaultDescription
simplestringnoSimple expression for the recipient list
rhaistringnoRhai expression
languagestringnoNamed expression language
sourcestringnoExpression source for language
delimiterstringno,URI delimiter
parallelboolnofalseSend in parallel
parallel_limitintegernoMax parallel sends
stop_on_exceptionboolnofalseStop on first error
strategystringnoAggregation strategy
- recipient_list:
    simple: "${header.recipients}"

routing_slip

Route through a list of endpoints carried on the exchange.

FieldTypeRequiredDefaultDescription
simplestringnoSimple expression for the slip
rhaistringnoRhai expression
languagestringnoNamed expression language
sourcestringnoExpression source for language
uri_delimiterstringno,URI delimiter
cache_sizeintegerno1000Endpoint cache size
ignore_invalid_endpointsboolnofalseSkip invalid endpoints
- routing_slip:
    simple: "${header.routeSlip}"

dynamic_router

Resolve the next endpoint at each step until the expression returns empty.

FieldTypeRequiredDefaultDescription
simplestringnoSimple expression
rhaistringnoRhai expression
languagestringnoNamed expression language
sourcestringnoExpression source for language
uri_delimiterstringno,URI delimiter
cache_sizeintegerno1000Endpoint cache size
ignore_invalid_endpointsboolnofalseSkip invalid endpoints
max_iterationsintegerno1000Max routing iterations
- dynamic_router:
    simple: "${header.nextEndpoint}"

throttle

Rate-limit the exchange flow.

FieldTypeRequiredDefaultDescription
max_requestsintegeryesMax requests per period
period_secsintegerno1Time period in seconds
strategystringnoThrottle strategy
stepslistno[]Child steps
- throttle:
    max_requests: 10
    steps:
      - to: "log:throttled"

load_balance

Distribute exchanges across target endpoints.

FieldTypeRequiredDefaultDescription
strategystringnoround_robinLoad balance strategy
distribution_ratiostringnoWeighted distribution
stepslistno[]Target endpoints
- load_balance:
    strategy: "round_robin"
    steps:
      - to: "log:a"
      - to: "log:b"

enrich

Enrich the exchange by requesting data from an endpoint.

FormSyntax
Shortenrich: "http:..."
Fullenrich: { 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.

FieldTypeRequiredDefaultDescription
repositorystringyesRepository name
expressionstringyesMessage ID expression
stepslistno[]Steps for first-time exchanges
eagerboolnoReserve the key before processing
remove_on_failureboolnoRemove 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.

FieldTypeRequiredDescription
repositorystringyesRepository name
operationstringyesset, get, get_and_remove, push, or pop
keystringyesClaim check key expression
filterstringnoSelective 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.

FieldTypeRequiredDefaultDescription
repositorystringno"memory"Repository name
keystringyesCache key expression (None = bypass cache)
ttldurationnoTime-to-live for the cached entry
max_entry_bytesintegerno10 MiBMaximum body size to cache
on_misslistyesSub-pipeline to run on cache miss
- cache:
    key: "${header.cacheKey}"
    ttl: "5s"
    on_miss:
      - set_body: "computed"

cache_invalidate

Remove a single key from the cache repository.

FieldTypeRequiredDescription
keystringyesCache key expression
- cache_invalidate:
    key: "${header.cacheKey}"

cache_peek_stale

Serve a cached entry, ignoring its in-band expiry. Used as a stale-read fallback.

FieldTypeRequiredDescription
keystringyesCache key expression
- cache_peek_stale:
    key: "${header.cacheKey}"

sampling

Process one exchange out of every N.

FormSyntax
Shortsampling: 5 (period)
Fullsampling: { period: 5 }
- sampling: 5
- sampling:
    period: 10

sort

Sort the body array by a key expression.

FieldTypeRequiredDefaultDescription
expressionstringyesSort key expression
reverseboolnofalseDescending sort
languagestringnoExpression 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.

FieldTypeRequiredDescription
batchobjectnoBatch config: correlation, sort, completion
streamobjectnoStream 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

FieldTypeRequiredDefaultDescription
dead_letter_channelstringnoDLC endpoint URI
retryobjectnoRedelivery policy
on_exceptionslistnoPer-exception clauses
use_original_messageboolnofalseUse original message in DLC

Redelivery policy

FieldTypeRequiredDefaultDescription
max_attemptsintegeryesMax retry attempts
initial_delay_msintegerno100Initial delay in ms
multiplierfloatno2.0Backoff multiplier
max_delay_msintegerno10000Max delay in ms
jitter_factorfloatno0.0Jitter factor (0.0-1.0)
handled_bystringnoRoute here after retries are exhausted

OnException clause

FieldTypeRequiredDescription
kindstringnoError variant name to match
message_containsstringnoSubstring match on error message
retryobjectnoPer-clause redelivery policy
stepslistnoHandler steps
handledboolnoAbsorb the error
continuedboolnoClear error and continue the pipeline

Circuit breaker config

FieldTypeRequiredDefaultDescription
failure_thresholdintegerno5Failures before opening
open_duration_msintegerno30000Duration in the open state

Security policy config

Choose exactly one form: roles, scopes, ref, wasm, or permission.

FieldTypeRequiredDescription
roleslistnoRequired roles
scopeslistnoRequired scopes
all_requiredboolnoAll roles/scopes required
trust_upstream_principalboolnoAccept a pre-populated principal with no token
refstringnoReference to a policy
wasmstringnoWASM policy source
configmapnoPolicy-specific config
permissionobjectnoPermission-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.

FieldTypeRequiredDefaultDescription
hoststringno0.0.0.0Listen host
portintegerno8080Listen port
pathstringno""Base path
operationslistno[]HTTP operations

REST operation:

FieldTypeRequiredDefaultDescription
methodstringyesHTTP method (GET, POST, ...)
pathstringno/Sub-path
operation_idstringnoUnique operation ID
tostringnoTarget endpoint URI
stepslistno[]Child steps
consumesstringnoapplication/jsonRequest content type
producesstringnoapplication/jsonResponse content type
success_statusintegernoSuccess HTTP status
request_schemaobjectnoRequest body schema
responseobjectnoResponse definition
descriptionstringnoOperation description
parametersmapno{}Additional parameters

Template declaration

FieldTypeRequiredDefaultDescription
idstringyesTemplate identifier
parameterslistno[]Template parameters
routeslistno[]Route definitions with {{param}} placeholders

Templated route instantiation

FieldTypeRequiredDescription
route_template_refstringyesTemplate ID to instantiate
route_idstringnoOverride route ID
parametersmapnoConcrete 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.

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.

FieldTypeDefaultDescription
routesarray of strings[]Glob patterns for route files (YAML or JSON). Discovery runs at startup and on every file change when watch is enabled.
watchboolfalseEnable the file watcher. When true, route file changes trigger a hot reload. See Hot reload.
watch_debounce_msinteger (ms)300Delay after the last file event before a reload. Increase if one save triggers several reloads.
log_levelstring"INFO"One of TRACE, DEBUG, INFO, WARN, ERROR.
timeout_msinteger (ms)5000Per-exchange timeout enforced by the runtime. Must be > 0.
drain_timeout_msinteger (ms)10000Maximum time the runtime waits for in-flight exchanges to finish on shutdown. Must be > 0.
includearray of stringsPaths to other TOML files merged before the profile pass. See Configuration.

The watcher and the file are wired through camel-core::reload_watcher::watch_and_reload (see ADR-0004). The --watch and --no-watch CLI flags override this field at startup.

[components]

Component configuration is untyped TOML keyed by component name. Each component bundle parses its own block. The schema below is the union of fields the runtime and core components recognize.

[components.http]
connect_timeout_ms = 5000
response_timeout_ms = 30000
allow_internal = false

[components.kafka]
brokers = "localhost:9092"
group_id = "camel"

The full per-component schema lives in each component's documentation. The Kafka block is the most complex and supports a [components.kafka.brokers_named.<name>] sub-table for pre-configured clusters referenced from URIs with ?brokerName=<name>. See Kafka for the named-broker fields.

Common fieldTypeDefaultDescription
brokersstring or list of stringscomponent-specificConnection string for the underlying client. Kafka accepts a comma-separated string.
host, portstring, integercomponent-specificListen address for components that open servers.
connect_timeout_msintegercomponent-specificTimeout for establishing a connection.
allow_internalboolfalseAllow 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.

FieldTypeDefaultDescription
max_attemptsinteger or null5Maximum restart attempts. null retries forever.
initial_delay_msinteger (ms)1000Delay before the first attempt. Must be > 0.
backoff_multiplierfloat2.0Multiplier applied to the delay after each failure. Must be >= 1.0.
max_delay_msinteger (ms)60000Cap on the per-attempt delay. Must be > 0.

[observability]

The observability block holds four optional sub-tables. The [observability.tracer] block configures the built-in tracing layer. The other three activate optional exporters.

FieldTypeDefaultDescription
tracertable(built-in defaults)Built-in tracing layer config.
oteltableabsentOpenTelemetry exporter. Absent disables OTLP.
prometheustableabsentPrometheus scrape endpoint. Absent disables the endpoint.
healthtableabsentHTTP health/readiness endpoint. Absent disables the endpoint.

[observability.otel]

OTLP export configuration. The protocol and sampler accept the values listed below; values not in the enum fail at load.

FieldTypeDefaultDescription
enabledboolfalseMaster switch for OTLP export.
endpointstring"http://localhost:4317"OTLP collector endpoint.
service_namestring"rust-camel"Resource attribute identifying the service.
protocolstring"grpc"OTLP transport: grpc or http.
samplerstring"always_on"Sampling strategy: always_on, always_off, ratio.
sampler_ratiofloatnullSampling probability for the ratio strategy. Range 0.0-1.0.
metrics_interval_msinteger (ms)60000Period for the metrics export loop. Must be > 0.
logs_enabledbooltrueInclude log records in the OTLP stream.
resource_attrstable{}Extra resource attributes attached to every export.

[observability.prometheus]

FieldTypeDefaultDescription
enabledboolfalseStart the scrape endpoint.
hoststring"0.0.0.0"Bind address.
portinteger9090Bind port.

[observability.health]

FieldTypeDefaultDescription
enabledboolfalseStart the HTTP health endpoint.
hoststring"0.0.0.0"Bind address.
portinteger8081Bind port.
handler_timeout_msinteger (ms)6000Per-probe timeout. Must exceed the internal 5s registry tick.
forced_ttl_msinteger (ms) or nullnullOptional TTL for forced-unhealthy entries. Disabled by default.

[security]

The security block holds five optional sub-tables. Pick one. Most deployments choose exactly one of oidc, native, or keycloak. The permissions and policies sub-tables extend the chosen identity layer with authorization.

[security.keycloak]
server_url = "https://kc.example.com"
realm = "my-realm"
client_id = "camel"
client_secret = "{{env:KC_SECRET}}"
FieldTypeDefaultDescription
oidctableabsentGeneric OIDC token validation.
nativetableabsentBuilt-in issuer with m2m clients.
keycloaktableabsentKeycloak realm with validation, JWKS, introspection, and UMA.
permissionstable of tablesabsentNamed permission evaluators keyed by policy name.
policiestableabsentRegistry of WASM security policies referenced by route configuration.

[security.oidc]

FieldTypeDefaultDescription
issuerstring(required)OIDC issuer URL used to discover endpoints.
jwks_uristringnullJWKS endpoint. Defaults to ${issuer}/protocol/openid-connect/certs.
audiencearray of strings[]Required aud claim values.
client_idstringnullOAuth2 client ID.
client_secretstringnullOAuth2 client secret. Prefer a placeholder over a literal.
token_endpointstringnullToken endpoint for client credentials flows.
introspection_endpointstringnullToken introspection endpoint.

[security.native]

The native block provides a built-in token issuer plus a list of m2m clients. Set subject to the m2m principal.

FieldTypeDefaultDescription
subjectstring(required)Principal name for the m2m identity.
issuerstringnullIssuer claim on issued tokens.
bearer_tokenstringnullPre-issued bearer token. Prefer a placeholder.
api_keystringnullPre-shared API key. Prefer a placeholder.
rolesarray of strings[]Roles granted to the identity.
scopesarray of strings[]Scopes granted to the identity.
token_issuertableabsentBuilt-in token issuer config.
clientsarray of tables[]m2m clients allowed to authenticate.

[security.native.token_issuer] fields:

FieldTypeDefaultDescription
issuerstring(required)Issuer URL.
audiencearray of strings[]Required aud claim.
token_ttl_secsinteger (s)900Issued-token lifetime.
signing_key_envstring(required)Environment variable holding the signing key (PEM).

[[security.native.clients]] array elements:

FieldTypeDefaultDescription
client_idstring(required)Client identifier.
client_secret_envstring(required)Environment variable holding the client secret.
rolesarray of strings[]Roles assigned to tokens minted for this client.
scopesarray of strings[]Scopes assigned to tokens minted for this client.

[security.keycloak]

FieldTypeDefaultDescription
server_urlstring(required)Keycloak base URL.
realmstring(required)Realm name.
client_idstring(required)Client ID.
client_secretstring(required)Client secret. Prefer a placeholder.
validationtable(defaults below)Token validation options.
jwkstable(defaults below)JWKS cache tuning.
introspectiontable(defaults below)Introspection cache tuning.
umatableabsentUMA authorization provider.
allow_internalboolfalseAllow HTTP and private addresses. Set true only for local Keycloak.

[security.keycloak.validation] fields:

FieldTypeDefaultDescription
methodstring"local"Validation method. "local" validates signature and claims locally.
audiencearray of strings[]Required aud claim values.
clock_skew_secsinteger (s)30Tolerance for exp and nbf claims.

[security.keycloak.jwks] fields:

FieldTypeDefaultDescription
cache_ttl_secsinteger (s)3600How long the JWKS cache holds keys.
refresh_skew_secsinteger (s)60Refresh the cache this many seconds before expiry.

[security.keycloak.introspection] fields:

FieldTypeDefaultDescription
max_entriesinteger10000Maximum cached introspection results.
default_ttl_secsinteger (s)60TTL for positive results.
negative_ttl_secsinteger (s)5TTL 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
FieldTypeDefaultDescription
providerstring(required)Provider key. Currently wasm.
pathstringnullProvider-specific path. WASM providers need a .wasm file.
configtableabsentKey-value pairs passed to the provider.
cachetable(defaults below)Result cache tuning.
limitstableabsentWASM limits. See WASM limits.

[security.permissions.<name>] cache fields:

FieldTypeDefaultDescription
positive_ttl_secsinteger (s)30TTL for allow decisions.
negative_ttl_secsinteger (s)5TTL for deny decisions.
max_entriesinteger10000Cache 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"
FieldTypeDefaultDescription
pathstring(required)Path to the .wasm file. Relative to the project root or absolute.
limitstableabsentWASM limits. See WASM limits.
configtable{}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.

FieldTypeDefaultDescription
pathstring(required)Path to the .db file. Created if it does not exist. Must not be empty.
durabilitystring"immediate"immediate fsyncs on every commit. eventual skips fsync for throughput.
compaction_threshold_eventsinteger10000Trigger 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.

FieldTypeDefaultDescription
pathstring(required)Path to the .redb file. Must not be empty.
durabilitystring"immediate"immediate fsyncs on every key. eventual skips fsync.

[stream_caching]

The Stream Cache step buffers stream bodies past a threshold so the body can be read more than once. The cache applies to the whole runtime, not per route.

FieldTypeDefaultDescription
thresholdinteger (bytes)camel_api::stream_cache::DEFAULT_STREAM_CACHE_THRESHOLDBodies 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.

FieldTypeDefaultDescription
typestring"noop"noop or kubernetes.

[platform] with type = "kubernetes" accepts:

FieldTypeDefaultDescription
namespacestringnullNamespace for the lease object. Defaults to the pod's namespace.
lease_name_prefixstring"camel-"Prefix on the lease object name.
lease_duration_secsinteger (s)15Lease lifetime. Must be > 0.
renew_deadline_secsinteger (s)10Maximum time the leader can hold the lease between renewals. Must be > 0.
retry_period_secsinteger (s)2How often a non-leader retries acquisition. Must be > 0.
jitter_factorfloat0.2Randomisation 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.

FieldTypeDefaultDescription
max-operationsintegerruntime defaultMaximum operations per script. Counter resets each call.
max-string-sizeinteger (bytes)runtime defaultMaximum string size in bytes.
max-array-sizeintegerruntime defaultMaximum array size in elements.
max-map-sizeintegerruntime defaultMaximum map size in key-value pairs.
max-expression-depthintegerruntime defaultMaximum expression nesting depth.
max-function-expression-depthintegerruntime defaultMaximum nesting depth for function call expressions.
execution-timeout-msinteger (ms)runtime defaultWall-clock timeout enforced by the consuming code.

[languages.js.limits]

Boa JavaScript engine limits.

FieldTypeDefaultDescription
execution-timeout-msinteger (ms)runtime defaultWall-clock timeout enforced by the consuming code.
max-loop-iterationsintegerruntime defaultMaximum loop iterations before Boa terminates.
max-recursion-depthintegerruntime defaultMaximum recursion depth for function calls.
max-stack-sizeinteger (slots)runtime defaultMaximum VM stack size in slots, not bytes.

[languages.minijinja.limits]

MiniJinja template engine limits.

FieldTypeDefaultDescription
max-template-source-sizeinteger (bytes)runtime defaultMaximum compiled template source size.
max-context-sizeinteger (bytes)runtime defaultMaximum serialised context size.
max-output-sizeinteger (bytes)runtime defaultMaximum rendered output size.
fuelintegerruntime defaultMiniJinja VM instruction budget.
max-recursion-depthintegerruntime defaultMaximum recursion depth for includes and blocks.
execution-timeout-msinteger (ms)runtime defaultWall-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
FieldTypeResolved defaultDescription
max-total-source-bytesinteger (bytes)16777216 (16 MiB)Maximum total source bytes across a template dependency closure.
max-include-countinteger64Maximum number of included or imported templates per closure.
max-include-depthinteger16Maximum include and extends nesting depth.
max-template-sizeinteger (bytes)1048576 (1 MiB)Maximum size of a single template file.
reload-timeout-msinteger (ms)5000Wall-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
FieldTypeDefaultDescription
pluginstring(required)Plugin identifier or .wasm path. Must be non-empty.
configtable{}Key-value pairs passed to the plugin.
limitstableabsentWASM 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.

FieldTypeRuntime defaultDescription
timeout-secsinteger (s)runtime defaultMaximum execution time per guest call.
max-memoryinteger (bytes)52428800 (50 MiB)Maximum linear memory the guest can allocate. Enforced by wasmtime.
max-concurrent-callsintegerruntime defaultMaximum concurrent invocations against this plugin.
max-wasm-sizeinteger (bytes)10485760 (10 MB)Maximum .wasm file size.
allow-call-schemesstringnull (deny all)Comma-separated URI schemes the guest may call. Empty or null fails closed.
max-stream-bytesinteger (bytes)runtime defaultMaximum body bytes streamed between host and guest.
max-instancesinteger10000Maximum core instances per store.
max-tablesinteger10000Maximum tables per store.
max-table-elementsintegerunlimitedMaximum elements per table.

[datasources]

Named datasource pools. Each entry is keyed by a name routes reference with the sql-ds://<name> URI scheme. The block is a map of DatasourceConfig values.

[default.datasources.appdb]
db_url = "sqlite:file:memdb?mode=memory&cache=shared"
max_connections = 5
FieldTypeDefaultDescription
db_urlstring(required)Connection URL. postgresql://, postgres://, and ws:// (SurrealDB) are recognised. Must not be empty.
providerstringnullProvider override. Defaults from the URL scheme.
max_connectionsintegernullMaximum pool size.
min_connectionsintegernullMinimum pool size.
idle_timeout_secsinteger (s)nullIdle connection timeout.
max_lifetime_secsinteger (s)nullMaximum connection lifetime.
ssl_modestringnullTLS mode. Provider-specific values.
ssl_root_certstringnullPath to the root CA.
ssl_certstringnullPath to the client certificate.
ssl_keystringnullPath to the client key.
extratable{}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.

VariableField
CAMEL_TIMEOUT_MStimeout_ms
CAMEL_DRAIN_TIMEOUT_MSdrain_timeout_ms
CAMEL_WATCHwatch
CAMEL_WATCH_DEBOUNCE_MSwatch_debounce_ms
CAMEL_LOG_LEVELlog_level
CAMEL_RUNTIME_JOURNAL_PATHruntime_journal.path
CAMEL_RUNTIME_JOURNAL_DURABILITYruntime_journal.durability
CAMEL_RUNTIME_JOURNAL_COMPACTION_THRESHOLD_EVENTSruntime_journal.compaction_threshold_events
CAMEL_IDEMPOTENT_REPO_PATHidempotent_repo.path
CAMEL_IDEMPOTENT_REPO_DURABILITYidempotent_repo.durability
CAMEL_SUPERVISION_INITIAL_DELAY_MSsupervision.initial_delay_ms
CAMEL_SUPERVISION_MAX_ATTEMPTSsupervision.max_attempts

CAMEL_CONFIG_FILE and CAMEL_PROFILE are also read, but outside the allowlist. CAMEL_CONFIG_FILE selects the file path before load. CAMEL_PROFILE selects the profile section.

Reference: Config crate

Environment variable interpolation

Substitute environment variables into YAML route files with ${env:VAR} tokens. The tokens expand before YAML parsing, so they work in endpoint URIs, log messages, header values, and any other string field.

Syntax

# =============================================================================
# env-interpolation example routes
#
# Uses ${env:VAR_NAME} to inject runtime environment variables.
# Supports default values with ${env:VAR_NAME:-default} syntax.
# Substitution happens before YAML parsing — works in URIs, messages, values.
#
# Try setting these before running:
#   GREET_TARGET=log:my-app
#   POLL_PERIOD_MS=2000
#   LOG_PREFIX="[demo]"
#
# If unset, defaults kick in automatically (see route 2).
# =============================================================================

routes:
  # ---------------------------------------------------------------------------
  # Route 1: env var in log message and endpoint URI
  # ---------------------------------------------------------------------------
  - id: "env-greet"
    from: "timer:env-tick?period=${env:POLL_PERIOD_MS}&repeatCount=3"
    steps:
      - log: "${env:LOG_PREFIX} Firing env-interpolation route"
      - set_header:
          key: "app"
          value: "${env:APP_NAME}"
      - to: "${env:GREET_TARGET}"

  # ---------------------------------------------------------------------------
  # Route 2: env var with default fallback syntax
  # ---------------------------------------------------------------------------
  - id: "env-defaults"
    from: "timer:env-defaults?period=${env:DEFAULT_POLL_MS:-3000}&repeatCount=2"
    steps:
      - log: "${env:DEFAULT_LOG_PREFIX:-[defaults]} Using fallback defaults"
      - to: "${env:DEFAULT_TARGET:-log:info}"

${env:VAR} reads the variable VAR. ${env:VAR:-default} uses default when VAR is unset. An unset variable with no default fails route discovery. The error names the variable. Set a default or export the variable to avoid the failure.

How it works

The DSL loader (camel_dsl::interpolate_env) scans route source for ${env:...} patterns and replaces them before YAML parsing. Substituted values pass through sanitize_env_value, which strips control characters and newlines. This blocks newline injection from a hostile or malformed variable.

The PropertiesResolver type in camel-config exposes the same resolution as a public API for config-value placeholders.

Setup

    let config =
        CamelConfig::from_file("Camel.toml").map_err(|e| CamelError::Config(e.to_string()))?;

    let mut ctx = CamelContext::builder().build().await.unwrap(); // allow-unwrap
    ctx.register_component(TimerComponent::new());
    ctx.register_component(LogComponent::new());

    let routes = discover_routes(&config.routes).map_err(|e| CamelError::Config(e.to_string()))?;

The Camel.toml for this example is minimal. Route discovery and component registration follow the standard pattern.

[default]
routes = ["routes/**/*.yaml"]
log_level = "INFO"

When to use

  • Twelve-factor apps: inject configuration that varies per deploy through the environment, not through files in source control.
  • Secrets: pass credentials and tokens from the environment. The route file never stores the secret value.
  • Per-environment endpoints: point routes at different brokers, HTTP hosts, or databases without editing route files.

Reference: PropertiesResolver in the Config crate

Hot reload

Hot reload swaps route pipelines at runtime without stopping the context. When a route file changes on disk, the runtime compiles the new pipeline and swaps it atomically. In-flight exchanges complete against the pipeline snapshot they entered.

Architecture

The runtime stores the active pipeline behind ArcSwap. A swap publishes a new Arc in one atomic step. New exchanges see the new pipeline immediately. Exchanges already in flight hold their existing Arc and finish against the old pipeline. Old and new pipelines coexist until the last in-flight exchange drains.

Reference: ADR-0004

Configuration

Set watch_debounce_ms in Camel.toml to control the debounce delay. The watcher waits this long after the last file event before it reloads. Increase the value if one save triggers several rapid reloads.

[default]
routes = ["routes/**/*.yaml"]
log_level = "INFO"

# Debounce delay for the file watcher (milliseconds).
# Increase if you see multiple rapid reloads on a single save.
watch_debounce_ms = 300

Usage

Load the debounce from config

The hot-reload-yaml example reads watch_debounce_ms from Camel.toml and passes it to watch_and_reload.

    // ── 0. Load configuration (watch_debounce_ms etc.) ───────────────────────
    // Falls back to the CamelConfig field default (300 ms) if Camel.toml is absent.
    let debounce_ms = CamelConfig::from_file("Camel.toml")
        .map(|c| c.watch_debounce_ms)
        .unwrap_or(300);
    println!("[0] watch_debounce_ms = {debounce_ms} ms  (set in Camel.toml)");

Start the watcher

The hot-reload example resolves the directories to watch, then starts watch_and_reload in a background task. A CancellationToken stops the watcher on shutdown.

    tokio::spawn(async move {
        let watch_dirs = resolve_watch_dirs(&watch_patterns);
        let result = watch_and_reload(
            watch_dirs,
            ctrl,
            move || {
                camel_dsl::discover_routes(&watch_patterns)
                    .map_err(|e| CamelError::RouteError(e.to_string()))
            },
            Some(shutdown_watcher),
            std::time::Duration::from_secs(10),
            std::time::Duration::from_millis(300),
        )
        .await;

The watched route file uses a plain YAML route:

- from: timer:hot-reload?period=1000
  route_id: hot-reload-route
  steps:
    - to: log:info?showHeaders=true

How it works

  1. The file watcher monitors route directories for changes.
  2. After the debounce window, it calls discover_routes to reload route definitions.
  3. It computes reload actions (swap, add, remove) by comparing the old and new routes.
  4. 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

ServiceCrateDescription
Prometheuscamel-prometheusRoute metrics at /metrics plus health endpoints
OpenTelemetrycamel-otelTraces, metrics, and logs over OTLP
Authcamel-authToken validation, claim mapping, permission checks
Function runtimecamel-functionOut-of-process user code invoked as function: pipeline steps
Bridgecamel-bridgeSpawns 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:

EndpointPurpose
/metricsPrometheus scrape
/healthzliveness probe (200 while alive)
/readyzreadiness probe (200 healthy, 503 not)
/healthdetailed JSON health report

Exposure posture

Diagnostic endpoints follow the Prometheus scrape convention: unauthenticated by default, with TLS and auth as opt-in hooks. The default bind address prefers loopback. Binding to 0.0.0.0 emits a startup warning.

See ADR-0052 for the full posture.

Cardinality contract

Metric label values must come from a closed or bounded set. Never pass raw Exchange body, header, property, or correlation-key data as a label value. Each distinct label combination creates a new Prometheus series. The registry has no cardinality cap or eviction.

See ADR-0032 for the exchange-data trust boundary.

Reference: camel-prometheus crate

Tracing

rust-camel integrates with OpenTelemetry for distributed tracing, metrics, and log export. OtelService owns the lifecycle of the global OTel providers.

OtelService

OtelService is a Lifecycle implementation. It installs and owns the process-global OpenTelemetry tracer, meter, and logger providers. Keep one active instance per process.

Configure the OTLP endpoint and service identity with OtelConfig:

    // Create OpenTelemetry configuration
    // Points to the OTLP collector (grafana/otel-lgtm) running locally
    // metrics_interval_ms=15000 exports metrics every 15s (default 60s is too slow for demos)
    let otel_config = OtelConfig::new("http://localhost:4317", "rust-camel-otel-demo")
        .with_metrics_interval_ms(15000);

Note: Service registration is Rust API only. YAML routes compile to the same RouteDefinition. The service wiring stays in application code.

Create the service and register it with the context:

    // Create the OpenTelemetry service
    // OtelService manages:
    // - Global TracerProvider (spans exported via OTLP)
    // - Global MeterProvider (metrics exported via OTLP)
    // - Global LoggerProvider + Log bridge (logs exported via OTLP)
    // - as_metrics_collector() for automatic route metrics
    let mut otel_service = OtelService::new(otel_config);

    // Initialize tracing subscriber with both stdout and OTel log bridge
    let logger_provider = otel_service.init_logger_provider()?;
    let otel_layer =
        opentelemetry_appender_tracing::layer::OpenTelemetryTracingBridge::new(&logger_provider);

    use tracing_subscriber::layer::SubscriberExt;
    use tracing_subscriber::util::SubscriberInitExt;
    tracing_subscriber::registry()
        .with(tracing_subscriber::fmt::layer())
        .with(otel_layer)
        .init();

    // Build CamelContext with the OtelService as a lifecycle service.
    // with_lifecycle() auto-registers the metrics collector from OtelService.
    let mut ctx = CamelContext::builder()
        .build()
        .await
        .unwrap() // allow-unwrap
        .with_lifecycle(otel_service)
        .with_tracer_config(TracerConfig {
            enabled: true,
            detail_level: DetailLevel::Medium,
            outputs: TracerOutputs {
                stdout: StdoutOutput {
                    enabled: false, // suppress noisy JSON to stdout
                    ..Default::default()
                },
                file: None,
            },
            ..Default::default()
        })
        .await;

Note: Service registration is Rust API only. YAML routes compile to the same RouteDefinition. The service wiring stays in application code.

with_lifecycle() auto-registers the metrics collector from OtelService::as_metrics_collector(). The runtime records route-level metrics automatically.

Operational invariants

  • One global provider set. OtelService::start() installs providers through opentelemetry::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 active OtelService per process.
  • Start before metric use. OtelMetrics resolves its Meter on first use and caches it. Recording before OtelService::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:

HelperAction
extract_contextextract trace context from transport headers
inject_contextinject trace context into transport headers
extract_into_exchangeextract trace context into an Exchange
inject_from_exchangeinject trace context from an Exchange
  • 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:

  1. TokenAuthenticator validates a bearer or API token and returns a Principal. Implementations include IntrospectionAuthenticator (RFC 7662), ApiKeyAuthenticator, StaticTokenAuthenticator, and LocalJwtValidator.
  2. ClaimsMapper maps token or introspection claims into Principal fields: subject, roles, scopes, issuer, audience. JsonPointerClaimsMapper resolves JSON Pointer paths, so any OIDC provider works without code.
  3. PermissionEvaluator evaluates resource, action, and scope requests and returns a PermissionDecision. Route-level security_policy.permission calls it.

The enforcement boundary is SecurityPolicyLayer in camel-core. It evaluates BEFORE route steps run. A granted decision stores Principal properties on the Exchange. A denied decision returns Unauthorized into route error handling.

See ADR-0010 for the pre-pipeline authorization decision.

Keycloak-style auth

The native auth pipeline reproduces a Keycloak flow without external dependencies. It issues JWTs, validates them, and applies role-based policies. The same pipeline works with a real Keycloak through camel-component-keycloak.

Issue tokens with NativeTokenIssuer:

    let alice_token = token_issuer
        .issue_token("alice", "alice-secret", Some("read write"), None)
        .await?;
    let bob_token = token_issuer
        .issue_token("bob", "bob-secret", Some("read"), None)
        .await?;

Validate tokens with LocalJwtValidator and NativeJwksProvider:

    println!("--- JWT Validation ---");

    let alice_principal = validator
        .authenticate_bearer(&alice_token.access_token)
        .await;
    match &alice_principal {
        Ok(p) => println!("Alice OK subject={} roles={:?}", p.subject, p.roles), // allow-secret
        Err(e) => println!("Alice invalid ({e})"),                               // allow-secret
    }

    let bob_principal = validator.authenticate_bearer(&bob_token.access_token).await;
    match &bob_principal {
        Ok(p) => println!(
            "Bob JWT:   VALID  (subject={}, roles={:?})",
            p.subject, p.roles
        ),
        Err(e) => println!("Bob JWT:   INVALID ({e})"),
    }

Apply a RolePolicy that checks for required roles:

    println!("--- Role-Based Security Policy ---");
    let admin_policy: Arc<dyn SecurityPolicy> = Arc::new(RolePolicy::new(
        vec!["admin".to_string()],
        true,
        false,
        validator.clone(),
    ));

    let mut alice_exchange = Exchange::default();
    alice_exchange.input.headers.insert(
        "authorization".to_string(),
        Value::String(format!("Bearer {}", *alice_token.access_token)), // allow-secret
    );

    let mut bob_exchange = Exchange::default();
    bob_exchange.input.headers.insert(
        "authorization".to_string(),
        Value::String(format!("Bearer {}", *bob_token.access_token)), // allow-secret
    );

    let alice_decision = admin_policy.evaluate(&mut alice_exchange).await;
    let bob_decision = admin_policy.evaluate(&mut bob_exchange).await;

    match alice_decision {
        Ok(AuthorizationDecision::Granted { principal }) => {
            println!(
                "Alice vs RolePolicy[admin]: GRANTED (subject={})",
                principal.subject
            );
        }
        Ok(AuthorizationDecision::Denied { reason, .. }) => {
            println!("Alice vs RolePolicy[admin]: DENIED ({reason})");
        }
        Err(e) => println!("Alice vs RolePolicy[admin]: ERROR ({e})"),
        _ => println!("Alice vs RolePolicy[admin]: UNKNOWN decision"),
    }

    match bob_decision {
        Ok(AuthorizationDecision::Granted { principal }) => {
            println!(
                "Bob vs RolePolicy[admin]:   GRANTED (subject={})",
                principal.subject
            );
        }
        Ok(AuthorizationDecision::Denied { reason, .. }) => {
            println!("Bob vs RolePolicy[admin]:   DENIED ({reason})");
        }
        Err(e) => println!("Bob vs RolePolicy[admin]:   ERROR ({e})"),
        _ => println!("Bob vs RolePolicy[admin]:   UNKNOWN decision"),
    }

    println!();
    println!("--- Route with Security Policy ---");

    let mut ctx = CamelContext::builder().build().await.unwrap(); // allow-unwrap
    ctx.register_component(TimerComponent::new());
    ctx.register_component(LogComponent::new());

    let role_policy = RolePolicy::new(vec!["admin".to_string()], true, false, validator.clone());
    let wrapped = BearerInjectingPolicy::new(alice_token.access_token.to_string(), role_policy);

    let secured_route = RouteBuilder::from("timer:tick?period=2000&repeatCount=2")
        .route_id("admin-only-route")
        .security_policy(SecurityPolicyConfig::new(wrapped))
        .to("log:info?showHeaders=true")
        .build()?;

    ctx.add_route_definition(secured_route).await?;
YAML equivalent for the secured route

The Rust example wraps RolePolicy in BearerInjectingPolicy to inject a static demo token. In a YAML route, the bearer token arrives in the transport Authorization header and the policy is declarative.

routes:
  - id: admin-only-route
    from: timer:tick?period=2000&repeatCount=2
    security_policy:
      roles: [admin]
      all_required: true
    steps:
      - to: log:info?showHeaders=true

YAML security_policy accepts one of roles, scopes, ref, wasm, or permission. Routes with security_policy do not support the canonical hot-reload path.

WASM authorization policy

A WASM plugin can serve as an authorization policy. The plugin reads camel.auth.* properties from the Exchange and returns a grant or denial.

    let fixtures_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("fixtures");
    let wasm_path = fixtures_dir.join("role-check.wasm");
    let registry = Arc::new(std::sync::Mutex::new(camel_core::Registry::new()));

    // This example uses the programmatic WasmSecurityPolicy::new() API.
    // For production routes, prefer Camel.toml registration via
    // [security.policies.wasm.<name>] + YAML `security_policy: wasm: <name>`.
    // See crates/components/camel-component-wasm/README.md for details.
    let wasm_policy = WasmSecurityPolicy::new(
        &wasm_path,
        WasmConfig::default(),
        Arc::new(camel_core::RegistryComponentContext::new(registry)),
        HashMap::new(),
    )
    .await?;

    let policy =
        AuthenticatedWasmPolicy::new(validator, alice_token.access_token.to_string(), wasm_policy);

Note: Service registration is Rust API only. YAML routes compile to the same RouteDefinition. The service wiring stays in application code.

YAML equivalent for a production route

Register the WASM policy in Camel.toml under [security.policies.wasm.<name>], then reference it by name in the route.

routes:
  - id: wasm-secured-route
    from: timer:tick?period=1000&repeatCount=5
    security_policy:
      wasm: role-check
    steps:
      - to: log:info?showHeaders=true

For production routes, prefer Camel.toml registration through [security.policies.wasm.<name>] with YAML security_policy: wasm: <name>.

See ADR-0050 for the WASM sandbox capability posture.

Security defaults

The startup-validation phase enforces fail-closed security defaults. Routes refuse to start when required configuration is missing. See ADR-0033 for the full policy.

Reference: camel-auth crate

Function runtime

The camel-function crate runs user code in isolated containers, invoked as function: pipeline steps. The model follows serverless functions: stateless, event-driven units scoped to one execution each.

use camel_function::{ContainerProvider, FunctionConfig, FunctionRuntimeService};
use camel_core::context::CamelContext;
use std::time::Duration;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let provider = ContainerProvider::builder()
        .image("kennycallado/deno-runner:latest")
        .boot_timeout(Duration::from_secs(30))
        .build()?;

    let service = FunctionRuntimeService::with_container_provider(
        FunctionConfig::default(),
        provider,
    );

    let mut ctx = CamelContext::builder()
        .with_lifecycle(service)
        .build()
        .await?;

    ctx.start().await?;
    Ok(())
}

A Function runs in its own container. The runtime starts a Deno runner per registered runtime, then loads each function: step's source into that runner. The step sends an Exchange snapshot, gets an ExchangePatch back, and applies it to the message.

The FunctionInvoker is the contract the function: step calls. It owns the RunnerPool keyed by runtime, manages function registration with ref counting, and dispatches invocations. Errors map into the pipeline as FunctionInvocationError with NotRegistered, RunnerUnavailable, Timeout, or Transport variants.

function: differs from script:. script: runs synchronously inside the pipeline for predicates and simple transformations. function: runs out of process for logic that needs its own runtime. See ADR-0006 for the engine split.

The runtime manages container lifecycle through ContainerProvider. Spawn on first use, warm pool, health check on a background task. ctx.stop() triggers clean shutdown of all runners. See ADR-0005 for the staged prepare, finalize, discard flow that keeps hot reloads transactional.

YAML equivalent for a function step

The Rust code above wires the FunctionRuntimeService into the context. The actual function: step lives in a YAML route and gets loaded at startup. The body, header, and property helpers match the Deno runner API.

routes:
  - id: "enrich-users"
    from: "timer:enrich?period=2000"
    steps:
      - set_body: "hello world"
      - function:
          runtime: deno
          timeout_ms: 5000
          source: |
            export default (camel) => {
              camel.setBody(String(camel.body()).toUpperCase());
              camel.setHeader("X-Enriched", "true");
            };
      - log: "enriched"

Running the example

The function-deno-enrich example loads a route from YAML, spawns a Deno container, and enriches a timer-driven body. Build the runner image first:

cd crates/services/camel-function
docker build -t kennycallado/deno-runner:latest runner/

Then start the example with cargo run from examples/function-deno-enrich. The route fires every 2 seconds.

Example source on GitHub

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:

  1. 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.rs and are &'static str. Three specs ship today: JMS_BRIDGE, XML_BRIDGE, CXF_BRIDGE.
  2. 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.
  3. BridgeTlsMaterial generates a fresh CA plus server and client certs on every spawn. PEM files go to a 0700 TempDir on Unix, 0600 on the key file. The directory is cleaned up when the material drops.
  4. 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 varPurpose
QUARKUS_HTTP_SSL_PORTSSL port the bridge binds
QUARKUS_TLS_BRIDGE_KEY_STORE_PEM_0_CERTServer cert PEM path
QUARKUS_TLS_BRIDGE_KEY_STORE_PEM_0_KEYServer key PEM path
QUARKUS_TLS_BRIDGE_TRUST_STORE_PEM_CERTSCA 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:

  1. CAMEL_JMS_BRIDGE_BINARY_PATH (or the equivalent var for the spec) overrides everything. Use this for local development.
  2. A workspace-root build at {workspace}/bridges/{name}/build/native/{name}. Picked up automatically when cargo xtask build-{name}-bridge has run.
  3. A previously downloaded and verified copy in the cache dir, default ~/.cache/rust-camel/{name}/.
  4. 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

VariableEffect
CAMEL_JMS_BRIDGE_BINARY_PATHUse a local JMS bridge binary, skip download
CAMEL_XML_BRIDGE_BINARY_PATHUse a local XML bridge binary
CAMEL_CXF_BRIDGE_BINARY_PATHUse a local CXF bridge binary
CAMEL_JMS_BRIDGE_RELEASE_URLOverride release download URL (must be https://github.com/**)
CAMEL_XML_BRIDGE_RELEASE_URLSame override for the XML bridge
CAMEL_CXF_BRIDGE_RELEASE_URLSame override for the CXF bridge
CAMEL_BRIDGE_LOG_STDERRDirectory 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 LeadershipService that coordinates which pod owns a named lock. Routes with the master: scheme activate only on the leader.
  • Readiness gate: a ReadinessGate that reports route readiness to the orchestrator. On Kubernetes, it patches status.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 pod status.conditions to signal readiness.
  • KubernetesPlatformIdentity: detects pod name, namespace, and labels from the Downward API.

See crates/camel-api/src/platform.rs for the trait contracts.

Leader election

Leader election uses Kubernetes Lease objects from the coordination.k8s.io API group. Each named lock maps to one Lease in the configured namespace.

The KubernetesLeadershipService runs a background loop for each lock:

  1. Read the current Lease from the API.
  2. If the Lease expired or does not exist, try to acquire it.
  3. If this pod holds the Lease, renew it before the lease duration expires.
  4. If another pod holds a valid Lease, wait and retry.

Fencing token

The Lease carries a camel.io/leader-term annotation. This annotation is a monotonic fencing token. Each takeover increments the term. The Master component stamps every Exchange from a master: route with the current term. Downstream sinks can reject envelopes that carry a stale term. See ADR-0035.

Configuration

KubernetesPlatformConfig controls the election timing:

FieldDefaultDescription
namespace"" (auto-detect)Namespace for Lease objects
lease_name_prefix"camel-"Prefix for Lease names
lease_duration15sValidity duration of a Lease
renew_deadline10sRenew window before Lease expiry
retry_period2sInterval between election cycles when not leader
jitter_factor0.2Random jitter for retry timing (0.0-1.0)

Validation rules from KubernetesPlatformConfig::validate():

  • renew_deadline must be less than lease_duration.
  • retry_period must be less than renew_deadline.
  • jitter_factor must 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 to False with reason "Starting".
  • notify_ready(): sets the condition to True with reason "CamelReady".
  • notify_not_ready(reason): sets the condition to False with 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:

  1. Subscribes to leadership events from the LeadershipHandle.
  2. On StartedLeading, starts the delegate consumer.
  3. On StoppedLeading, stops the delegate consumer.
  4. 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.health wiring.
  • health-demo: runnable server with /healthz, /readyz, and /health endpoints.

Graceful shutdown drains in-flight exchanges up to drain_timeout_ms during CamelContext::stop. Route lifecycle control via ControlBus (ADR-0034) does not yet have a narrative page.

Health

rust-camel exposes health endpoints through the ObservabilityConfig.health block. When enabled, a HealthServer starts on the configured host and port. It serves four HTTP endpoints:

  • /healthz: liveness probe. Returns 200 while the runtime is alive.
  • /readyz: readiness probe. Returns 200 when all routes are Healthy or Degraded. Returns 503 when any route is Unhealthy.
  • /startupz: startup probe. Same response rules as /readyz.
  • /health: detailed JSON report with per-route status. Always returns 200.

A probe handler that times out fails closed. The probe returns 503 with an Unhealthy status.

Configuration

The ObservabilityConfig.health field accepts an optional HealthCamelConfig:

            health: Some(HealthCamelConfig {
                enabled: true,
                host: "0.0.0.0".to_string(),
                port: health_port,
                handler_timeout_ms: 6000,
                forced_ttl_ms: None,
            }),

Note: Service registration is Rust API only. YAML routes compile to the same RouteDefinition. The service wiring stays in application code.

The enabled flag starts the HealthServer. host and port set the bind address. handler_timeout_ms limits each probe handler. The default is 6 seconds (DEFAULT_HANDLER_TIMEOUT). forced_ttl_ms controls how long a forced-unhealthy state persists before the registry re-evaluates the route.

Route

A route registered with the context participates in health checks:

    let route = RouteBuilder::from("timer:health?period=5000")
        .route_id("health-demo-route")
        .to("log:health?showBody=false")
        .build()?;
YAML equivalent
routes:
  - id: health-demo-route
    from: timer:health?period=5000
    steps:
      - to: log:health?showBody=false

The HealthServer evaluates every registered route. A route whose probes return Healthy contributes a healthy status. A route with a Degraded probe still passes readiness (200 on /readyz). A route with an Unhealthy probe fails readiness (503).

Degraded vs Unhealthy

The health subsystem distinguishes two states below Healthy:

  • Degraded: the component can still process Exchanges. The route passes readiness (200, pod Ready).
  • Unhealthy: the component cannot process Exchanges. The route fails readiness (503, pod NotReady).

See the glossary for the canonical definition of these terms.

Reference: Health crate

Extending rust-camel

rust-camel exposes four extension points. Each one has a Rust trait or WIT contract and registers into CamelContext at startup.

ExtensionContractRegisters
Custom componentComponentBundle in camel-component-apiURI scheme; config under [components.<key>]
Expression languageLanguage in camel-language-apiName; six built-in (Simple, JSONPath, XPath, JS, Rhai, MiniJinja)
Data formatDataFormat in camel-apiName; JSON, CSV, XML, Protobuf built-in
WASM plugincamel:plugin WIT in camel-witGuest 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:

  1. Record RouteStartRequested, project Route as Starting
  2. Start the Consumer and Pipeline
  3. Record RouteStarted, project Started

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.

ADRTitleSummary
0001Tower data plane, custom-trait control planeSeparates Exchange processing (Tower Service<Exchange>) from component lifecycle (custom traits).
0002CQRS RuntimeBus for route lifecycleRoute lifecycle mutations go through RuntimeCommandBus with projections and optional event journal.
0003Hexagonal lifecycle coreLifecycle layer uses ports and adapters for persistence and testability.
0004Hot reload via atomic pipeline swapPipeline swap uses ArcSwap so in-flight Exchanges complete against the snapshot they entered.
0005Function out-of-process staged reloadfunction: steps run in isolated containers with prepare/finalize/discard registration.
0006Script synchronous, async to functionJavaScript evaluation is synchronous; async paths delegate to function:.
0007Route-supervised consumer failureConsumer task failure is route-supervised with optional restart policy and backoff.
0008Route templates via JSON tree substitutionTemplate placeholders expand via JSON tree walk before DSL deserialization.
0009HTTP co-hosting API and static routesAPI routes and static mounts share one server per host/port with deterministic dispatch.
0011CanonicalRouteSpec minimal contractv1 is a stable minimal route contract, not a full RouteDefinition mirror.
0015Endpoint-created PollingConsumerPull-based adapter created from an Endpoint for pollEnrich and WASM camel_poll.
0016CanonicalRouteSpec v2 contractv2 adds lifecycle metadata with strict rejection for unsupported fields.
0017DSL YAML snake_case namingDSL keys use snake_case to match Rust field names and schema output.
0018Two-phase route lifecycle persistenceLifecycle commands persist intent before side effects, compensate on failure.
0022StepLifecycle trait and drainStateful pipeline steps get a separate drain hook for background work.
0024PipelineOutcome replaces CamelError::StoppedPipelineOutcome enum replaces CamelError::Stopped for pipeline control flow.
0025Outcome-aware structural EIPsStructural EIPs return PipelineOutcome directly instead of Tower Result.
0026JSON canonical route authoringJSON is the canonical full-DSL format for SDKs and generators; YAML is human convenience.
0029Resequencer continuation boundaryCompiler splits step list at Resequence; post-steps compile into a continuation owned by the service.
0030Exchange-aware DataFormat hooksDataFormat gains default marshal_in_exchange / unmarshal_in_exchange hooks.
0031WASM source worldFourth WIT world source lets WASM guests act as Consumers with their own consumption loop.
0041Component metadata capabilities schemaComponentMetadata schema with OptionKind, UriOption, ComponentCapabilities, CapabilityQuery.
0042Arc<[CompiledStep]> shared snapshotShared snapshot avoids per-Exchange Vec clone for compiled pipeline steps.
0043Pipeline cancellation between stepstask_local! cancel token checked between steps for cooperative cancellation.
0044Route-admission backpressureSemaphore permit acquired before dequeue prevents unbounded in-flight work.
0045camel-core architecture charterCodifies Clean + DDD + CQRS + vertical slices + hexagonal discipline crate-wide.
0046Apache Camel inspiration, not conformanceApache Camel is design inspiration, not conformance authority.
0047Template rendering engineMiniJinja-based external template engine with compile-once caching and atomic hot reload.
0053WIT interface versioningcamel:plugin uses one package-level WIT SemVer, independent from Rust crate versions.

Security

Authentication, authorization, trust boundaries, and capability models.

ADRTitleSummary
0010SecurityPolicy pre-pipeline authorizationRoute-level authorization wraps the Pipeline before any step runs.
0032Exchange-data trust boundaryOperator config is trusted; exchange data is untrusted and must not drive control-plane actions.
0033Security defaults and fail-closed startup validationFive-disposition security policy enforced by a single startup-validation phase.
0034ControlBus capability authorizationControlBus requires an authorizedRoutes allowlist and denies self-restart.
0035Leader-epoch fencing tokenEvery master: delegate envelope carries a monotonic fencing token for split-brain safety.
0036Bridge IPC mutual TLSBridge uses mutual TLS with ephemeral certificates; fail-closed guard rejects placeholder paths.
0037Exec component fail-closed capability modelAllowlisted binaries, argument policy, no shell, bounded stdin.
0050WASM sandbox capability posturePer-world grants for Camel host functions and selective WASI registration.
0051Credential redaction at diagnostic boundariesCredential-bearing types use manual redacting Debug; Serialize must not expose credential bytes.
0052Diagnostic endpoint exposure postureDiagnostic endpoints follow the Prometheus scrape model; network isolation is the operator's duty.

Error Handling

Disposition, drain, supervision, and repository patterns.

ADRTitleSummary
0012Log-level convention by handler-contract boundariesEmitters inside a handler contract log at warn! or below; outside emitters may log at error!.
0019Error disposition in-pipeline recoveryRouteErrorHandler trait injected into the pipeline decides disposition after each step failure.
0023Idempotent Repository traitKey-only IdempotentRepository trait in camel-api for duplicate detection.
0028Claim Check Repository traitPayload-bearing ClaimCheckRepository trait distinct from key-only IdempotentRepository.

Performance and Limits

DoS caps, cardinality limits, and resource bounds.

ADRTitleSummary
0038Configurable DoS capsPer-format config channel for operator-overridable data-format DoS caps.
0039Configurable loop iteration capPer-step max_iterations escape hatch for loop iteration limits.
0040Configurable materialize limitsConfigurable materialize limits for XSLT, XJ, and WASM producers.

Integration

WASM, functions, components, and cross-cutting contracts.

ADRTitleSummary
0013NetworkRetryPolicy and migrationCentralized retry semantics and migration boundaries for adapter retries.
0014WASM plugin config unificationUnified WASM plugin runtime configuration across all plugin types.
0020LLM component provider adapter boundaryLLM component isolates SDK behind a project-owned LlmProvider trait.
0021LLM retry with retry-after manual loopLLM retry honors provider retry_after via manual loop, diverging from ADR-0013.
0027MQTT component 3.1.1 per-endpointMQTT 3.1.1 via rumqttc with one connection per Consumer or Producer.
0048Attestation provenance (retired)Retired HMAC-SHA256 attestation decision kept for history.
0049Workspace non-exhaustive policyPublic contract enums are #[non_exhaustive] by default before the 1.0 API freeze.

Architecture Decision Records

Every architectural choice has a recorded decision. Each ADR states the context, the decision, and the consequences.

Split Tower Data Plane from Control Plane

rust-camel separates exchange processing (data plane) from component/endpoint/consumer lifecycle (control plane). The data plane uses Service<Exchange> throughout — every Processor and Producer is a Tower service. The control plane uses its own trait hierarchy (Component, Endpoint, Consumer) with richer lifecycle semantics.

Forcing components into Tower's request/response model would break lifecycle operations (start, stop, suspend, health) that don't map to call(). Keeping Tower strictly for exchange processing makes EIP composition idiomatic while giving the control plane the contracts it actually needs.

CQRS RuntimeBus for Route Lifecycle Control

Route lifecycle mutations go through RuntimeCommandBus and reads through RuntimeQueryBus, backed by projections and an optional redb event journal. Direct mutable state would be simpler, but the CQRS model gives us crash recovery, command deduplication, and projection-backed reads that don't block the command path.

The added architectural weight is justified because route lifecycle operations (start, stop, hot-reload) are infrequent, and the journal makes the control plane auditable and recoverable without adding a separate database dependency.

Amendment: two-phase lifecycle persistence

ADR-0018 refines this decision: lifecycle commands that perform runtime side effects persist intent first, expose intermediate states such as Starting through projections, then confirm success or compensate to Failed. Aggregate writes use optimistic versions so command handling, projections, event publication, and journal replay stay consistent under concurrent lifecycle operations.

Clarification: synchronous-projection CQRS (amended by ADR-0045)

This is synchronous-projection CQRS, not eventual-consistency CQRS: the read-side projection is updated within the same optimistic-versioned UnitOfWork as the command that produced it, so there is no projection lag on the read path. Supervision decisions that read route status therefore see a state consistent with the last completed command. "Strong consistency" here refers to the read-model freshness guarantee, not CAP-theorem linearizability. CQRS scope is the control plane only; the data plane (Exchange/Pipeline processing) is not CQRS — see ADR-0045 §3.

Hexagonal Architecture for camel-core Lifecycle

The lifecycle layer of camel-core is structured as a hexagonal application: domain and application logic are isolated behind ports (RouteRepositoryPort, ProjectionStorePort, RuntimeEventJournalPort, RuntimeExecutionPort), with concrete adapters providing in-memory and redb implementations.

The extra indirection is intentional — it lets us swap persistence backends, test lifecycle logic without a real runtime, and keep the domain model free of Tokio or storage concerns. A flat concrete implementation would be faster to write but harder to test and extend.

Scope (amended by ADR-0045)

This decision was originally scoped to the lifecycle/ layer only. ADR-0045 extends the hexagonal discipline crate-wide as the primary pillar of the camel-core architecture charter: every behavioral area is a vertical slice with its own internal domain / application / ports / adapters layout. The flat root modules (context.rs, health_registry.rs, datasource.rs, template.rs, registry.rs, language_registry.rs, component_metadata_catalog.rs, startup_validation.rs) are vertical slices awaiting that internal organization — they are remediation targets, not a permanent exception.

ADR-0045 also declares the ceiling: because camel-core deliberately stays one crate through 1.0, the rings are enforced by module discipline + boundary tests rather than by compiler-enforced crate isolation.

Hot Reload via Atomic Pipeline Swap

When a route's steps change but its from: URI stays the same, hot reload compiles the new Pipeline and swaps atomically via ArcSwap. The Consumer is never stopped. Each in-flight Exchange completes against the Pipeline snapshot it entered — ArcSwap guarantees readers hold a stable reference for the lifetime of their access, so old and new Pipelines can coexist momentarily without corruption.

A simpler stop/recreate approach would work but adds unnecessary downtime and Consumer reconnection overhead. The atomic swap keeps the Consumer running and makes the reload effectively zero-downtime for the common case. The tradeoff is more complexity in the reload diffing logic (ReloadAction::Swap vs Restart). There is no explicit drain step — snapshot isolation via ArcSwap makes it unnecessary.

function: Executes Out of Process with Staged Registration

function: steps run in isolated containers managed by a ContainerProvider, not in-process. Registration follows a staged prepare/finalize/discard flow to keep hot reloads transactional — a new function version is prepared before the old one is discarded.

Embedded JS (Boa) or WASM would be simpler but cannot support async I/O, npm packages, or full language runtimes. Out-of-process execution gives functions a real event loop and filesystem access at the cost of transport overhead and a more complex lifecycle. The staged reload prevents half-registered functions from being served during a reload.

script: Is Synchronous; Async JS Belongs in function:

Status: Accepted Absorbs: DEC-1 (JS engine choice: Boa)

script: uses Boa for synchronous expression evaluation only — predicates, header/body transformations, simple logic. Async JS (await, fetch, npm imports) is explicitly out of scope for script: and belongs in function:.

Allowing async in script: would let user code evade circuit breakers, retry logic, and pipeline-level metrics, since those operate at the Tower layer above the script call. It would also introduce a JS event loop inside the Tokio runtime with no clean integration boundary. The function: step already handles the async use case with proper isolation and flow auditability.

Engine choice: Boa (absorbed from DEC-1)

In an integration framework, scripting languages serve as expression evaluators, not orchestration engines. This matches Apache Camel, where all scripting languages (Groovy, JS/Nashorn, Simple, OGNL) are strictly synchronous. Async I/O from a script would also obscure error-handling visibility and lose flow auditability (the YAML would no longer describe the full route). camel-language-js therefore stays on Boa; the original "Boa vs rquickjs" comparison is closed (rquickjs async support is no longer a motivating factor).

StepEngineScope
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 (Option), startup_order (Option), concurrency (Option). Strict rejection enforced for error_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)

CodeCategoryInside handler contract?Required levelSignal replacement
(a)Pipeline Processor/Producer invocationYesdebug! 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)Yesdebug! 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)Nowarn! or error!increment_errors(route_id, "b-prime:<component>:<site>") required if downgraded
(g)Endpoint/Producer creation failure (permanent)Noerror! (preferred)force_unhealthy_for_route(route_id, "endpoint-creation", reason) if downgraded
(e)Server-loop accept/retry transient (grpc, container, http)Nowarn!increment_errors(route_id, "e:<component>:<site>") required if downgraded
(c)Route lifecycle: start/stop/suspend/resume (consumer_management.rs, route_controller.rs)System-brokenerror!None
(d)CLI, bootstrap, application startup/shutdownSystem-brokenerror!None
(f)ControlBus dual-cut (origin side)System-brokenerror!None
(h)Pre-pipeline authorization/policy evaluation faultSystem-brokenerror!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 that Err is returned "if the pipeline failed without an error handler absorbing the error".
  • error_handler.rs::send_to_handler (lines 248–276) returns Ok(exchange) in every branch (no-handler-configured, handler-not-ready, handler-call-failed) and logs error! itself in those branches.

Consequences:

  1. Handler absorbed → send_and_wait returns Ok → the if let Err(...) emitter branch never fires. The route handler (or error_handler.rs itself) is the single ERROR owner.
  2. send_and_wait returns Err → no handler absorbed iterror_handler.rs logged nothing at ERROR → the consumer's if let Err(...) is the only ERROR signal for that failure. Downgrading it to warn! deletes operator visibility for a genuinely unhandled failure.

Therefore:

Site shapeCategoryRequired 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-brokenerror!
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-contracterror! (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 at crates/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 at crates/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 via register_for_route once the endpoint is recreated. There is no force_degraded_for_route API 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:

  1. Allowlist (scripts/xtask/allowlist-log-levels.txt) — explicit per-site <rel path>:<line> entries. Paired allowlist-log-levels.txt.max enforces monotone-non-increasing post-seed.
  2. Inline escape (// allow-log-levels on the same line as error!) — counted across all src/**/*.rs files. Paired allow-inline.max enforces count <= max; migrations adding deferred sites MUST bump allow-inline.max atomically in the same commit.

Every inline escape MUST be preceded (within 3 lines) by a TODO(ADR-0012-<flavor>): ... via bd <id> marker where <flavor>{e-metrics, g, ...} and <id> is a live bd chore id. Lint fails if the TODO marker is missing or the bd id is absent. (Liveness of the bd id is enforced by periodic CI job; the lint verifies format only.) This prevents deferred sites from becoming invisible and permanent — every escape is traceable to a deferral chore.

Migration scope (informational)

Roughly 116 error!() sites across crates/. Estimated ~25 relevelled, ~6 kept as system-broken, ~3 removed as duplicates of the handler log. First migration wave: crates/components/camel-sql/ (including the duplicate-error! bug at consumer.rs:429 on the bridged path) plus crates/components/camel-direct/ (dead bridge_error_handler field removal — direct is producer-driven synchronous, bridging is semantically incoherent for it; closes TODO(DIR-005) as won't-fix).

Two sites were initially mis-categorized as b-bridged and corrected after the second-expert review (see docs/superpowers/reviews/2026-06-04-adr-0012-second-expert-review.md): camel-sql consumer.rs:205 (StreamList downstream send_and_wait) and camel-direct lib.rs:296 (consumer send_and_wait). Both are normal-data sends whose Err means the failure was NOT absorbed by the route handler; they are categorized (b′) outside-contract and MUST keep error! (with // log-policy: outside-contract + increment_errors metric once rc-mf3 lands).

Cross-references

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 of op: FnMut() -> Future<Output = Result<T, E>>. label: Option<&'static str> emits a component identifier as a structured component tracing field (e.g., "ws-producer: transient error — retrying"). Pass None for the unlabeled path. Cancels only via the policy's max_attempts.
  • retry_async_cancelable(policy, label, op, is_retryable, &CancellationToken) — same as retry_async, plus cancellation honored during inter-retry sleep. Cancel-during-sleep returns the last operation error (no synthetic Cancelled variant). Cancel during op itself is the caller's responsibility.
  • The former retry_camel_error convenience wrapper was removed (zero non-test consumers); callers whose error type is CamelError use retry_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 as retry_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 use NetworkRetryPolicy::should_retry / delay_for for 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 attempt counter.
    • Resend after transport error would duplicate non-idempotent writes. Example: JMS producer.

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-cvq was 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-1nm resolved: retry_async and retry_async_cancelable now accept label: Option<&'static str>. All 8 migrated components pass component identity via Some("ws-producer") etc., emitting a structured component tracing field. Operators can filter by component label.
    • Container test dead-store cleanup (pre-existing attempt = 0 before break).

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:

  1. Only the wasm: URI scheme (Processor) parsed them from configuration. Bean, AuthorizationPolicy, and SecurityPolicy were all instantiated with WasmConfig::default() hardcoded at the call site (camel-cli/main.rs:305, authorization_policy.rs::build_permission_registry, all SecurityPolicy callers).
  2. max_memory_bytes was never enforced for any plugin type, including Processor. The 50 MiB default in config.rs:14 was documentation only; every WasmHostState started with StoreLimits::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

  1. Introduce a shared WasmLimitsConfig type in camel-config with Option<T> fields for timeout_secs, max_memory, and max_concurrent_calls. None means "use the runtime default"; the defaults are explicitly applied in WasmConfig::from_limits, the single source of truth.
  2. Embed WasmLimitsConfig in BeanConfig and PermissionProviderConfig. Users tune plugins from Camel.toml:
    [default.beans.<name>.limits]
    timeout-secs = 600
    max-memory = 4294967296
    
  3. 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 same WasmConfig; the URI form is the data-plane surface, Camel.toml is the control-plane surface.
  4. Enforce max_memory_bytes via wasmtime::StoreLimitsBuilder::memory_size in WasmRuntime::create_host_state. Every consumer of create_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-zdi body and in release notes.
  • WasmConfig::default() still exists for tests and as the source of truth for the defaults, but production callers go through from_limits or from_uri.
  • WasmHostState::create_host_state now requires max_memory_bytes. All call sites must be updated when adding new WASM consumers.
  • WasmSecurityPolicy has 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 with path + [limits] + [config] sub-tables. WasmSecurityPolicyConfig struct in crates/camel-config/src/config.rs.
  • Builder: build_security_policy_registry in crates/components/camel-component-wasm/src/security_policy.rs, parallel to build_permission_registry.
  • Wiring: camel-cli/src/lib.rs build_security_compile_context_from_config populates the SecurityPolicyRegistry from Camel.toml + threads through SecurityCompileContext::with_security_policy_registry.
  • DSL semantics: YAML wasm: <name> references the registry name. Per-route config: 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 PollingConsumer trait; endpoints opt in by overriding polling_consumer(). File/SEDA/JMS can return Some(...); HTTP server, Kafka return None.
  • B. Producer file:...?mode=read bridge — REJECTED: violates ADR-0014 (fake URI reuse) and inverts the producer semantic.
  • C. New ReadEndpoint trait — 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 Consumer shape.

Consequences

  • Adding timeout: Duration to PollingConsumer::receive is a breaking change for any external implementor of the trait. Acceptable while the trait has zero implementors in-tree (all current Endpoint::polling_consumer overrides return None). Bump camel-component-api minor version.
  • FileEndpoint::polling_consumer() is the first non-None implementation in the workspace; it must establish the eager-finalization lifecycle (delete/move/idempotency-mark happen inside receive, 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, not AggregationStrategy, to avoid collision with the existing EIP-22 AggregateStrategyDef family.

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

FieldNone defaultRationale
auto_startuptrueMatches current behavior
startup_order0Neutral ordering
concurrencySequentialSafe default

Versioning Policy

  • version is a monotonic schema counter: 1, 2, 3, ...
  • New runtime accepts old canonical specs (backward compatible): validate_contract accepts version >= 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_NAME is a protocol identifier, not versioned

Expansion Principle

A field deserves canonical inclusion only if ALL six criteria are met:

  1. Needed across a stable boundary (runtime command, config, hot-reload, persisted config)
  2. Stable data-only representation exists
  3. Runtime can enforce it without the full DSL model
  4. Default behavior is safe and deterministic
  5. Losing it changes observable behavior
  6. 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:

FieldBehavior
security_policyAlways error, no override
error_handlerError by default, droppable with allow_loss=true
unit_of_workError 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 unless allow_loss=true

compile_canonical_route

  • Respects spec.auto_startup (default true if None)
  • Propagates startup_order and concurrency
  • No longer forces auto_startup(true)

Roadmap

VersionConcernFields
v2Lifecycle/executionauto_startup, startup_order, concurrency
v3Metadata + observabilitydescription, group, metrics_disabled, trace_enabled
v4+Resilience/opssupervision_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

  1. YAML step keys (set_header, wire_tap, poll_enrich, etc.) are snake_case.
  2. YAML field names inside step structs (timeout_ms, cache_ttl_secs, etc.) are snake_case.
  3. serde(rename = "...") is only allowed for Rust reserved words (loop, while, type, etc.).
  4. 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 (not pollEnrich).
  • Existing YAML route files using pollEnrich must update to poll_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

  • Starting is a public lifecycle state, not an implementation detail.
  • RouteRuntimeAggregate::fail() increments version so replayed RouteFailed events and live compensation paths converge.
  • Repository writes must use save_if_version or 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 returns PipelineOutcome (Completed | Stopped | Failed) instead of Result<Exchange, CamelError>. The in-pipeline disposition table below is unchanged; see ADR-0024 for the PipelineOutcome semantics and reply-channel adapter.

Phase 4 amendment (2026-06-22, ADR-0025): retry_step retries the failed compiled step via RetryableStep (generalised from &mut BoxProcessor). RetryOutcome::Stopped exits before the disposition phase — Stop is successful control flow, not exhausted error handling. The StepDisposition model 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 return Ok(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.

ProcessorReasonStatus
MulticastServiceParallel/sequential fan-out must honour stop_on_exception; per-endpoint readiness belongs in call().migrated
ErrorHandlerServiceDeprecated compatibility shell; retry/DLC handling happens in call().migrated
AggregatorServiceAggregation buckets and timeout work are call-time state; readiness has no external endpoint to validate.migrated
RecipientListServiceRecipients are dynamically resolved; readiness is per resolved recipient in call().migrated
WireTapServiceFire-and-forget tap failures MUST NOT block the main pipeline.pending-fix
LoadBalancerServiceFailover/selection strategies must skip broken endpoints in call(), not fail before selection runs.pending-fix
SplitterServiceFragment sub-pipeline readiness is checked per fragment in call(); outer readiness MUST NOT bypass split error policy.pending-fix
StreamingSplitterServiceStreaming fragment readiness is checked per fragment in call(); outer readiness MUST NOT abort before stream policy runs.pending-fix
FilterServiceConditional 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.

ConceptApache Camel 4.xrust-camel
Error handler placementOuter layer wrapping routeIn-pipeline RouteErrorHandler injection
handled=trueBreak original route; optional sub-routeHandled: absorb → DLC → route terminates normally
continued=trueResume original route after errorContinued: clear error → DLC → advance to next step
RedeliveryRetry from point of failureretry_step retries the failed step
DLC defaultDeadLetterChannel handlerCatch-all Handled policy when no onException
Readiness errorsN/A (no readiness concept)Routed through RouteErrorHandler (not permanent)
CircuitBreaker + handled errorSeparate EIP; open = onCallNotPermittedHandled 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_policyretry_stephandle_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

  • ExceptionDisposition replaces handled: bool throughout camel-api. ExceptionPolicy carries a disposition: ExceptionDisposition field. The DSL gains a continued: true field on onException, mutually exclusive with handled: true.
  • RouteChannelService (constructed only when an errorHandler is configured) chains Security → CB(before_call) → Pipeline(run_steps) → CB(after_result). Boundary errors from Security or CB gates go through handle_boundary, which routes them to the DLC.
  • CircuitBreaker after_result receives the post-handler pipeline result. A Handled error counts as CB success — the handler absorbed it, so the CB does not trip. Users who want the CB to count absorbed errors must use Propagate disposition instead.
  • When a DLC is configured with no explicit onException clauses, resolve_error_handler injects a catch-all Handled policy, preserving the old "DLC absorbs all errors" behaviour.
  • ErrorHandlerLayer / ErrorHandlerService are deprecated. New routes should use the RouteChannelService path (automatic when errorHandler is configured). The old Tower layer path is used only when no errorHandler is present.
  • RouteChannelService is pub but gated behind the internal-adapters feature flag; it is not part of the stable public API.
  • send_to_handler always returns Ok(exchange) — the Err branches in handle_step / handle_boundary are 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:

  1. Depend on siumai types directly — Similar to how camel-sql depends on sqlx types directly in its producer.
  2. Define a project-owned LlmProvider trait — Isolate siumai behind a strict adapter boundary.

The SQL precedent (direct sqlx dependency) does not transfer because:

  • sqlx is mature (0.8 stable) with a stable API surface.
  • siumai is 0.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 mock only).
  • 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 shared retry_async helpers 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 sleep and re-acquired on the next attempt, freeing the concurrency slot for other requests during the wait.
  • Total timeout wraps everything — one tokio::time::timeout deadline 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_cancelable with a delay_override callback at that time (do not pre-emptively grow camel-component-api).
  • Cancellation is delivered via Rust's standard future-drop semantics (dropping Body::Stream drops the inner provider stream). No explicit CancellationToken is 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). Uses Vec (not flattening into a single Arc) 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) } → pushes lc into the accumulating vec (lines 144-146).
  • CompiledStep::Segment { lifecycle: Some(lcs) } → extends the vec with lcs (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:

  1. Cancel consumer intake token (line 154).
  2. force_complete_all on aggregator (line 160) — completes open buckets; emits exchanges into pipeline.
  3. Cancel pipeline token (line 166).
  4. Take handles and join both consumer + pipeline tasks (lines 171-193). Zero process() in flight after join.
  5. Drain stateful steps: iterate assembly.lifecycle from ArcSwap snapshot, call shutdown(RouteStop) in route order (lines 202-220).
  6. Drain aggregator via StepLifecycle::shutdown on ManagedRoute.agg_service (lines 225-240).
  7. 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:

  1. stop_route — drains lifecycle via StepLifecycle::shutdown.
  2. swap_pipeline_raw — bypasses the lifecycle check (route is stopped, no in-flight).
  3. 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:

  1. Pre-join: force_complete_all (line 160 of consumer_management.rs) — completes open buckets, emits exchanges into the pipeline. The join then processes those emits.
  2. Post-join: shutdown(RouteStop) on the Arc<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 Vec accumulates via extend in compile_children_segments (lines 135-177).
  • At drain time, PipelineAssembly.lifecycle is also a flat Vec — the Option<Vec<...>> from each CompiledStep is flattened into PipelineAssembly.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:

  • StepLifecycle trait + StepShutdownReason (camel-api).
  • CompiledStep field additions (Process.lifecycle, Segment.lifecycle).
  • PipelineAssembly + new_shared_pipeline_with_lifecycle.
  • Drain loop in stop_route_internal.
  • swap_pipeline lifecycle check + swap_pipeline_raw.
  • AggregatorService::StepLifecycle impl.
  • compile_children_segments lifecycle aggregation.

What Phase 0 does NOT do:

  • Implement StepLifecycle on every stateful processor (Phase 1+).
  • Wire PipelineOutcome::Stopped through Segment data plane (ADR-0025, already done in Phase 4 — but lifecycle collection predates it and is compatible).

Load-bearing citations

File:lineElement
camel-api/src/step_lifecycle.rs:31pub trait StepLifecycle
camel-api/src/step_lifecycle.rs:6StepShutdownReason enum
step_compilers/mod.rs:43pub enum CompiledStep
step_compilers/mod.rs:49Process { lifecycle: Option<Arc<dyn StepLifecycle>> }
step_compilers/mod.rs:64Segment { lifecycle: Option<Vec<Arc<dyn StepLifecycle>>> }
step_compilers/mod.rs:123fn compile_children_segments
step_compilers/mod.rs:135-177Lifecycle aggregation via push/extend
pipeline_runtime.rs:20struct PipelineAssembly
pipeline_runtime.rs:22lifecycle: Vec<Arc<dyn StepLifecycle>>
pipeline_runtime.rs:50fn new_shared_pipeline_with_lifecycle
pipeline_runtime.rs:70fn swap_pipeline_raw (no lifecycle bypass)
consumer_management.rs:135fn stop_route_internal
consumer_management.rs:160force_complete_all (pre-join)
consumer_management.rs:202-220Drain loop (post-join, pre-token-reset)
consumer_management.rs:225-240Aggregator shutdown via StepLifecycle
route_controller.rs:659fn swap_pipeline (lifecycle check)
route_controller.rs:702fn swap_pipeline_raw (bypass)
route_helpers.rs:120ManagedRoute.agg_service
aggregator.rs:181impl StepLifecycle for AggregatorService

ADR-0023: Idempotent Repository Trait Boundary

Date: 2026-06-27 Status: Accepted (Phase 1) References: ADR-0024, ADR-0025 Related: Phase 1 — Tasks 2, 3 (Idempotent Consumer EIP)

Decision

IdempotentRepository trait in camel-api (key-only, Result-returning)

The IdempotentRepository trait lives in camel-api (crates/camel-api/src/idempotent.rs:13) so any crate (component, processor, test) can implement it without depending on camel-core lifecycle internals. This mirrors StepLifecycle placement in camel-api (ADR-0022 §Trait location).

Contract C1: contains() returns Result<bool, CamelError> (NOT bool). Backends (Redis, SQL-backed, S3) have transient read failures. The Idempotent Consumer propagates Err — it must never treat a failed read as "not a duplicate" (crates/camel-api/src/idempotent.rs:32-36).

Key-only: The trait stores keys (String), not full messages. Motivation: an idempotent repository tracks which messages have been seen, not what they contained. Storing full messages would blow memory/backing-store and is the job of a different pattern (Claim Check, Phase 2). Future: a key_fn: Arc<dyn Fn(&Exchange) -> String> on the Idempotent Consumer step derives the key from the exchange (e.g. exchange.message_id(), header-based, body-hash).

// File: crates/camel-api/src/idempotent.rs:14-46
#[async_trait]
pub trait IdempotentRepository: Send + Sync + Debug + 'static {
    fn name(&self) -> &str;
    async fn contains(&self, key: &str) -> Result<bool, CamelError>;
    async fn add(&self, key: &str) -> Result<bool, CamelError>;
    async fn remove(&self, key: &str) -> Result<(), CamelError>;
    async fn clear(&self) -> Result<(), CamelError>;
}

MemoryIdempotentRepository in camel-core (DashMap-backed)

The in-memory implementation uses DashMap<String, ()> for concurrent read/write access without a coarse lock (crates/camel-core/src/idempotent/memory_repository.rs:24). All trait methods take &self, so the repository can be shared via Arc<dyn IdempotentRepository> across pipeline steps.

add() uses DashMap::insert and returns Ok(!was_present)None from insert means the key was new (memory_repository.rs:43).

Registered as the default "memory" repository during CamelContextBuilder::build() (crates/camel-core/src/context_builder.rs:305-311).

NamedRegistry<T> in camel-core (Mutex-based, fallible register)

A generic named-object registry with duplicate detection. Unlike the auth crate's NamedRegistry (crates/services/camel-auth/src/registry.rs:23, DashMap-based, infallible register()), this registry uses Mutex<HashMap<String, Arc<T>>> so register() can reject duplicates with Err(RegistryError::AlreadyRegistered) (crates/camel-core/src/registry.rs:10-54).

Type alias: IdempotentRegistry = NamedRegistry<dyn IdempotentRepository> (crates/camel-core/src/registry.rs:79).

The auth crate's DashMap version overwrites silently — appropriate for security policies where last-writer-wins is acceptable. The Phase 1 registry rejects duplicates because idempotent repository registration is a programming error (two repos with the same name would cause unpredictable routing). If an override is needed, the caller must first remove the existing registration (not yet exposed — future work).

Segment-not-Process decision for Idempotent Consumer (Task 3)

The Idempotent Consumer EIP (Task 3) MUST use OutcomeSegment (not BoxProcessor/Process mode). Rationale:

compose_pipeline (crates/camel-core/src/lifecycle/adapters/route_compiler.rs:35) converts PipelineOutcome::Stopped to Ok(ex) via into_tower_result(). If the Idempotent Consumer ran as a BoxProcessor (Process mode), a duplicate-detected Stopped(ex) would become Ok(ex) — indistinguishable from a successful first-time pass. Downstream steps would continue processing a duplicate exchange.

Using OutcomeSegment (compose_outcome_segment via crates/camel-core/src/lifecycle/adapters/outcome_composition.rs) preserves PipelineOutcome::Stopped across the Idempotent Consumer boundary. The sub-pipeline after the duplicate check is skipped (Stopped propagates to run_steps which terminates the current branch). This is the core fix for Option E/ADR-0024.

RegistryError visibility

RegistryError is pub — it's the error type returned by register_idempotent_repository(), which is a public method on CamelContext. Making RegistryError pub allows external callers to match on AlreadyRegistered.

Supertrait bounds

IdempotentRepository requires Send + Sync + Debug + 'static. Debug enables logging/tracing of repository identity. Send + Sync + 'static are the standard Arc<dyn ...> bounds for trait-object storage in registries.

Context

Problem

Before Phase 1, the Idempotent Consumer EIP had no pluggable key store. The processor would need to hard-code an in-memory HashSet or HashMap, making it impossible to share state across restarts or cluster nodes. Real deployments need Redis, SQL-backed (via SQLx in camel-sql), or other backends.

Key store requirements

  • Pluggable: Backend must be swappable without changing the EIP processor.
  • Thread-safe: Multiple pipeline steps may check/add concurrently.
  • Read-failure-transparent (C1): Transient backend failure must propagate, not silently be treated as "not a duplicate."
  • Key-only: Duplicate detection checks keys, not full messages.
  • Default memory backend: Zero-dependency setup for simple cases.
  • Context-scoped: Repository lives in CamelContext so it is available to all routes and lifecycle-managed.

Why not a single global registry?

Idempotent repository scope is per-route or per-pattern instance, not global. A global registry would require name-scoping at call sites and prevent clean isolation between contexts. CamelContext-scoped registration provides the right granularity: the context owns the lifecycle of its repositories.

Why Mutex, not DashMap for NamedRegistry?

DashMap insert() always succeeds (overwrites silently). NamedRegistry needs fallible register() — reject if name is taken. Mutex<HashMap> provides atomic check-and-insert. Contention is negligible: registrations happen at context-build time, not per-exchange.

The auth crate's DashMap NamedRegistry (crates/services/camel-auth/src/registry.rs:23) overwrites silently, which is appropriate for security policies (last-writer-wins for policy evaluation). The two registries coexist for different use cases.

Phase 2 reuse

NamedRegistry<T> will be reused in Phase 2 (Claim Check) with no structural changes — ~3 lines per call site. The generic bound T: ?Sized + Send + Sync + 'static accommodates both dyn IdempotentRepository and dyn ClaimCheckStore.

Consequences

Trait location

IdempotentRepository in camel-api (idempotent.rs) means any crate can implement it without depending on camel-core. Future backends (Redis in camel-component-redis, SQL in camel-sql via SQLx) can implement the trait remotely.

Interface stability

The trait has no #[non_exhaustive] attribute — adding methods would break existing implementations. Phase 1 considers the 5-method interface stable. If a future backend needs a len() or keys() method, a separate trait or default method with unimplemented!() can be added.

Default memory backend

MemoryIdempotentRepository is registered as "memory" in CamelContextBuilder::build(). Phase 1 is default-only: register() rejects duplicates (RegistryError::AlreadyRegistered), so re-registering "memory" after build() fails. A replace/remove API is future work if override is needed.

No autowiring

The Idempotent Consumer EIP (Task 3) does not auto-discover repositories by name — the DSL step explicitly names which repository to use. This is intentional: auto-discovery would introduce implicit behavior that breaks when multiple repositories of the same type exist.

RegistryError is not CamelError

RegistryError is a separate enum because it represents a compile-time/configuration error (duplicate name), not a data-plane error. Callers inside camel-core can match on AlreadyRegistered directly. If a future layer needs to propagate it through Tower, an Into<CamelError> impl can be added.

PipelineOutcome interaction (Task 3)

The Idempotent Consumer returns PipelineOutcome::Stopped(ex) when a duplicate is detected. This requires OutcomeSegment mode because StopPipelineOutcome::StoppedStopped(ex) is preserved through run_steps only for Segment steps. Process steps (BoxProcessor) go through into_tower_result() which maps StoppedOk(ex) — losing the semantic distinction. See ADR-0024 §3 for the Tower outcome boundary.

Phase 1 boundary

Phase 1 Tasks 2-4 implement:

  • IdempotentRepository trait + MemoryIdempotentRepository (this ADR).
  • NamedRegistry<T> + IdempotentRegistry + CamelContext wiring (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:lineElement
camel-api/src/idempotent.rs:13pub trait IdempotentRepository
camel-api/src/idempotent.rs:32-36Contract C1: contains returns Result<bool, CamelError>
camel-core/src/idempotent/memory_repository.rs:24struct MemoryIdempotentRepository (DashMap-backed)
camel-core/src/idempotent/memory_repository.rs:43add() via DashMap::insert, returns Ok(!was_present)
camel-core/src/registry.rs:10-54struct NamedRegistry<T> (Mutex-based, fallible register)
camel-core/src/registry.rs:79type IdempotentRegistry = NamedRegistry<dyn IdempotentRepository>
camel-core/src/context.rs:27-28idempotent_repositories: IdempotentRegistry field
camel-core/src/context.rs:329-342register_idempotent_repository() / idempotent_repository() methods
camel-core/src/context_builder.rs:305-311Default "memory" repository registration in build()
camel-core/src/lifecycle/adapters/route_compiler.rs:35compose_pipelineinto_tower_result() maps Stopped→Ok
camel-core/src/lifecycle/adapters/outcome_composition.rscompose_outcome_segment — preserves Stopped across Segment
camel-api/src/step_lifecycle.rs:31Parallel: StepLifecycle in camel-api (ADR-0022)
crates/services/camel-auth/src/registry.rs:23Auth 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:

  1. Introducing PipelineOutcome at the correct layer (one above Tower).
  2. Making Stop a route-internal compiled step (not a Tower service returning an error).
  3. Replacing CamelError::Stopped in JMS/OpenSearch with CamelError::ConsumerStopping.
  4. Removing CamelError::Stopped entirely (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.

ConceptApache Camel 4.xrust-camel (after ADR-0024)
Stop EIP semanticsSuccessful break, exchange preservedPipelineOutcome::Stopped(ex)Ok(ex) at Tower boundary
Consumer response on StopBuilt from exchange stateBuilt from exchange state (same as Completed)
Error handler interactionStop bypasses error handlerCompiledStep::Stop bypasses the handler loop
Control-flow encodingIn-pipeline return valuePipelineOutcome (one layer above Tower)
"shutting down" signalShutdownStrategy / lifecycleCamelError::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: ReplyError shown here is the consumer-side wrapper; in production the actual translation site (SequentialPipeline::call / TracedPipeline::call) returns Result<Exchange, CamelError> directly — Failed(err) maps to Err(err). The ReplyError shape is the spec's illustrative name; the implemented adapter (see Task 2) is PipelineOutcome::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 variantHandler returnsPipelineOutcome
Process completes normally(not called)continue loop with returned Exchange
Process errors, handler matches and absorbsStepDisposition::Handled(ex)PipelineOutcome::Completed(ex)
Process errors, handler clears and continuesStepDisposition::Continued(ex)continue loop with ex
Process errors, handler propagatesStepDisposition::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 recoversRetryOutcome::Recovered(ex)continue with recovered ex
Segment returns Failed + retry returns StoppedRetryOutcome::Stopped(ex)PipelineOutcome::Stopped(ex)
Segment returns Failed + retry exhaustsStepDisposition::* (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 PipelineOutcomeResult<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 the OutcomePipeline trait + CompiledStep::Segment variant, 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 Service::Response") applies to public Service impls — i.e., the top-level route pipeline and the consumer reply channel. Nested structural sub-pipelines (Filter, Choice, Loop, Multicast, Split, doTry) are themselves Service impls from their outer pipeline's perspective, and currently communicate Stop to the outer pipeline via the legacy CamelError::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::Response boundary without breaking the boundary rule, so an internal control sentinel is required until EIPs become outcome-aware.

Migration contract (per e_gpt oracle Option E, 2026-06-22):

  • During Phase 3: nested Stop is mapped to StopService at sub-pipeline compilers (step_compilers/{control_flow,routing,splitting}.rs). The outer run_steps recognises Err(CamelError::Stopped) from a step and translates it to PipelineOutcome::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::Stopped across sub-pipeline boundaries without going through Tower Response. Once that lands, StopService and CamelError::Stopped can be removed entirely (Tasks 7 and 22 deferred).

Boundary rule clarification: PipelineOutcome must not cross any public Service::Response. The CamelError::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:lineSitePhase 3 task
crates/components/camel-component-wasm/src/serde_bridge.rs:216WASM error bridge mappingTask 20
crates/components/camel-component-api/src/network_retry.rs:331doc commentTask 23
crates/components/camel-jms/src/producer.rs:140JMS poll_ready misuseTasks 17-18
crates/components/camel-opensearch/src/producer/mod.rs:539OpenSearch poll_ready misuseTask 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:

  • StopService deleted (crates/camel-processor/src/stop.rs removed).
  • CamelError::Stopped variant removed entirely.
  • eip_outcome_to_result() removed.
  • flatten_stop flag on SequentialPipeline/TracedPipeline removed.
  • Err(Stopped) bypass in run_steps removed.
  • Structural EIPs (Filter/Choice/Loop/Throttle/Split/StreamingSplit/Multicast/LoadBalance/doTry) migrated to OutcomePipeline trait + CompiledStep::Segment variant.
  • 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:

  1. Shared constantCAMEL_STOP moved to camel-api as pub const CAMEL_STOP (previously local in throttler.rs and sampling.rs). Re-exported from camel_api.

  2. Helperpub fn is_camel_stop(exchange: &Exchange) -> bool in camel-api checks exchange.property(CAMEL_STOP)as_bool()unwrap_or(false).

  3. run_steps — after each CompiledStep::Process step returns Ok(next), checks is_camel_stop(&next). If true → PipelineOutcome::Stopped(next).

  4. BoxProcessorSegment — when converting Ok(ex) to PipelineOutcome, checks is_camel_stop. If true → PipelineOutcome::Stopped(ex).

  5. SequentialOutcomeSegment — defensive check after child Completed(next). If is_camel_stopStopped(next).

  6. ThrottleSegment::Drop — changed to return PipelineOutcome::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_steps level)
  • Sampling inside BoxProcessorSegment stops sibling (Segment level)
  • 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

OptionDescriptionRuling
ATrait OutcomePipeline onlyLacks ownership/cloning home
BTower Service w/ PipelineOutcome responsepoll_ready boilerplate unjustified
CBox<dyn Fn> pointerLoses trait extensibility for stateful impls
DTrait + wrapper structCHOSEN — 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

  1. Continued after Segment failure continues after the outer Segment step, NOT inside the child pipeline.
  2. Retry retries the whole structural EIP from pre-step Exchange, NOT the failed child step.
  3. If retry attempt returns Stopped(ex), use the retry-attempt stopped Exchange, NOT the original.
  4. doTry remains a local error-handler island; the outer handler sees only unhandled Failed.
  5. Redelivery headers apply to Segment retries same as Process retries.
  6. PipelineOutcome never becomes public Service<Exchange>::Response.
  7. Parallel Split/Multicast must not aggregate completed sibling output after any branch returns Stop.
  8. Tracing/metrics must classify Stop as successful control flow, NOT error.

Glossary

Stop EIPRoute 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:

  1. Shared code names use RouteDsl* (not Yaml*). Both formats lower through the same route_dsl_to_declarative_route function.
  2. The project publishes a generated full-DSL JSON Schema at schemas/dsl/route-schema.json plus TypeScript types under schemas/ts/. Both are regenerated via cargo xtask schema; drift is detected by cargo xtask schema --check (CI gate, non-mutating).
  3. New verbs MUST land with JSON schema + tests + examples first. YAML parity must follow before release.
  4. Discovery supports both formats when configured; default CLI glob remains routes/*.yaml; JSON requires explicit .json glob. No API or doc implies YAML is the "primary" format beyond human-ergonomics defaults.
  5. Error messages carry input format context via InputFormat boundary 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. CanonicalRouteSpec and 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 as u16, not Duration.
  • set_credentials(.., password: Into<Bytes>) — password is byte-oriented.
  • AsyncClient::builder(opts).capacity(N).build() — builder pattern replaces AsyncClient::new(opts, N).
  • Publish.topicBytes (was String).

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 classified outside-contract follow ADR-0012 and carry a replacement signal (rt.metrics().increment_errors(...)); the producer's driver loop logs retried connection errors at warn! level (no error!) 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
  • attachments keyword is parsed as no-op (no attachment support in Phase 2b)

The filter is parsed at route-compile time into ClaimCheckFilter (camel-processor). ClaimCheckService::call() applies it via the merge_stashed() helper. No-filter = backward-compatible body-only restore.

CamelError::RouteError for "not found"

The memory repository returns CamelError::RouteError(format!("...")) for missing-key lookups and empty-stack pops. No new CamelError variant is introduced.

File: crates/camel-core/src/claim_check/memory_repository.rs:55-56, crates/camel-core/src/claim_check/memory_repository.rs:88.

References

  • ADR-0022: StepLifecycle trait and drain — docs/adr/0022-steplifecycle-trait-and-drain.md
  • ADR-0023: Idempotent Repository trait (structural parent) — docs/adr/0023-idempotent-repository-trait.md
  • ADR-0024: PipelineOutcome replaces CamelError::Stopped — docs/adr/0024-pipeline-outcome-replaces-camel-error-stopped.md
  • NamedRegistry<T>: crates/camel-core/src/registry.rs:33-34
  • IdempotentRegistry alias: crates/camel-core/src/registry.rs:77-78
  • Body enum: 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]
  • pre compiles normally into the main pipeline (before the resequencer).
  • post compiles via compose_pipeline_with_contracts into a BoxProcessor continuation owned by the ResequencerService.
  • 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:

  1. The resequencer's single PipelineAssembly swaps atomically with full lifecycle drain (unlike the aggregator's two-pipeline shape that cannot drain via CompiledStep.lifecycle).
  2. No agg_service side-channel — the resequencer IS a pipeline step, reachable by the standard lifecycle drain.
  3. 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:

  1. Set shutdown flag; close input channel (actor sees EOF).
  2. Await actor JoinHandle (bounded deadline).
  3. policy.flush() — emit remaining in order via post-driver.
  4. Close post-driver channel sender.
  5. Await post-driver JoinHandle (5s deadline).
  6. 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 InOut and not allow_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=true on the ack.

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_splits rejects any route containing BOTH a top-level aggregate-requiring-split step AND a top-level Resequence. The predicate tests EVERY top-level Aggregate for has_timeout_condition || force_completion_on_stop (NOT find_top_level_aggregate_requiring_split, whose first-match-then-break under-detects).
  • N3: Reject more than one top-level Resequence.
  • N4: Reject any Resequence reached via compile_children (nested inside Choice/Split/Loop/Filter). The step-compiler registry arm returns RouteError("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:

  1. Logs at warn! (ADR-0012 best-effort).
  2. Increments resequencer_post_ack_failures_total{route} metric.
  3. 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 — log warn! + 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 Process mode (inline reordering in run_steps): impossible — the unary Tower contract cannot emit multiple outputs from one call().
  • 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 and submit-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)

  1. 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-exchange blocks until the pipeline accepts.
    • Cancellation via channel close wakes blocking_recv in accept-http; guest exits run() cleanly.
    • Integration tests: lifecycle start/stop, e2e webhook, backpressure sequential — all pass.
  2. Is package distribution practical? YES.

    • Guest dependencies are minimal: wit-bindgen only (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.
  3. Are crash/lifecycle semantics acceptable? YES.

    • Guest trap → call_run returns Err(wasmtime::Error)spawn_blocking task exits with CamelError::ProcessorError → runtime detects via background_task_handle() → route enters Failed state → restart recreates consumer.
    • stop() cancels token + increment_epoch() + graceful join with timeout — does NOT own run_task (runtime owns it via background_task_handle()).
    • Integration test: crash recovery — guest that traps on 3rd request → consumer reports error → test verifies error propagation.

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). Without store.set_epoch_deadline(N) before call_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 forces handle.block_on() in spawn_blocking, which puts the blocking thread into a tokio runtime context. Host functions using blocking_recv/blocking_send then panic. Solution: use sync bindings and call call_run directly on the blocking-pool thread.
  • with: mapping for resources. Wasmtime bindgen generates empty (uninhabited) enums for imported resources. Use with: { "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 emit rerun-if-changed for its path: wit dir. Integration tests must resolve the guest .wasm via CARGO_TARGET_DIR to avoid testing stale binaries.

Known tech debt

  • to_plugin_wasm_exchange: field-by-field converter between two bindgen! outputs (source vs plugin worlds). Eliminates when WIT-001 unifies type definitions.
  • path_filter wired 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 definition
  • crates/components/camel-component-wasm/src/source_consumer.rs — host consumer
  • examples/wasm-source-webhook/ — guest example
  • crates/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, bd rc-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. The MetricsCollector::record_counter and record_histogram contract (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 requires allow_dynamic_query=true).
  • Migration is mechanical: run camel doctor once; 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 ConfigCheck and a run_startup_validation() entry point. Each Require-Explicit-Choice member becomes one ConfigCheck impl. New members do not require a new architecture.
  • deny_unknown_fields is the highest-friction flip (39 DSL structs). The doctor command 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> returning Ok(()) until later batches register checks). The skeleton compiles, the trait is public, and the ConfigCheck impls that Batch 1 would register (gRPC TLS, aggregator completion bound) are in their respective components. Wiring CamelContext::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

  1. Static route declaration: The target routeId MUST be declared in the endpoint URI (controlbus:route?routeId=target&action=stop). The CamelRouteId header override is removed entirely.

  2. Authorized-routes allowlist: The endpoint MUST declare authorizedRoutes (comma-separated). Only routes in this list can be targeted. If authorizedRoutes is absent, the endpoint fails closed (all commands rejected).

  3. Self-restart denial: If the target routeId equals 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 SecurityPolicy grants for control operations. Rejected: SecurityPolicy is exchange-level auth, not component-configuration-level authz. Mixing the two conflates concerns.
  • allowDynamicRouteId opt-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 CamelRouteId header must declare routeId in the URI + authorizedRoutes.
  • camel doctor migration (post-v1.0.0): A future doctor release will flag every controlbus: endpoint missing authorizedRoutes. 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

  1. 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.
  2. Snapshot semantics: the bridge stamps with its spawn-time snapshot, not a live read. Stale bridges carry stale terms.
  3. Sink contract: downstream systems (databases, message brokers, external APIs) SHOULD check x-camel-leader-epoch and 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.
  4. 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 carries x-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:

  1. 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.
  2. Quarkus native image: Build-time TLS properties (use-separate-server, plain-text, ssl.client-auth, insecure-requests, tls-configuration-name) are hardcoded in application.yml. Placeholder self-signed certs in src/main/resources/tls/ enable SSL at native build time. Runtime cert paths are overridden via ${ENV:default} expressions.

  3. 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.

  4. Connection retry: connect_channel() retries the TLS handshake 10×100ms because Quarkus PortAnnouncer fires on StartupEvent, which can precede full SSL listener readiness in native images.

  5. quarkus-config-yaml: Required Gradle dependency. Without it, Quarkus silently ignores application.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 of values.
  • prefix { values } — every element must byte-start-with one of values.
  • 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 — explicit KEY=VALUE pairs.
  • 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_with the 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 / stderr are base64 String, not Bytes. Bytes lacks a Serialize impl 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, not Duration. Duration has no stable JSON Serialize implementation.
  • ExecError::Io is collapsed into Spawn(#[from] std::io::Error). They are the same type — std::io::Error covers 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::spawn during the select!. When kill_tree fires and the pipes close, the drain tasks complete with whatever bytes they accumulated. The partial output is included in the ExecResult.

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() on CamelExecExitAccepted to handle success/failure without losing output.
  • Auditable: Every execution emits an ExecAuditEvent with 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 emits exec_policy_denials_total, exec_timeouts_total, exec_exit_code, exec_stdout_truncated_total, and exec_duration_secs via the trait API, but PrometheusMetrics and OtelMetrics inherit 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). Only record_exchange_duration/record_circuit_breaker_change are 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 MiB
  • XmlConfig { max_depth } — default 100
  • ZipConfig — existing, gains Deserialize
  • CsvConfig — existing, gains Deserialize

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_fields catches 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→json path (keeps default depth).
  • Custom/user-registered data format registry (deferred; config: Value lane 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 LoopStepDefBuilderStep::DeclarativeLoopLoopConfigLoopService/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_LIMIT callers (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 maxPayloadBytes URI param, threaded through config → endpoint → producer.
  • XJ: switch from materialize() to into_bytes(effective); remove post-materialization check.
  • WASM: new max_stream_bytes field on WasmConfig + 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 is Option<bool>; None means "don't care".
    • ComponentMetadata — top-level descriptor: scheme, schema_version, version, description, uri_syntax, capabilities, and uri_options.
  • Add a metadata() method to the Component trait returning ComponentMetadata, with a default ComponentMetadata::minimal(scheme) implementation so new components compile without change.

  • Harvest metadata at registration time in Registry::register() — call component.metadata() once, store the result indexed by scheme. Re-registering a component replaces its metadata.

  • ComponentMetadataCatalog trait returns owned ComponentMetadata values (not references) because the Registry is behind Arc<Mutex<>>.

  • RuntimeComponentMetadataCatalog in camel-core wraps the registry and implements ComponentMetadataCatalog for 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 TypeOptionKind
DurationDuration
boolBool
u8..u64, usize, i8..i64, isizeInt
f32, f64Float
String, &strString
Vec<T>List(Box::new(infer_option_kind(inner)))
Anything elseString (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:

KeyTypeSemantics
desc = "text"Lit::StrHuman-readable description
requiredflag or Lit::BoolMarks the option as mandatory
secretflag or Lit::BoolCredential-bearing field; must not appear in diagnostics
deprecated = "reason"Lit::StrDeprecation notice shown in tooling
aliases = ["a", "b"]ExprArrayAlternative parameter names
kind = "string"Lit::StrExplicit 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&param.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 in camel-api, with one initial variant: Prefix { separator: String }.
  • Add an optional pattern: Option<UriOptionMatch> field to UriOption, 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) -> Self that sets pattern: Some(UriOptionMatch::Prefix { separator: separator.to_string() }).
  • Add a #[uri_param(pattern = "<separator>")] macro key, valid only on Vec<(String, String)> fields, with eight compile-time guardrails: incompatible with required, default, secret, name, aliases, and any non-string kind; empty separator rejected; separator without trailing . rejected.
  • Extend the shared lint helper resolve_option with two-phase resolution: Phase 1 — combined exact-name OR alias match, considering only options whose pattern is None; Phase 2 — longest-prefix-wins pattern match, considering only options whose pattern is Some(_), with a non-empty suffix requirement (bare param. does NOT match Prefix { separator: "param." }).

Consequences

  • Components with open namespaces can now declare a single UriOption with a Prefix pattern, 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 UriOptionMatch variant expands a closed JSON-Schema union. Stale validators or exhaustive generated consumers may reject the new variant at parse time despite the Rust-side forward-compat guarantee. Each future variant requires schema compatibility review and regenerated downstream consumers.
  • Component migration (camel-xj, camel-xslt) is deferred to a follow-up change.

ADR-0042: Arc<[CompiledStep]> shared snapshot for pipeline steps

Status

Accepted

Context

SequentialPipeline and TracedPipeline held Vec<CompiledStep> and cloned the entire Vec (including all boxed services) per Exchange in call(). Each BoxProcessor (a BoxCloneService) may carry inner service state, so the per-Exchange Vec clone is not a cheap internal Arc bump — it is a real allocation and state-copy.

Decision

Store steps as Arc<[CompiledStep]> (wrapped in a SharedSnapshot newtype that adds the necessary Send/Sync impls). call() does Arc::clone (refcount bump). run_steps takes Arc<[CompiledStep]> by value, iterates by reference, cloning only the single step being invoked.

Consequences

  • In-flight Exchanges hold an Arc to the old snapshot during hot-reload swap. Snapshot isolation (ADR-0004) is strengthened, not weakened: the old Arc is dropped only after every in-flight run_steps future completes.
  • Lifecycle ownership remains a PipelineAssembly concern. 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 unconditional Send/Sync impls. The !Sync on the inner types is an artifact of Tower's trait-object bounds (Box<dyn ... + Send> lacks + Sync), NOT a sign of interior mutability. Concurrent &CompiledStep access is sound because run_steps only reads shared references and .clone()s owned copies before invoking.

    The INVARIANT in the struct doc — no Rc/RefCell/Cell/ UnsafeCell in CompiledStep — is what makes the unsafe impl Send

    • Sync sound. 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), then stop_route_internal replaces 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:

  1. UoW hook semantics: Stopped is a successful termination — UoW completion hooks fire, and the exchange is delivered to the reply channel as Ok(ex). A cancelled exchange is NOT successfully processed — its data may be incomplete. Failed(ConsumerStopping) routes through UoW failure hooks, giving the operator visibility.

  2. Error handler behavior: Failed(ConsumerStopping) reaches the error handler's match_policy. Note: the default RouteErrorHandler has NO special handling for ConsumerStopping — 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 of run_steps, hitting the cancel check immediately and returning Failed(ConsumerStopping) again — so retries are effectively self-limiting. Custom handlers that want to skip retry on ConsumerStopping can match the variant in their match_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) in CANCEL_TOKEN.scope(cancel.clone(), ...).
  • run_steps reads CANCEL_TOKEN and checks is_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_steps directly), skip the check.
  • The in-flight step's .await completes naturally; the NEXT iteration exits.

Consequences

  • In-flight Exchanges drain to completion during graceful stop. stop_route_internal closes the channel and waits for drain_in_flight to reach zero (bounded by shutdown_timeout) BEFORE cancelling pipeline_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! against pipeline_cancel.cancelled() so route stop is not blocked waiting for a permit.

acquire_owned() returns an owned OwnedSemaphorePermit that 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 .await cooperatively yields — runtime waits.
  • Readiness Err: becomes PipelineOutcome::Failed via RetryableStep — never escapes as poll_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 CompiledStep backpressure-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 derived thiserror::Error on LanguageRegistryError (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-adapters feature 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 contextCQRS flavorWhy
Route lifecycle control planeSynchronous-projection CQRS (strong consistency, no projection lag) — command/query buses, event journal, projection updated within the same UnitOfWork as the commandSafety: supervision decisions need consistent reads; ADR-0002 + ADR-0018
Data plane (Exchange / Pipeline processing)Not CQRS — Tower data planeHot path; ADR-0001
Hot-reload, metrics, audit historyEvent-sourced / eventually consistent where toleratedLag 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.rs wires one InMemoryRuntimeStore via Arc::clone into 4 typed ports) — ACCEPTED PERMANENT. The ports are fully segregated at the trait level (RouteRepositoryPort / ProjectionStorePort / EventPublisherPort / CommandDedupPort); RuntimeBus depends only on dyn 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.

  • InFlightCount is served by the execution port, not the projection port (lifecycle/application/queries.rs returns an exhaustiveness-guard error; RuntimeBus::ask intercepts and delegates to RuntimeExecutionPort::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 through ProjectionStorePort would 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):

  • RuntimeEvent derived Serialize (lifecycle/domain/runtime_event.rs) — REMEDIATED in Tier B (rc-d0pu.2): the RuntimeEventRecord DTO (lifecycle/adapters/runtime_event_record.rs) now carries Serde via a #[serde(with)] bridge on JournalEntry.event; the entity derive is stripped. Wire format byte-compatible (roundtrip test). Kept here for the historical record.
  • LanguageRegistryError derived thiserror::Error (lifecycle/domain/error.rs:33) — REMEDIATED in Tier B (rc-d0pu.2): relocated to language_registry.rs (languages application slice); domain/error.rs no longer imports thiserror (only DomainError's manual impl remains). A #[deprecated] re-export shim at lifecycle::domain keeps the old path compiling; shim removal tracked in bd rc-rfr9. Kept here for the historical record.

Use-case purity gaps (Tier C C2):

  • abort_context takes 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 destructive shutdown() is exposed via the new narrow RouteDestructiveTeardownPort (one method). Impl on the concrete controller handle lives in lifecycle/adapters/route_ordering_impl.rs. Kept here for the historical record.

Port → adapter type leak (Tier C C1):

  • ReloadExecutorPort references PreparedRoute from lifecycle::adapters — REMEDIATED (rc-d0pu.3-purge): PreparedRoute is now a thin { route_id: String } token in lifecycle/domain/route_compilation.rs; the heavy ManagedRoute is staged internally on DefaultRouteController.prepared_staging. The port_traits_do_not_import_from_adapter_ring allow-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, formerly lifecycle/ports.rs) — REMOVED (rc-rfr9): the compatibility shim that re-exported lifecycle::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 of LanguageRegistryError at the lifecycle::domain path. Canonical replacement: camel_core::LanguageRegistryError (re-exported at lib.rs:115 from crate::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 for LanguageRegistryError above.

5. Ring → module mapping

The four Clean Architecture rings map to camel-core modules as follows (the decomposition unit is the vertical slice):

ModuleClassificationRing
context.rscomposition root (thinned in C2)Frameworks & Drivers
context_builder.rscomposition root (wiring)Frameworks & Drivers
health_registry.rssingle-ring adapter module (stateful probe registry: RwLock<HashMap> + CancellationToken + Duration fields; check_all use-case orchestrates tokio::timeout + futures::join_all)Interface Adapters
datasource.rssingle-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.rssingle-ring slice (has trait, zero I/O)Entities
template.rssingle-ring slice (TemplateRegistry Mutex store)Interface Adapters
language_registry.rssingle-ring sliceUse Cases
component_metadata_catalog.rssingle-ring adapter ("thin wrapper")Interface Adapters
registry.rssingle-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.md Key Terms per the project's term-landing rule.

Self-grill record

Questions generated:

  1. [glossary] Do the charter's terms conflict with / duplicate existing CONTEXT-MAP Key Terms?
  2. [sharpen] Is "strong-consistency CQRS" precise, or does it collide with CAP-theorem usage?
  3. [scenario] §1 forbids Serde in entities; §5 maps RuntimeEvent (which derives Serialize) to Entities — does a constructed cross-check break?
  4. [cross-ref] Does the code today implement §3's claim, or is §3 describing design intent?

Answers (with citations):

  1. [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.md term-landing rule) they must also land in Key Terms. → added a Consequences bullet.
  2. [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.
  3. [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. → added RuntimeEvent/DomainError to §4 entity-purity gaps.
  4. [cross-ref] §3 matches the design contract (ADR-0018: "command handling, projections, event publication, and journal replay stay consistent"; RuntimeUnitOfWorkPort exists). 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:

  1. 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.

  2. 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.

  3. 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).

  4. 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.

  5. 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:

  1. 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).
  2. 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_all is sequential; D2 aggregation receives Err(e) in a Vec, not an Exchange with an attached exception — ADR-0019). Both are decisions that only reading Camel reveals.
  • 1 real bug (G3 CAMEL_SPLIT_SIZE never 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

  1. "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.
  2. Translate asserts literally. expectedBodiesReceived(...) in an error-handling test assumes the Processor-chain model. The equivalent assert in rust-camel depends on the applied ExceptionDisposition. It is not a translation; it is a re-derivation.
  3. 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.
  4. 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.
  5. Document divergences in ephemeral docs. Spike docs live under docs/* (gitignored by policy). Documented divergences must land in tracked docs (ADRs, CONTEXT.md, or as notes in 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 them divergence, gap-coverage, or pin-invariant.
  • Close the bd once the divergence lands in a tracked doc (new ADRs, amendments, or CONTEXT.md updates).
  • 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, commit 8d31e74a. 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 in crates/camel-processor/CONTEXT.md).

Self-grill record

Questions generated:

  1. [glossary] Does "Camel inspiration corpus" use terms that collide with CONTEXT-MAP entries?
  2. [sharpen] How do we operationalize "divergence density" so it is not subjective?
  3. [scenario] Does the protocol apply retrospectively to stable ADRs (for example Script EIP ADR-0006)?
  4. [cross-ref] Is the spike evidence traceable or ephemeral?

Answers (with citations):

  1. [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.
  2. [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.
  3. [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.
  4. [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::Stream context is rejected with guidance to add stream_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's fuel feature 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-sql already 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-xslt or camel-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/transform and language/source contracts 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

  • minijinja becomes 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: to SetBodyConfig — rejected. It duplicates canonical language/source and perpetuates legacy per-language convenience fields.
  • Add a template: Step — rejected. Body-value transformation already belongs to set_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. BeanProcessor dispatches 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 uses fileName (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 only useEnrichedBody/throwOnNoPoll. More importantly, bodies and properties have no intrinsic trust label. A route can copy adversarial Message data into either, so compile-time validation of template_from: property.x would assert provenance the runtime does not track (crates/camel-api/src/exchange.rs:54-65,115-123).

Self-grill Record

Questions generated:

  1. [glossary] Does “template” conflict with an existing domain term?
  2. [sharpen] Are rendering and external resource loading one responsibility?
  3. [scenario] What happens when one global HTML autoescape policy renders JSON, a prompt, or plain text?
  4. [cross-ref] Does the current DSL already express this operation, and when is source compiled?
  5. [cross-ref] Do the recorded MiniJinja API claims match the current public API?

Answers (with citations):

  1. [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).
  2. [sharpen] No. Language creates 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.
  3. [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/none declaration resolves both cases; URL fragments require a context-specific filter. MiniJinja exposes AutoEscape::Html, AutoEscape::Json, and AutoEscape::None (MiniJinja 2.21 API, References).
  4. [cross-ref] Yes. SetBodyConfig.language/source already exists (crates/camel-dsl/src/route_ast.rs:450-464), and route resolution invokes create_expression/create_predicate before execution (crates/camel-core/src/lifecycle/adapters/step_resolution.rs:59-86). New DSL fields and Steps are dropped.
  5. [cross-ref] Partly. Current MiniJinja has synchronous Template::render and Environment::set_loader, not an async render API or public Source trait. 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:

  1. [glossary] Is an external template resource the same concept as a dynamic Exchange-sourced template?
  2. [sharpen] Can acquisition, compilation, and rendering share the Language SPI without hiding I/O or provenance?
  3. [scenario] Does pollEnrich preserve application data while supplying a reusable exact-file template under concurrent HTTP traffic?
  4. [cross-ref] Can the DSL and Language SPI represent template_from and context_from today?
  5. [cross-ref] Does camel-file provide template hot reload?

Answers (with citations):

  1. [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).
  2. [sharpen] No. Language::create_expression accepts one static script and Expression::evaluate receives 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.
  3. [scenario] No. UseEnrichedBody replaces 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_ORIGINAL is not supported.
  4. [cross-ref] No. SetBodyConfig and LanguageExpressionDef carry only the existing static language/source form (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.
  5. [cross-ref] No. FilePollingConsumer rescans on receive and keeps a local seen set (crates/components/camel-file/src/polling_consumer.rs:55-92); ModificationDetectingStream only 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-67
  • crates/languages/camel-language-rhai/src/lib.rs:6-76
  • crates/camel-dsl/src/route_ast.rs:426-464,500-514
  • crates/camel-api/src/declarative.rs:16-25
  • crates/camel-core/src/lifecycle/adapters/step_resolution.rs:59-86
  • crates/components/camel-component-llm/src/producer.rs:164-186,228-252
  • crates/components/camel-component-llm/src/producer_cache.rs:266-325
  • crates/components/camel-opensearch/src/producer/mod.rs:287-298
  • crates/components/camel-sql/src/query.rs:37-52,248-265
  • crates/components/camel-file/src/lib.rs:563-580
  • crates/components/camel-file/src/polling_consumer.rs:55-92
  • crates/components/camel-file/src/poll_logic.rs:28-93,375-422
  • crates/camel-processor/src/content_enricher.rs:86-97
  • crates/camel-processor/src/enrichment_strategy.rs:24-36
  • crates/camel-processor/src/stream_cache.rs:37-54
  • MiniJinja 2.21 crate API
  • MiniJinja Environment API
  • MiniJinja Template API
  • Tera 2.0 Tera API
  • Handlebars 6.4 Handlebars API
  • Askama 0.16 crate API
  • Apache Camel FreeMarker component
  • Apache Camel Mustache component
  • Apache Camel Velocity component
  • docs/adr/0030-exchange-aware-dataformat-hooks.md
  • docs/adr/0033-security-defaults-fail-closed-startup-validation.md
  • docs/adr/0039-configurable-loop-iteration-cap.md
  • docs/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-ao5 delivery.

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:

  1. Workers share the conductor's environment. All subagents inherit $ATTESTATION_HMAC_SECRET from the same devShell. A worker can compute a valid HMAC — the "only conductor can sign" assumption was false.

  2. 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.

  3. 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 /apply can 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-attestation commands
  • mod attestation (HMAC-SHA256, constant-time comparison, RFC 4231 test)
  • .opencode/plugin/attestation-guard.ts (runtime guard)
  • CI verify-attestation step in quality gates
  • $ATTESTATION_HMAC_SECRET in 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-api
  • crates/components/camel-component-api
  • crates/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):

CrateEnumSiteContract role
camel-apiRuntimeCommandruntime.rs:444CQRS command (ADR-0002)
camel-apiRuntimeQueryruntime.rs:565CQRS query (ADR-0002)
camel-apiRuntimeCommandResultruntime.rs:542CQRS result (ADR-0002)
camel-apiRuntimeQueryResultruntime.rs:579CQRS result (ADR-0002)
camel-apiRuntimeEventruntime.rs:587lifecycle event (ADR-0002)
camel-apiCanonicalStepSpecruntime.rs:113versioned route contract (ADR-0011/0016)
camel-apiCanonicalSplitExpressionSpecruntime.rs:180versioned route contract
camel-apiCanonicalSplitAggregationSpecruntime.rs:199versioned route contract
camel-apiCanonicalAggregateStrategySpecruntime.rs:217versioned route contract
camel-apiCanonicalConcurrencySpecruntime.rs:281versioned route contract
camel-component-apiConsumerStartupModeconsumer.rs:38component contract
camel-component-apiConcurrencyModelconsumer.rs:374component contract
camel-language-apiLanguageErrorerror.rs:4error 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

  1. New contract enums added to a contract crate are #[non_exhaustive] from birth.
  2. Existing contract enums gain #[non_exhaustive] before the 1.0 freeze (finding I2).
  3. 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 set Completed | Stopped | Failed is the deliberate outcome algebra of ADR-0024. The translation site into_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) — the InOnly | InOut MEP 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

OptionDescriptionRuling
AResolve as triage note attached to finding I2, camel-api onlyRejected — leaves component-api/language-api to decide ad-hoc; reintroduces the cross-crate drift a workspace ADR exists to prevent
BBlanket #[non_exhaustive] on every public enum in every crateRejected — 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 setsCHOSEN — matches the existing "contract crate" category, targets the real external-match surface, keeps closed-set safety enums exhaustive
DDefer until more T1 contract crates are auditedRejected — 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 *-api crates 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.
  • CanonicalConcurrencySpec codegen 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:

  1. [glossary] Does "contract enum" / "non_exhaustive policy" collide with an existing CONTEXT-MAP Key Term or ADR-0045's "module-discipline ceiling"?
  2. [sharpen] Is this one decision or two — "MUST contract enums be non_exhaustive" vs "WHERE is the policy recorded"?
  3. [scenario] If #[non_exhaustive] is added to PipelineOutcome, does the into_tower_result() exhaustive match break or silently degrade?
  4. [cross-ref] Do camel-component-api and camel-language-api actually exist with public contract enums, or is the cross-crate claim speculative?

Answers (with citations):

  1. [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.
  2. [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).
  3. [scenario] It would degrade dangerously if applied blindly — an out-of-crate _ => on PipelineOutcome at 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), so PipelineOutcome is 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)
  4. [cross-ref] Confirmed real, not speculative. crates/components/camel-component-api exposes ConsumerStartupMode (consumer.rs:38) and ConcurrencyModel (consumer.rs:374); crates/languages/camel-language-api exposes LanguageError (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:

  1. Each world has an explicit list of WASI interfaces.
  2. All four worlds may register wasi:clocks and wasi:random when their components import them.
  3. No world registers filesystem, sockets, CLI, environment, or stdio by default.
  4. The source world keeps its http-listener interface. This interface grants no general socket access.
  5. Worlds that have Camel functions use camel_call for logging. The host does not call inherit_stderr().
  6. 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-listener interface 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

  1. 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.md records the cross-cutting term.
  2. 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.
  3. Scenario: A guest that imports filesystem will fail to instantiate. That failure is intentional. A guest that imports only clocks and random keeps compatibility.
  4. Code: runtime.rs, wasm_plugin_context.rs, and source_host.rs still call add_to_linker_async. runtime.rs still uses inherit_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:

  1. Implement Debug manually and replace each credential value with [REDACTED] or omit the field.
  2. Store each credential in a dedicated wrapper whose Debug implementation 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 derive Debug or Serialize.
  • redacting-wrapper: may derive Debug; must not derive Serialize.
  • protocol-dto: may derive Serialize; must not derive Debug.

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-secrets keeps its current sink-focused scope until the enforcement revisit.

Self-Grill Record

Questions generated:

  1. [glossary] Does "credential boundary" conflict with the exchange-data trust boundary or handler-contract boundary?
  2. [sharpen] Which values are credentials, and does a private-key file path count as credential bytes?
  3. [scenario] Can a field-name lint catch WASM guest secrets without flagging TLS paths and cancellation tokens?
  4. [cross-ref] Does the workspace already use the proposed redaction pattern, and do existing ADRs already own this rule?

Answers:

  1. [glossary] No. CONTEXT-MAP.md defines 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.
  2. [sharpen] Credential bytes grant access or prove identity. A path identifies a file but does not contain its private-key bytes. camel-http::TlsConfig stores client_key_path: Option<String>, while the auth token responses store Zeroizing<String> token values (camel-http/src/config.rs, camel-auth/src/native_issuer.rs, camel-auth/src/oauth2.rs).
  3. [scenario] No. StateStore stores arbitrary values under data, so a name check misses the documented api-key = secret-123 case. The same check over key or token flags client_key_path and 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).
  4. [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 covers Debug and Serialize confidentiality 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

  1. 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).

  2. TLS and authentication are opt-in hooks. The crate exposes an extension point to wrap the router with TLS (the axum_server::tls_rustls pattern, as in camel-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.

  3. 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 a warn! at startup. The warning states that the endpoint is reachable from all interfaces with no application layer restricting it.

  4. 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-prometheus and camel-health document 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 startup warn! (code work, correction stream, bd rc-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:

  1. [glossary] Does "diagnostic endpoint" collide with the "HTTP co-hosting" of ADR-0009 or with the trust boundary of ADR-0032?
  2. [sharpen] Does "unauthenticated by default" contradict ADR-0033 (fail-closed defaults)?
  3. [scenario] If an operator binds to 0.0.0.0 on an untrusted network, what protects them under this posture?
  4. [cross-ref] Does any existing ADR already cover diagnostic endpoint exposure, so this should be an amendment rather than a new ADR?

Answers:

  1. [glossary] No collision. ADR-0009 governs the data plane (API routes http: plus static mounts http-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.
  2. [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.
  3. [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 startup warn! 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.
  4. [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.

  1. One version covers every interface and world in the package. We do not version plugin, bean, authorization-policy, or source separately.
  2. The rust-camel v1.0 release establishes camel:plugin@1.0.0. Pre-v1 unversioned packages have no compatibility guarantee.
  3. 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.
  4. 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.
  5. 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.
  6. @since annotations 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.
  7. 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.
  8. Canonical WIT files, generated host bindings, shipped guest examples, and compatibility tests change in one code change. rc-aaxe tracks the initial 1.0.0 application. rc-osj0 tracks 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 types and host interfaces 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:

  1. [glossary] Does “WIT package version” overlap Rust crate SemVer or the WASM sandbox capability posture?
  2. [sharpen] Is the compatibility unit one package, one interface, or one world?
  3. [scenario] What happens if wasm-exchange gains a field after v1.0 while the package remains camel:plugin@1.x?
  4. [cross-ref] Can an existing ADR own this decision, or can versioning wait until after the v1.0 release?

Answers:

  1. [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.
  2. [sharpen] The package is the compatibility unit. plugin, bean, and source share camel:plugin interfaces and types (camel-plugin.wit:8-94, camel-bean.wit:12-18, camel-source.wit:9-82). Per-world versions would split shared types.
  3. [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.
  4. [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.0 later changes the identity consumed by the four Wasmtime bindgen! sites in camel-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

  1. requires pre-built <artifact detail> — a buildable artifact covered by a dedicated CI job. Example: WASM guests. The test file MUST appear in allowlist-ignore.txt (a path consumed by both this lint and the CI job). The CI job builds the artifact, then runs cargo test --ignored on each allowlisted file. Without the allowlist entry, the test will not run in CI.
  2. 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:

  1. Reconsider whether the test should exist at all and delete it.
  2. Reconsider whether the prerequisite can be cheaply satisfied in CI and migrate the test to camel-test with testcontainers.
  3. 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

OptionDescriptionRuling
AThree-prefix vocabulary with requires liveRejected — blesses an anti-pattern the project already solved with testcontainers in camel-test. The original ADR-0054 took this path; review rejected it.
BDo nothingRejected — leaves the WASM CI gap open and any future #[ignore] ungoverned.
CDocker Compose for live servicesRejected — testcontainers already provides superior self-provisioning, and no separate infra file is needed.
DDelete #[ignore] from buildable testsRejected — 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.
EClosed vocabulary with an // allow-ignore escape hatchRejected — a closed vocabulary with an escape hatch is not closed.
FClosed vocabulary of two prefixes (requires pre-built, slow test:) with requires live as migration errorCHOSEN — 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 --ignored on the allowlisted files.
  • External-service tests cannot silently hide as #[ignore]. The lint rejects requires live and points contributors at camel-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 as slow test:.
  • Allowlist maintenance burden for requires pre-built is 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:

  1. Why testcontainers over docker-compose for external-service tests?
  2. Why is Ollama special, given testcontainers solves the same problem for Kafka and Redis?
  3. Why two prefixes and not just one?
  4. Why delete the duplicate inline tests instead of moving them to camel-test?
  5. Why not run Ollama tests on a nightly schedule instead of marking them slow test:?

Answers (with citations):

  1. Testcontainers over docker-compose. camel-test/Cargo.toml already declares testcontainers and testcontainers-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.
  2. 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.
  3. Two prefixes because they have different enforcement shapes. requires pre-built must 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 every slow test: to add a no-op allowlist entry, or drop the allowlist from requires pre-built, which is the wrong direction.
  4. Deletion over move. camel-test/tests/kafka_test.rs and camel-test/tests/redis_test.rs already 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.
  5. Nightly is a follow-up, not a prerequisite for this decision. A nightly workflow that pulls qwen3.5:4b and runs the Ollama suite is a separate infrastructure decision. ADR-0054 records the slow test: classification that makes that future job trivial to implement; whether and when to run it belongs to its own ADR or CI change. The current decision is self-contained.

Outcome: approve as revised ADR-0054. The closed vocabulary drops to two prefixes, requires live is a migration error, the existing camel-test + testcontainers pattern is the canonical answer for external-service tests, and the self-grill record grounds the Ollama exception in a specific artifact (gigabyte-scale model weights) that testcontainers cannot provision. Self-grill mode: self-grill-proposals skill

ADR-0055: Publish Topology — No Cyclic Dev/Build-Dependencies on Publishable Crates

Date: 2026-08-11 Status: Proposed Related: ADR-0045 (camel-core architecture charter), ADR-0049 (lint+policy pairing precedent)

Context

Problem

cargo publish resolves [dev-dependencies] and [build-dependencies] against the crates.io registry during package verification, so weak edges participate in the topological publish order. A cycle closed only by weak edges cannot be sorted: the holder cannot publish before its dev-dep target, and the target cannot publish before the holder. The workaround was an xtask hack (commit 146c28ee, ~200 LoC) that mutated each cyclic crate's Cargo.toml on disk at publish time — commenting out the camel-* dev-dep lines, publishing with --no-verify, then restoring the original bytes. This carried a real failure mode: a crash between write and restore left a dirty tree, and the published manifest silently differed from source.

Diagnostic discovery (the over-breaking bug)

The first analysis reported "25 broken weak edges / 16 cyclic crates" and proposed mass test relocation. Tarjan SCC analysis of the real combined normal+weak graph revealed this was an artifact of an over-breaking greedy loop in resolve_publish_order: the loop snipped weak edges from any still-unscheduled crate, not only crates inside a non-trivial SCC. The real topology had two non-trivial SCCs closed by four weak edges:

  • SCC-A: {camel-builder, camel-component-http, camel-component-ws, camel-core, camel-otel} — closed by camel-core --dev--> camel-component-http, camel-core --dev--> camel-component-ws, and the mutual camel-component-http --dev--> camel-core.
  • SCC-B: {camel-endpoint, camel-endpoint-macros} — closed by camel-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-cycliccamel-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:

  1. StubComponent substitution — when a real component is incidental scaffolding (registered only to assert scheme registration or a count), a local stub implementing the Component trait replaces it.
  2. 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.toml on 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-support split crate: rejected — camel-component-api dev-deps are non-cyclic (verified); the feared "I need test-support but dev-depending on it is cyclic" case does not exist in this workspace.
  • Set camel-test publish = false: rejected — it is already a true leaf (the phantom edges were the holder direction decoded backwards) and is a downstream-facing public utility.
  • Keep the strip/restore hack, just add the lint: rejected — the hack's dirty-tree failure mode and published-manifest drift are unnecessary once the lint enforces acyclicity.

Enforcement

cargo xtask lint-publish-cycles (wired into AGENTS.md ## QUALITY GATES) fails when no_verify is non-empty OR when any publishable crate declares camel-test in any dependency kind. It reuses the same SCC-accurate resolve_publish_order predicate as cargo xtask publish --show-cycles.

ADR-0056: Cache Repository Port

Date: 2026-08-11 Status: Accepted References: ADR-0001, ADR-0023, ADR-0028, ADR-0033, ADR-0046, ADR-0049, ADR-0055

Decision

Decision 1: memory-default despite the anchor

The default cache repository is "memory", registered during CamelContextBuilder::build() with max_capacity = 10_000. This matches the Idempotent (ADR-0023) and Claim Check (ADR-0028) precedent: every context gets a zero-dependency in-process backend out of the box.

The motivating anchor (EFFIS/GIBS geo-layer tiles) needs a persistent backend (redb). The user opts in via [default.cache_repo] in Camel.toml:

[default.cache_repo]
backend = "redb"
path = "data/cache.redb"
stale_retention = "168h"

This registers a second repository named "persistent" alongside the default "memory". The DSL step selects the repository by name. The memory default is unchanged — the anchor case is an opt-in override, not a reason to change the default.

File: crates/camel-core/src/context_builder.rs:233-235.

Decision 2: in-band expires_at everywhere

Every CacheEntry carries an expires_at: Option<SystemTime> field. The CacheRepository::set method accepts ttl: Option<Duration>, computes expires_at = SystemTime::now() + ttl, and stores it in the entry. The CacheRepository::get method checks expiry in-band: if expires_at <= now, the entry is treated as absent (Ok(None)).

This design has three consequences:

  1. 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).

  2. SystemTime, not Instant. SystemTime is clock-aware and serializable. Instant is 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).

  3. Serializable. CacheEntry derives Serialize/Deserialize so persistent backends (redb, Redis) can store and restore entries without a per-backend serialization layer.

File: crates/camel-api/src/cache.rs:18-25 (CacheEntry struct), crates/camel-api/src/cache.rs:77-82 (set computes expires_at), crates/camel-core/src/cache/memory.rs:72-91 (get checks expiry in-band).

Decision 3: moka size-eviction only (no Expiry, no time_to_live)

The MemoryCacheRepository configures moka with max_capacity only — no expire_after or time_to_live. This is what makes peek_stale work on the memory tier: moka never evicts by time, so an expired entry stays in the cache until it is evicted by size pressure or explicitly invalidated.

get() checks expires_at in-band and returns Ok(None) for expired entries. peek_stale() returns the raw entry from moka without checking expiry, so callers can read stale data when the upstream is unavailable.

File: crates/camel-core/src/cache/memory.rs:50-54 (moka builder — no time-based eviction), crates/camel-core/src/cache/memory.rs:72-91 (get checks expiry), crates/camel-core/src/cache/memory.rs:105-107 (peek_stale skips expiry check).

Decision 4: mandatory max_capacity on memory default

MemoryCacheRepository::new() requires a max_capacity parameter. The default registration in CamelContextBuilder::build() uses 10_000. This follows ADR-0033 (security defaults, D-A5: bounded resource consumption) and the AggregatorConfig precedent: an unbounded default memory cache is a DoS risk.

The [default.cache_repo] config can override the capacity via max_capacity = 5000 when backend = "memory". If omitted, the default 10_000 stands.

File: crates/camel-core/src/context_builder.rs:235 (default 10_000), crates/camel-core/src/cache/memory.rs:44 (constructor requires max_capacity).

Decision 5: retention window != TTL

Persistent backends (redb, Redis) reclaim entries at expires_at + retention, not at expires_at. The retention window is a configurable duration (default 168h / 7 days) that extends the entry's life in storage after its logical expiry.

This gives peek_stale post-expiry reach on persistent tiers: an entry that expired 6 hours ago is still readable via peek_stale if the retention window is 168 hours. The entry is invisible to normal get() (which checks expires_at), but the bytes remain in storage for the stale-read fallback.

The memory tier does not need a retention window — moka keeps entries until size-eviction removes them, which is effectively an unbounded retention.

File: crates/camel-core/src/cache/memory.rs:105-107 (peek_stale on memory — no retention window needed).

Decision 6: no sweep() on the trait

CacheRepository does not expose a sweep() method. Reclamation of expired entries is per-backend:

  • Memory (moka): no sweep needed. Entries are evicted only by size pressure. Expired entries consume space until evicted, which is acceptable for an in-process cache bounded by max_capacity.
  • Redb: a background sweep task runs at a configurable interval (default 60s) and deletes entries whose expires_at + retention < now.
  • Redis: EXPIRE / PEXPIRE handles reclamation natively (the entry is deleted at the Redis server level when the TTL expires).

Adding sweep() to the trait would force every backend to implement a method that is a no-op for memory and Redis. This is contract dishonesty — the trait should not promise a capability that most backends do not need. The idempotent and claim check traits set the same precedent: no sweep method.

File: crates/camel-api/src/cache.rs:63-101 (trait — no sweep method).

Rejected alternatives

cache:// component (ADR-0001 + ADR-0046)

A cache:// URI scheme would make caching a component endpoint, following the Apache Camel pattern. Rejected because:

  • A component endpoint implies a Consumer or Producer lifecycle (start, stop, health). Caching is a pipeline step, not an endpoint — it does not consume from or produce to an external system.
  • The cache:// scheme would need to be a pseudo-component (no real endpoint), adding complexity to the component registry and URI resolution.
  • ADR-0046 establishes Apache Camel as inspiration, not conformance authority. The pipeline-step approach is more idiomatic for rust-camel's Tower-native architecture (ADR-0001).

Body as stored type (not Serialize, Stream un-cacheable)

Storing Body directly in the cache would avoid the Vec<u8> + ContentType split. Rejected because:

  • Body is not Serialize — it contains StreamBody (an Arc<Mutex<Option<BoxStream>>>) which cannot be serialized for persistent backends.
  • Stream variants 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 (via StreamCacheService).
  • CacheEntry with Vec<u8> + ContentType is serializable, backend-agnostic, and reconstructible into Body by the CacheService step.

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_stale impossible — 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:

  • ClaimCheckRepository owns payloads (full Message with 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 in camel-core, NamedRegistry wiring.
  • 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. CacheEntry must be Serialize.
  • Bounded memory. An unbounded memory cache is a DoS risk (ADR-0033 D-A5). max_capacity is mandatory.
  • No trait bloat. The trait should not expose backend-specific operations (sweep, compaction). Each backend manages its own reclamation.

Consequences

Trait location

CacheRepository lives in camel-api (crates/camel-api/src/cache.rs:63). Any crate can implement it without depending on camel-core. Future backends (Redis in camel-component-redis, SQL in camel-sql) can implement the trait remotely.

Interface stability

The trait has no #[non_exhaustive] attribute — adding methods would break existing implementations. The 7-method interface (name, get, set, peek_stale, invalidate, clear, stats) is considered stable. If a future backend needs a len() or keys() method, a separate trait or default method with unimplemented!() can be added.

Default memory backend

MemoryCacheRepository is registered as "memory" with max_capacity = 10_000 in CamelContextBuilder::build(). The replace_cache_repository method allows overriding the default capacity when [default.cache_repo] backend = "memory" supplies a custom max_capacity.

No autowiring

The Cache EIP step explicitly names which repository to use (default: "memory"). No auto-discovery. This matches the Idempotent Consumer precedent (ADR-0023 §No autowiring).

ContentType is exhaustive-by-contract

ContentType is a closed 4-variant enum (Bytes, Text, Json, Xml). It carries the exhaustive-by-contract exception note (ADR-0049) because the CacheService step matches all variants for ContentType → Body reconstruction. Adding a variant would require updating the match in CacheService.

File: crates/camel-api/src/cache.rs:29-41.

CacheStats is NOT #[non_exhaustive]

CacheStats is a plain struct — backends construct it with struct literals. Adding fields is backward-compatible (existing literals still compile with ..Default::default()). No #[non_exhaustive] attribute.

File: crates/camel-api/src/cache.rs:46-56.

Load-bearing citations

File:lineElement
camel-api/src/cache.rs:18-25CacheEntry struct with expires_at: Option<SystemTime>
camel-api/src/cache.rs:29-41ContentType enum (exhaustive-by-contract)
camel-api/src/cache.rs:46-56CacheStats struct (not non_exhaustive)
camel-api/src/cache.rs:63-101CacheRepository trait (no sweep, no non_exhaustive)
camel-api/src/cache.rs:77-82set computes expires_at from ttl
camel-core/src/cache/memory.rs:44MemoryCacheRepository::new requires max_capacity
camel-core/src/cache/memory.rs:50-54moka builder — size-eviction only, no time-based eviction
camel-core/src/cache/memory.rs:72-91get checks expires_at in-band
camel-core/src/cache/memory.rs:105-107peek_stale skips expiry check
camel-core/src/context_builder.rs:233-235Default "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:

  1. Split each Connection value on ,.
  2. Trim whitespace from each segment.
  3. Lowercase the segment for comparison.
  4. Keep only segments that are valid RFC 7230 tokens. A token is one or more tchar characters. A tchar is an ASCII alphanumeric character or one of ! # $ % & ' * + - . ^ _ | ~`.
  5. Drop empty or malformed segments. The parser never panics.
  6. 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 host and hop-by-hop headers outbound.
  • rc-2jj2: the consumer strips valid response headers (cache-control, pragma, warning, via).
  • rc-d3o4: the bridgeEndpoint flag 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 PipelineOutcome contract 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

SourceElement
RFC 2616 section 13.5.1Compatibility hop-by-hop header set (static members)
RFC 7230 section 6.1Dynamic Connection-named stripping; removal of Connection and connection-option headers
RFC 7231 section 7.1.1.2Server-owned date field
RFC 7234cache-control, pragma, warning, via as valid response headers
ADR-0024PipelineOutcome contract (status, body, Stop) - not amended
ADR-0046Apache Camel as inspiration, not conformance authority
rc-eoftProducer forwards host and hop-by-hop headers outbound
rc-2jj2Consumer strips valid response headers
rc-d3o4bridgeEndpoint does not gate URL resolution
rc-f0cnNo end-to-end bridge header contract test
rc-vy6wEpic: 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_list returned Ok(original) when every recipient failed. The adapter wrapped that into Completed(original). The cache: Segment then wrote the inbound body back under the key for the full time-to-live. The corruption was silent on timer: routes.
  • rc-n8rc: Body::Stream was consumed twice on the error path (the HTTP symptom-masker of rc-20yn).
  • rc-65yi: the body was lost when cache_peek_stale ran inside a do_try catch that shared a key with a cache: 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 (Tower Service<Exchange>, reaches the invariant through the Result to PipelineOutcome adapter): attempt = one recipient endpoint call. Success = the call returned Ok. Operational failure = at least one call was attempted and zero calls returned Ok. Intentional halt = none (recipient_list does not produce Stopped).
  • multicast (OutcomePipeline Segment, parallel sibling of recipient_list): attempt = one branch sub-pipeline run. Success = the branch returned Completed. Operational failure = at least one branch ran and zero branches returned Completed. Intentional halt = a branch returned Stopped and that halt propagates.
  • cache (OutcomePipeline Segment; propagator, not generator): attempt = the on_miss sub-pipeline run on a cache MISS. Success = on_miss returned Completed. A Stopped or Failed propagated from on_miss is 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 as Failed (Contract C1 from ADR-0023). Intentional halt = a Stopped propagated from on_miss.
  • do_try (OutcomePipeline Segment): attempt = try_body run. Success = try_body returned Completed, or try_body returned Failed and a matching catch clause ran and returned Completed. Operational failure = try_body returned Failed and no catch matched, or every matching catch re-propagated. Intentional halt = a Stopped from try_body (propagated; skips catch and finally per ADR-0025 section 5.1), or a Stopped from 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 last JoinSet::join_next().await call that completed with an error (completion order).
  • Parallel arm (multicast): the error from the highest-index branch that returned Failed (branch-index order — results are sorted by branch index before the representative error is selected). This is the legacy LastWins semantics.

The parallel-arm error is a representative error in both siblings. It is not the causally-first error. The two siblings use different selection orders (completion vs. branch-index), but both yield a representative, not a causal, error. A test may assert a specific error identity only when it controls the selection order through a synchronization primitive (recipient_list) or fixed branch arrangement (multicast).

Multicast outcome

multicast is governed by the zero-success invariant and the Stopped-wins rule. When at least one branch returns Stopped, multicast propagates Stopped (ADR-0025 section 3). When zero branches return Stopped and zero branches return Completed, multicast reports Failed(last_error).

The partial-success aggregation policy is out of scope for this ADR. The current multicast returns Failed when any branch fails, even when other branches succeed. This is inconsistent with recipient_list, which aggregates the successful results on partial success. The inconsistency is tracked as bd rc-b41j. A future change that reconciles the two siblings SHALL update this section.

Governed Segments

This ADR is the compliance authority for: recipient_list, multicast, cache, do_try, split, streaming-split, load_balance.

The normative test scenarios in the segment-outcome-composition spec capability cover the first four (delivered by the outcome-aware-segment-composition change). The remaining three (split, streaming-split, load_balance) inherit compliance from this ADR. They are verified separately.

Migration / existing-code alignment

multicast already complies with the zero-success invariant. Verified in crates/camel-processor/src/multicast_segment.rs: sequential_multicast and parallel_multicast track last_error, return Stopped on the first Stopped branch, and return Failed(last_error) when outputs is empty and last_error is set.

recipient_list is the non-compliant Segment. It is corrected in outcome-aware-segment-composition Task 2.1, with this ADR as authority. The fix changes the zero-success path of RecipientListService::call to return Err(last_error) instead of Ok(original). The existing Result to PipelineOutcome adapter then yields Failed, and cache: skips write-back.

stop_on_exception defaults to false in RecipientListConfig. This matches Apache Camel. The default is unchanged.

Consequences

  • A zero-success recipient_list inside a cache: on_miss no longer poisons the cache. The cache retains the previously seeded stale entry.
  • do_try catches keyed on Failed receive the error. They do not receive a laundered Completed(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. Stopped is reserved for the intentional Stop EIP (ADR-0025 section 3). Using it for an operational error would hide the failure from do_try catches keyed on Failed.
  • Change stop_on_exception default to true. Rejected. It would break Apache Camel parity. Partial-multicast routes legitimately continue past a failed branch.
  • Body-equality invariant ("a Segment must not report Completed with an unchanged body when its work failed"). Rejected. It would reject legitimate no-op Segments that return Completed(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 Stopped governed by the Stop EIP (ADR-0025 section 3).
  • Representative error: the parallel-arm last_error. For recipient_list, selection is by JoinSet::join_next completion order; for multicast, by highest branch index (legacy LastWins). Neither is the causally-first error.

API reference

Rust API documentation lives in cargo doc, not in this guide. Read the rustdoc for canonical type signatures, trait contracts, and method details.

Read the API docs locally

Build and open the rustdoc for the whole workspace from the repository root:

cargo doc --open

For a single crate, target it by name. The --no-deps flag skips dependencies and builds faster:

cargo doc -p camel-api --no-deps --open

The local build always matches your checkout. Use it when you edit the code or track a branch that is not released yet.

Read the API docs online

Published crates are on docs.rs. docs.rs builds the latest released rustdoc for each crate:

  • camel-api - core types: Exchange, Message, Body, CamelError, Processor, BoxProcessor, PipelineOutcome, the CQRS bus traits, and CanonicalRouteSpec.
  • camel-core - CamelContext, route lifecycle, hot reload, and the component, language, function, and service registries.
  • camel-builder - the fluent RouteBuilder API that constructs a RouteDefinition by method chaining.
  • camel-dsl - YAML and JSON route parsing into RouteDefinition.

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 aboutOpen this crate
Core types and contractscamel-api
Route authoring in Rustcamel-builder
Runtime, lifecycle, registriescamel-core
Declarative routes (YAML, JSON)camel-dsl
EIP pattern implementationscamel-processor
Inbound and outbound adapterscamel-component-api and components/*
Expression and predicate evaluationcamel-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:

  1. A # <pattern name> heading.
  2. One sentence naming the pattern and its Hohpe and Woolf category.
  3. An {{#include}} directive that pulls the route code from a compiled example.
  4. Two to four paragraphs of prose.
  5. A reference link to the governing crate CONTEXT.md.
  6. An ADR citation where the page states architectural rationale.
  7. A link to the example source on GitHub.

Section hub pages (index.md) are navigation aids. They hold one purpose sentence and a list of child pages with one-line descriptions. No code, no deep explanation.

Voice and style

Write like a senior engineer talking to a peer. Short sentences, active voice, concrete examples over abstractions. The full rules, including banned words and the em-dash policy, are in VOICE.md. Read it before you write prose.

Linters

Two structural linters run over the guide. Run them before you commit:

  • cargo run -p xtask -- lint-adr-cite --deny docs/src/ verifies every ADR citation resolves to a file under docs/adr/.
  • cargo run -p xtask -- lint-glossary verifies the glossary stays consistent with the Key Terms in CONTEXT-MAP.md.

These are link and structure checks. They do not assess prose quality. Prose quality depends on the ste-writing skill and human review.

Two-source rule

Every durable claim in the guide cites a source it can defend. The two acceptable sources are a crate CONTEXT.md (the crate authority) or an ADR in docs/adr/ (the decision record). A claim with no citation is speculation.

Define each domain term once on its canonical page. Link to it from everywhere else. Do not re-explain. If two pages both explain the Exchange model, one of them is wrong.

Publishing

A GitHub Actions workflow publishes docs/book from main. An administrator sets the Pages source to GitHub Actions once under Settings, then Pages. Generated HTML is not committed. The output directory is disposable, so future release-versioned books can stage without changing the source chapter URLs.