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.29"
camel-core = "0.29"
camel-builder = "0.29"
camel-component-timer = "0.29"
camel-component-log = "0.29"
tokio = { version = "1", features = ["full"] }
tracing-subscriber = "0.3"

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

Write your first route next: First route in Rust.

Path 2: Run YAML routes with the CLI

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

cargo install camel-cli

This places camel on your PATH. Confirm the install:

camel --version

Scaffold a project and run it:

camel new my-integration
cd my-integration
camel run

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

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

Run the examples from source

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

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

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

Troubleshooting

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.

${env:} placeholders in rest: blocks resolve default-only at generate time. String-typed positions take the concrete default in the emitted document; integer- and boolean-typed positions with a placeholder fail generation. The process environment is never read.

Next steps

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.

${env:} placeholders in rest: blocks resolve default-only at generate time. A placeholder in a string-typed position takes the concrete default in the emitted document. A placeholder in an integer- or boolean-typed position fails generation; this is tree-walk canon parity with boot, lint, and the LEAN tier. A token without a default fails and names the variable. $${env:X} stays literal. The command never reads the process environment.

camel plugin new

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

camel plugin new my-plugin
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.

Catch-all clause (kind: "*")

The kind: "*" clause matches every error kind. Declare it last because evaluation is first-match-wins. A specific clause declared before it keeps precedence. Combining kind: "*" with message_contains narrows the clause by message. Both conditions must hold.

The pattern kind: "*" with handled: true and retry.handled_by gives the handler route full ownership of the HTTP response. The handler sets the status through the CamelHttpResponseCode header, the body, and custom headers. Custom headers must be string-valued.

routes:
  - id: "catch-all"
    from: "direct:catch-all"
    error_handler:
      on_exceptions:
        - kind: "*"
          handled: true
          retry:
            max_attempts: 1
            handled_by: "direct:shaper"
    steps:
      - bean:
          name: "validate"
          method: "process"
  - id: "shaper"
    from: "direct:shaper"
    steps:
      - set_body:
          value: "shaped"
      - set_header:
          key: "X-Custom"
          value: "yes"
      - set_header:
          key: "CamelHttpResponseCode"
          value: "422"

The do_try catch wildcard (exception: ["*"]) is the segment-scoped equivalent. It applies within a do_try block instead of the whole route.

Global error handler

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

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

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

Continued disposition in practice

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

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

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

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

doTry blocks

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

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

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

doTry vs route-level error_handler

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 shutdown. It is raised in the producer call() when the channel or semaphore is closing. Distinct from Stop EIP. Error handling, ADR-0024.
  • Credential redaction boundary — types that hold passwords, tokens, keys, or credential bytes must not expose those values through 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.
  • DivertCopyToInterceptAction variant that copies the exchange to a mock: target with WireTap semantics and then runs the real producer. Testing, ADR-0064.
  • EnrichmentStrategy — strategy that merges the original Exchange with the polled or enriched Exchange in the 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.
  • InterceptRule — exact-URI rule that maps a send URI to a SkipTo or DivertCopyTo action. Testing, ADR-0064.
  • LlmProvider — trait abstraction over LLM backends (OpenAI, Ollama, Mock). Camel-shaped, not siumai-shaped. All siumai imports stay in the adapter. Components, ADR-0020.
  • Message — body and headers container inside an Exchange. exchange.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 path templates. The default JSON binding auto unmarshals requests and marshals responses, with JSON Schema validation. Explicit binding: raw accepts non-JSON media, injects no automatic data-format steps, sets the declared produces as the response Content-Type, and leaves the request as Body::Stream. 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.
  • SkipToInterceptAction variant that replaces the original send and routes the exchange to a mock: target. Testing, ADR-0064.
  • Starting Route — externally observable Route lifecycle state between accepted start intent and confirmed Consumer or Pipeline side effect. Operators can see 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: the glossary tracks Key Terms only.

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: [{ kind: "..." }]Match by error kind.
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 six variants:

VariantHolds
Body::TextUTF-8 string
Body::JsonParsed JSON value
Body::BytesRaw byte buffer
Body::StreamAsync stream (materialized by Stream Cache)
Body::XmlXML document
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(move |exchange: &camel_api::Exchange| {
            let mut routed = routed_clone.lock().unwrap(); // allow-unwrap
            let key = exchange.correlation_id().to_string();
            if routed.insert(key.clone()) {
                let dest = exchange
                    .input
                    .header("destination")
                    .and_then(|v| v.as_str())
                    .unwrap_or("a");
                Some(format!(
                    "log:routed-{}?showBody=true&showHeaders=true",
                    dest
                ))
            } else {
                // Second call on the same exchange: routing is complete.
                routed.remove(&key);
                None
            }
        }))
        .build()?;
YAML equivalent
- id: dynamic-router-demo
  from: timer:tick?period=1000&repeatCount=10
  steps:
    - set_header:
        key: destination
        value: a
    - dynamic_router:
        simple: "log:routed-${header.destination}?showBody=true&showHeaders=true"

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

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

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

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

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

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

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

The example source is at examples/dynamic-router.

Recipient List

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

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

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

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

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

The recipient list compiles into a plain processor step, not an outcome-aware segment. A branch error surfaces through the same step-error boundary as any other step: the route error handler decides recovery. A partial dispatch failure leaves the remaining branches' results available to the aggregation strategy.

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

The example source is at examples/recipientlist.

Routing Slip

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

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

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

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

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

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

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

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

The example source is at examples/routing-slip.

Scatter-Gather

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

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

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

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

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

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

Wire Tap

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

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

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

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

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

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

The example source is at examples/wiretap.

Multicast

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

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

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

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

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

Per ADR-0025, multicast is an outcome-aware structural EIP. A branch that returns PipelineOutcome::Stopped propagates that stop out of the block. Aggregation does not run on stopped output. In parallel mode, a branch sub-pipeline that panics counts as a failed branch. The block maps the panic to a Failed outcome with a ProcessorError that names the branch. The zero-success and partial-success rules then apply to that branch as a failure. The route error handler sees any partial outcome through the same boundary that step errors use. Per ADR-0001, the block compiles into a Service<Exchange> step in the Tower pipeline, with each branch a child step on the route channel. The processor contract is documented in camel-processor/CONTEXT.md.

The example source is at examples/multicast.

Load Balancer

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

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

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

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

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

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

The example source is at examples/load-balancer.

Transformation

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

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

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

Convert Body

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

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

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

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

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

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

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

Marshal and Unmarshal

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

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

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

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

The string parameter keeps the route declaration format-agnostic. Built-in formats are json, csv, xml, and zip. Protobuf ships as the separate camel-dataformat-protobuf crate and must be registered in the data format registry before a route uses it. Each format owns its body-type mapping and its configuration. See Data Formats for the format catalog, the DataFormat trait, and per-format options. This page covers only the route-level step.

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

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

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

Transform

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

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

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

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

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

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

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

The example source is at examples/transform-pipeline.

Script

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

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

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

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

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

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

The example source is at examples/language-rhai.

Poll Enrich

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

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

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

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

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

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

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

Reference: Processor crate · Example source

Content Enricher

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

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

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

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

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

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

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

Reference: Processor crate · Example source

Messaging

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

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

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

Aggregator

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

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

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

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

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

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

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

The example source is at examples/aggregator.

Splitter

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

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

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

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

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

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

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

The example source is at examples/splitter.

Streaming Splitter

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

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

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

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

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

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

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

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

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

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

The example source is at examples/streaming-split.

ZIP Splitter

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

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

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

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

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

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

The example source is at examples/zip-splitter.

Sort

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

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

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

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

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

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

The example source is at examples/sort.

Sampling

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

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

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

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

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

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

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

The example source is at examples/sampling.

Resequencer

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

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

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

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

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

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

The example source is at examples/resequencer.

Claim Check

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

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

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

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

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

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

The example source is at examples/claim-check.

Cache

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

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

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

With coalesce_misses: true, concurrent misses on the same key run the on_miss sub-pipeline once. The first miss becomes the leader. The other misses wait and share the leader's body and error. This prevents a stampede when many exchanges miss the same key at once.

The cache_invalidate step removes a key from the repository. Use it when an upstream event makes a cached entry stale. Set key to remove one entry. Set key_prefix to remove every entry whose key starts with that prefix, a namespace purge. Exactly one of the two is required. On success the step sets the CamelCacheInvalidatedCount exchange property. The value is 1 for an exact key and the removed count for a prefix. A backend without key iteration fails closed on key_prefix. The memory backend has no key iteration.

The cache_peek_stale step reads a cached entry and ignores its in-band expiry. This serves a post-expiry entry as a fallback when the source is unavailable.

cache_peek_stale on_miss policy

The cache_peek_stale step accepts an on_miss policy. The value "stop" is the default. On a miss the step sets PipelineOutcome::Stopped and the branch stops. The value "continue" passes the exchange through unchanged. Both values set the CamelCachePeekHit=false and CamelCachePeekStale=false exchange properties on a miss.

On a hit the step sets CamelCachePeekHit=true. It sets CamelCachePeekStale=true when the served entry is past its expires_at. A fresh entry sets CamelCachePeekStale=false. The properties enable a stale-while-revalidate (SWR) route: peek with on_miss: continue, branch on CamelCachePeekHit, fetch and cache on a miss, and serve the peeked body otherwise.

- cache_peek_stale:
    key: "${header.cacheKey}"
    on_miss: continue
- choice:
    when:
      - simple: "${exchangeProperty.CamelCachePeekHit} == false"
        steps:
          - set_body: "fresh-data"
          - cache:
              key: "${header.cacheKey}"
              ttl: "5s"
              on_miss:
                - log: "SWR miss — fresh body stored"
    otherwise:
      - log: "Serving cached body: ${body}"

cache_clear and cache_stats

The cache_clear step removes every entry from the repository. It takes an optional repository name and defaults to "memory". The body passes through unchanged.

- cache_clear: {}
- cache_clear:
    repository: "persistent"

The cache_stats step replaces the body with a JSON snapshot of the repository statistics. It takes an optional repository name and defaults to "memory".

- cache_stats: {}

The snapshot has these fields: repository, hits, misses, evictions, entries, peek_stale_served, invalidations, and bytes. The bytes field is the total stored payload size when the backend reports it. The redb backend reports a size. The memory backend reports null.

Stale-on-error with a circuit breaker

Compose cache_peek_stale with a route-level circuit_breaker to serve a stale entry when the downstream service fails. The fallback list holds a sub-pipeline. The breaker runs the fallback only while the circuit is open.

    circuit_breaker:
      failure_threshold: 1
      open_duration_ms: 60000
      fallback:
        - cache_peek_stale:
            key: "user-profile-42"

The route body wraps the upstream fetch in a cache step that stores the result under a static key. When the fetch fails failure_threshold times in a row, the circuit opens. While open, the fallback runs cache_peek_stale against the same static key and serves the last cached entry, even when that entry is past its TTL. On a miss (no entry), the default on_miss: stop policy stops the fallback cleanly. The exchange completes without a CircuitOpen error.

The fallback runs on routes with and without an error_handler. A failing fallback step follows the route's error handling. A route with an error_handler routes the failure through the handler. A route without one surfaces the raw error. See Circuit Breaker for the breaker states and Route structure for the fallback field.

The control plane also accepts the five cache steps. A CanonicalRouteSpec sent through RuntimeCommand::RegisterRoute supports cache, cache_invalidate, cache_clear, cache_stats, and cache_peek_stale in the route body and in circuit_breaker.fallback. An unknown step still fails with an error that names the step. See Control Bus and ADR-0016.

Use the Cache pattern when a route computes the same result more than once. API responses, database lookups, and transform-heavy pipelines benefit from caching.

The default repository is "memory" (moka-backed, size-eviction only). A persistent "persistent" repository (redb-backed) is available when [default.cache_repo] backend = "redb" is set. The redb backend survives process restarts. Its sweep task reclaims entries whose expires_at + stale_retention has passed. The memory backend does not run a sweep. Expired entries stay in memory until size pressure evicts them. cache_size is required for the redb backend (for example, cache_size = "256MiB"), bounding the redb page cache; sweep_interval is optional (default 1h).

A shared "redis" repository (redis-backed) is available when [default.cache_repo] backend = "redis" is set. Every process that connects to the same Redis shares one cache, so cached entries work across processes. Redis expires each key at expires_at + stale_retention. The redis backend runs no background sweep task. cache_size and sweep_interval do not apply to it. See ADR-0063 for the design.

[default.cache_repo]
backend = "redis"
url = "redis://127.0.0.1:6379"
stale_retention = "30m"

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

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

The example source is at examples/cache-example.

Resilience and control

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

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

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

Circuit breaker

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

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

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

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

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

The example source is at examples/circuit-breaker.

Half-open fallback asymmetry

During the probe-in-flight window, concurrent callers behave differently by route shape. A route with an error_handler compiles the breaker into a CircuitBreakerGate. The gate serves the fallback to concurrent callers while the probe runs. A route without an error_handler compiles the breaker into a Tower CircuitBreakerService. That service rejects concurrent callers with CircuitOpen while the probe runs, even when a fallback is configured.

Both behaviors are sound. The gate keeps a single probe in flight and serves stale fallback data. The service keeps a single probe in flight and rejects. The asymmetry is intentional. See Route structure for the YAML form.

Do Try

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

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

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

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

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

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

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

The example source is at examples/do-try.

Throttler

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

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

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

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

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

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

The example source is at examples/throttler.

Idempotent Consumer

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

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

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

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

Use the Idempotent Consumer when a route must tolerate redelivery without repeating work. Payment processing, order creation, and any at-least-once message source benefit from a deduplication gate. The example registers a memory-backed repository. A memory-backed repository loses its keys when the process exits. Two durable backends survive a restart. "redb" stores keys in an on-disk file. "redis" stores keys in a shared Redis keyspace (ADR-0063). Configure the redis backend with [default.idempotent_repo]:

[default.idempotent_repo]
backend = "redis"
url = "redis://127.0.0.1:6379"

The redis repository registers under the name "redis", so a route selects it with repository = "redis". A redis repository also shares deduplication keys across processes.

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

The example source is at examples/idempotent-consumer.

Delayer

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

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

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

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

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

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

The example source is at examples/delayer.

Loop

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

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

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

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

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

The example source is at examples/loop.

Validator

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

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

The included route fires a timer twice. The .set_body(...) step sets the body to an XML order. The .validate(...) step takes an expression string and compiles it to a predicate through the simple language. The included route uses ${body.contains('<order>')}. The log step after the validator runs only when the predicate holds. A second route in the example sends an invalid order. It pairs the validator with an error_handler, so the validation error is logged instead of crashing the process.

On a mismatch the validator returns an error. The error flows back through the same RouteErrorHandler boundary as any other step error. A route-level error_handler or a do-try catch block can recover it. Wrap the validator in a do-try block to keep the exchange in the pipeline after a failure. Use the bare validator to fail fast on bad input. Schema-file validation (XSD, JSON Schema) runs through the validator: component endpoint (to: "validator:<path>?type=xml"), not the validate step.

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

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

The example source is at examples/validator.

Processing steps

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

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

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

Stream Cache

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

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

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

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

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

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

Bean

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

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

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

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

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

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

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

Stop

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

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

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

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

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

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

Components

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

Catalog

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
mcpbothcamel-component-mcp
execproducercamel-component-exec
validatorproducercamel-validator
xsltproducercamel-xslt
xjproducercamel-xj
cxfbothcamel-cxf
keycloakbothcamel-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.
  • MCP. Model Context Protocol server and client.
  • WASM. Sandboxed plugins with capability model.
  • Cron. Scheduled message generation.
  • Direct. Synchronous in-process routing.
  • SEDA. Asynchronous staging between routes.
  • ControlBus. Runtime control messages.
  • Master. Leader-only route execution.
  • Template. External template rendering.
  • Validator. Schema validation.
  • Exec. External process execution.
  • Keycloak. OIDC auth and JWKS validation.
  • XML transform. XSLT and JSON-XML conversion.
  • Mock. Testing assertions.

Timer and log

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

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

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

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

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

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

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

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

Timer

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

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.

Driving an HTTP producer

A timer exchange carries no CamelHttpMethod header and a non-empty body. An http: producer therefore defaults to POST for such an exchange. A GET-only upstream rejects the request. Set an explicit method on the destination URI:

- to: "http://wfs.example.org/ows?httpMethod=GET"

The same rule applies to any non-HTTP consumer (cron, file, quartz). The CamelHttpMethod header is an alternative. See HTTP method selection for both forms.

Log

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

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

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

Putting it together

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

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

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

File

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

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

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

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

Source

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

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

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

Sink

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

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.

Endpoints declared through a rest: block are http: consumers after lowering, and they are secured the same way: the block-level security_policy is copied onto every lowered route, so its roles or scopes policy runs at the request boundary. See Route structure for the block key and examples/rest-crud for the runnable secured variant.

Client

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

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

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

HTTP method selection

The Producer resolves the outbound method in this order: the httpMethod URI option, then the CamelHttpMethod header, then a body-based fallback. An empty body selects GET. A non-empty body selects POST.

An exchange from a non-HTTP consumer (timer, cron, file, quartz) carries no CamelHttpMethod header and usually a non-empty default body. A timer exchange, for example, holds a tick label. Driving an http: producer from such a source therefore selects POST by default. A GET-only upstream then rejects the request, often with a message that does not name the method as the cause.

Set an explicit method when the source is not HTTP. Append httpMethod=GET to the destination URI, or set the header:

- to: "http://wfs.example.org/ows?httpMethod=GET"
- set_header:
    key: CamelHttpMethod
    value: GET
- to: "http://wfs.example.org/ows"

Direction choice

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

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

gRPC

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

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

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

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

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

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

URI

grpc://<host>:<port>/<package>.<Service>/<Method>?protoFile=<path>&transport=<mode>
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 key is rejected: a URI cannot carry a TLS configuration, so tls=true can never be satisfied and fails closed. tls=false still parses and means explicit plaintext. Under tls, the endpoint URL is rewritten to https://. The insecure_skip_verify=true option hard-errors. The component fails closed on an incomplete mTLS identity.

Security

The component enforces security through the unified transport auth kernel (ADR-0061 Rule 1):

  1. Authentication. The interceptor extracts credentials per the route plan's credential sources, authenticates via kernel_authenticate, and installs the typed principal carrier on a fresh Exchange per request in all four RPC modes. AccessMode::Public skips extraction entirely; missing or invalid credentials return Status::unauthenticated (provider-down maps to unavailable).
  2. Authorization. The route's compiled RouteSecurityPlan drives the pre-pipeline dispatch check: a non-Public route requires the kernel carrier on the Exchange or the dispatch is denied before the pipeline runs. Route-level policies evaluate in the pipeline layer against the carrier principal.

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

Streaming

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

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

WebSocket and SOAP (CXF)

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

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

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

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

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

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

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

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

WebSocket URI

ws://host:port/path[?options]
wss://host:port/path[?options]
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 the unified transport auth kernel (ADR-0061; see Authentication and authorization). When a route carries a compiled security plan, the upgrade handler authenticates per the plan's credential sources via kernel_authenticate and stamps the typed principal carrier onto every message Exchange; a non-Public route without the carrier is denied pre-pipeline by the strict dispatch check. Failed authentication returns 401 at the upgrade. Policy evaluation errors return 500 and increment the e:ws:policy-eval metric (ADR-0012 class e). The health pin becomes the operator signal for bind failures (ADR-0012 class g).

CXF URI

cxf://http://host:port/path?wsdl=file.wsdl&service={ns}Name&port={ns}Port[&operation=opName][&profile=profile_name][&timeout_ms=N][&mtom_enabled=true|false]
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

failover:// URLs are accepted for Classic (activemq) brokers and rejected for artemis (use a single primary URL or multiple broker entries).

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

Consumer

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

The body is typed from the JMS content_type. text/* becomes Body::Text. application/json becomes Body::Json when valid JSON. Binary content becomes Body::Bytes. A TextMessage carrying an explicit ContentType property (for example application/xml from a non-Camel producer, or from this component's own producer) now keeps that value, so such payloads surface as Body::Bytes instead of Body::Text. The consumer pre-flights the bridge slot before starting. A missing or degraded bridge fails fast with JMS bridge not available.

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

Producer

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

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

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

Java bridge

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

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

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

Trust boundary

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

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

Error handling

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

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

Limitations

  • The bridge uses AUTO_ACKNOWLEDGE. Messages are acknowledged on delivery, not after processing. A failed route cannot request redelivery.
  • Durable topic subscribers are not supported.
  • IBM MQ is not supported.
  • The 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-16383)
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. This is the secure-by-default posture. The auto-enable check uses the host from the [components.redis] block. An endpoint that names its own host in the URI follows the global decision when it sets no ssl parameter. The component logs a tracing::warn! when auto-enabling TLS. Two opt-outs keep the connection plaintext:

  • ?ssl=false on the endpoint URI. The endpoint-level value wins over the global config.
  • tls_mode = false in the [components.redis] block. This forces TLS off for every endpoint that does not set ssl itself. tls_mode = true forces TLS on for those endpoints, even for loopback hosts. When tls_mode is unset, auto-enable applies.

Sentinel endpoints select TLS by scheme (redis-sentinel:// or rediss-sentinel://). The global TLS settings do not apply to them.

The tls boolean stays as a legacy force-on flag. Set tls = true to enable TLS on loopback hosts. The boolean cannot force TLS off: a plain tls = false keeps the auto-enable for remote hosts. Use tls_mode = false or ?ssl=false for that.

A build without a TLS feature (tls-rustls-webpki-roots, tls-rustls-native-certs, or tls-native-tls) rejects every TLS endpoint at startup with a Config error. The error names the missing feature. The error is never classified as transient, so no retry loop runs. The camel release binary compiles the redis-tls feature by default, so remote Redis works without extra build steps.

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

TLS CA trust

tls_ca_cert names a PEM file that holds the CA certificate. The field lives in the global [components.redis] block; it is not a URI parameter. apply_defaults() copies the global value onto each endpoint, and an endpoint-level value wins over the global one.

The component reads the file at endpoint creation, and only for a standalone endpoint that resolved to TLS. A plaintext endpoint and a sentinel endpoint ignore the setting without any filesystem access. Sentinel CA trust is follow-up work (bd rc-hbde6).

The component trusts the PEM as the root for the connection. It passes the bundle to the CA-trusting client constructor, so the server certificate is verified against this root. There is no insecure bypass.

An unreadable file fails closed. Endpoint creation returns a Config error that names the path, before any connect attempt. One caveat: the error message contains the path, so a path that itself contains transient-classifier words (for example readonly) can be misclassified by the retry heuristic (bd rc-ezi0f).

Connection handling

The Producer holds a single multiplexed connection per Endpoint. It opens lazily on the first call, and a reconnect drops the cached connection and re-resolves the master through the topology — that re-resolution is where a sentinel failover is picked up. The producer does not re-resolve on every command.

Each Consumer mode — Pub/Sub and queue — holds ONE persistent connection per session. Every message or popped item is delivered over that same connection; the consumer does not reconnect between messages. A blocking-pop timeout (BLPOP/BRPOP returning nil) keeps the connection. The consumer reconnects only when the Pub/Sub stream ends or a transient transport error strikes, and a Pub/Sub reconnect replays all subscriptions. Reconnects are bounded by the configured NetworkRetryPolicy: when the budget is exhausted the consumer returns an error and Route supervision restarts the Route (ADR-0007).

Connections have a 10-second connect timeout by default (connection_timeout_secs in the config block). The route ErrorHandler owns the operational signal for non-transient errors.

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

Sentinel / failover

Redis Sentinel gives Redis high availability. Sentinel nodes monitor a master and its replicas. When the master fails, the sentinels elect a replica and promote it to master. Clients must re-discover the new master to keep working.

The component connects to a Sentinel topology with the redis-sentinel:// scheme:

redis-sentinel://sentinel-a:26379,sentinel-b:26379/<master-name>/<db>?command=<cmd>[&key=<key>][&channels=<list>]

The authority holds the comma-separated sentinel node list. The first path segment is the master group name. The second path segment is the optional database number. It defaults to 0. The rediss-sentinel:// scheme is the TLS variant. It enables TLS on the sentinel and the resolved master connections.

The runtime resolves a route URI by scheme. Register RedisSentinelComponent for redis-sentinel:// routes and RedissSentinelComponent for rediss-sentinel:// routes. Register them next to RedisComponent:

ctx.register_component(RedisComponent::new());
ctx.register_component(RedisSentinelComponent::new());
// Only for rediss-sentinel:// (TLS) routes:
ctx.register_component(RedissSentinelComponent::new());

RedisBundle::register_all registers all three schemes from the [components.redis] block.

You can also select Sentinel with the [components.redis.sentinel] config block:

[components.redis.sentinel]
nodes = ["redis://sentinel-a:26379", "redis://sentinel-b:26379"]
master_name = "mymaster"
# Optional sentinel credentials.
# username = "sentinel-user"
# password = "sentinel-pass"

nodes holds the sentinel node URLs. master_name is the master group name. The optional username and password authenticate the sentinel connections. The node password stays in the top-level [components.redis] block. The two credential sets are separate.

// Producer: timer writes a key every 3s through Sentinel.
let producer = RouteBuilder::from("timer:tick?period=3000&repeatCount=3")
    .route_id("redis-sentinel-producer")
    .set_header("CamelRedis.Key", Value::String("greeting".into()))
    .set_header(
        "CamelRedis.Value",
        Value::String("hello via redis sentinel!".into()),
    )
    .to("redis-sentinel://127.0.0.1:26379/mymaster/0?command=SET")
    .to("log:info?showHeaders=true")
    .build()?;

Failover behavior

On a transport error, the producer and consumer reconnect loops re-resolve the current master through the sentinel nodes. The resolved master is never cached. Every reconnect asks the sentinels again. This is bounded transport reconnect, not consumer self-supervision. The retry budget comes from NetworkRetryPolicy. When the budget runs out, the consumer returns Err and Route supervision takes over (ADR-0007).

The health check also re-resolves the master through the sentinel nodes on each check. After a failover, the check reports the new master, not a cached address.

Best-effort Pub/Sub

Pub/Sub delivery is best-effort. When a sentinel-triggered stream ends, the consumer re-subscribes to the new master. Messages published during the failover gap are lost. Duplicates are possible on reconnect. Do not use Pub/Sub for workloads that need durability.

Feature flag

The sentinel cargo feature on camel-component-redis enables Sentinel topology construction. Enable it in your manifest:

camel-component-redis = { workspace = true, features = ["sentinel"] }

Without the feature, the component recognizes redis-sentinel:// and a non-empty [components.redis.sentinel] block and rejects them at startup with a clear error. It fails closed. It does not fall back to a standalone connection.

Error handling

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

Reference: Redis crate CONTEXT. Example sources: examples/redis-example, examples/redis-sentinel.

Database

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

SQL

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

URI

sql:<query>[?outputType=<mode>&dataSource=<name>&allowDynamicQuery=<bool>&bridgeErrorHandler=<bool>]
ParameterRequiredDefaultDescription
queryyesSQL query or statement. Can also come from a file
outputTypenoSelectListSelectList (JSON array) or StreamList (NDJSON stream)
dataSourcenoNamed datasource from Camel.toml. Omit to use the inline db_url
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 query operation gates its dynamic query sources behind allow_dynamic_query (default false, ADR-0032). When the gate is off, the CamelSurrealDbQuery header and the Exchange body are ignored, and the Producer runs the query from the Endpoint configuration. Set allow_dynamic_query=true to accept SurrealQL from the header (first priority) or the body (second priority).

Datasource configuration

Both components use named datasources from Camel.toml:

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

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

Reference: SQL crate CONTEXT, SurrealDB crate CONTEXT.

SurrealDB

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

A producer route writes a user and selects the table:

use camel_builder::RouteBuilder;

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

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

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

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

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

URI

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

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

OperationDirectionRequired URI paramsDescription
queryproducer / pollingRun raw SurrealQL from the header, the body, or the Endpoint config (dynamic sources gated by allow_dynamic_query)
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
allow_dynamic_querynofalseAccept SurrealQL from the CamelSurrealDbQuery header or the Exchange body (ADR-0032)

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

Producer

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

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

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

The query operation resolves SurrealQL with this priority: the CamelSurrealDbQuery header, then the Exchange body, then the query configured on the Endpoint. The header and the body are untrusted Exchange data under ADR-0032. The component gates both dynamic sources behind allow_dynamic_query (default false). When the gate is off, the Producer runs the configured query only. The CamelSurrealDbParams header binds $name placeholders.

Consumer

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

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

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

Datasource

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

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

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

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

Headers

HeaderDirectionOperationsDescription
CamelSurrealDbQueryinputquerySurrealQL text (priority: header > body > endpoint config; requires allow_dynamic_query=true)
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 query operation gates its dynamic query sources behind allow_dynamic_query (default false), the same default-deny posture as camel-sql's allowDynamicQuery. With the gate off, the CamelSurrealDbQuery header and the body are ignored, and the Producer runs the query from the Endpoint configuration. Set allow_dynamic_query=true on a controlled route to accept query text from Exchange data.

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

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

OpenSearch

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

Index a document

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

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

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

Search documents

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

use camel_builder::RouteBuilder;

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

Bulk operations

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

use camel_builder::RouteBuilder;

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

URI

opensearch://<host>:<port>/<index>?operation=<op>[&option=value...]
opensearchs://<host>:<port>/<index>?operation=<op>[&option=value...]  (TLS)
ParameterRequiredDefaultDescription
hostnolocalhostOpenSearch hostname. Falls back to the global config
portno9200OpenSearch port. Falls back to the global config
indexNameyesTarget index name. Overrides the index from the URI path. 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
retryMaxAttemptsno10Maximum retry attempts
retryInitialDelayMsno100Initial backoff delay
retryMultiplierno2.0Backoff multiplier
retryMaxDelayMsno30000Maximum backoff delay
retryJitterno0.2Jitter 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() returns ready unconditionally. The call future waits on the semaphore. A closed semaphore maps to CamelError::ConsumerStopping (ADR-0019, ADR-0024). The shared client initializes lazily on the first call. Arc<Mutex<Option<OpenSearch>>> serializes initialization, then each call clones and reuses the client.

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

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

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

Error handling

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

Reference: camel-opensearch CONTEXT. Example source: examples/opensearch-example.

LLM

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

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

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

URI

llm:<operation>?provider=<name>[&model=<model>][&temperature=<n>][&max_tokens=<n>][&stream=<bool>][&system_prompt=<text>]
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_secs is not a URI parameter. Configure it in the provider section of Camel.toml (or the global LLM config). It sets the activity timeout for streaming mode and the total deadline for materialized mode.

Operations

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

Producer

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

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

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

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

Cost and cache

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

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

Providers

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

Retry and timeout

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

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

Error handling

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

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

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

MCP

The MCP component connects routes to Model Context Protocol servers. It has two roles. The Consumer role exposes tools and resources on one shared Streamable-HTTP listener per bind. The Producer role sends mcp:call and mcp:read requests to remote MCP servers. The protocol baseline is 2026-07-28, stateless: no initialize handshake, no sessions.

The mcp: DSL block declares a server catalog. It lowers each tool to an mcp:<server>/tool/<name> consumer route and each resource to an mcp:<server>/resource/<name> consumer route:

mcp:
  server:
    name: crm
    bind: 127.0.0.1:9100
    security_policy: { roles: [mcp-client] }
  tools:
    - name: lookup
      input_schema: { type: object, properties: { id: { type: string } }, required: [id] }
  resources:
    - name: customers
      uri: crm://customers

URI

mcp:<server>/tool/<name>?schema=<schema>
mcp:<server>/resource/<name>?uri=<uri>
mcp:call?server=<remote>&tool=<name>
mcp:read?server=<remote>&uri=<uri>

Consumer URIs come from DSL lowering. The schema and uri values travel on the query string, never in Exchange headers or bodies. Producer URIs dispatch exactly one JSON-RPC request per Exchange. The producer never auto-loops an LLM call. The route owns every dispatch decision.

Server (Consumer)

One Streamable-HTTP listener serves each bind address. Every tool and resource route on that server shares it. The first consumer on a bind starts the listener. A later consumer with a conflicting config (tls, allowed_hosts, catalog caps) is rejected. Registration of a name or URI held by a live owner is rejected; a dead owner's entry is replaced on restart (ADR-0068).

Security follows the unified transport auth kernel (ADR-0061): a server without a security_policy starts Public by default, and the per-bind exposure gate applies — a non-loopback bind serving any Public route requires a [binds."<addr>"] allow_public_exposure = true acknowledgement or start fails naming the bind (an acknowledged exposure warns permanently). The DSL block's security_policy propagates to every lowered tool and resource route; the kernel authenticates each request per the route plan's credential sources (normalized RFC 9110 headers) and installs the typed principal before the pipeline. Catalog caps (max_tools, max_resources, default 128 each) reject surplus registrations at start.

Remote hosts that announce a protocol version other than 2026-07-28 get a -32022 rejection and one warn! record per event. The server reads no Mcp-Session-Id header.

Client (Producer)

mcp:call?server=crm-prod&tool=lookup sends the Exchange body as tool arguments. The reply content goes to the Exchange body. The CamelMcpResult header carries {"is_error": <bool>, "content": <content>}. The producer does not act on the flag. The route author decides what to do with a failed call.

Configuration

Server runtime config lives in Camel.toml under [mcp.servers.<name>] (bind, tls, security_policy, max_tools, max_resources, allowed_hosts). Remotes live under [mcp.remotes.<name>] (url, transport). A DSL mcp: block names a server; that name must match a TOML key or the consumer start fails. When the block declares bind/tls/max_tools/max_resources, those values ARE the runtime listener values; TOML and the DSL declaring the same key with different values fails startup naming both sources (ADR-0061 Rule 9 — never silent TOML-wins). TOML-only keys (allowed_hosts) still apply.

Reference: MCP crate CONTEXT, ADR-0060. Example source: examples/mcp-example.

WASM

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

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

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

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

URI

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

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

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
timeZonenoUTCIANA timezone identifier (e.g. America/New_York)
includeMetadatanotrueAttach 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
blocknoNot supported. The Component rejects it at Endpoint creation with block is not supported
exchangePatternnoNot supported. The Component rejects it at Endpoint creation with exchange_pattern is not supported

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

Consumer

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

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

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

Producer

to: "direct:name" builds a DirectProducer. The Producer holds one in-flight call at a time through a bounded semaphore. poll_ready checks only the registry. It fails fast when no Consumer is registered and failIfNoConsumers is not false. call acquires the permit before the dispatch timeout starts. It then hands the Exchange to the Consumer's channel and awaits the reply.

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

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

Request-Reply

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

Error handling

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

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

SEDA

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

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

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

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

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

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

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

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

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

URI

seda:<name>[?size=<n>][&concurrentConsumers=<n>][&multipleConsumers=<bool>][&blockWhenFull=<bool>][&discardIfNoConsumers=<bool>][&timeout=<ms>][&waitForTaskToComplete=<mode>][&exchangePattern=<pattern>]
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 consumer's start() spawns one forwarder task per unit of concurrentConsumers (one per subscriber queue in Fanout mode). The forwarders share one receiver. Each forwarder awaits send_and_wait for InOut and waitForTaskToComplete=Always exchanges, and the forwarders process exchanges in parallel when concurrentConsumers is greater than 1. InOnly exchanges without a reply channel do not block a forwarder.

concurrentConsumers is reported to the Runtime through ConcurrencyModel::Concurrent. This parallel InOut processing was a defect until 2026-08-09. Finding I1 and bd issue rc-exa2 (audit-fix-misc-correctness) tracked the defect when a single forwarder serialized send_and_wait.

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

Producer

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

  • 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.

Kubernetes identity

KubernetesPlatformService builds its election identity from the pod it runs on. Production deployments MUST expose POD_NAME through the Kubernetes Downward API. Expose POD_NAMESPACE as well. The platform uses it when the configuration sets no namespace.

The node ID resolves from the first non-empty source in a fixed chain. The chain tries the POD_NAME environment variable, then the HOSTNAME environment variable, then the local hostname. Resolution from a fallback source logs a warning. When no source resolves, platform construction fails with a configuration error.

The Lease holderIdentity has the format <namespace>/<node_id>. This is the value operators see in kubectl get lease. The namespace resolves in this order: the configured namespace, the pod namespace, then default.

An upgrade may leave Leases with a holder in the old format. The first post-upgrade acquisition rewrites each Lease's holder. The format change does not bypass lease expiry or optimistic concurrency.

Configuration

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

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 and is REQUIRED — a keycloak or oidc provider without audiences fails at startup (ADR-0061: an unscoped audience binding would accept tokens minted for any client of the issuer). clock_skew_secs tolerates the difference between the local clock and the issuer clock.

UMA permission evaluation

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

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

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

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

Transport hardening

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

Error handling

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

Reference: Keycloak component CONTEXT. Example: examples/security-keycloak shows the native auth pipeline with static credentials and role-based policies. ADRs: 0010 Security policy pre-pipeline authz, 0012 Log-level convention, 0033 Security defaults fail-closed startup validation.

XSLT and XJ

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

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

XSLT

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

let route = RouteBuilder::from("direct:in")
    .to("xslt:/etc/transforms/order.xslt?output=xml&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_component_timer::TimerComponent;
use camel_core::CamelContext;

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

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

let route = RouteBuilder::from("timer:tick?period=1000&repeatCount=1")
    .route_id("mock-demo")
    .set_body(camel_api::Body::Text("hello"))
    .map_body(|body: camel_api::Body| {
        camel_api::Body::Text(body.as_text().unwrap_or("").to_uppercase())
    })
    .to("mock:result")
    .build()?;

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

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

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

URI

mock:<name>
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.

BodyMatcher and HeaderMatcher are the public matcher vocabulary. Use expect_body_matcher(BodyMatcher) and expect_header_matcher(key, HeaderMatcher) to register matcher expectations. They share the ordered slot list with expect_body. Insertion order across expect_body and expect_body_matcher is preserved. Header matcher entries use any-exchange semantics, same as expect_header and expect_header_regex.

BodyMatcher variants: Equals(Body), Regex(String), Contains(String), StartsWith(String), EndsWith(String), Exists, JsonSubset(serde_json::Value). HeaderMatcher variants: Equals(serde_json::Value), Regex(String), Exists. exists takes no argument. jsonSubset takes a JSON object. regex takes a string pattern. An invalid regex is a malformed-pattern error. It never passes and never latches into fail_fast_error.

JsonSubset matches recursively over objects. Arrays compare exactly. A text body that parses as JSON is accepted. A text body that does not parse fails the matcher. A non-object pattern or a non-object received top-level value fails the matcher.

Failures name the matcher kind, its pattern, and the received value. The display forms are equals <json>, regex <pattern>, contains <needle>, startsWith <prefix>, endsWith <suffix>, exists, jsonSubset <json>. When the body is not text, a string-matcher failure adds body is not text. When the body is not JSON, a JsonSubset failure adds body is not JSON or body is not a JSON object. When a header regex sees a non-string value, the failure adds value is not a string. The received value is rendered whole.

expect_header_regex remains available. It coerces non-string header values with to_string() before matching. HeaderMatcher::Regex is strict. It requires a string value and fails with value is not a string otherwise. The divergence is intentional.

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

MockConfig

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.

String methods mutate in place

Rhai string methods such as replace, trim, and pad mutate the subject in place and return unit (). They do not return a new string. This differs from JavaScript, Python, and Rust.

Call the method as a bare statement. The statement form mutates the body in place:

body.replace(",", "%2C");

Never write body = body.replace(...). The right-hand side is unit, so the assignment silently drops the value. The body stays unchanged. The same applies to headers: headers["k"] = headers["k"].replace(...) writes unit into the map entry, and the value becomes Null. The failure is silent. No error is raised.

The rhai_replace_* characterization tests in the Rhai crate pin this behavior.

Reference: Language SPI · Rhai crate

MiniJinja

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

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

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

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

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

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

Reference: Language SPI · MiniJinja crate

Data Formats

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

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

Available formats

FormatCrateBody mapping
jsonbuilt-in (camel-processor)Text ↔ Json
csvbuilt-in (camel-processor)Text ↔ Json
xmlbuilt-in (camel-processor)Text ↔ Json
zipbuilt-in (camel-processor)Any body → zipped Bytes
protobufcamel-dataformat-protobufJson ↔ Bytes

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

Reference: DataFormat trait

Protobuf

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

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

When to use protobuf

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

Construction

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

use camel_dataformat_protobuf::ProtobufDataFormat;

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

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

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

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

Body type support

Body 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 error_handler and circuit_breaker objects are documented below. The security_policy object is documented in Authorization.

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 with optional fallback sub-pipeline
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.

Circuit breaker

Set circuit_breaker to protect a route from a failing downstream service. The breaker opens after failure_threshold consecutive failures. While open, it rejects exchanges for open_duration_ms. See Circuit breaker for the breaker states.

The optional fallback list holds a sub-pipeline. The breaker runs the sub-pipeline instead of rejecting the exchange while the circuit is open. An absent or empty fallback keeps the existing behavior: the breaker returns CircuitOpen.

    circuit_breaker:
      failure_threshold: 1
      open_duration_ms: 60000
      fallback:
        - cache_peek_stale:
            key: "user-profile-42"

The fallback runs on routes with and without an error_handler. A fallback step that stops cleanly (for example, a cache_peek_stale MISS with the default on_miss: stop) surfaces Ok(exchange) with the exchange state intact. No CircuitOpen escapes.

A failing fallback step follows the route's error handling. A route with an error_handler routes the failure through the handler. A route without one surfaces the raw error to the caller.

The Cache page shows the stale-on-error composition with cache_peek_stale.

Half-open fallback asymmetry

During the half-open probe-in-flight window, the fallback behavior differs by route shape. A route with an error_handler compiles the breaker into a CircuitBreakerGate. The gate serves the fallback to concurrent callers while the probe runs. A route without an error_handler compiles the breaker into a Tower CircuitBreakerService. That service rejects concurrent callers with CircuitOpen while the probe runs, even when a fallback is configured.

Both behaviors are sound. The gate keeps a single probe in flight and serves stale fallback data. The service keeps a single probe in flight and rejects. The asymmetry is intentional. See Circuit breaker for the breaker states.

Security policy

Set security_policy to authorize exchanges before the steps run. The object takes one of five forms: roles, scopes, ref, wasm, or permission. The full policy model lives in Authorization.

The optional credential_sources list names where the route reads its credential. When absent, the default is [authorization_header]: the route reads the Authorization header only. Each entry names one source:

FormMeaning
authorization_headerBearer token in the Authorization header
query_param: { param: <name> }Token in a query parameter
cookie: { name: <name> }Token in a cookie
header: { name: <name> }API key in a named custom header
routes:
  - id: tile-service
    from: "http://0.0.0.0:8090/tiles"
    security_policy:
      roles: ["tile-user"]
      credential_sources:
        - cookie: { name: session }
        - query_param: { param: token }
        - header: { name: X-Api-Key }
        - authorization_header
    steps:
      - set_header:
          key: CamelHttpResponseCode
          value: 200
      - set_header:
          key: Content-Type
          value: "application/json"
      - set_body: '{"layer":"streets","status":"authenticated"}'

Extraction runs in the declared order. The first source that supplies a credential wins. credential_sources is valid only with the roles or scopes form. Load-time validation rejects malformed credential_sources entries: an empty list, an empty parameter or cookie name, and a header name that is not a valid RFC 9110 token. See Authorization for the extraction semantics and ADR-0059.

The optional provider string names the configured authenticator for the route. The name must match one of the configured providers: keycloak, oidc, or native. When more than one provider is configured, provider is required. A route without it fails to load, and the error names the available providers. An unknown provider name also fails the load. Like credential_sources, provider is valid only with the roles or scopes form.

REST block policy

A rest: block accepts the same security_policy object, declared once on the block. Lowering copies it onto every route the block produces, so every operation enforces the same policy before its handler runs. The object takes the same five forms and passes the same load-time validation as the route-level key, including credential_sources, provider, and audiences.

rest:
  - host: 0.0.0.0
    port: 9090
    path: /api/users
    security_policy:
      roles: ["user"]
      provider: "native-demo"
    operations:
      - method: GET
        operation_id: listUsers
        to: direct:listUsers
        produces: application/json
      - method: POST
        operation_id: createUser
        consumes: application/json
        produces: application/json
        success_status: 201
        to: direct:createUser
      - method: PUT
        path: /{id}
        operation_id: updateUser
        consumes: application/json
        produces: application/json
        to: direct:updateUser
      - method: DELETE
        path: /{id}
        operation_id: deleteUser
        to: direct:deleteUser
        success_status: 204

The source lives at examples/rest-crud/routes/secured.yaml. Run the runnable variant with cargo run -p rest-crud --bin secured: a request without a credential gets 401 before any handler runs, and a token whose principal holds the required role is granted. A block without security_policy lowers to public routes.

List-form variant

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

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

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

Next

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
coalesce_missesboolnofalseRun one on_miss per concurrent miss wave on the same key
on_misslistyesSub-pipeline to run on cache miss
- cache:
    key: "${header.cacheKey}"
    ttl: "5s"
    on_miss:
      - set_body: "computed"

With coalesce_misses: true, concurrent misses on the same key run the on_miss sub-pipeline once. The first miss leads. The rest wait and share the leader's body and error.

cache_invalidate

Remove a single entry or a namespace from the cache repository. Set key for an exact-key removal or key_prefix for a namespace purge. Exactly one of the two is required.

FieldTypeRequiredDefaultDescription
repositorystringno"memory"Repository name
keystringnoExact cache key expression (mutually exclusive with key_prefix)
key_prefixstringnoNamespace prefix expression (mutually exclusive with key)

On success the step sets the CamelCacheInvalidatedCount exchange property: 1 for an exact key, the removed count for a prefix. A backend without key iteration (memory) fails closed on key_prefix.

- cache_invalidate:
    key: "${header.cacheKey}"
- cache_invalidate:
    key_prefix: "user-profile-"

cache_clear

Remove every entry from the cache repository.

FieldTypeRequiredDefaultDescription
repositorystringno"memory"Repository name
- cache_clear: {}
- cache_clear:
    repository: "persistent"

cache_stats

Replace the body with a JSON snapshot of the cache repository statistics.

FieldTypeRequiredDefaultDescription
repositorystringno"memory"Repository name
- cache_stats: {}

The snapshot holds repository, hits, misses, evictions, entries, peek_stale_served, invalidations, and bytes. The bytes field is the stored payload size when the backend reports it (null for the memory backend).

cache_peek_stale

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

FieldTypeRequiredDescription
keystringyesCache key expression
on_missstringnoOn-miss policy: "stop" (default) or "continue"

on_miss does not have the same meaning as the on_miss field of cache. In cache, the field holds a sub-pipeline. In cache_peek_stale, the field holds a policy word. Do not write a step list under cache_peek_stale.on_miss.

- cache_peek_stale:
    key: "${header.cacheKey}"
    on_miss: continue

sampling

Process one exchange out of every N.

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
fallbacklistnoSub-pipeline executed while the circuit is open

The fallback list holds a sub-pipeline of steps. The breaker runs it instead of rejecting the exchange while the circuit is open. See Circuit breaker for the full surface.

Security policy config

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

FieldTypeRequiredDescription
roleslistnoRequired roles
scopeslistnoRequired scopes
all_requiredboolnoAll roles/scopes required
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
security_policyobjectnoBlock authorization copied to every lowered route
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
bindingstringnojsonBinding mode: json or raw
success_statusintegernoSuccess HTTP status
request_schemaobjectnoRequest body schema
responseobjectnoResponse definition
descriptionstringnoOperation description
parametersmapno{}Additional parameters

Operations bind in one of two modes. The default json mode accepts JSON-essence media types for consumes and produces: bare application/json, parameterized forms such as application/json; charset=utf-8, and +json suffixes such as application/problem+json. It unmarshals requests, marshals responses, and validates declared schemas automatically. Any other media type in json mode fails route load. The raw mode accepts any RFC 9110 type/subtype media type, leaves the request as Body::Stream with no automatic unmarshal or marshal, and sends the trimmed produces value as the response Content-Type. request_schema and response.schema are rejected in raw mode. The default success status is injected in both modes; a raw POST returns 201 with the declared produces type.

- method: post
  path: /ingest
  binding: raw
  consumes: application/octet-stream
  produces: text/plain
  to: direct:ingest

Raw binding streaming contract

raw operations own the stream semantics of their request and reply bodies. The contract:

  • No pipeline caching. Lowering injects no unmarshal/marshal, so no StreamCacheService-wrapped processor compiles ahead of the user steps. The request Body::Stream reaches the first user step unpollied; the injected Content-Type and default-status steps never read it.
  • Single consumption. The request stream is consumed at most once. A second consumption attempt fails with AlreadyConsumed and propagates as a route error — never a panic. A reply whose stream was already consumed returns HTTP 500 with an empty body.
  • Metadata preservation. The HTTP consumer records the request Content-Type and Content-Length in the stream metadata before the exchange enters the route, and the pipeline never alters them.
  • Original or new reply stream. A route may reply with the original request stream (echo) or a newly generated Body::Stream; both are streamed to the wire under the route-supplied Content-Type.
  • Request limits fail closed. A request with Content-Length over max_request_body is rejected 413 before the stream opens. A chunked request over the cap fails with the limit error when consumed.
  • Response limits cover materialized bytes only. max_response_body caps materialized reply bodies — an over-cap materialized reply is replaced with HTTP 500 (Response body exceeds configured limit). A streamed reply is not byte-capped: capping a stream mid-flight would truncate an already-committed response, so routes that need response caps must materialize the body first.
  • Client disconnects do not fail the consumer. If a client drops the connection during a streamed reply, the server keeps serving subsequent requests.

Template declaration

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. An include list can also live inside [default] or a named profile section. Profile-scoped lists override top-level lists on key conflicts.

Note: include entries are literal paths. A relative entry resolves against the directory that holds the main config file. ${env:} expansion runs after the merge, over values only. Placeholders inside an include list stay literal, so include = ["${env:CAMEL_INCLUDE_CONF}"] fails with included file not found.

Switching the cache backend between profiles needs one extra step. Profile merges are additive: keys omitted by a profile survive from [default], and validation rejects any cross-backend cache_repo key. Do not set [default.cache_repo]; define the complete table inside each profile instead:

[dev.cache_repo]
backend = "redb"
path = "/var/lib/rust-camel/dev-cache.redb"
cache_size = "64MiB"

[prod.cache_repo]
backend = "redis"
url = "redis://prod-redis.internal:6379"

A [<profile>.cache_repo] table whose counterpart is absent from [default] inserts whole at merge time, so no redb key survives under the redis profile.

Repository registration names

Both [idempotent_repo] and [cache_repo] accept an optional name string. The name is the registry key that EIP steps (idempotent consumer, cache) resolve repositories by, and for the redis backends it is also a keyspace segment (camel:idem:<name>:*, camel:cache:<name>:*), so two differently-named repositories never share keys. Allowed characters are [A-Za-z0-9:_-]; glob metacharacters are rejected because clear scans by prefix.

When name is omitted, the backend convention applies. The defaults are historical and pinned by existing scenarios; the table shows the full mapping:

SectionBackendDefault name
[idempotent_repo]redbredb
[idempotent_repo]redisredis
[cache_repo]memorymemory
[cache_repo]redbpersistent
[cache_repo]redisredis

The cache redb default is persistent, not redb — an asymmetry kept for backward compatibility. Set name explicitly when the distinction matters to your routes.

One caveat on the cache memory backend: the config only replaces the default memory repository when max_capacity is set. A name (or any other cache_repo key) on a memory table without max_capacity registers nothing — the built-in default stays.

One boundary of the cross-repository prefix-collision rule: validation compares declared endpoints, not resolved addresses. A standalone url and a sentinel_nodes topology that both point at the same physical Redis instance and database are treated as distinct databases, because resolving the sentinel topology requires network I/O that validate() does not perform. The repositories stay separated by their distinct default key prefixes and name segments; set explicit key_prefix values when mixing both shapes against one instance.

Environment overrides

After includes and profile merges, the loader overlays a fixed allowlist of CAMEL_* environment variables onto the merged tree. The loader ignores a CAMEL_* variable outside the allowlist and logs a warning. Two exceptions, CAMEL_PROFILE and CAMEL_CONFIG_FILE, select the profile and the config file itself. They do not override config fields and do not warn.

camel run and CamelConfig::from_env_or_default() apply these overrides to the loaded file; CamelConfig::from_file() does not.

Allowlisted variables, by group:

  • General: CAMEL_TIMEOUT_MS, CAMEL_DRAIN_TIMEOUT_MS, CAMEL_WATCH, CAMEL_WATCH_DEBOUNCE_MS, CAMEL_LOG_LEVEL
  • Runtime journal: CAMEL_RUNTIME_JOURNAL_PATH, CAMEL_RUNTIME_JOURNAL_DURABILITY, CAMEL_RUNTIME_JOURNAL_COMPACTION_THRESHOLD_EVENTS
  • Idempotent repo: CAMEL_IDEMPOTENT_REPO_PATH, CAMEL_IDEMPOTENT_REPO_DURABILITY
  • Cache repo: CAMEL_CACHE_REPO_BACKEND, CAMEL_CACHE_REPO_PATH, CAMEL_CACHE_REPO_MAX_CAPACITY, CAMEL_CACHE_REPO_STALE_RETENTION, CAMEL_CACHE_REPO_MAX_ENTRIES, CAMEL_CACHE_REPO_PAYLOAD, CAMEL_CACHE_REPO_PAYLOAD_DIR, CAMEL_CACHE_REPO_CACHE_SIZE, CAMEL_CACHE_REPO_SWEEP_INTERVAL, CAMEL_CACHE_REPO_MASTER_NAME, CAMEL_CACHE_REPO_KEY_PREFIX, CAMEL_CACHE_REPO_DB, CAMEL_CACHE_REPO_SENTINEL_NODES
  • Supervision: CAMEL_SUPERVISION_INITIAL_DELAY_MS, CAMEL_SUPERVISION_MAX_ATTEMPTS

CAMEL_CACHE_REPO_SENTINEL_NODES is the only override whose value is a list. Its value is a comma-separated list of host:port entries, for example CAMEL_CACHE_REPO_SENTINEL_NODES=sentinel-1:26379,sentinel-2:26379. The loader splits the value on commas, trims each entry, and drops blank entries. An empty value yields an empty list. The list replaces the file value, and, on the redis backend, an empty node list normalizes to absent. The override clears the field to unset.

The empty-means-unset rule composes with the complete per-profile [<profile>.cache_repo] tables shown above. One image ships one file with a complete table per environment, and env overrides adjust values that differ per deployment. Flipping CAMEL_CACHE_REPO_BACKEND fails validation when the merged table carries keys of the other backend; switch backends through the profile tables instead.

An empty value preserves the file or profile value for these scalar cache repo variables: CAMEL_CACHE_REPO_PAYLOAD, CAMEL_CACHE_REPO_PAYLOAD_DIR, CAMEL_CACHE_REPO_CACHE_SIZE, CAMEL_CACHE_REPO_SWEEP_INTERVAL, CAMEL_CACHE_REPO_MASTER_NAME, CAMEL_CACHE_REPO_KEY_PREFIX, and CAMEL_CACHE_REPO_DB. The loader skips the empty override instead of passing an empty string to typed deserialization.

Note: Connection strings and credentials are outside the allowlist. Set them with ${env:VAR} placeholders in Camel.toml values, never through env overrides. The loader ignores a variable such as CAMEL_CACHE_REPO_URL and logs a warning.

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.

[binds."<addr>"]

Per-bind public-exposure acknowledgements (ADR-0061 Rule 4). When any route on a NON-loopback bind (http://0.0.0.0:8080, wss://0.0.0.0:9000, an MCP bind, …) compiles to a Public security plan, startup refuses unless the bind carries an explicit acknowledgement; an acknowledged exposure warns permanently on every start (ADR-0052 rule 3). Loopback binds (127.0.0.1, localhost, ::1) need no acknowledgement.

[binds."0.0.0.0:8080"]
allow_public_exposure = true
FieldTypeDefaultDescription
allow_public_exposureboolfalseAcknowledge that this bind intentionally serves unauthenticated routes. Required for non-loopback Public binds; refusal names the bind and the routes.

[components]

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

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

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

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

Common 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 five optional sub-tables. The [observability.tracer] block configures the built-in tracing layer, and [observability.metrics] gates its metric families. The other three activate optional exporters.

FieldTypeDefaultDescription
tracertable(built-in defaults)Built-in tracing layer config.
metricstable(built-in defaults)Metric-family levers. Absent table means all defaults.
oteltableabsentOpenTelemetry exporter. Absent disables OTLP.
prometheustableabsentPrometheus scrape endpoint. Absent disables the endpoint.
healthtableabsentHTTP health/readiness endpoint. Absent disables the endpoint.

Span and metric enablement are independent:

  • [observability.tracer] enabled gates SPAN creation only. With Prometheus (or OTel) active, an explicit enabled = false still runs the tracer pipeline so metric families keep flowing; no spans are created.
  • The pipeline itself is turned off only when neither tracing nor any exporter is active. The [observability.metrics] levers below can suppress individual non-error families but never disable the pipeline.

[observability.metrics]

Metric-family levers. Unknown keys fail at load, consistent with the sibling observability tables. There is no lever for the error family: camel_errors_total is structurally non-disableable and is exported regardless of any combination of these keys.

FieldTypeDefaultDescription
enabledbooltrueMaster switch for the non-error metric families. false suppresses exchanges, duration, and component families; errors always flow.
exchangebooltrueOpt-out for the camel_exchanges_total family. Only takes effect when enabled is true.
durationbooltrueOpt-out for the camel_exchange_duration_seconds family. Only takes effect when enabled is true.
componentsboolfalseOpt-in for the component-operations metric family (camel_component_operations_total).

[observability.otel]

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

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.

Placeholders in Camel.toml use ${env:VAR} and ${env:VAR:-default}. The :- separator is native: default is used when VAR is unset. An unset variable without a default aborts load, naming the field. The legacy {{...}} syntax is rejected with an error pointing at the ${env:} forms. See Environment variable interpolation.

[security.keycloak]
server_url = "https://kc.example.com"
realm = "my-realm"
client_id = "camel"
client_secret = "${env:KC_SECRET}"
FieldTypeDefaultDescription
oidctableabsentGeneric OIDC token validation.
nativetableabsentBuilt-in static credential store.
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_uristring(required)JWKS endpoint.
audiencearray of strings[]Required aud claim values.
client_idstringnullOAuth2 client ID.
client_secretstringnullOAuth2 client secret. Resolves ${env:VAR}; unset variable without default fails load.
token_endpointstringnullToken endpoint for client credentials flows.
introspection_endpointstringnullToken introspection endpoint.

[security.native]

The native block is a static credential store with no external identity provider. Set at least one credential: a scalar bearer_token or api_key, or one or more [[security.native.credentials]] entries. A config with no credential fails load.

FieldTypeDefaultDescription
subjectstring(required)Principal name for the scalar bearer_token and api_key identities.
issuerstring"native"Issuer recorded on synthesized principals. null falls back to "native".
bearer_tokenstringnullPre-issued bearer token. Resolves ${env:VAR}; unset variable without default fails load.
api_keystringnullPre-shared API key. Resolves ${env:VAR}; unset variable without default fails load.
rolesarray of strings[]Roles granted to the scalar identities.
scopesarray of strings[]Scopes granted to the scalar identities.
credentialsarray of tables[]Static credentials, each with its own subject, roles, and scopes.

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

FieldTypeDefaultDescription
subjectstring(required)Principal name for this credential. Must not be empty.
secret_envstringabsentEnvironment variable holding the secret. Read at startup; fails closed if unset or empty.
secretstringabsentPlaintext secret. Logged with a warning at startup; use secret_env in production.
rolesarray of strings[]Roles granted to this credential's principal.
scopesarray of strings[]Scopes granted to this credential's principal.

Each credentials entry must set exactly one of secret_env or secret; setting both or neither fails at load. The block uses deny_unknown_fields, so unknown keys are rejected at load.

[security.keycloak]

FieldTypeDefaultDescription
server_urlstring(required)Keycloak base URL.
realmstring(required)Realm name.
client_idstring(required)Client ID.
client_secretstring(required)Client secret. Resolves ${env:VAR}; unset variable without default fails load.
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.

backend selects the store. "redb" is the default. The redb repository registers under the name "redb". The redis repository registers under the name "redis", and route steps select it with repository = "redis". Redis keys survive a process restart and are shared by every process that connects to the same Redis.

FieldTypeDefaultDescription
backendstring"redb""redb" (persistent on-disk store) or "redis" (persistent, shared across processes).
namestringabsentRegistration-name override (also the redis keyspace segment). Defaults: "redb"/"redis". Allowed charset [A-Za-z0-9:_-].
pathstring(required for redb)Path to the .redb file. Must not be empty. Redb only.
durabilitystring"immediate"immediate fsyncs on every key. eventual skips fsync. Redb only.
urlstring(required for redis)Standalone endpoint, redis:// or rediss://. Mutually exclusive with sentinel_nodes. Redis only.
sentinel_nodesstring array(alternative to url)Sentinel node addresses. Mutually exclusive with url. Redis only.
master_namestring(required with sentinel_nodes)Master name resolved through the sentinels. Redis only.
sentinel_usernamestringabsentSentinel AUTH username. Redis only.
sentinel_passwordstringabsentSentinel AUTH password. Redacted from Debug output. Redis only.
passwordstringabsentData-node AUTH password. Redacted from Debug output. Rejected in url mode. Redis (sentinel mode) only.
usernamestringabsentData-node AUTH username. Redacted from Debug output. Rejected in url mode. Redis (sentinel mode) only.
dbintegerabsentData-node database index. Valid range 0 to 16383. Defaults to 0 when absent. Rejected in url mode. Redis (sentinel mode) only.
key_prefixstring"camel:idem"Redis key prefix for this repository's keyspace. Allowed charset [A-Za-z0-9:_-]. Redis only.

In url mode the URI carries the password and the database. The password rides the userinfo and the database rides the ?db=N query parameter, as in redis://:pass@host:port?db=N. A username in the URI is not supported.

A runnable redis configuration lives in examples/redis-repositories:

[default.idempotent_repo]
backend = "redis"
url = "redis://127.0.0.1:6379"

[cache_repo]

Optional cache repository configuration. When unset, only the default "memory" cache repository is registered. With backend = "redb", a persistent "persistent" repository (redb-backed) is registered alongside "memory". With backend = "redis", a shared "redis" repository is registered alongside "memory", and route steps select it with repository = "redis".

FieldTypeDefaultDescription
backendstring"memory""memory" (moka-backed, size-eviction only), "redb" (persistent, survives restarts), or "redis" (persistent, shared across processes).
namestringabsentRegistration-name override (also the redis keyspace segment). Defaults: "memory"/"persistent"/"redis" (the redb default is a kept asymmetry). The memory backend only honors it when max_capacity is set.
pathstring(required for redb)Path to the .redb file. Created if it does not exist. Must not be empty. Redb only.
stale_retentionduration string7d (wiring fallback)How long after expiry a stale entry stays readable. Redb: the sweep reclaims the entry after this window. Redis: the key expires at expires_at + stale_retention. Duration strings, for example "168h", "7d", "30m". The value in force at set() time applies; later changes are not retroactive (see ADR-0065). Redb and redis.
max_entriesinteger1000000Maximum entry count for the redb backend; new-key writes are rejected at the cap. Redb only.
cache_sizebyte-size string(required for redb)Bounds the redb page cache, e.g. "384MB", "256MiB", or plain bytes (1073741824). Decimal suffixes are powers of 1000, binary suffixes powers of 1024. Redb only.
sweep_intervalduration string1hHow often the redb background sweep runs. Must be positive. Redb only.
payloadstring"inline"Payload storage mode. "inline" keeps payload bytes in the repository entry. "disk" offloads payload bodies to blob files under payload_dir. Rejected on the memory backend. Redb and redis.
payload_dirpath string(required when disk)Directory holding offloaded payload files. Required and non-empty when payload = "disk"; rejected otherwise. No default. Supports ${env:} strict interpolation. Redb and redis.
payload_sweep_intervalduration string1hHow often the offloaded-payload sweep runs when payload = "disk". The interval also widens every blob's death epoch as a grace window. Must be at least one second. Redb and redis.
payload_max_ttlduration string720h (30d)Expiry fabricated for entries stored without a TTL when payload = "disk", so index row and blob file share one death timeline. Must be at least one second. Redb and redis.
max_capacityinteger10000 (default memory repo)Entry cap for the memory backend. Memory only.
urlstring(required for redis)Standalone endpoint, redis:// or rediss://. Mutually exclusive with sentinel_nodes. Redis only.
sentinel_nodesstring array(alternative to url)Sentinel node addresses. Mutually exclusive with url. Redis only.
master_namestring(required with sentinel_nodes)Master name resolved through the sentinels. Redis only.
sentinel_usernamestringabsentSentinel AUTH username. Redis only.
sentinel_passwordstringabsentSentinel AUTH password. Redacted from Debug output. Redis only.
passwordstringabsentData-node AUTH password. Redacted from Debug output. Rejected in url mode. Redis (sentinel mode) only.
usernamestringabsentData-node AUTH username. Redacted from Debug output. Rejected in url mode. Redis (sentinel mode) only.
dbintegerabsentData-node database index. Valid range 0 to 16383. Defaults to 0 when absent. Rejected in url mode. Redis (sentinel mode) only.
key_prefixstring"camel:cache"Redis key prefix for this repository's keyspace. Allowed charset [A-Za-z0-9:_-]. Redis only.

In url mode the URI carries the password and the database. The password rides the userinfo and the database rides the ?db=N query parameter, as in redis://:pass@host:port?db=N. A username in the URI is not supported.

Fields that do not apply to the configured backend are rejected at validation (fail-closed), and a malformed cache_size, sweep_interval, stale_retention, payload_sweep_interval, or payload_max_ttl fails validation with an error naming the field. The same applies to payload fields set while payload is inline or unset.

A runnable redis configuration lives in examples/redis-repositories:

[default.cache_repo]
backend = "redis"
url = "redis://127.0.0.1:6379"
stale_retention = "30m"

[stream_caching]

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

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 that routes reference through the datasource URI parameter (for example sql:SELECT * FROM users?datasource=my-db). The block is a map of DatasourceConfig values.

[default.datasources.appdb]
db_url = "sqlite:file:memdb?mode=memory&cache=shared"
max_connections = 5
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

Cache repo overrides carry a typed contract. String-typed fields receive their raw value verbatim, with no numeric or boolean coercion. Numeric-typed fields parse strictly typed values. Duration fields accept humantime forms with explicit units. A unitless numeric value is rejected at validation with an error naming the required format. An empty value is skipped for the newer scalar variables, so the file or profile value stays effective. Legacy variables receive the raw value, empty or not.

VariableFieldValue classEmpty valueDuration rule
CAMEL_CACHE_REPO_BACKENDcache_repo.backendstring-verbatimraw value (not skipped)
CAMEL_CACHE_REPO_PATHcache_repo.pathstring-verbatimraw value (not skipped)
CAMEL_CACHE_REPO_MAX_CAPACITYcache_repo.max_capacitynumeric-typedraw value (not skipped)
CAMEL_CACHE_REPO_STALE_RETENTIONcache_repo.stale_retentionstring-verbatimraw value (not skipped)humantime units required. Unitless numeric rejected: cache_repo.stale_retention: invalid duration '604800' — use a unit-bearing form such as '7d' or '24h'
CAMEL_CACHE_REPO_MAX_ENTRIEScache_repo.max_entriesnumeric-typedraw value (not skipped)
CAMEL_CACHE_REPO_PAYLOADcache_repo.payloadstring-verbatimskipped (file/profile value stays effective)
CAMEL_CACHE_REPO_PAYLOAD_DIRcache_repo.payload_dirstring-verbatimskipped (file/profile value stays effective)
CAMEL_CACHE_REPO_CACHE_SIZEcache_repo.cache_sizestring-verbatimskipped (file/profile value stays effective)
CAMEL_CACHE_REPO_SWEEP_INTERVALcache_repo.sweep_intervalstring-verbatimskipped (file/profile value stays effective)humantime units required. Unitless numeric rejected: cache_repo.sweep_interval: invalid duration '3600' — use a unit-bearing form such as '7d' or '24h'
CAMEL_CACHE_REPO_MASTER_NAMEcache_repo.master_namestring-verbatimskipped (file/profile value stays effective)
CAMEL_CACHE_REPO_KEY_PREFIXcache_repo.key_prefixstring-verbatimskipped (file/profile value stays effective)
CAMEL_CACHE_REPO_DBcache_repo.dbnumeric-typedskipped (file/profile value stays effective)
CAMEL_CACHE_REPO_SENTINEL_NODEScache_repo.sentinel_nodesCSV listempty list replaces the file value and normalizes to absent on redis

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

Note: Connection strings and credentials are outside the allowlist. Set them with ${env:VAR} placeholders in Camel.toml values, never through env overrides. The loader ignores CAMEL_CACHE_REPO_URL, CAMEL_CACHE_REPO_USERNAME, CAMEL_CACHE_REPO_PASSWORD, CAMEL_CACHE_REPO_SENTINEL_USERNAME, and CAMEL_CACHE_REPO_SENTINEL_PASSWORD and logs a warning.

Reference: Config crate

Environment variable interpolation

Substitute environment variables into route files and Camel.toml with ${env:VAR} tokens. The tokens work in endpoint URIs, log messages, header values, and any other string field.

The expansion point differs by surface. Route files expand in the raw route source, before YAML parsing. Camel.toml expands every string leaf of the parsed, merged tree. The tree combines the main file, include files, and CAMEL_* environment overrides. Expansion runs before typed deserialization.

Syntax

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

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

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

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

The same syntax works in Camel.toml:

[security.keycloak]
client_secret = "${env:KC_SECRET}"

[observability.otel]
endpoint = "${env:OTEL_ENDPOINT:-http://localhost:4317}"

Escapes

InputOutput
${env:VAR}Value of VAR; fails closed if unset and no default
${env:VAR:-default}Value of VAR, or default when unset
$$A single $
$${env:VAR}The literal text ${env:VAR}

The standalone $$ escape works on every surface: route files and all Camel.toml leaves. The full-form escape $${env:VAR} yields the literal placeholder text on route files and plain Camel.toml leaves.

Exception — credential leaves: Camel.toml sections that hold credentials or connection secrets reject the escaped full form. On security, datasources, idempotent_repo, and cache_repo leaves, a $${env:VAR} leaves a residual ${env:VAR} marker, which fails load. There is no legitimate reason for a credential field to hold the literal text of a placeholder.

Fail-closed

Both surfaces fail closed. An unset variable without a default aborts:

  • Route discovery fails with an error naming the variable.
  • Camel.toml load aborts with an error naming the field.

Use ${env:VAR:-default} for optional values.

Legacy {{...}} syntax

Camel.toml rejects the legacy {{...}} placeholder syntax. Any {{ in a string leaf fails load with an actionable message: placeholders use ${env:NAME} or ${env:NAME:-default}. Route files never supported the {{...}} form.

How it works

The DSL loader (camel_dsl::interpolate_env) scans raw route source before YAML parsing. camel-config walks the merged Camel.toml tree after the builder merges the main file, include files, and CAMEL_* environment overrides (resolve_tree_placeholders). The walk replaces ${env:...} patterns before typed deserialization. Substituted values pass through sanitize_env_value, which strips control characters and newlines. This blocks newline injection from a hostile or malformed variable.

The PropertiesResolver type in camel-config retains the legacy {{...}} API for compatibility. Camel.toml loading does not use it.

Setup

    let config =
        CamelConfig::from_file("Camel.toml").map_err(|e| CamelError::Config(e.to_string()))?;

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

    let routes = discover_routes(&config.routes).map_err(|e| CamelError::Config(e.to_string()))?;

The Camel.toml for this example is minimal. Route discovery and component registration follow the standard pattern.

[default]
routes = ["routes/**/*.yaml"]
log_level = "INFO"

When to use

  • Twelve-factor apps: inject configuration that varies per deploy through the environment, not through files in source control.
  • Secrets: pass credentials and tokens from the environment. The route file never stores the secret value.
  • Per-environment endpoints: point routes at different brokers, HTTP hosts, or databases without editing route files.

Reference: PropertiesResolver in the Config crate

Hot reload

Hot reload swaps route pipelines at runtime without stopping the context. When a route file changes on disk, the runtime compiles the new pipeline and swaps it atomically. In-flight exchanges complete against the pipeline snapshot they entered.

Architecture

The runtime stores the active pipeline behind ArcSwap. A swap publishes a new Arc in one atomic step. New exchanges see the new pipeline immediately. Exchanges already in flight hold their existing Arc and finish against the old pipeline. Old and new pipelines coexist until the last in-flight exchange drains.

Reference: ADR-0004

Configuration

Set watch_debounce_ms in Camel.toml to control the debounce delay. The watcher waits this long after the last file event before it reloads. Increase the value if one save triggers several rapid reloads.

[default]
routes = ["routes/**/*.yaml"]
log_level = "INFO"

# Debounce delay for the file watcher (milliseconds).
# Increase if you see multiple rapid reloads on a single save.
watch_debounce_ms = 300

Usage

Load the debounce from config

The hot-reload-yaml example reads watch_debounce_ms from Camel.toml and passes it to watch_and_reload.

    // ── 0. Load configuration (watch_debounce_ms etc.) ───────────────────────
    // Falls back to the CamelConfig field default (300 ms) if Camel.toml is absent.
    let debounce_ms = CamelConfig::from_file("Camel.toml")
        .map(|c| c.watch_debounce_ms)
        .unwrap_or(300);
    println!("[0] watch_debounce_ms = {debounce_ms} ms  (set in Camel.toml)");

Start the watcher

The hot-reload example resolves the directories to watch, then starts watch_and_reload in a background task. A CancellationToken stops the watcher on shutdown.

    tokio::spawn(async move {
        let watch_dirs = resolve_watch_dirs(&watch_patterns);
        let result = watch_and_reload(
            watch_dirs,
            ctrl,
            move || {
                camel_dsl::discover_routes(&watch_patterns)
                    .map_err(|e| CamelError::RouteError(e.to_string()))
            },
            Some(shutdown_watcher),
            std::time::Duration::from_secs(10),
            std::time::Duration::from_millis(300),
        )
        .await;

The watched route file uses a plain YAML route:

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

How it works

  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 service binds exactly the address the caller supplies; there is no loopback default. Binding to 0.0.0.0 emits a startup warning.

See ADR-0052 for the full posture.

Cardinality contract

Metric label values must come from a closed or bounded set. Never pass raw Exchange body, header, property, or correlation-key data as a label value. Each distinct label combination creates a new Prometheus series. The registry has no cardinality cap or eviction.

See ADR-0032 for the exchange-data trust boundary.

Reference: camel-prometheus crate

Tracing

rust-camel integrates with OpenTelemetry for distributed tracing, metrics, and log export. OtelService owns the lifecycle of the global OTel providers.

OtelService

OtelService is a Lifecycle implementation. It installs and owns the process-global OpenTelemetry tracer, meter, and logger providers. Keep one active instance per process.

Configure the OTLP endpoint and service identity with OtelConfig:

    // Create OpenTelemetry configuration
    // Points to the OTLP collector (grafana/otel-lgtm) running locally
    // metrics_interval_ms=15000 exports metrics every 15s (default 60s is too slow for demos)
    let otel_config = OtelConfig::new("http://localhost:4317", "rust-camel-otel-demo")
        .with_metrics_interval_ms(15000);

Note: Service registration is Rust API only. YAML routes compile to the same RouteDefinition. The service wiring stays in application code.

Create the service and register it with the context:

    // Create the OpenTelemetry service
    // OtelService manages:
    // - Global TracerProvider (spans exported via OTLP)
    // - Global MeterProvider (metrics exported via OTLP)
    // - Global LoggerProvider + Log bridge (logs exported via OTLP)
    // - as_metrics_collector() for automatic route metrics
    let mut otel_service = OtelService::new(otel_config);

    // Initialize tracing subscriber with both stdout and OTel log bridge
    let logger_provider = otel_service.init_logger_provider()?;
    let otel_layer =
        opentelemetry_appender_tracing::layer::OpenTelemetryTracingBridge::new(&logger_provider);

    use tracing_subscriber::layer::SubscriberExt;
    use tracing_subscriber::util::SubscriberInitExt;
    tracing_subscriber::registry()
        .with(tracing_subscriber::fmt::layer())
        .with(otel_layer)
        .init();

    // Build CamelContext with the OtelService as a lifecycle service.
    // with_lifecycle() auto-registers the metrics collector from OtelService.
    let mut ctx = CamelContext::builder()
        .build()
        .await
        .unwrap() // allow-unwrap
        .with_lifecycle(otel_service)
        .with_tracer_config(TracerConfig {
            enabled: true,
            detail_level: DetailLevel::Medium,
            outputs: TracerOutputs {
                stdout: StdoutOutput {
                    enabled: false, // suppress noisy JSON to stdout
                    ..Default::default()
                },
                file: None,
            },
            ..Default::default()
        })
        .await;

Note: Service registration is Rust API only. YAML routes compile to the same RouteDefinition. The service wiring stays in application code.

with_lifecycle() auto-registers the metrics collector from OtelService::as_metrics_collector(). The runtime records route-level metrics automatically.

Operational invariants

  • One global provider set. OtelService::start() installs providers 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), 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.

Native auth

The native auth pipeline reproduces a Keycloak-style flow without external dependencies. It validates static credentials against a local store and applies role-based policies. The same pipeline works with a real Keycloak through camel-component-keycloak.

Register static credentials in Camel.toml. Each [[security.native.credentials]] entry binds a subject to a credential, supplied inline (secret) or by environment-variable reference (secret_env). Roles and scopes are optional:

[security.native]
subject = "native-user"
issuer = "native"

[[security.native.credentials]]
subject = "svc-orders"
secret_env = "ORDERS_SECRET"
roles = ["service"]
scopes = ["read:orders", "write:orders"]

The CLI builds a NativeCredentialStore from these entries and wraps it in a StaticTokenAuthenticator. Each entry maps its credential to a Principal with the entry's roles and scopes.

The example defines the bearer values it presents to the authenticator:

    let alice_token = "alice-token";
    let bob_token = "bob-token";

Validate a bearer value with StaticTokenAuthenticator:

    println!("--- Validation ---");

    let alice_principal = authenticator.authenticate_bearer(alice_token).await;
    match &alice_principal {
        Ok(p) => println!("Alice OK subject={} roles={:?}", p.subject, p.roles), // allow-secret
        Err(e) => println!("Alice invalid ({e})"),                               // allow-secret
    }

    let bob_principal = authenticator.authenticate_bearer(bob_token).await;
    match &bob_principal {
        Ok(p) => println!("Bob: VALID  (subject={}, roles={:?})", p.subject, p.roles),
        Err(e) => println!("Bob: INVALID ({e})"),
    }

Apply a RolePolicy that checks for required roles:

    println!("--- Role-Based Security Policy ---");
    let admin_policy: Arc<dyn SecurityPolicy> =
        Arc::new(RolePolicy::new(vec!["admin".to_string()], true));

    let mut alice_exchange = Exchange::default();
    alice_exchange.input.headers.insert(
        "authorization".to_string(),
        Value::String(format!("Bearer {}", alice_token)), // allow-secret
    );

    let mut bob_exchange = Exchange::default();
    bob_exchange.input.headers.insert(
        "authorization".to_string(),
        Value::String(format!("Bearer {}", bob_token)), // allow-secret
    );

    let alice_principal = authenticator.authenticate_bearer(alice_token).await?;
    let alice_typed = ExamplePrincipal(alice_principal);
    let alice_auth = AuthContext {
        principal: &alice_typed,
        transport: TransportId::Http,
    };

    let bob_principal = authenticator.authenticate_bearer(bob_token).await?;
    let bob_typed = ExamplePrincipal(bob_principal);
    let bob_auth = AuthContext {
        principal: &bob_typed,
        transport: TransportId::Http,
    };

    let alice_decision = admin_policy
        .evaluate(&mut alice_exchange, &alice_auth)
        .await;
    let bob_decision = admin_policy.evaluate(&mut bob_exchange, &bob_auth).await;

    match alice_decision {
        Ok(AuthorizationDecision::Granted { principal }) => {
            println!(
                "Alice vs RolePolicy[admin]: GRANTED (subject={})",
                principal.subject
            );
        }
        Ok(AuthorizationDecision::Denied { reason, .. }) => {
            println!("Alice vs RolePolicy[admin]: DENIED ({reason})");
        }
        Err(e) => println!("Alice vs RolePolicy[admin]: ERROR ({e})"),
        _ => println!("Alice vs RolePolicy[admin]: UNKNOWN decision"),
    }

    match bob_decision {
        Ok(AuthorizationDecision::Granted { principal }) => {
            println!(
                "Bob vs RolePolicy[admin]:   GRANTED (subject={})",
                principal.subject
            );
        }
        Ok(AuthorizationDecision::Denied { reason, .. }) => {
            println!("Bob vs RolePolicy[admin]:   DENIED ({reason})");
        }
        Err(e) => println!("Bob vs RolePolicy[admin]:   ERROR ({e})"),
        _ => println!("Bob vs RolePolicy[admin]:   UNKNOWN decision"),
    }

    println!();
    println!("--- Route with Security Policy ---");

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

    let role_policy = RolePolicy::new(vec!["admin".to_string()], true);
    let wrapped = BearerInjectingPolicy::new(alice_token.to_string(), role_policy);

    let secured_route = RouteBuilder::from("timer:tick?period=2000&repeatCount=2")
        .route_id("admin-only-route")
        .security_policy(SecurityPolicyConfig::new(wrapped))
        .to("log:info?showHeaders=true")
        .build()?;

    ctx.add_route_definition(secured_route).await?;
YAML equivalent for the secured route

The Rust example wraps RolePolicy in BearerInjectingPolicy to inject a static demo token. In a YAML route, the bearer token arrives in the transport Authorization header and the policy is declarative.

routes:
  - id: admin-only-route
    from: timer:tick?period=2000&repeatCount=2
    security_policy:
      roles: [admin]
      all_required: true
    steps:
      - to: log:info?showHeaders=true

YAML security_policy accepts one of roles, scopes, ref, wasm, or permission as its policy form. An optional credential_sources list declares where the credential comes from (see below). Routes with security_policy do not support the canonical hot-reload path.

Credential sources

By default, a route reads its credential from the Authorization header (ADR-0033). A browser cannot set that header on an <img src> request. Map tiles served to Leaflet, MapLibre, or OpenLayers need another transport. The credential_sources key names the extraction sources:

routes:
  - id: tile-route
    from: "http://0.0.0.0:8080/tiles"
    security_policy:
      roles: [tile-user]
      credential_sources:
        - cookie: { name: session }
        - authorization_header
    steps:
      - to: "log:info"

Each entry names one source:

FormMeaning
authorization_headerBearer token in the Authorization header
query_param: { param: <name> }Token in a query parameter
cookie: { name: <name> }Token in a cookie
header: { name: <name> }API key in a named custom header

Extraction runs in the declared order. The first source that supplies a credential wins; later sources are fallbacks. When no source supplies a credential, the request fails with 401 before policy evaluation. Store lookups run in constant time.

http:// and ws:// consumers support the key. On a ws:// route, a roles or scopes policy authenticates the token extracted from the declared sources; the removed trust_upstream_principal flag no longer exists, and exchange-property principal evidence never authorizes.

Load-time validation rejects malformed declarations: an unknown source form, an empty cookie name, or a header name that is not a valid RFC 9110 token.

Diagnostic records never render a declared credential value. The 401 reply body carries a generic reason only (ADR-0051). The operator sets SameSite=Lax (or stricter) and HttpOnly on session cookies where the cookie is issued. Cookie auth on state-changing verbs still requires CSRF defense.

See ADR-0059 for the extraction architecture.

WASM authorization policy

A WASM plugin can serve as an authorization policy. The plugin reads camel.auth.* properties from the Exchange and returns a grant or denial.

    let fixtures_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("fixtures");
    let wasm_path = fixtures_dir.join("role-check.wasm");
    let registry = Arc::new(std::sync::Mutex::new(camel_core::Registry::new()));

    // This example uses the programmatic WasmSecurityPolicy::new() API.
    // For production routes, prefer Camel.toml registration via
    // [security.policies.wasm.<name>] + YAML `security_policy: wasm: <name>`.
    // See crates/components/camel-component-wasm/README.md for details.
    // Context is constructed after policy load; observability args pass
    // None/false until then.
    let wasm_policy = WasmSecurityPolicy::new(
        &wasm_path,
        WasmConfig::default(),
        Arc::new(camel_core::RegistryComponentContext::new(
            registry, None, false,
        )),
        HashMap::new(),
    )
    .await?;

    let policy = AuthenticatedWasmPolicy::new(authenticator, alice_token.to_string(), wasm_policy);

Note: Service registration is Rust API only. YAML routes compile to the same RouteDefinition. The service wiring stays in application code.

YAML equivalent for a production route

Register the WASM policy in Camel.toml under [security.policies.wasm.<name>], then reference it by name in the route.

routes:
  - id: wasm-secured-route
    from: timer:tick?period=1000&repeatCount=5
    security_policy:
      wasm: role-check
    steps:
      - to: log:info?showHeaders=true

For production routes, prefer Camel.toml registration through [security.policies.wasm.<name>] with YAML security_policy: wasm: <name>.

See ADR-0050 for the WASM sandbox capability posture.

Security defaults

The startup-validation phase enforces fail-closed security defaults. Routes refuse to start when required configuration is missing. See ADR-0033 for the full policy.

Reference: camel-auth crate

Function runtime

The camel-function crate runs user code in isolated containers, invoked as function: pipeline steps. The model follows serverless functions: stateless, event-driven units scoped to one execution each.

use camel_function::{ContainerProvider, FunctionConfig, FunctionRuntimeService};
use camel_core::context::CamelContext;
use std::time::Duration;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let provider = ContainerProvider::builder()
        .image("kennycallado/deno-runner:latest")
        .boot_timeout(Duration::from_secs(30))
        .build()?;

    let service = FunctionRuntimeService::with_container_provider(
        FunctionConfig::default(),
        provider,
    );

    let mut ctx = CamelContext::builder()
        .with_lifecycle(service)
        .build()
        .await?;

    ctx.start().await?;
    Ok(())
}

A Function runs in its own container. The runtime starts a Deno runner per registered runtime, then loads each function: step's source into that runner. The step sends an Exchange snapshot, gets an ExchangePatch back, and applies it to the message.

The FunctionInvoker is the contract the function: step calls. It owns the RunnerPool keyed by runtime, manages function registration with ref counting, and dispatches invocations. Errors map into the pipeline as FunctionInvocationError with NotRegistered, RunnerUnavailable, Timeout, or Transport variants.

function: differs from script:. script: runs synchronously inside the pipeline for predicates and simple transformations. function: runs out of process for logic that needs its own runtime. See ADR-0006 for the engine split.

The runtime manages container lifecycle through ContainerProvider. Spawn on first use, warm pool, health check on a background task. ctx.stop() triggers clean shutdown of all runners. See ADR-0005 for the staged prepare, finalize, discard flow that keeps hot reloads transactional.

YAML equivalent for a function step

The Rust code above wires the FunctionRuntimeService into the context. The actual function: step lives in a YAML route and gets loaded at startup. The body, header, and property helpers match the Deno runner API.

routes:
  - id: "enrich-users"
    from: "timer:enrich?period=2000"
    steps:
      - set_body: "hello world"
      - function:
          runtime: deno
          timeout_ms: 5000
          source: |
            export default (camel) => {
              camel.setBody(String(camel.body()).toUpperCase());
              camel.setHeader("X-Enriched", "true");
            };
      - log: "enriched"

Running the example

The function-deno-enrich example loads a route from YAML, spawns a Deno container, and enriches a timer-driven body. Build the runner image first:

cd crates/services/camel-function
docker build -t kennycallado/deno-runner:latest runner/

Then start the example with cargo run from examples/function-deno-enrich. The route fires every 2 seconds.

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 Lease holderIdentity has the form <namespace>/<node_id>. The namespace is resolved once: config namespace, then pod namespace, then default. The same value scopes the Lease API client. This is the value an operator sees in kubectl get lease.

The node id resolves from the first non-empty source in the chain POD_NAMEHOSTNAME → local hostname. POD_NAME comes from the Downward API. Fallback sources log a warning. When no source resolves, platform construction fails with a config error. After an upgrade, the first acquisition rewrites each Lease holder to the new format. This rewrite does not bypass lease expiry or optimistic concurrency.

The KubernetesLeadershipService runs a background loop for each lock:

  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.

Self-fencing

Renewal obeys a renewal budget. The budget is renew_deadline, measured from the last successful renewal. Each renew attempt gets only the remaining budget. A hung attempt fails when its budget runs out.

A leader that cannot renew within the budget steps down. It emits StoppedLeading and stops its delegate. The step-down does not depend on the Lease state the leader observes.

Failures within the budget keep the leader in place. This covers transient API failures and optimistic-concurrency conflicts. The loop retries at the jittered retry_period cadence. The sleep before each retry is shortened so it never crosses the budget boundary.

Validation enforces renew_deadline < lease_duration. The holder therefore fences itself before the Lease can legally expire for peers. This ordering holds modulo Kubernetes clock skew on Lease timestamps.

Reference: crates/platforms/camel-platform-kubernetes/CONTEXT.md (Self-fencing).

Fencing token

The Lease carries a camel.io/leader-term annotation. This annotation is a monotonic fencing token. Each takeover increments the term. The Master component stamps every Exchange from a master: route with the current term. Downstream sinks can reject envelopes that carry a stale term. See ADR-0035.

Configuration

KubernetesPlatformConfig controls the election timing:

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

Testing

This section describes how to test routes with the lean camel test boot and with route interception. Interception rewrites to: send points at compile time. It supports isolated unit tests without mock: lines in production routes.

Route interception

Use route interception to replace or copy a send without changing the route. Rules run at compile time. Validation happens once, in InterceptRules::new; the compiler consults the frozen rules at each send point.

Two actions exist:

  • SkipTo replaces the original send. The exchange goes only to the mock: target.
  • DivertCopyTo copies the exchange to a mock: target and then runs the real producer. The copy uses WireTap semantics: detached when the bound (20) admits it, inline CallerRuns when saturated.

Targets must be mock: URIs. InterceptRules::new rejects other targets at build time. The match is exact URI, first-match-wins.

Rules freeze at first successful route registration or at context start. After freeze, set_intercept_rules returns CamelError::Config. Use CamelContextBuilder::with_intercept_rules before freeze.

SkipTo example

use camel_core::intercept::{InterceptAction, InterceptRule, InterceptRules};
use camel_core::{CamelContext, RouteDefinition};
use camel_core::route::BuilderStep;

let rules = InterceptRules::new(vec![InterceptRule {
    uri: "seda:out".into(),
    action: InterceptAction::SkipTo { uri: "mock:tap".into() },
}])?;

let mut ctx = CamelContext::builder()
    .with_intercept_rules(rules)
    .build()
    .await?;

ctx.add_route_definition(
    RouteDefinition::new("direct:in", vec![BuilderStep::To("seda:out".into())])
        .with_route_id("send-route"),
)
.await?;

The send to: seda:out never reaches seda:. The exchange goes to mock:tap only. The seda: producer is not resolved.

DivertCopyTo example

use camel_core::intercept::{InterceptAction, InterceptRule, InterceptRules};
use camel_core::{CamelContext, RouteDefinition};
use camel_core::route::BuilderStep;

let rules = InterceptRules::new(vec![InterceptRule {
    uri: "kafka:orders".into(),
    action: InterceptAction::DivertCopyTo { uri: "mock:orders-copy".into() },
}])?;

let mut ctx = CamelContext::builder()
    .with_intercept_rules(rules)
    .build()
    .await?;

// Consumer route still receives the real message.
ctx.add_route_definition(
    RouteDefinition::new("kafka:orders", vec![BuilderStep::To("mock:arrival".into())])
        .with_route_id("consumer"),
)
.await?;
ctx.add_route_definition(
    RouteDefinition::new("direct:in", vec![BuilderStep::To("kafka:orders".into())])
        .with_route_id("send"),
)
.await?;

The exchange goes to mock:orders-copy and to the real kafka:orders producer. A failure in the copy does not change the real outcome.

For processor composition, camel_processor::compose_divert builds the same divert from a WireTapService copy stage and a BoxProcessor real stage. The runtime owns the lifecycle: WireTapLifecycle::start reopens admission with a fresh token and tracker after restart.

Further detail lives in crates/camel-core/CONTEXT.md and crates/camel-processor/CONTEXT.md. The contract is defined in ADR-0064.

Declarative camel test

camel test loads each *.test.yaml document. The document selects route files, injects direct: inputs, and asserts mock: expectations. An optional intercepts block adds route interception without editing production routes. camel run ignores test documents and never parses the intercepts block.

Intercepts

Declare intercepts as a map from source URI to an action object. The object holds exactly one key: skipTo or divertCopyTo. The value must be a mock: URI.

intercepts:
  kafka:orders:
    skipTo: mock:orders
  seda:audit:
    divertCopyTo: mock:audit

skipTo replaces the original send before the compiler resolves the source component. The real component does not need to be in the lean set, and the exchange never reaches it. divertCopyTo copies the exchange to the mock: target and then runs the real producer. The real component must be in the lean set, because the compiler still resolves it. Divert uses WireTap semantics: detached when the bound admits it, inline CallerRuns when saturated. A failure in the copy does not change the real outcome.

Target and expectation share the endpoint name. skipTo: mock:orders and expects: {mock:orders: {count: 1}} both resolve to endpoint orders on the mock: component. Use the same name in both places to collect the intercepted exchange.

Matching uses the full URI verbatim. Query parameters are part of the key. kafka:orders does not match kafka:orders?x=1. List the exact URI that the route sends to.

Failure handling stays unchanged. Parse errors in the intercepts map and route-load errors from interception (for example, a divertCopyTo whose source has no registered component) are document errors. camel test reports them on stderr and exits with code 2. No endpoint result counts toward passed or failed in that case.

The contract lives in ADR-0064 and the route-interception spec (openspec/specs/route-interception/spec.md in the repository — outside the rendered book).

camel lint warns R-MOCK-IN-PRODUCTION on inline to: mock: and endpoints: mock: sends in route files. The warning is exempt for tests/fixtures/ paths and *.test.yaml documents. Migrate the send to an intercepts: block in a *.test.yaml document, as described above.

Bean stubs

A beans: block declares stub beans for the bean: steps in the routes. A stub bean is an in-process processor registered in the bean registry before the context boots. The bean: step resolves against it, so the test runs without a real bean implementation. The block maps a bean name to a declaration.

beans:
  validator:
    kind: echo
  enricher:
    kind: setBody
    config:
      body: enriched

Each declaration has a kind and an optional methods list and config map. The kind selects the stub behavior.

KindConfigBehavior
echononePasses the exchange through untouched.
setBodybody (required)Replaces the input body with the configured string.
failmessage (optional)Fails with the configured message. Without message, it fails with exactly fail bean <name>.

echo accepts no config keys. setBody requires body and rejects any other key. fail accepts only message. A config key that does not fit the kind is a document error.

The methods list is an allowlist. When omitted, the stub accepts every method the routes invoke on it. When present, the runner cross-validates it against the methods the routes call before boot. A route that calls a method outside the list is a document error and exits with code 2.

A fail stub surfaces as a document error. The runner reports it on stderr and exits with code 2. Settling and evaluation are skipped. The default message fail bean <name> uses the declared bean name.

The stub beans mirror the bean: step. The step looks up a bean by name and calls a method on it. The stub supplies that lookup in the test. See Bean for the step contract. The example pair lives in examples/yaml-dsl/config/beans-demo.yaml and beans-demo.test.yaml.

Endpoint expectations

expects maps a mock: endpoint name to an expectation object. The object may hold count, minCount, maxCount, a bodies list, and a headers map. count is mutually exclusive with minCount and with maxCount. minCount together with maxCount means the inclusive range [minCount, maxCount]; minCount above maxCount is a document error. An explicit maxCount: 0 asserts absence: the endpoint must receive no exchanges during the settle window. Example: expects: {mock: out: {minCount: 1, maxCount: 2}} passes with 1 or 2 exchanges and fails with 3.

bodies uses strict grammar. Each entry is a bare string or a single-key matcher map. A bare string is exact equality (equals). A map with one recognized body-matcher key selects that matcher. Any other form is a document error. camel test exits with code 2 and names the field and the key.

Body matchers in v1: equals, regex, contains, startsWith, endsWith, exists, jsonSubset. exists takes null and takes no argument. jsonSubset takes a JSON object. A regex value must be a valid pattern. The runner rejects an invalid pattern at parse time and exits with code 2.

headers values use dual grammar. Any literal JSON value stays exact structural equality (equals). A map whose sole key is equals, regex, or exists selects that matcher. Any other value stays a literal. jsonSubset on a header is a document error. camel test exits with code 2.

expects:
  mock:result:
    count: 2
    bodies:
      - regex: "^order-[0-9]+$"
      - jsonSubset: {status: "ok"}
    headers:
      X-Trace: { regex: "^[a-f0-9]{8}$" }
      mode: {batch: 1, predicate: "raw"}

jsonSubset requires a JSON object pattern. The received body may be Body::Json or Body::Text that parses as JSON. A text body that does not parse fails the matcher. The received top-level JSON must be an object. Objects match recursively. Every pattern key must exist with a matching value. Nested objects match by subset. Arrays compare exactly by length, order, and element equality. Extra fields in the received object do not fail the assertion.

A sole predicate key is reserved. camel test rejects it with predicate matchers are not supported and exits with code 2. This applies in every matcher position. A multi-key object that contains predicate stays a literal in dual positions. It does not select a matcher.

Matcher mismatches are assertion failures. camel test prints a FAIL line that names the matcher, its pattern, and the received value. The received value is rendered whole. The document exits with code 1. Parse-time errors (invalid regex, non-object jsonSubset, wrong key count) exit with code 2.

Migration note: only literals whose single key is a matcher key change meaning in dual positions (expectReply.body, expects.headers values, expectReply.headers values). For example expectReply: {body: {equals: "x"}} previously meant literal equality of {"equals": "x"}. With matchers it selects equals "x". Wrap the literal to keep the old meaning: body: {equals: {equals: "x"}}. expects.bodies entries were strings before, so matcher maps there add no migration. A sole predicate key, or a sole jsonSubset key on a header, parsed as a literal before; it now fails at parse with exit 2.

sequence asserts cross-endpoint arrival order. It is a top-level list of mock: endpoint refs in the order the arrivals must have happened. The list needs at least two entries; fewer is a document error and camel test exits with code 2. Duplicates are allowed: the same endpoint may appear for consecutive arrivals. Each entry must carry the mock: scheme; the prefix is stripped to the bare endpoint name exactly as for an expects key, so mock:probe-a addresses the endpoint probe-a.

The assertion projects the arrivals at the listed endpoints in global arrival order and requires the projection to equal the declared list exactly. Arrivals at unlisted endpoints are ignored, so the assertion narrows to the probes that matter while expects handles the rest. A mismatch is an assertion failure: camel test prints a FAIL line naming the first divergence — the position, the expected endpoint, and the actual endpoint — and exits with code 1. Parse errors (fewer than two entries, an entry that is not a mock: URI or names an empty endpoint path) exit with code 2.

Probe pattern

Observe an intermediate route send by diverting a copy to a probe endpoint, then assert the order of those observations with sequence:. divertCopyTo copies the exchange to the probe before the real send continues, so the route is unchanged.

intercepts:
  seda:audit: {divertCopyTo: mock:probe-a}
  seda:persist: {divertCopyTo: mock:probe-b}
expects:
  mock:probe-a: {count: 1}
  mock:probe-b: {count: 1}
sequence: [mock:probe-a, mock:probe-b]

Cross-endpoint order is deterministic only between causally-ordered sends — sequential route steps, reply chains. Two concurrent branches that race to different probes produce a happened-order that sequence: faithfully reports but that is nondeterministic between runs. Assert order only over sends the route orders; concurrent branches assert happened-order only.

Reply assertions

An input may declare expectReply to assert against the reply message the direct: producer returns. The block holds two optional keys: body and headers. At least one must be present. An empty expectReply is a document error.

inputs:
  - to: "direct:enrich"
    body: "plain"
    expectReply:
      body: "enriched"

expectReply.body uses dual grammar. Every bare scalar (string, number, boolean, null) and every array is literal equals. A string becomes Body::Text. Other scalars and arrays become Body::Json. An object with one recognized body-matcher key selects that matcher. Any other object is literal equals with structural equality. expectReply.headers values use the same dual header grammar as expects.headers. The reply must satisfy every expected header. Extra headers on the reply do not fail the assertion.

The reply message is the route output when the route set one. Otherwise it is the final input message. Nothing in the lean camel test component set sets the output today. The reply pairs with the input by delivery order. Inputs deliver strictly sequentially, so reply[i] matches the i-th input.

Each asserted input produces one result row labeled reply[i] <input.to>. A mismatch is an assertion failure. It surfaces as a FAIL line and counts toward failed. The document exits with code 1. A delivery error is a document error. It exits with code 2 and skips reply evaluation.

A document may omit expects when at least one input declares expectReply. The reply assertions then drive the outcome. A document with neither endpoint expectations nor any expectReply still fails to parse.

The example pair lives in examples/yaml-dsl/config/reply-demo.yaml and reply-demo.test.yaml.

Repository stubs

A repositories: block declares in-memory stubs for the named repositories that cache:, idempotent:, and claimCheck: steps resolve against. The block maps a registry kind to a map of repository name to stub target. The only valid target in v1 is the literal memory.

repositories:
  cache:
    persistent: memory
  idempotent:
    dedupe: memory
  claimCheck:
    store: memory

Three registry kinds exist: cache, idempotent, and claimCheck. Each maps repository names to the stub target. The runner registers a fresh memory backend under each declared name before the routes load. The steps then resolve at compile time. Only the memory target is supported. Any other target is a document error.

The built-in name memory is not stubbable. Registering it would collide with the built-in repository, so the runner rejects it. Blank repository names are rejected too. An undeclared name still fails route load. A stub resolves only its explicitly declared name. A typo hits the same compile-time ComponentNotFound gate as production. An unknown registry kind is a document error that lists the three supported kinds.

Stubs are lossy. The R-REPOSITORY-STUB warning on stderr names each stubbed registry and repository and lists the semantics the memory backend does not exercise: for cache, prefix purge, TTL/stale timing, disk offload, and stats; for idempotent and claimCheck, persistence; for all, backend failure. Cover these in the integration tier.

The example pair lives in examples/yaml-dsl/config/repositories-demo.yaml and repositories-demo.test.yaml.

Env fixtures

An optional env: map declares string fixture values for the unit-tier interpolation seams. Route files, inline routes: sources, and the doc-side identifier fields (repository and bean stub keys, intercept sources and targets, mock: references, inputs[].to) consult the map before their inline :-default. The map is the only resolution source beyond the defaults: the ambient process environment is never read, so runs stay hermetic.

env:
  CACHE_REPO_NAME: faststub
repositories:
  cache:
    "${env:CACHE_REPO_NAME:-persistent}": memory

The placeholder on the stub key resolves to faststub. A route file carrying the matching reference resolves through the same map, so the step asks for the repository the document stubbed:

- cache:
    repository: "${env:CACHE_REPO_NAME:-persistent}"
    key: k

Both sides name faststub; the stub registers and the route loads. Without the env: map the same placeholder pair would resolve to the default persistent on both sides — the stub key and the step would still agree, but the fixture steers the name without editing either file.

Every env: value must be a string. An integer, boolean, or null value is a document error. Values are data, never re-interpolated: the value text is substituted verbatim, so a value that itself looks like a placeholder stays literal. Typing follows the route-side contract: a substituted leaf keeps string typing, so an integer- or boolean-typed field carrying a placeholder fails the document exactly as camel run rejects it, even when the env: map supplies the value. Numeric-typed knobs are tracked separately.

Identifier interpolation has a fixed grammar and scope. The doc-side identifier fields resolve ${env:NAME:-default} through the same scanner as the route sources. A stub key and its route reference thus always name the same value. Interpolation runs after deserialization and before the other document checks. The scheme, blank-name, and built-in memory guards see the resolved value. A placeholder without a default and without an env: entry fails document validation at exit 2. The message names the variable and the field position. Assertion data and path fields stay literal. Input body and headers values, expectReply blocks, matcher contents, bean methods and config values, repository stub targets, settle, and the routeFiles and routeFilesFromRoot paths are never interpolated. The scope follows the mock-testkit spec requirement "Doc-side identifiers interpolate through the document env layer for name-match parity with route sources" (openspec/specs/mock-testkit/spec.md in the repository, outside the rendered book). The regression that motivated the requirement is tracked as bd rc-4hexo.

CI output and filters

camel test accepts five flags for CI use: --junit, --filter-file, --filter-endpoint, --unit, and --integration.

--junit <FILE> writes a JUnit XML report after the run. The report holds one testsuite per attempted document, named by the document path as displayed in stdout. Each suite carries a <property name="tier"> row with the derived tier (lean or full) when the tier is known. Each assertion row becomes one testcase with the same label as its PASS/FAIL line (endpoint name, reply[i] <to> reply label, <settle>). A failing row carries a <failure> element. A document-level error (unreadable file, parse error, boot failure, route load failure, input delivery failure) becomes one <error> testcase named <document> in that document's suite. An expansion-level error (unreadable directory entry, zero-document directory) becomes one synthetic suite named by the path in the error, with a single <error> testcase named <expansion>. The report is written on exit-0, exit-1, and exit-2 runs alike. It is not written when a filter flag fails validation (see below). A report write failure prints to stderr and exits 2.

--filter-file <GLOB> narrows the expanded document set to documents whose entire displayed-path string matches the glob. The glob follows glob-crate semantics: * does not cross /, and ** does. The match happens before reading, so filtered-out documents are never read or parsed. Directory arguments display the paths as collected: a . argument yields ./-prefixed paths, and an absolute argument yields absolute paths. Patterns must account for the prefix. For example, --filter-file './sub/**' matches the ./sub/-prefixed paths a . argument produces.

--filter-endpoint <NAME> narrows the set to file-admitted documents whose expects map contains the given name. The match is exact against the bare endpoint name (the URI suffix after mock:). Scenario documents declare no expects, so an endpoint filter excludes them. Select scenario documents with --filter-file or by naming them on the command line. A file-admitted document that fails to parse still reports its error and sets exit 2, regardless of the endpoint filter.

--unit and --integration are symmetric tier filters. --unit runs only documents that derive the lean tier. --integration runs only documents that derive the full tier. The tier is content-derived, so the filter applies after parsing and tier derivation. A nonmatching document found through directory expansion is excluded silently. A nonmatching document named explicitly on the command line fails with tier-filter-collision and exits 2. Supplying both flags together is misuse. camel test rejects it before any document is read and exits 2.

Every executed document prints one tier annotation line before its PASS/FAIL rows: [lean] for the unit tier, [full] for the integration tier. CI parsers that consume stdout must account for these lines.

Exit codes follow a fixed contract. Verdict failures exit 1: expectation mismatch, settle timeout, reply assertion failure, scenario receive-timeout, and validation-mismatch. Apparatus failures exit 2: runtime scenario-var-unresolved (an unset variable is an authoring bug, not a product failure), action-transport-failure, partner-startup-failure, shutdown-failure, and infra-unavailable. Document validation failures exit 2: unreadable file, parse error, boot failure, and harness wiring errors. Precedence is 2 over 1 over 0.

Scenario documents run through one of two execution paths. The build selects the path.

BuildEndpoint schemesExecution path
defaultfake: onlyNo-boot smoke path. Any other scheme reports infra-unavailable, names the adapter, and exits 2.
integration-httpfake:, direct:, http:Embedded full boot. Real composition root, real wire, harness partner listeners. Any other scheme reports infra-unavailable, names the adapter, and exits 2.
integration-sqlno scenario endpoint references required; sql: actionsEmbedded full boot. An sql:-only document runs without integration-http.

The default build provides only the in-memory fake: partner adapter. A scenario whose endpoints are all fake: runs the no-boot smoke path. A fake:-only scenario keeps that path in any build.

The integration-http feature is enabled by default in camel-cli since 2026-09-05. The build boots the real composition root. A scenario whose endpoints are all fake:, direct:, or http: qualifies. Each http: endpoint binds a harness partner listener on 127.0.0.1:0. A direct: send stimulates the booted context through its own producer path. The document runs over the real wire.

The integration-sql feature is independent of integration-http. It is on by default in camel-cli. An sql:-only document boots the same composition root without integration-http. The integration-sql CI job proves this independence: it builds camel-cli with --no-default-features --features integration-sql,itest-e2e and runs the scenario e2e suite.

Filters combine as AND across kinds and OR within repeats of one kind. The tier filter counts as a kind. When at least one filter is given and no document survives, camel test prints a misuse error naming the filters and exits 2. An invalid glob pattern prints to stderr and exits 2 before any document runs.

Split a large suite across CI jobs with --filter-file. Each job runs one shard and writes its own report. Example: a job that runs only the shard-1 documents:

camel test . --junit shard-1.xml --filter-file './src/**/shard-1*'

Annotating pull requests from the report requires the CI platform's JUnit publisher or report-ingest integration. On GitHub Actions, upload the report as an artifact and pass it to a JUnit-annotation action of your choice.

Scenario documents

A scenario: document is the integration-tier contract of ADR-0069. The document declares an action list. The runner executes four actions in order: send, receive, sleep, and validate. A send takes an optional method field, for example method: PUT. The field is uppercased at load. Without the field, a body implies POST and no body implies GET. The README of the camel-integration-test crate is the grammar reference.

A scenario document may declare a partners: section to script the responses a harness partner serves. The section is a map from the declared endpoint string to a sequence of script entries. The same document interpolates ${name} in endpoint strings, body string leaves, and header values. bindVar fills a scenario variable with the partner's bound authority. The crate README documents the partners: shape, the interpolation surface, and the two-layer bindVar rule.

A scenario can script failure paths. A partners: entry may declare delay, fault: close, and times. The harness holds for the delay before it serves the response or commits the fault. The fault: close drops the connection without an HTTP response. The times repeats an entry for a fixed number of matching requests. A route-level error_handler.retry redials after a fault, so a fault-to-healthy sequence tests retry behavior.

A validate action with a partner target asserts the recorded-request count. The count is exact and non-negative. Optional method and path filters narrow it. A deadline polls until the count settles or the deadline passes. The runnable example pair lives in examples/integration-testing/partner-retry-route.test.yaml and partner-retry.routes.yaml.

A partner target's URI must equal a harness endpoint reference the scenario's own send/receive actions declare, or self-declare one. The object target form self-declares: provisioning: harness on an http endpoint plus a partners: entry naming the URI. The validate's own reference then wires the partner exactly as a send/receive reference does: the driver binds the partner and fills the reference's bindVar with the bound authority.

Proxy routes whose query varies per request need this form. The varying query makes each request dial a different wire path, so no literal arrival lane exists for a receive to name; the recorded traffic is the only assertion surface, and the scenario runs with no sacrificial receive. The route step reads the bound authority and appends the query — for example to: ${env:UPSTREAM}/tiles?bbox=1.2:

scenario:
- send: {to: direct:start}
- validate:
    target: {partner: {endpoint: http://upstream/tiles, provisioning: harness, bindVar: UPSTREAM}}
    expectation: {count: 1}
    deadline: 5s
partners:
  http://upstream/tiles:
  - path: /tiles?bbox=1.2
    response: {status: 200, body: tile}

One declared harness endpoint and one bindVar can serve every path a route dials. The route's to: URIs share the one authority and differ only in path, for example http://${MOCK}/orders and http://${MOCK}/billing. The partners: section scripts each path under the same declared key, one entry per path with its own response body. The two-key rule governs resolution: the declared endpoint string is the provisioning key that binds the listener, and an interpolated reference such as http://${MOCK}/billing is a dynamic reference the router resolves to the already-bound partner by authority. Arrivals queue per request path on that single listener.

Do not declare one endpoint per path. The N-bindVar fan-out provisions one listener per path for what is one logical partner, and the extra bindings reassign ports spuriously; this caused the 2026-09-06 pilot incident. Each dynamic-reference receive names its own path, and each drains its own arrival lane on the single listener: from: http://${MOCK}/orders drains the orders lane, from: http://${MOCK}/billing drains the billing lane. A dynamic receive must name a path — a bare authority is an apparatus error. The registered key remains the adapter-lookup key; the path on the wire picks the lane. In the client role, standalone roundtrip receives drain their own path's parked roundtrip oldest-first: two receives naming different paths of one partner never cross-match (bd rc-cr5yf). The runnable example pair lives in examples/integration-testing/partner-multi-path.test.yaml and partner-multi-path.routes.yaml.

A migration note for suites that grew one script per assertion: express each independent assertion chain as its own scenario document. Documents run independently, and the runner continues past a failing document, so one camel test run reports every chain. ADR-0069 section 11 keeps the ordered action list as the pin: the actions inside one document stay ordered.

Back-to-back send: actions with no intervening receive: dispatch genuinely concurrent requests. The crate README documents the burst-send recipe for asserting the concurrent arrivals. A direct: send may declare expectReply to assert the route's synchronous reply; the README's usage/grammar area details the verb. The scenario-tier field takes matcher verbs directly, unlike the unit tier's expectReply block.

A scenario document may declare one document-level logs: block. The harness captures every tracing event emitted while the document runs, and it evaluates the block after the action list completes. Three clauses exist, and every declared clause must hold. contains lists substrings of captured event messages. regex lists unanchored patterns; each pattern matches against the composite camel-log message. noLevelAbove sets a level cap over the whole document window; the cap spans every target, route processors and harness tasks alike. An unknown level, an invalid pattern, or an unknown key fails the load.

Log capture is process-global. Documents that run concurrently in one process attribute events conservatively: the harness files an event in every open window, so a sibling document's WARN can fail this document's noLevelAbove cap. Serialize log-asserting documents, or keep them on the current-thread itest path, when a document needs strict isolation.

Datasource steering

A scenario reads SQL state through the booted context's datasource catalog. The datasource itself lives in Camel.toml, not in the document: the datasource: field of a sql: action and of a validate sql target names a key from the [datasources] table. That table is a strict interpolation surface. Leaf values resolve ${env:NAME} and ${env:NAME:-default}, and a residual marker fails the load instead of passing through (see crates/camel-config/CONTEXT.md).

[datasources.appdb]
provider = "sqlx"
db_url = "${env:APPDB_URL:-sqlite:file:memdb_demo?mode=memory&cache=shared}"

At boot the placeholder resolves through the same layered source as the route files. The source checks harness-provisioned bindVar values first, then document env: values, then variables listed in envPassthrough:, then the inline default (ADR-0069 section 4). A hermetic document pins the value itself:

env:
  APPDB_URL: "sqlite:file:memdb_demo?mode=memory&cache=shared"

The shared cache is a requirement, not a preference, for every :memory: datasource. A bare sqlite :memory: database is per-connection. An INSERT on one pooled connection and a SELECT on another can therefore hit different databases, and a validation can pass against state the document never seeded. The boot rejects sqlite::memory: without cache=shared with the sql-memory-not-shared error (crates/camel-integration-test/CONTEXT.md).

The recipe above shows the recommended shape, the named shared-memory URI. A name such as memdb_demo holds one database, and every pool connection shares it, so the author selects max_connections for the workload. sqlite:file: matches no automatic datasource factory prefix, so the datasource pins provider = "sqlx". The bare sqlite::memory:?cache=shared form is still accepted. Its shared name comes from sqlx-internal naming, and with the Any driver each pooled connection can hold a private database. Pin max_connections = 1 with that form.

A document that needs a real database lists the variable in envPassthrough: and keeps the inline default:

envPassthrough:
- APPDB_URL

The CI job or Compose file then supplies APPDB_URL, for example a service-container Postgres URL. The harness never provisions the database. The address arrives through the variable, so the surrounding infrastructure stays the author's concern (ADR-0069 section 9).

Two laws keep the surface orthogonal. The datasource name (appdb) is an identifier path, and interpolation never touches it. The db_url value is an env leaf path, and the layered source always resolves it. The same laws govern every strict-prefix table (crates/camel-config/CONTEXT.md), so this section describes one instance of a general steering pattern, not a datasource-specific rule (bd rc-l7m7t, bd rc-4hexo).

Isolation and teardown

Each scenario boot owns its datasource catalog and its pools. The boot teardown closes those pools after the context stops. An in-memory sqlite database therefore dies with its boot: a later document booting the same [datasources] alias in the same process starts from an empty database. camel test runs documents sequentially in one process, so this per-boot freshness keeps one document's seeded rows out of the next document's validations. The guarantee is a contract of the teardown seam, not an accident of driver internals (crates/camel-integration-test/CONTEXT.md).

The guarantee is load-bearing for named shared-memory URIs. A datasource pinned to sqlite:file:<name>?mode=memory&cache=shared shares one named database across every connection that uses the name, in any boot. A lingering connection from an earlier boot would carry that database's rows into the later boot; the teardown close is what kills it. The adversarial tests pin this shape directly.

The scenario-tier convention for memory fixtures is the named shared-memory URI, for example sqlite:file:memdb_demo?mode=memory&cache=shared. A named memory URI shares one database across every pool connection. The author therefore selects max_connections for the workload, and the named_shared_memory_uri_probe in the sqlx pool factory pins multi-connection sharing. The bare sqlite::memory:?cache=shared form is still accepted, but with the Any driver each pooled connection can hold a private database there, so that form pins max_connections = 1.

Durable datasources are outside the per-boot guarantee. A file-backed sqlite database, or a service-container Postgres behind envPassthrough:, keeps its rows across boots. No harness mechanism cleans it between documents. Isolation for durable datasources is the document author's responsibility — the same law that governs user-provided infrastructure generally (ADR-0069 section 9): the harness provisions hermetic defaults, never cleanup for resources it does not own.

The authoring convention for durable datasources is the prepare-action clean-first idiom: the first sql: prepare statement deletes the state a previous run may have left, before any INSERT re-seeds it.

scenario:
- sql:
    datasource: appdb
    prepare:
    - DELETE FROM orders          # clean first: a prior document's rows
    - INSERT INTO orders VALUES ('seed-a')

DELETE FROM (whole-table) or a table-recreating statement are the two clean-first shapes; TRUNCATE applies where the engine supports it. A document that skips the clean-first statement works only as long as it runs alone. The adversarial boot-freshness tests in crates/camel-integration-test pin both directions: a second memory-sqlite boot reads zero, a second file-backed boot reads everything.

Known limitation, parallel mode (bd rc-gcf9n): camel test executes documents sequentially today. When parallel document execution lands, two concurrently booted documents that share one memory name could collide on the same shared memory database. The planned remedy is a per-boot unique suffix in the memory name (memdb_{scenario}_{boot}) minted by the harness for hermetic memory datasources. This note records a plan, not a rule for the current runner. Until parallel lands, documents that share a durable datasource must not run concurrently.

Extending rust-camel

rust-camel exposes four extension points. Each one has a Rust trait or WIT contract and registers into CamelContext at startup.

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.
0058Outcome-aware segment composition contractDefines how outcome-aware EIP segments compose with PipelineOutcome semantics.
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.
0057HTTP header emission policySorts HTTP headers into three RFC-derived buckets that decide what the component emits.
0059Auth extraction path divergenceDocuments where credential extraction diverges from the shared trust-boundary path and why.

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.
0056Cache Repository portCacheRepository port in camel-api with a memory-default backend for cache storage.

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.
0060MCP as a first-class componentMCP becomes a first-class component with its own adapter confinement and trust rules.

Process and Tooling

Workspace policy for tests, builds, and publishing.

ADRTitleSummary
0054#[ignore] test classification policyIgnored tests must carry a reason and a classification; an xtask lint enforces the policy.
0055Publish topology without cyclic dev-dependenciesPublishable crates must not form cyclic dev/build dependency chains in the publish topology.

Architecture Decision Records

Every architectural choice has a recorded decision. Each ADR states the context, the decision, and the consequences.

Split Tower Data Plane from Control Plane

rust-camel separates exchange processing (data plane) from component/endpoint/consumer lifecycle (control plane). The data plane uses Service<Exchange> throughout — every Processor and Producer is a Tower service. The control plane uses its own trait hierarchy (Component, Endpoint, Consumer) with richer lifecycle semantics.

Forcing components into Tower's request/response model would break lifecycle operations (start, stop, suspend, health) that don't map to call(). Keeping Tower strictly for exchange processing makes EIP composition idiomatic while giving the control plane the contracts it actually needs.

CQRS RuntimeBus for Route Lifecycle Control

Route lifecycle mutations go through RuntimeCommandBus and reads through RuntimeQueryBus, backed by projections and an optional redb event journal. Direct mutable state would be simpler, but the CQRS model gives us crash recovery, command deduplication, and projection-backed reads that don't block the command path.

The added architectural weight is justified because route lifecycle operations (start, stop, hot-reload) are infrequent, and the journal makes the control plane auditable and recoverable without adding a separate database dependency.

Amendment: two-phase lifecycle persistence

ADR-0018 refines this decision: lifecycle commands that perform runtime side effects persist intent first, expose intermediate states such as Starting through projections, then confirm success or compensate to Failed. Aggregate writes use optimistic versions so command handling, projections, event publication, and journal replay stay consistent under concurrent lifecycle operations.

Clarification: synchronous-projection CQRS (amended by ADR-0045)

This is synchronous-projection CQRS, not eventual-consistency CQRS: the read-side projection is updated within the same optimistic-versioned UnitOfWork as the command that produced it, so there is no projection lag on the read path. Supervision decisions that read route status therefore see a state consistent with the last completed command. "Strong consistency" here refers to the read-model freshness guarantee, not CAP-theorem linearizability. CQRS scope is the control plane only; the data plane (Exchange/Pipeline processing) is not CQRS — see ADR-0045 §3.

Hexagonal Architecture for camel-core Lifecycle

The lifecycle layer of camel-core is structured as a hexagonal application: domain and application logic are isolated behind ports (RouteRepositoryPort, ProjectionStorePort, RuntimeEventJournalPort, RuntimeExecutionPort), with concrete adapters providing in-memory and redb implementations.

The extra indirection is intentional — it lets us swap persistence backends, test lifecycle logic without a real runtime, and keep the domain model free of Tokio or storage concerns. A flat concrete implementation would be faster to write but harder to test and extend.

Scope (amended by ADR-0045)

This decision was originally scoped to the lifecycle/ layer only. ADR-0045 extends the hexagonal discipline crate-wide as the primary pillar of the camel-core architecture charter: every behavioral area is a vertical slice with its own internal domain / application / ports / adapters layout. The flat root modules (context.rs, health_registry.rs, datasource.rs, template.rs, registry.rs, language_registry.rs, component_metadata_catalog.rs, startup_validation.rs) are vertical slices awaiting that internal organization — they are remediation targets, not a permanent exception.

ADR-0045 also declares the ceiling: because camel-core deliberately stays one crate through 1.0, the rings are enforced by module discipline + boundary tests rather than by compiler-enforced crate isolation.

Hot Reload via Atomic Pipeline Swap

When a route's steps change but its from: URI stays the same, hot reload compiles the new Pipeline and swaps atomically via ArcSwap. The Consumer is never stopped. Each in-flight Exchange completes against the Pipeline snapshot it entered — ArcSwap guarantees readers hold a stable reference for the lifetime of their access, so old and new Pipelines can coexist momentarily without corruption.

A simpler stop/recreate approach would work but adds unnecessary downtime and Consumer reconnection overhead. The atomic swap keeps the Consumer running and makes the reload effectively zero-downtime for the common case. The tradeoff is more complexity in the reload diffing logic (ReloadAction::Swap vs Restart). There is no explicit drain step — snapshot isolation via ArcSwap makes it unnecessary.

function: Executes Out of Process with Staged Registration

function: steps run in isolated containers managed by a ContainerProvider, not in-process. Registration follows a staged prepare/finalize/discard flow to keep hot reloads transactional — a new function version is prepared before the old one is discarded.

Embedded JS (Boa) or WASM would be simpler but cannot support async I/O, npm packages, or full language runtimes. Out-of-process execution gives functions a real event loop and filesystem access at the cost of transport overhead and a more complex lifecycle. The staged reload prevents half-registered functions from being served during a reload.

script: Is Synchronous; Async JS Belongs in function:

Status: Accepted Absorbs: DEC-1 (JS engine choice: Boa)

script: uses Boa for synchronous expression evaluation only — predicates, header/body transformations, simple logic. Async JS (await, fetch, npm imports) is explicitly out of scope for script: and belongs in function:.

Allowing async in script: would let user code evade circuit breakers, retry logic, and pipeline-level metrics, since those operate at the Tower layer above the script call. It would also introduce a JS event loop inside the Tokio runtime with no clean integration boundary. The function: step already handles the async use case with proper isolation and flow auditability.

Engine choice: Boa (absorbed from DEC-1)

In an integration framework, scripting languages serve as expression evaluators, not orchestration engines. This matches Apache Camel, where all scripting languages (Groovy, JS/Nashorn, Simple, OGNL) are strictly synchronous. Async I/O from a script would also obscure error-handling visibility and lose flow auditability (the YAML would no longer describe the full route). camel-language-js therefore stays on Boa; the original "Boa vs rquickjs" comparison is closed (rquickjs async support is no longer a motivating factor).

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).

Amendment 2026-08-28 — ADR-0066: error family non-disableable; retry and breaker accounting

ADR-0066 amends this ADR in three ways.

  1. The error family is the only non-disableable metric family. MetricsCollector::increment_errors (this ADR's signal-replacement API) is always emitted. No [observability.metrics] lever can disable it. The success-path families introduced by dashboard-observability (rc-6s6h) are gateable.
  2. Retry accounting. One increment_errors per exhausted NetworkRetryPolicy sequence, executed by the policy helpers (retry_async / retry_async_cancelable). Call sites do not increment on their own Err arm for attempts the helper retries. Cancellation is not failure. Per-attempt telemetry lives on camel_retry_attempts_total{scheme,operation}.
  3. Breaker rejections are not errors. Open-breaker fast-fails (CamelError::CircuitOpen) count on camel_circuit_breaker_rejections_total{route} and are excluded from camel_errors_total in the pipeline tracer. The taxonomy categories (a)-(h) and the signal-replacement rules above are unchanged.

See ADR-0066 for the full collector binding and lifetime contract.

Cross-references

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: Amended by ADR-0063 (originally Accepted, Phase 1) References: ADR-0024, ADR-0025 Related: Phase 1 — Tasks 2, 3 (Idempotent Consumer EIP)

Decision

IdempotentRepository trait in camel-api (key-only, Result-returning)

The IdempotentRepository trait lives in camel-api (crates/camel-api/src/idempotent.rs:13) so any crate (component, processor, test) can implement it without depending on camel-core lifecycle internals. This mirrors StepLifecycle placement in camel-api (ADR-0022 §Trait location).

Contract C1: contains() returns Result<bool, CamelError> (NOT bool). Backends (Redis, SQL-backed, S3) have transient read failures. The Idempotent Consumer propagates Err — it must never treat a failed read as "not a duplicate" (crates/camel-api/src/idempotent.rs:32-36).

Key-only: The trait stores keys (String), not full messages. Motivation: an idempotent repository tracks which messages have been seen, not what they contained. Storing full messages would blow memory/backing-store and is the job of a different pattern (Claim Check, Phase 2). Future: a key_fn: Arc<dyn Fn(&Exchange) -> String> on the Idempotent Consumer step derives the key from the exchange (e.g. exchange.message_id(), header-based, body-hash).

// File: crates/camel-api/src/idempotent.rs:14-46
#[async_trait]
pub trait IdempotentRepository: Send + Sync + Debug + 'static {
    fn name(&self) -> &str;
    async fn contains(&self, key: &str) -> Result<bool, CamelError>;
    async fn add(&self, key: &str) -> Result<bool, CamelError>;
    async fn remove(&self, key: &str) -> Result<(), CamelError>;
    async fn clear(&self) -> Result<(), CamelError>;
}

MemoryIdempotentRepository in camel-core (DashMap-backed)

The in-memory implementation uses DashMap<String, ()> for concurrent read/write access without a coarse lock (crates/camel-core/src/idempotent/memory_repository.rs:24). All trait methods take &self, so the repository can be shared via Arc<dyn IdempotentRepository> across pipeline steps.

add() uses DashMap::insert and returns Ok(!was_present)None from insert means the key was new (memory_repository.rs:43).

Registered as the default "memory" repository during CamelContextBuilder::build() (crates/camel-core/src/context_builder.rs:305-311).

NamedRegistry<T> in camel-core (Mutex-based, fallible register)

A generic named-object registry with duplicate detection. Unlike the auth crate's NamedRegistry (crates/services/camel-auth/src/registry.rs:23, DashMap-based, infallible register()), this registry uses Mutex<HashMap<String, Arc<T>>> so register() can reject duplicates with Err(RegistryError::AlreadyRegistered) (crates/camel-core/src/registry.rs:10-54).

Type alias: IdempotentRegistry = NamedRegistry<dyn IdempotentRepository> (crates/camel-core/src/registry.rs:79).

The auth crate's DashMap version overwrites silently — appropriate for security policies where last-writer-wins is acceptable. The Phase 1 registry rejects duplicates because idempotent repository registration is a programming error (two repos with the same name would cause unpredictable routing). If an override is needed, the caller must first remove the existing registration (not yet exposed — future work).

Segment-not-Process decision for Idempotent Consumer (Task 3)

The Idempotent Consumer EIP (Task 3) MUST use OutcomeSegment (not BoxProcessor/Process mode). Rationale:

compose_pipeline (crates/camel-core/src/lifecycle/adapters/route_compiler.rs:35) converts PipelineOutcome::Stopped to Ok(ex) via into_tower_result(). If the Idempotent Consumer ran as a BoxProcessor (Process mode), a duplicate-detected Stopped(ex) would become Ok(ex) — indistinguishable from a successful first-time pass. Downstream steps would continue processing a duplicate exchange.

Using OutcomeSegment (compose_outcome_segment via crates/camel-core/src/lifecycle/adapters/outcome_composition.rs) preserves PipelineOutcome::Stopped across the Idempotent Consumer boundary. The sub-pipeline after the duplicate check is skipped (Stopped propagates to run_steps which terminates the current branch). This is the core fix for Option E/ADR-0024.

RegistryError visibility

RegistryError is pub — it's the error type returned by register_idempotent_repository(), which is a public method on CamelContext. Making RegistryError pub allows external callers to match on AlreadyRegistered.

Supertrait bounds

IdempotentRepository requires Send + Sync + Debug + 'static. Debug enables logging/tracing of repository identity. Send + Sync + 'static are the standard Arc<dyn ...> bounds for trait-object storage in registries.

Context

Problem

Before Phase 1, the Idempotent Consumer EIP had no pluggable key store. The processor would need to hard-code an in-memory HashSet or HashMap, making it impossible to share state across restarts or cluster nodes. Real deployments need Redis, SQL-backed (via SQLx in camel-sql), or other backends.

Key store requirements

  • Pluggable: Backend must be swappable without changing the EIP processor.
  • Thread-safe: Multiple pipeline steps may check/add concurrently.
  • Read-failure-transparent (C1): Transient backend failure must propagate, not silently be treated as "not a duplicate."
  • Key-only: Duplicate detection checks keys, not full messages.
  • Default memory backend: Zero-dependency setup for simple cases.
  • Context-scoped: Repository lives in 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. The Redis implementation ships as the camel-redis-repo repository service crate (Amended by: ADR-0063), not inside the component.

Interface stability

The trait has no #[non_exhaustive] attribute — adding methods would break existing implementations. Phase 1 considers the 5-method interface stable. If a future backend needs a len() or keys() method, a separate trait or default method with unimplemented!() can be added.

Default memory backend

MemoryIdempotentRepository is registered as "memory" in CamelContextBuilder::build(). Phase 1 is default-only: register() rejects duplicates (RegistryError::AlreadyRegistered), so re-registering "memory" after build() fails. A replace/remove API is future work if override is needed.

No autowiring

The Idempotent Consumer EIP (Task 3) does not auto-discover repositories by name — the DSL step explicitly names which repository to use. This is intentional: auto-discovery would introduce implicit behavior that breaks when multiple repositories of the same type exist.

RegistryError is not CamelError

RegistryError is a separate enum because it represents a compile-time/configuration error (duplicate name), not a data-plane error. Callers inside camel-core can match on AlreadyRegistered directly. If a future layer needs to propagate it through Tower, an Into<CamelError> impl can be added.

PipelineOutcome interaction (Task 3)

The Idempotent Consumer returns PipelineOutcome::Stopped(ex) when a duplicate is detected. This requires OutcomeSegment mode because 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-0063: Redis repository service. The Redis backends form a separate port family from Claim Check. ADR-0063 records why the StepLifecycle guidance above does not apply to Redis repository connections: they hold no background work, so no connection lifecycle exists to manage — docs/adr/0063-redis-repository-service.md
  • ADR-0024: PipelineOutcome replaces CamelError::Stopped — docs/adr/0024-pipeline-outcome-replaces-camel-error-stopped.md
  • NamedRegistry<T>: crates/camel-core/src/registry.rs:33-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 nine compile-time guardrails: incompatible with required, default, secret, name, aliases, and any non-string kind; empty separator rejected; separator without trailing . rejected; bare . rejected (strips to an empty name).
  • 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

Amendment (2026-08-20): unsafe impls removed

The unsafe impl Send / unsafe impl Sync for SharedSnapshot described below no longer exist. BoxProcessor is now tower::util::BoxCloneSyncService, which is Send + Sync by construction. CompiledStep therefore shares through the standard Arc auto traits, and no unsafe impl is needed. The compile-time guard in route_compiler.rs now asserts both CompiledStep: Send and CompiledStep: Sync (change fix-pipeline-syncbox-mutex-convoy). The Decision, Consequences, and Safety sections below stay unchanged as history; they describe the state before the type change. Send/Sync safety is now proven by the compiler. The INVARIANT rule (no interior mutability) is not what the compiler proves. Types with interior mutability such as Mutex are still Sync.

Context

SequentialPipeline and TracedPipeline held Vec<CompiledStep> and cloned the entire Vec (including all boxed services) per Exchange in call(). Each BoxProcessor (a BoxCloneService) may carry inner service state, so the per-Exchange Vec clone is not a cheap internal Arc bump — it is a real allocation and state-copy.

Decision

Store steps as Arc<[CompiledStep]> (wrapped in a SharedSnapshot newtype that adds the necessary Send/Sync impls). call() does Arc::clone (refcount bump). run_steps takes Arc<[CompiledStep]> by value, iterates by reference, cloning only the single step being invoked.

Consequences

  • In-flight Exchanges hold an Arc to the old snapshot during hot-reload swap. Snapshot isolation (ADR-0004) is strengthened, not weakened: the old Arc is dropped only after every in-flight run_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

Amendment 2026-09-11: Ollama execution tier (resolves bd rc-gfum)

Self-grill question 5 deferred the decision on where and when to run the 8 Ollama slow test: cases. bd rc-gfum resolved it. The slow test: classification does not change. The amendment adds only the execution tier.

Ruling: hybrid. Two tiers run the suite:

  1. Primary verification (local, weekly). A systemd --user timer on the developer host runs the full 8-test suite against the real qwen3.5:4b (3.4 GB) and embeddinggemma (621 MB) models, which are already present. The timer is Persistent=true, so it fires after a boot if the host was off. Results append one JSON line per run to .opencode/fleet/events.log, which the agent fleet already monitors. A failure files a P1 bd bug linked discovered-from:rc-gfum to raise human attention.
  2. CI wiring smoke (GitHub Actions, weekly + manual). One job pulls only the 621 MB embeddinggemma model and runs only ollama_embed with --ignored. It catches CI-environment rot (adapter construction, socket transport, config round-trip) without paying for CPU-only 4B inference. The model cache key holds 621 MB, which is 6% of the 10 GB repository quota. Job timeout is 15 minutes.

Why not a nightly 4B job on GitHub runners. A 4B model on a 2-4 vCPU, no-GPU runner generates roughly 2-8 tokens per second. Seven of the eight tests generate text; two carry tool-schema and multi-turn context that forces tens of seconds of CPU prompt ingestion before the first token. The realistic job envelope is 20-40 minutes, the model cache consumes one third of the shared quota, and ollama_cache_hit asserts a timing ratio (elapsed2 < elapsed1/2) that is flaky on a contended CPU runner. The local tier already provides inference-quality coverage green, so the nightly job would buy noise.

The bd closes once this note lands and the first local timer run appends a pass line to events.log.

ADR-0055: Publish Topology — No Cyclic Dev/Build-Dependencies on Publishable Crates

Date: 2026-08-11 Status: Proposed Related: ADR-0045 (camel-core architecture charter), ADR-0049 (lint+policy pairing precedent)

Context

Problem

cargo publish resolves [dev-dependencies] and [build-dependencies] against the crates.io registry during package verification, so weak edges participate in the topological publish order. A cycle closed only by weak edges cannot be sorted: the holder cannot publish before its dev-dep target, and the target cannot publish before the holder. The workaround was an xtask hack (commit 146c28ee, ~200 LoC) that mutated each cyclic crate's Cargo.toml on disk at publish time — commenting out the camel-* dev-dep lines, publishing with --no-verify, then restoring the original bytes. This carried a real failure mode: a crash between write and restore left a dirty tree, and the published manifest silently differed from source.

Diagnostic discovery (the over-breaking bug)

The first analysis reported "25 broken weak edges / 16 cyclic crates" and proposed mass test relocation. Tarjan SCC analysis of the real combined normal+weak graph revealed this was an artifact of an over-breaking greedy loop in resolve_publish_order: the loop snipped weak edges from any still-unscheduled crate, not only crates inside a non-trivial SCC. The real topology had two non-trivial SCCs closed by four weak edges:

  • SCC-A: {camel-builder, camel-component-http, camel-component-ws, camel-core, camel-otel} — closed 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: Amended by ADR-0063 (originally Accepted) References: ADR-0001, ADR-0023, ADR-0028, ADR-0033, ADR-0046, ADR-0049, ADR-0055

Decision

Decision 1: memory-default despite the anchor

The default cache repository is "memory", registered during CamelContextBuilder::build() with max_capacity = 10_000. This matches the Idempotent (ADR-0023) and Claim Check (ADR-0028) precedent: every context gets a zero-dependency in-process backend out of the box.

The motivating anchor (EFFIS/GIBS geo-layer tiles) needs a persistent backend (redb). The user opts in via [default.cache_repo] in Camel.toml:

[default.cache_repo]
backend = "redb"
path = "data/cache.redb"
stale_retention = "168h"

This registers a second repository named "persistent" alongside the default "memory". The DSL step selects the repository by name. The memory default is unchanged — the anchor case is an opt-in override, not a reason to change the default.

File: crates/camel-core/src/context_builder.rs:233-235.

Decision 2: in-band expires_at everywhere

Every CacheEntry carries an expires_at: Option<SystemTime> field. The CacheRepository::set method accepts ttl: Option<Duration>, computes expires_at = SystemTime::now() + ttl, and stores it in the entry. The CacheRepository::get method checks expiry in-band: if expires_at <= now, the entry is treated as absent (Ok(None)).

This design has three consequences:

  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:95-115 (get checks expiry in-band).

Decision 3: moka size-eviction only (no Expiry, no time_to_live)

The MemoryCacheRepository configures moka with max_capacity only — no expire_after or time_to_live. This is what makes peek_stale work on the memory tier: moka never evicts by time, so an expired entry stays in the cache until it is evicted by size pressure or explicitly invalidated.

get() checks expires_at in-band and returns Ok(None) for expired entries. peek_stale() returns the raw entry from moka without checking expiry, so callers can read stale data when the upstream is unavailable.

File: crates/camel-core/src/cache/memory.rs:50-54 (moka builder — no time-based eviction), crates/camel-core/src/cache/memory.rs:95-115 (get checks expiry), crates/camel-core/src/cache/memory.rs:128-135 (peek_stale skips expiry check).

Decision 4: mandatory max_capacity on memory default

MemoryCacheRepository::new() requires a max_capacity parameter. The default registration in CamelContextBuilder::build() uses 10_000. This follows ADR-0033 (security defaults, D-A5: bounded resource consumption) and the AggregatorConfig precedent: an unbounded default memory cache is a DoS risk.

The [default.cache_repo] config can override the capacity via max_capacity = 5000 when backend = "memory". If omitted, the default 10_000 stands.

File: crates/camel-core/src/context_builder.rs:235 (default 10_000), crates/camel-core/src/cache/memory.rs:44 (constructor requires max_capacity).

Decision 5: retention window != TTL

Persistent backends (redb, Redis) reclaim entries at expires_at + retention, not at expires_at. The retention window is a configurable duration (default 168h / 7 days) that extends the entry's life in storage after its logical expiry.

This gives peek_stale post-expiry reach on persistent tiers: an entry that expired 6 hours ago is still readable via peek_stale if the retention window is 168 hours. The entry is invisible to normal get() (which checks expires_at), but the bytes remain in storage for the stale-read fallback.

The memory tier does not need a retention window — moka keeps entries until size-eviction removes them, which is effectively an unbounded retention.

File: crates/camel-core/src/cache/memory.rs:128-135 (peek_stale on memory — no retention window needed).

Decision 6: no sweep() on the trait

CacheRepository does not expose a sweep() method. Reclamation of expired entries is per-backend:

  • Memory (moka): no sweep needed. Entries are evicted only by size pressure. Expired entries consume space until evicted, which is acceptable for an in-process cache bounded by max_capacity.
  • Redb: a background sweep task runs at a configurable interval (default 60s) and deletes entries whose expires_at + retention < now.

Amendment (2026-08-18): the 60s default documented above never shipped — the wiring hardcoded 1h. sweep_interval now makes the interval configurable via [default.cache_repo]; the default stays 1h because an O(N) sweep over a large persistent cache costs more than delayed reclamation.

  • Redis: EXPIRE / 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:68-120 (trait — no sweep method).

Rejected alternatives

cache:// component (ADR-0001 + ADR-0046)

A cache:// URI scheme would make caching a component endpoint, following the Apache Camel pattern. Rejected because:

  • A component endpoint implies a Consumer or Producer lifecycle (start, stop, health). Caching is a pipeline step, not an endpoint — it does not consume from or produce to an external system.
  • The cache:// scheme would need to be a pseudo-component (no real endpoint), adding complexity to the component registry and URI resolution.
  • ADR-0046 establishes Apache Camel as inspiration, not conformance authority. The pipeline-step approach is more idiomatic for rust-camel's Tower-native architecture (ADR-0001).

Body as stored type (not Serialize, Stream un-cacheable)

Storing Body directly in the cache would avoid the Vec<u8> + ContentType split. Rejected because:

  • 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. The Redis backend ships as the camel-redis-repo repository service crate (Amended by: ADR-0063), not inside the component. Its reclamation uses one SET ... EXAT (expires_at + stale_retention) write, so the server deadline is retention-bounded garbage collection, not expiry enforcement (ADR-0063 Decision 8).

Interface stability

The trait has no #[non_exhaustive] attribute — adding methods would break existing implementations. The 7-method interface (name, get, set, peek_stale, invalidate, clear, stats) is considered stable. If a future backend needs a len() or keys() method, a separate trait or default method with unimplemented!() can be added. The anticipated len()/keys() extension materialized as the default async method invalidate_prefix (chosen over a separate trait — single registry lookup, no downcast); CacheStats grew peek_stale_served/invalidations/bytes (source-breaking for external struct literals; migrate with ..Default::default()).

Amendment (bd rc-22wj, pre-1.0): the stats method signature was corrected from sync fn stats(&self) -> CacheStats to async fn stats(&self) -> CacheStats (default body unchanged, still infallible). A synchronous signature made it structurally impossible for RedbCacheRepository to offload its payload-sum byte scan off the tokio worker. Call sites await; no twin sync/async pair was introduced. Ruled by escalation review (e_gpt) over the rejected twin-method and redb stored_bytes() alternatives.

Default memory backend

MemoryCacheRepository is registered as "memory" with max_capacity = 10_000 in CamelContextBuilder::build(). The replace_cache_repository method allows overriding the default capacity when [default.cache_repo] backend = "memory" supplies a custom max_capacity.

No autowiring

The Cache EIP step explicitly names which repository to use (default: "memory"). No auto-discovery. This matches the Idempotent Consumer precedent (ADR-0023 §No autowiring).

ContentType is exhaustive-by-contract

ContentType is a closed 4-variant enum (Bytes, Text, Json, Xml). It carries the exhaustive-by-contract exception note (ADR-0049) because the CacheService step matches all variants for ContentType → Body reconstruction. Adding a variant would require updating the match in CacheService.

File: crates/camel-api/src/cache.rs:29-41.

CacheStats is NOT #[non_exhaustive]

CacheStats is a plain struct — backends construct it with struct literals. Adding fields is backward-compatible (existing literals still compile with ..Default::default()). No #[non_exhaustive] attribute.

File: crates/camel-api/src/cache.rs:46-62.

Load-bearing citations

File: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-62CacheStats struct (not non_exhaustive)
camel-api/src/cache.rs:68-120CacheRepository 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:95-115get checks expires_at in-band
camel-core/src/cache/memory.rs:128-135peek_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).

With stop_on_exception=false, zero success reports Failed(last_error). The error is the highest-branch-index representative in the parallel arm. It is the iteration-last error in the sequential arm. Partial success aggregates successful branch outputs only. multicast reports Completed with the aggregated outputs. Discarded failures are logged at warn. This resolves the recipient_list inconsistency tracked as bd rc-b41j.

Governed Segments

This ADR is the compliance authority for: recipient_list, multicast, cache, do_try, split, streaming-split, load_balance.

The normative test scenarios in the segment-outcome-composition spec capability cover the first four (delivered by the outcome-aware-segment-composition change). The remaining three (split, streaming-split, load_balance) inherit compliance from this ADR. They are verified separately.

Migration / existing-code alignment

multicast already complies with the zero-success invariant. Verified in crates/camel-processor/src/multicast_segment.rs: sequential_multicast and parallel_multicast track last_error, return Stopped on the first Stopped branch, and return Failed(last_error) when the success set is empty and last_error is set. Both arms enforce the partial-success guard per bd rc-b41j. The guard checks outputs.is_empty() in sequential_multicast and completed.is_empty() in parallel_multicast. Partial success aggregates the successful branches and logs discarded failures at warn.

recipient_list is the non-compliant Segment. It is corrected in outcome-aware-segment-composition Task 2.1, with this ADR as authority. The fix changes the zero-success path of RecipientListService::call to return Err(last_error) instead of Ok(original). The existing Result to PipelineOutcome adapter then yields Failed, and cache: skips write-back.

stop_on_exception defaults to false in RecipientListConfig. This matches Apache Camel. The default is unchanged.

Consequences

  • A zero-success recipient_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.

ADR-0059: Auth Extraction Path Divergence

Date: 2026-08-15 Status: Accepted References: ADR-0010, ADR-0032, ADR-0033, ADR-0051 Bd: rc-7x1z

Context

A route credential can arrive by more than one channel: an Authorization header, a query parameter, a cookie, or a named custom header. Two code paths validate that credential, and they diverge.

The HTTP layer owns the first path. SecurityPolicyLayer runs before the pipeline (ADR-0010). It calls authenticate() inside policy.evaluate. Extraction reads the route-declared credential_sources from the Exchange input. The policy owns extraction.

Components own the second path. WS and gRPC pre-authenticate the request. They extract the credential before policy.evaluate runs. They store the resulting principal in the Exchange property camel.auth.principal. The policy then falls back to that preloaded principal via trust_upstream_principal. The component owns extraction.

Before this change, the two paths used different extraction code. HTTP used a hardcoded Authorization prefix strip. WS used extract_token_multi. gRPC still uses its own hardcoded prefix strip. The split caused two defects:

  • A route could not declare a cookie or query source on the HTTP path, because the layer never reached the multi-source extractor.
  • The preloaded-principal branch carried a serialization split: the writer stored a JSON string, but the reader expected a JSON object. Every trust_upstream_principal grant on the component path returned 500.

Decision

Extraction is standardized behind extract_token_multi. Every source — the Authorization header, a query parameter, a cookie, and a named custom header — flows through the same extraction and the same constant-time store lookup. A route declares its sources in credential_sources, in order. First match wins.

The divergence stays. The HTTP layer calls extraction inside authenticate(). Components call extraction before policy.evaluate. A WS roles/scopes route requires explicit trust_upstream_principal: true. The component gates evaluation on successful authentication, so the spoof caveat from ADR-0010 does not apply on that path.

The preloaded-principal branch reads and writes one serialization format. The canonical store_principal_properties / principal_from_exchange pair stores the principal as a JSON string in camel.auth.principal. The trust branch now delegates to principal_from_exchange (tracked in bd rc-7x1z), so the read matches the write.

Consequences

Unifying HTTP onto extract_bearer_token widened the default-path acceptance. The auth scheme is now case-insensitive and whitespace-tolerant per RFC 9110 (RFC 7235). A lowercase or uppercase bearer scheme, a leading space, or a double space now authenticate when the store holds the trimmed token. Previously rejected malformed credentials now either authenticate (if the store accepts the token) or are rejected through the normal path. No previously granted route changes outcome. Fail-closed is preserved. WS behavior is unchanged — it already used this extraction.

An empty token after the scheme (Bearer with nothing following) is treated as an absent source: with trust_upstream_principal=false the request stays unauthenticated; with trust=true it can grant through a preloaded principal — the same outcome as a request that carries no Authorization header at all.

The preloaded-principal fix is load-bearing. The store/read pair was historically split across two serialization formats (store_principal_properties wrote a JSON string; extract_principal_from_exchange read a JSON object). Every trust grant on the component path returned 500 as a result. The split was latent since the WS preloaded-principal path landed. This change unifies both sides on the canonical string format. The WS roles/scopes + trust combination now works; before the fix it always failed.

A deferred Phase-1 note: header values rejected by http::HeaderValue::from_str (non-ASCII bytes, control characters) are treated as absent sources, never fatal (ADR-0032). On the layer path with trust=true and a preloaded principal present, such a value flips an error to a grant — the same outcome as a request without that header.

First-match-wins precedence is deterministic: the first declared source with a present value wins, even when a later source holds a valid token.

gRPC is a fast-follow. Its extract_principal still hardcodes the Authorization header and the Bearer prefix (camel-component-grpc/src/server.rs). It does not read credential_sources yet.

ref, wasm, and permission policy variants carry no credential_sources. They are rejected when the key is present because a registry-resolved Arc<dyn SecurityPolicy> and the WASM/permission evaluators carry no authentication-capability metadata, and adding that metadata would violate the no-new-abstraction constraint.

Alternatives considered

  • Keep the two extraction code paths separate. Rejected. The split hid the serialization defect and blocked cookie/query sources on HTTP.
  • Resolve all sources at the component layer only. Rejected. HTTP's layer path needs the policy to carry sources; component-internal resolution is not a DSL surface.
  • Rewire trust_upstream_principal into a verified-principal channel. Deferred. Out of scope for this change.

ADR-0060: MCP as a First-Class Component

Date: 2026-08-16 Status: Accepted Amends: none Cross-refs: ADR-0020 (adapter confinement), ADR-0032 (exchange-data trust boundary), ADR-0033 (fail-closed security defaults), ADR-0038 (per-item DoS caps), ADR-0052 (diagnostic endpoint posture) Origin: OpenSpec change add-mcp-component (bd rc-elcd)

Context

MCP (Model Context Protocol) tools are the natural extension surface for the LLM component (camel-component-llm). Without a transport, a route can only request a tool call. It cannot dispatch one to an external tool server, nor expose its own tools to an MCP host.

Two folds were rejected. Folding MCP into camel-component-llm double-binds the chat/embed-shaped LlmProvider to a tool-transport protocol. Folding it into camel-http conflates JSON-RPC semantics with HTTP server mechanics. MCP is its own bounded concern.

MCP is therefore a first-class Component (scheme mcp:), on the same footing as camel-http or camel-kafka.

Decision

Rule 1: First-class Server + Host component

camel-component-mcp owns both MCP roles. Creation path disambiguates the role, mirroring the split camel-http uses for HTTP server vs client:

  • Consumer (server): exposes tools and resources over Streamable HTTP.
  • Producer (client): dispatches tool calls and resource reads to remote MCP servers.

Rule 2: Route-owned tool dispatch

The component never auto-loops. The LLM component emits ChatEvent::ToolCall intent. The route decides to route to mcp:call. The route reshapes the result into ChatRole::Tool for the next turn.

The MCP producer is a dispatch target, never a decider. It issues exactly one JSON-RPC request per Exchange.

Rule 3: DSL-lowered catalog (declaration surface, TOML-owned runtime)

A mcp: DSL block declares a named server, its tools, and its resources. The block lowers each tool to an mcp:<server>/tool/<name> consumer route and each resource to an mcp:<server>/resource/<name> consumer route.

This is not a full rest:http: analogue. rest: lowering emits routes with processing steps (unmarshal, marshal, status injection); the mcp: block is a step-less catalog declaration — it registers names, schemas, and resource URIs on the server. Its lowered routes carry no processing steps (deny_unknown_fields forbids steps inside the block), so a tools/call on a bare lowered route echoes its arguments through the identity pipeline. Tool and resource behavior is expressed by explicit routes whose from: is an mcp: URI and that attach their own steps. Steps passthrough inside the mcp: block is a tracked limitation (bd rc-23y2), not a design goal.

Runtime server config is TOML-owned. mcp.servers.<name> (McpServerConfig) is the sole source of runtime server config: bind, tls, security_policy, max_tools, max_resources, allowed_hosts. The DSL block's server fields (bind, tls, security_policy, max_tools, max_resources) are a declaration/validation surface only — they document declared intent and mirror the TOML keys (declaration parity), but their values do NOT flow to the runtime. Divergence between a DSL bind and the TOML bind for the same name is not reconciled: TOML wins.

The DSL server name is the coupling key: it MUST match a mcp.servers.<name> key or consumer START fails cleanly (McpError::Endpoint naming the missing server). A DSL block whose server is absent from TOML config lowers routes that fail at consumer start, never silently.

Rule 4: rmcp confined to src/adapter/

All rmcp imports live in src/adapter/ (ADR-0020). Project-owned traits (McpClient, the server handler) carry Camel-shaped types across the boundary. The JSON-RPC envelope dies at the trait boundary. A boundary test scans for rmcp references outside the allowlist.

McpServerMap (HashMap<String, Arc<dyn McpClient>>) is the client-role twin of the LLM ProviderMap. It is a per-component map, not a global registry (ADR-0020).

Rule 5: Streamable HTTP only

The only transport is Streamable HTTP. The transport config key accepts exactly the string streamable-http; anything else fails at deserialization (see Implementation decisions). The deprecated HTTP+SSE transport is excluded at the Cargo level: the rmcp transport-sse-* features are never enabled.

Rule 6: Protocol baseline 2026-07-28, stateless

The component speaks exactly MCP 2026-07-28, the first stateless revision. No initialize handshake. No protocol sessions. No Mcp-Session-Id. Per-request protocol version and client capabilities travel in _meta.

The client connects with ClientLifecycleMode::Discover. A remote that does not speak 2026-07-28 fails fast at producer start as a CamelError. The server overrides supported_protocol_versions() to the single baseline; rmcp's inline guard rejects other peers with JSON-RPC -32022.

Rule 7: Exclusions

  • Prompts: deferred. Duplicates camel-template / MiniJinja (ADR-0047).
  • stdio transport: rejected. Camel is not a process supervisor.
  • Protocol sessions: rejected. The baseline is stateless.
  • Legacy transports (SSE): rejected at the Cargo level.
  • Resource subscriptions: rejected. v1 serves read-on-demand only.

Rule 8: Security posture

MCP server inputs are adversary-controlled Exchange data. They cross the ADR-0032 trust boundary and drive the data plane. The server endpoint is therefore NOT a diagnostic endpoint: ADR-0052 exempts only non-data-plane inbound. It does not inherit the ADR-0052 unauthenticated convention.

Instead (ADR-0033 Require-Explicit-Choice) the server uses two gates. The bind gate is presence-only: a mcp.servers.<name>.security_policy key must be set, or the consumer refuses to start (fail-closed). The enforcement gate is route-level: the adapter copies the inbound HTTP request headers onto the Exchange, and each request runs through the route's SecurityPolicy (camel-api, ADR-0033) before any route step. A mcp: block's server security_policy propagates to every lowered tool and resource route. The TOML security_policy stays a presence gate only. Loopback bind is preferred. A non-loopback bind emits a warn! at startup (ADR-0052 rule 3).

Catalog cardinality is capped. max_tools / max_resources default to 128 each and ride the ADR-0038 per-item config channel: hardened but raisable, no global disable switch. Breach rejects the (N+1)th route at consumer start with a clean CamelError. Silent truncation is forbidden (ADR-0038).

Rule 9: No cross-crate types

MCP types stay in the component crate. No MCP-shaped type crosses into camel-api or camel-component-api. The client Exchange contract is Body + headers (CamelMcpToolCall). The crate boundary is this ADR's own rule, following the ADR-0020 confinement precedent.

Implementation decisions (carried from implementation review)

The following decisions surfaced during implementation. They are recorded here as carried reviewer obligations — explicit decisions, not omissions.

Remote config fails fast at deserialization

Remote entries (remotes map) use #[serde(deny_unknown_fields)]. The transport field is an enum whose deserializer accepts only streamable-http. A typo or a legacy transport string fails at config load. Unknown keys fail too.

URL strings are NOT validated at startup. They are validated at first use — connect time — matching the LlmBundle precedent. Server entries are the opposite: validate_server_policy runs at consumer start, fail-closed (security policy present, bind is an IP literal, caps nonzero).

Producer propagates is_error faithfully

The producer receives McpToolResult with a structured is_error: bool. It carries the flag and the content into the Exchange body and the CamelMcpResult header. It does NOT act on the remote's error flag.

This is an explicit decision, not an omission. The route author decides how to handle a remote-reported failure. The component must not invent policy.

Same-process both-roles start order

The producer connects at route start() and the discover connect is not retried. In a same-process deployment that serves both roles, the server consumer must start before any producer route that targets it. Operators control this with route definition order or startup_order. A producer that starts first fails fast and stays not-ready; it does not silently recover.

Shared-listener bind conflicts fail closed

One shared listener serves each bind. A later consumer whose config conflicts with the live listener — different allowed_hosts, TLS shape, or catalog caps — is rejected with McpError::Endpoint. Duplicate tool/resource registration is rejected atomically under the registry lock. Two concurrent same-name starts cannot silently overwrite the first registration.

Non-object input schemas advertise as {}

A non-object input schema cannot be expressed in rmcp's catalog shape. It degrades to {} at the catalog, while call-time validation accepts anything for such a schema. This is a known drift between advertisement and enforcement. Hardening is tracked in bd rc-ap58.

Consequences

  • MCP tool dispatch composes in routes without an LLM auto-loop.
  • rmcp churn is confined to src/adapter/ (ADR-0020).
  • The DSL mcp: block gives operators a declarative server catalog.
  • Security posture is fail-closed: no bind without policy, caps enforced.
  • The protocol baseline is single-version. Legacy peers are rejected, not negotiated.
  • Prompts and subscriptions stay out of v1.

Options considered

Fold MCP into camel-component-llm

Rejected. Double-binds the chat/embed-shaped LlmProvider to a tool-transport protocol. Re-imports tool execution that ADR-0020 isolates.

Fold MCP into camel-http

Rejected. Conflates JSON-RPC semantics with HTTP server mechanics. Reuse its reqwest client and ServerRegistry pattern as dependencies; do not host MCP semantics there.

Generalized tool-dispatch SPI in camel-component-api

Rejected for v1. Follows the LlmProvider precedent: provider traits stay local while there is a single consumer. Promote to SPI only if a second consumer appears.

Self-grill record

Questions generated:

  1. [glossary] Does "MCP Server Consumer" collide with the Components "Consumer" term?
  2. [sharpen] Does the security posture contradict ADR-0052 (unauthenticated by convention)?
  3. [cross-ref] Does route-owned dispatch need an ADR at all, or is it plain component behavior?
  4. [scenario] What happens when a later consumer on the same bind differs in TLS or caps?

Answers:

  1. [glossary] No. "Consumer" names the runtime role (inbound adapter). "MCP Server Consumer" names the MCP-specific instance of that role. The terms differ at the identifier level; no glossary collision.
  2. [sharpen] No. ADR-0052 governs diagnostic endpoints — non-data-plane metadata. An MCP server carries tool invocations from a host; that is data plane, crossing the ADR-0032 boundary. It must authenticate. The loopback-preference and the non-loopback warn! transfer (ADR-0052 rule 3); the unauthenticated-by-convention rule does not.
  3. [cross-ref] No prior ADR governs tool dispatch semantics. The LLM component never executes tools; nothing in ADR-0020 or the EIP corpus decides who calls mcp:call. Recording it prevents a future auto-loop "convenience" from shipping as default behavior.
  4. [scenario] The second consumer is rejected at get_or_spawn with McpError::Endpoint naming the conflict. Fail-closed, never silent.

Outcome: approve as new ADR (0060). MCP as first-class Server+Host component; route-owned tool dispatch; DSL-lowered catalog; rmcp confined to src/adapter/ (ADR-0020); Streamable HTTP only; protocol baseline 2026-07-28 stateless. Exclusions: Prompts, stdio, sessions, legacy transports, subscriptions. Implementation decisions recorded as carried obligations; the schema-advertisement drift is tracked in bd rc-ap58. Self-grill mode: manual (4 principles L6: glossary consistency, conflict with existing ADRs, redundancy with implicit ADRs, numbering — 0060 next free after 0058).

ADR-0061: Unified Transport Authentication Kernel

Date: 2026-08-20 Status: Accepted Amends: ADR-0060 (Rule 3 amended, Rule 8 superseded) Supersedes: ADR-0059 (in part: the retained extraction divergence) Cross-refs: ADR-0010, ADR-0032, ADR-0033, ADR-0051, ADR-0052, ADR-0059, ADR-0060 Origin: OpenSpec change unify-transport-auth (bd rc-fzgm)

Amendment (rc-f79u, 2026-08-23): wasm source joins the kernel

The wasm source transport is the fifth kernel transport. TransportId gains the Wasm variant. The transport converges on the boundary-authentication shape (Gen B, like ws and grpc). The host authenticates at the axum host-edge handler, where the raw request exists. The guest never observes authentication. Denials never reach accept-http. Accepted requests keep the 202-immediate-ack semantics unchanged. The WIT contract is untouched.

Boundary authentication

The host-edge handler runs kernel_authenticate before the guest is woken. A denial renders 401 (unauthenticated) in the transport idiom. A non-Public plan without kernel wiring denies with 401. Missing wiring never degrades to Public. The host edge performs authentication only. Authorization stays pipeline-owned. Strict dispatch and the policy layers enforce it after the Exchange exists.

Carrier threading

The Exchange does not exist at the auth point. The minted principal rides the request metadata as a private HttpMeta field. The host stashes it into the SourceHostState pending slot. The slot holds one outstanding request. install_carrier places the principal on the Exchange at assembly, when the guest submits the request. The one-outstanding-request invariant prevents a follow-up accept from poisoning the slot.

Operator-authoritative bind

The operator bind is authoritative. A guest bind that conflicts with the operator bind fails at consumer startup, before TcpListener::bind. Neither direction overrides silently. Non-loopback binds run enforce_bind_exposure_gate with WasmSourceBindAcks. The CLI wires the acks like the MCP bind acks.

Classification delivery

SecurityContext.policy became Option<Arc<dyn SecurityPolicy>>. SecurityContext::from_plan builds plan-only contexts. The route controller delivers classification for every staged server route. deliver_security_context runs at both the start and the resume sites. A wasm: source route without a plan is Public pass-through, subject to the per-bind exposure gate.

The four-transport references below (Rule 8, Consequences) now read five. The wasm source is the fifth.

Context

Server-component authentication lived in two wiring generations. Gen A (http, mcp) passed headers into the Exchange and let the core SecurityPolicyLayer extract, authenticate, and authorize. Gen B (ws, grpc) received an injected SecurityContext and authenticated at the transport boundary. Both worked. They disagreed on where authentication happens, how denial is rendered, and what happens when no principal reaches the authorization layer.

Main had already ratified parts of the target contract (73076dbd, bd rc-5u38): fail-closed {{env:}} placeholders, a named security-provider registry with per-route security_policy.provider, OIDC JWKS prefetch, N native credentials, and gRPC honoring credential_sources. The framework lacked the one artifact that makes these coherent. That artifact is a compiled per-route security plan with explicit access modes and an unforgeable principal type.

This ADR records the decided architecture. Delivery has three phases (expert-ruled, KB rc-fzgm-e-opus-reruling). Phase 1 (kernel types, fail-closed layer, compiled plan, per-bind exposure gate) has landed. Phase 2 (transport convergence) and Phase 3 (audience enforcement) are planned.

Decision

Rule 1: Transports extract, the kernel authenticates

Transport components never implement authentication. They extract credentials per the plan and render denial in the transport idiom (HTTP status, ws close, tonic::Status, JSON-RPC error). The kernel in camel-auth owns authentication semantics and authorization: kernel_authenticate verifies credentials against the registered provider, install_carrier places the typed principal on the Exchange, and enforce_dispatch checks the route binding before dispatch.

This supersedes the divergence ADR-0059 retained (layer-authenticated HTTP vs boundary-authenticated components). ADR-0059's extraction standardization (credential_sources, first-match-wins source order) carries forward unchanged as the extraction input.

The plan is control plane (ADR-0032). It compiles before listeners bind. Credentials and principals cross the boundary as typed values, never raw tokens.

Rule 2: Kernel types

camel-api (security_policy.rs) owns the plan vocabulary:

  • TransportId: Http, Ws, Grpc, Mcp.
  • AccessMode: Public, Authenticated, Authorized(Arc<dyn SecurityPolicy>).
  • AuthPrincipal: read trait over a verified principal.
  • AuthContext: carries TransportId and the principal into SecurityPolicy::evaluate.
  • RouteSecurityPlan: compiled per route at staging. Fields: access mode, provider ref (None only for Public), credential sources (capability-checked per transport), audience binding.
  • AudienceBinding: reserved field. Enforcement lands in Phase 3.

The concrete AuthenticatedPrincipal lives in camel-auth. camel-api never names it.

Rule 3: Provider selects authentication, access mode decides enforcement

security_policy.provider is the authentication selector. The access mode is the enforcement predicate. The two axes are orthogonal and jointly required. A route declaring a policy or a provider is never downgraded to Public by missing wiring. Classification fails loudly instead.

The core SecurityPolicyLayer fails closed when the principal is absent for non-Public routes (ADR-0033 posture extended to principal absence). Additive role grants never conjure authentication. Explicit denial overrides grants.

Rule 4: Public by default, gated per bind

Public is the default access mode only for routes that declare no security at all. The default and its gate ship in the same phase (expert-ruled additive layering). A non-loopback bind that serves any Public route requires operator acknowledgment: allow_public_exposure = true under [binds."<bind-address>"] in Camel.toml. Every boot emits a warn! naming the bind and the exposed-route count. The acknowledgment is permanent. It never silences the warning (ADR-0052 rule 3). It never satisfies misconfigured siblings: any declaring route that fails classification still blocks startup. Loopback binds permit Public silently.

Rule 5: Policy-form provider resolution

Policy forms (ref, wasm, permission) are authorization-only today and reject the provider field. They classify as Authorized. Plan compilation resolves the provider as follows:

  • A named provider must resolve in the registry.
  • An unnamed form with exactly one registered provider resolves that sole provider into the plan.
  • An unnamed form with zero providers is a compilation error.
  • An unnamed form with more than one provider and no name is a compilation error.

The resolution never yields a Public downgrade. Extending these forms with an optional provider field is in scope for Phase 2 and not required by this change.

Rule 6: trust_upstream_principal removed

Removed (pre-1.0 breaking). Accepting an Exchange-property principal contradicts typed-principal unforgeability. No migration path can honor both the "never authorizes" invariant and the flag's property-trust behavior. Phase 1 deletes the property-evidence path and the DSL flag. Stale configs fail at load with an error naming the field (deny_unknown_fields). Property evidence has no authorization path from the first Phase-1 commit. The Bearer-token legacy dual-read is unaffected. It is deleted at the Phase 2 strict-mode task.

Rule 7: Per-provider isolation (decision now, enforcement Phase 3)

Each provider validates independently. Authentication requests and the authentication cache (AuthnCache, distinct from the permission cache) are provider-local. The cache key includes route audience, issuer, transport context, and provider identity. Providers that share audience, issuer, and transport still hold separate entries. Substitution tests prove that a token minted for one provider's route cannot authenticate another's. Fixtures use per-provider signature keys and the same issuer, so issuer rejection cannot mask missing key isolation.

Rule 8: The principal seal (honest statement)

AuthenticatedPrincipal construction is same-crate-only (camel-auth kernel.rs). The type has zero public constructors, zero feature-gated constructors, and zero test-only constructors. Cargo feature unification would make a cfg(test) or feature-gated constructor unsound. Tests mint through the real kernel_authenticate path, never a shortcut.

The seal guards against accidental construction and property spoofing from every other crate. Against hostile code inside camel-auth itself, the seal holds at review level only. This is the documented in-process trust boundary: the type system prevents accidents. It does not sandbox in-process code.

Rule 9: ADR-0060 amendments

Rule 3 (TOML-owned runtime), amended. The mcp: DSL block becomes the runtime owner of listener configuration (bind, TLS, caps), as rest: owns host/port. TOML mcp.servers.<name> remains the source for items with no DSL counterpart (allowed_hosts). Overlapping keys must agree. Divergence is a hard startup error, never silent TOML-wins. The step-less catalog declaration (Rule 3 lowering semantics, bd rc-23y2) is unchanged. The rest: block gains an optional security_policy with the same surface and the same load-time validation, so both declaration forms classify under the same kernel.

Rule 8 (mandatory MCP security_policy), superseded. The presence-only bind gate is replaced by the kernel's per-bind exposure gate with uniform semantics across all five transports. The component-local MCP gate is removed when Phase 2 converges the MCP registry onto the kernel gate. The route-level enforcement gate (headers copied to the Exchange, route policy before steps) is preserved as per-route dispatch enforcement.

Consequences

  • All five server transports converge on one kernel. Duplicate Gen A / Gen B authentication is deleted after convergence (Phase 2).
  • Declared security never silently downgrades. Missing wiring fails classification, and a non-Public route without a principal denies.
  • Non-loopback Public exposure requires per-bind acknowledgment and warns at every boot, forever.
  • Stale trust_upstream_principal configs fail at load with an error naming the field.
  • ADR-0059's divergence decision is superseded. Its extraction standardization carries forward.
  • ADR-0060 Rule 3 gains DSL listener ownership with hard conflict errors. ADR-0060 Rule 8's presence gate is gone.
  • AudienceBinding is reserved in the types. Its enforcement, the provider-local authn cache, and the substitution tests land in Phase 3.
  • Phase 3 note (2026-08-21): enforcement is live. Per-request audience and issuer sets are enforced on every provider (REPLACEMENT semantics); the provider-local authn cache keys entries by provider, binding, transport, and token hash; and the cross-transport substitution E2E suite (crates/camel-test/tests/audience_substitution_test.rs) pins cross-provider, issuer, transport, and audience isolation over real http and ws routes.

ADR-0062: Reserved Test Suffix and Placement Contract

Date: 2026-08-22 Status: Accepted (Amended 2026-09-11 — two reserved suffixes; see "Amendment") Origin: OpenSpec change test-placement-contract (bd rc-6760)

Context

camel test runs test documents. A test document names route files, declares inputs, and states expected outputs. It is not a route.

Before this change, no name separated the two file kinds. camel run filtered test documents in the CLI layer. An explicit *.test.yaml glob was honored as a user override. The matched files then parsed as routes and failed on unknown fields. The rule lived in the wrong crate and the wrong layer.

Route discovery already had one reserved-behavior gate. A .json route file loads only under a pattern that explicitly targets .json (DiscoveryError::JsonRequiresExplicitPattern). Wildcards never load JSON silently. Test documents needed the same treatment, plus a placement contract for where they live.

Decision

Rule 1: The suffix is reserved and owned by camel test

A file name ending in .test.yaml or .test.yml names a camel test document. The suffix is reserved. Only camel test consumes such files. Test documents are YAML only. A .test.json name is not test-suffixed and keeps the JSON explicit-pattern gate.

Rule 2: Discovery enforces the suffix, not the CLI

camel_dsl::discovery::is_test_document is the single suffix rule. Discovery checks it first in the glob-entry loop. The check runs before the extension gate, before any read, and before interpolation. A reject therefore never triggers an environment lookup.

  • A wildcard pattern skips test documents with no error. The file is never read.
  • A literal pattern (no * ? [ ] { } metacharacters) that names a test document fails with DiscoveryError::ReservedTestSuffix. The error names the file and names camel test as the owner.

This is the explicit-gate idiom, the same family as JsonRequiresExplicitPattern. When the operator names a file, ambiguity fails loudly. When a wildcard merely brushes the file, discovery stays silent.

camel run passes default globs, Camel.toml routes, and --routes patterns verbatim to discovery. Watch reload inherits the rule through the same resolver. camel lint applies the exported predicate before it invokes the engine and prints one info line. The lint corpus gate consumes the same predicate. No consumer keeps a private copy.

Rule 3: Colocation is the blessed placement

routes/foo.yaml with a routes/foo.test.yaml sidecar is the blessed default. The pair stays together in review, in git history, and in the scaffold.

A separate test directory is first-class. A test document may declare routeFilesFromRoot. Its entries resolve against the project root. The root is the nearest ancestor directory that holds a Camel.toml, found by walking up from the document. Monorepo semantics: the nearest ancestor wins, so a per-service Camel.toml anchors that service. This matches cargo workspace discovery. Anchoring never uses the git or workspace root. A walk that finds no Camel.toml fails with TestDocError::NoProjectRoot and names the walked path.

Rule 4: No in-string sigils

routeFilesFromRoot entries that start with a sigil never resolve; the load fails with a file-not-found error.

  • @/ is frontend idiom. It imports meaning from a foreign ecosystem.
  • $ collides with ${env:} interpolation.
  • ~/ reads as a home directory.
  • URI pseudo-schemes muddy the endpoint model, where schemes name components.

A file suffix carries none of these collisions.

Consequences

  • The previous explicit *.test.yaml glob override is removed. This is an accepted breaking change (pre-1.0). A route-shaped file that used the reserved suffix must be renamed.
  • expand_patterns_excluding_test_docs is deleted from camel-cli. Discovery owns the rule, so library consumers and watch reload get the same behavior.
  • Known cost: camel run --watch wakes on a test-document save and reloads to a no-op. The suffix skip keeps the reload harmless.
  • Known cost: a wildcard glob that matches only test documents reports no routes. That report is correct.
  • camel lint on a test document prints one info line and exits 0.

Alternatives considered

  • Keep the CLI-layer filter. Rejected. Discovery is the single choke point for route loading. A CLI filter left library consumers and watch reload unprotected.
  • Honor an explicit *.test.yaml glob as an override. Rejected, with the breaking change accepted. The override parsed test documents as routes and failed on unknown fields. A loud reserved-suffix error beats a silent-shape parse failure.
  • In-string sigils (@/, $, ~/, URI pseudo-schemes). Rejected. Each collides with an existing idiom (Rule 4).
  • Reserve .test.json as well. Not chosen. Test documents are YAML only in this change, and a second format would double the parser surface.

Amendment (2026-09-11): Two reserved suffixes

Origin: OpenSpec change job-ux-reshape (bd rc-10d50). Jobs left the test family: a job is an operator tool, not a test, and the job runner no longer consumes *.test.yaml.

  • .job.yaml / .job.yml names a camel job document. The suffix is reserved under the same contract as the test suffix.
  • The discovery rule generalises to a reserved-document contract: is_test_document (test family) and is_job_document (job family) join under is_reserved_document, which is the single gate for the wildcard skip and the literal-name error. ReservedTestSuffix is renamed ReservedDocumentSuffix; the error names both families and their runners.
  • A *.test.yaml document that declares execute: fails to load under camel job with an error that directs the author to rename the file. There is no alias and no compatibility shim (zero adoption at the time of the rename; accepted breaking change, pre-1.0).
  • Job documents live under [jobs].dir in Camel.toml (default jobs), resolved against the Camel.toml root. This table governs where job documents live, never where a job's routes come from: the explicit route source stays mandatory.
  • Test placement rules (colocation, routeFilesFromRoot anchoring) are unchanged and apply to the test family only.
  • ADR-0069 Decision 6 (the section classifies the document) is untouched: section classification still holds within a suffix; the suffix is now the first discriminator between the two families.

ADR-0063: Redis Repository Service

Date: 2026-08-22 Status: Accepted Amends: ADR-0023, ADR-0056 Cross-references: ADR-0028, ADR-0032, ADR-0033, ADR-0051, ADR-0054

Decision

Decision 1: repository service crate at crates/services/camel-redis-repo

The Redis backends ship as one new crate, crates/services/camel-redis-repo. The crate implements IdempotentRepository and CacheRepository from camel-api. It does not implement Component, own a URI scheme, or create Endpoints.

The Services family is the correct home. camel-auth already proves the family holds named, context-scoped infrastructure that is not a Lifecycle implementation. The repository backends share that shape: contract in camel-api, implementation outside camel-core, named lookup in CamelContext, and route steps that consume the lookup.

Repository reads and writes run during Exchange processing. Named registration happens at context build time through camel-config. The crate differs from a URI Component in contract and user-facing role, so the Components directory charter and its component-specific lints must not apply.

Decision 2: both repositories in one change

RedisIdempotentRepository and RedisCacheRepository ship together. They share connection acquisition, key namespacing, error mapping, and the executor seam. Splitting them would double the wiring and test overhead for zero isolation benefit. The traits stay independent, so shipping together creates no coupling.

Decision 3: connection seam through the component, commands as redis::Cmd

The service reuses the connection machinery of camel-component-redis instead of building a second client. The component widens its surface additively:

  • MultiplexedExecutor::get_conn moves from pub(crate) to pub (crates/components/camel-redis/src/executor.rs:268).
  • A new pub async fn refresh(&self) reconnects without &mut self (crates/components/camel-redis/src/executor.rs:313).
  • topology_from_config moves from pub(crate) to pub (crates/components/camel-redis/src/topology.rs).

The service issues repository commands (SET NX, SET ... EXAT, EXISTS, GET, UNLINK, SCAN) as redis::Cmd values through its own narrow trait RepoCommandExecutor (crates/services/camel-redis-repo/src/executor.rs:46). The component's RedisCommand dispatch path stays untouched. No existing component signature changes.

Decision 4: one executor per repository, no registry

Each repository owns one MultiplexedExecutor built from its own configuration. Two repositories that target the same server hold two multiplexed connections. That costs one extra socket. A deduplication registry would need identity equality over topology, credentials, TLS, and database index. It would also need lifetime and eviction rules. Two possible repositories do not justify that machinery. A registry, if ever wanted, is an additive future change with its own spec.

Decision 5: Sentinel always compiled in the service crate

The service enables camel-component-redis/sentinel unconditionally. Cargo feature unification compiles the component's Sentinel branch into every binary that links both the service crate and camel-config. The component's own sentinel feature stays default-off and fail-closed. That gate still governs builds whose dependency graph does not link the service crate. The asymmetry is intentional and documented in the redis-failover spec delta.

Decision 6: explicit config selection, no auto-detection

Sentinel routing is selected by explicit fields: sentinel_nodes plus master_name. sentinel_nodes and cluster_nodes are mutually exclusive and validated at startup. master_name missing under sentinel_nodes is rejected at startup.

Runtime probing for "is this host a Sentinel" would infer topology from unconfirmed signals. That inference contradicts ADR-0033 (fail closed, validate at startup) and ADR-0032 (master_name is operator config, never inferred). The probe also cannot produce a reliable positive signal. A Sentinel answers SENTINEL get-master-addr-by-name only for a known master name. Port heuristics are convention, not contract.

Decision 7: SET NX is never re-issued (Contract C1)

Idempotent add issues exactly one SET key 1 NX per trait call. A lost response leaves the outcome unknown: the first insert may have succeeded on the server. A retry could observe the key it set itself and return Ok(false) for a first-seen message. On any transport error, add returns Err(CamelError::Io) immediately. The Idempotent Consumer treats Err as an unknown outcome and retries or dead-letters, per ADR-0023 Contract C1.

The repository may call refresh() after the failure so later calls use a healthy connection. Command re-issue for add is what is forbidden. Retry-safe operations (GET, EXISTS, UNLINK, SCAN, plain SET) may refresh and re-issue at most once, because a re-issued command cannot corrupt state.

Decision 8: one atomic SET ... EXAT for cache writes

Cache set writes the serde_json CacheEntry with a single SET. When expires_at is Some, the command carries EXAT (expires_at + stale_retention) (crates/services/camel-redis-repo/src/cache_repo.rs:111-123). The server-side deadline is garbage collection only. It extends past logical expiry so peek_stale stays satisfiable inside the retention window, per ADR-0056 Decision 5. When expires_at is None, the command sets no Redis deadline: the entry lives until invalidated.

Plain SET is last-writer-wins. A re-issued identical SET stores the same bytes and the same EXAT, so a lost response on cache set is safe to retry.

Keys are namespaced {prefix}:{repo}:{key} by keyspace::namespaced (crates/services/camel-redis-repo/src/keyspace.rs:9). clear() walks SCAN MATCH {prefix}:{repo}:* and unlinks in batches. invalidate_prefix walks {prefix}:{repo}:{step_prefix}* and returns the removed count.

FLUSHDB and FLUSHALL are forbidden. A shared Redis would lose every other tenant's data. A prefix-scoped walk with batched UNLINK bounds the damage to one repository namespace.

Every namespace token, including the step prefix from cache_invalidate { key_prefix }, is validated against [A-Za-z0-9:_-] before it enters a SCAN pattern (crates/services/camel-redis-repo/src/keyspace.rs:17). Glob metacharacters are rejected as CamelError::Config before any SCAN runs. The step prefix is a simple-language expression resolved from exchange data, so this guard is an ADR-0032 trust-boundary obligation.

Decision 10: error mapping is Io for transient, Config for setup

Transient transport failures map to CamelError::Io (crates/services/camel-redis-repo/src/error.rs:7). Setup and validation failures map to CamelError::Config. The crate produces no ProcessorError. classify() therefore keeps reporting "io" for transport failures, and a failed read stays Err, never "absent", per Contract C1.

Decision 11: cluster rejected; no idempotent TTL

Configuration that requests a cluster topology is rejected at validation with a Config error. The repositories assume single-key routing. Cluster support, if a need appears, is a separate change.

Idempotent keys carry no TTL. This matches ADR-0023 and the redb backend. Camel-Java offers optional key expiry, but this project declared idempotent TTL out of scope. A later TTL needs its own explicitly specced change.

Decision 12: no Lifecycle implementation

The repositories implement no Lifecycle and no StepLifecycle. Construction resolves the topology once and connects eagerly, so an unreachable topology fails fast at context build. Sentinel failover is detected only through the explicit refresh on a later error. No Consumer task and no background task exist. Reclamation is server-side through EXAT. Dropping the repository owns connection cleanup.

ADR-0028 states that a persistent backend which needs connection lifecycle management should implement StepLifecycle on the backend client. That rule does not apply here: the multiplexed connection carries no timers, buckets, or queues, so no lifecycle exists to manage.

Decision 13: per-command response timeout

Every repository command carries a 30-second response timeout (DEFAULT_RESPONSE_TIMEOUT, crates/services/camel-redis-repo/src/executor.rs). connection_timeout_secs guards only the TCP connect. Without an execution-side bound, a half-open socket or a silent peer could park an Exchange-processing future indefinitely and defeat refresh-on-error, because the error never arrives. An elapsed timeout maps to transient CamelError::Io, so retry-safe operations refresh and re-issue once, add returns Err without a re-issue (Contract C1), and the next call re-resolves the topology. The component's connection configuration is untouched: the timeout wraps query_async inside MultiplexedRepoExecutor::execute in the service crate, so component consumers keep their existing behavior. The redis driver also enforces its own default response timeout on multiplexed connections; the service-crate timeout is the crate's own contract and does not depend on driver defaults. Plumbing the driver's response timeout through the component landed (OpenSpec change redis-response-timeout, bd rc-dq7a). MultiplexedExecutor now accepts with_response_timeout(Duration), applied in get_conn on the initial connect and on every refresh/reconnect rebuild. On that branch the driver's parallel 1 s connect default is disabled through set_connection_timeout(None), so the component's connection_timeout_secs wrapper stays the sole connect bound. The repository service crate constructs its executor with a 35 s driver response timeout (30 s backstop plus 5 s margin), so the service crate's own 30 s contract governs end to end and the driver deadline is defense-in-depth only. Component consumers that do not call the builder keep the driver default. Their behavior does not change.

Rejected alternatives

Module inside camel-component-redis

ADR-0023 and ADR-0056 anticipated Redis backends inside the Redis component, following Apache Camel packaging. Rejected: the blessed crate keeps repository APIs, unconditional Sentinel support, and the repository release surface separate from URI endpoint behavior. It also avoids pushing the component's optional sentinel feature onto component users who want no repository.

New crates/adapters/ family

Rejected: it creates a parent taxonomy, workspace globs, and documentation context for exactly one crate. A family is created after the family exists. SQL or Memcached repository backends, if they appear, land in Services under the same rule.

Shared connection registry

Rejected per Decision 4. One multiplexed connection per repository keeps independently configured repositories isolated with a one-sentence ownership rule and no invariants to police.

Sentinel auto-detection

Rejected per Decision 6. Probing infers topology at runtime, adds a new failure mode, and cannot work without the master name it claims to avoid.

Retrying SET NX

Rejected per Decision 7. A retry can convert a first-seen message into a false duplicate. The unknown outcome must surface as Err.

FLUSH-based clear()

Rejected per Decision 9. On a shared deployment, FLUSHDB is a cross-tenant data-loss incident.

Context

Problem

ADR-0023 and ADR-0056 declared Redis backends future work. Deployments that share cache and idempotent state across restarts or nodes need them. The Redis component already owns topology resolution, Sentinel failover, and a multiplexed executor. A second, independent Redis client inside the service crate would duplicate that machinery and diverge on failover behavior.

Forces

  • Reuse with a narrow seam. The service needs connection acquisition and topology resolution, not the component's command dispatch. Widening three items is the smallest additive surface that serves both.
  • Hexagonal boundary. Dependency direction stays camel-api <- camel-redis <- camel-redis-repo <- camel-config. camel-core imports nothing new.
  • Fail-closed culture. Topology selection, charset guards, and cluster rejection all validate at startup (ADR-0033).
  • Shared deployments. Redis instances are commonly shared. clear() and invalidate_prefix must never escape the repository namespace.
  • Unknown-outcome honesty. Contract C1 governs every repository backend. A lost response to SET NX must surface as Err, and transient failures must classify as "io".

Consequences

Registration through camel-config

[default.cache_repo] backend = "redis" and [default.idempotent_repo] backend = "redis" register the repositories at context build time (crates/camel-config/src/context_ext.rs). The DSL steps select repositories by name. No autowiring exists, matching ADR-0023 and ADR-0056.

Component surface grows by three items

get_conn, refresh, and topology_from_config become pub in camel-component-redis. Existing consumers are unaffected. The widening is recorded here so future component audits know the public surface is intentional.

Test seams live in the service crate

FakeRepoExecutor and FakeStaticTopology (crates/services/camel-redis-repo/src/executor.rs:164, crates/services/camel-redis-repo/src/executor.rs:291) drive both repositories without a live Redis. Live coverage runs in crates/camel-test/tests/redis_repositories_test.rs under the integration-tests feature, per ADR-0054.

Credentials stay redacted

Sentinel node credentials and connection URLs follow ADR-0051. Redaction happens in the component's topology code. The service adds no new credential-bearing Debug output.

Load-bearing citations

File:lineElement
crates/services/camel-redis-repo/src/lib.rs:20-24crate exports: RedisCacheRepository, RedisIdempotentRepository
crates/services/camel-redis-repo/src/executor.rs:46-51trait RepoCommandExecutor (execute, refresh)
crates/services/camel-redis-repo/src/executor.rs:56MultiplexedRepoExecutor wrapping the component executor
crates/services/camel-redis-repo/src/connection.rsone topology resolution per construction, cluster rejected first
crates/services/camel-redis-repo/src/keyspace.rs:9fn namespaced builds {prefix}:{repo}:{key}
crates/services/camel-redis-repo/src/keyspace.rs:17fn validate_namespace_token enforces [A-Za-z0-9:_-]
crates/services/camel-redis-repo/src/cache_repo.rs:111-123single SET with EXAT (expires_at + stale_retention)
crates/services/camel-redis-repo/src/idempotent_repo.rsadd issues one non-retried SET NX
crates/services/camel-redis-repo/src/error.rs:7fn to_camel_error maps Redis failures to Io
crates/components/camel-redis/src/executor.rs:268MultiplexedExecutor::get_conn (widened to pub)
crates/components/camel-redis/src/executor.rs:313MultiplexedExecutor::refresh(&self) reconnect primitive
crates/camel-config/src/context_ext.rs:258-279redis cache and idempotent repository registration
crates/camel-test/tests/redis_repositories_test.rslive integration suite

ADR-0064: Two-Tier Testing Contract

Date: 2026-08-23 Status: Accepted Ratified: e_glm, acting for the maintainer, 2026-08-23 Origin: bd rc-379d (epic rc-7roi) Amends: none Cross-refs: ADR-0002 (CQRS RuntimeBus — control-plane ceiling), ADR-0004 (hot-reload atomic pipeline swap), ADR-0024 (PipelineOutcome), ADR-0042 (Arc compiled-steps snapshot), ADR-0045 (camel-core architecture charter), ADR-0046 (Camel is inspiration, not conformance), ADR-0055 (publish topology), ADR-0062 (reserved test suffix), prior rulings: e_opus 2026-08-18 (docs/reviews/2026-08-18-camel-mock-expansion-ruling.md), e_opus 2026-08-23 (this ADR)

Context

rust-camel has one in-process test surface today. camel test boots a lean CamelContext, loads route documents, delivers direct: inputs, settles traffic, and evaluates mock expectations (crates/camel-cli/src/commands/test/runner.rs). The mock-testkit spec (openspec/specs/mock-testkit/spec.md) is its live contract.

This surface has no name that separates it from the different job of testing a route against a real adapter with a full runtime. Without that name, feature requests push the lean boot toward a general runtime. One such request (rc-5s8c) proposed adding http: to the lean boot; it never landed. That direction is wrong: it grows the in-process boot into a second, half-formed runtime, and it hides the growth inside register_component lines that no reviewer gates.

Two prior facts frame this decision:

  • The 2026-08-18 ruling fixed the mock as a sink, not an in-out simulator, and rejected any inspection channel that threads component state into the CQRS read side. That ruling forbids widening the data plane through the control plane (ADR-0002 / ADR-0045). Route interception (AdviceWith) was confirmed out of scope for the component and parked for the test surface.
  • The lean boot is real but narrow. boot_context() registers exactly five components: mock, direct, timer, log, seda (runner.rs:56-67). It registers no bean registry, discards producer replies in the delivery loop (runner.rs:168), and registers no dataformat.

This ADR names the two jobs, pins the boundary between them, and gives the route-interception feature (AdviceWith) a staging frame so it can land without reopening the plane question. It binds the unit tier only. It sketches the integration tier without binding it.

Decision

1. Two tiers, one boundary

rust-camel testing has two tiers. The boundary between them is fixed by two axes: the inbound stimulus that drives the route under test, and the runtime profile that hosts it.

  • Unit tier (camel test): a lean in-process boot. Stimulus is direct: only. Runtime profile is the closed lean component set below. No external transport, no bundle config, no full runtime.
  • Integration tier (sketched in section 4, not bound here): a full runtime boot with real adapters and transport-boundary assertions.

The two-tier framing is the load-bearing decision. Every other rule in this ADR follows from it.

2. The closed lean component set

The unit-tier lean boot registers exactly this set:

{ direct, log, mock, seda, timer }

This set is closed and pinned by this ADR. It mirrors the current boot_context() reality (runner.rs:56-67). An addition to the set requires an amendment to this ADR. The amendment is the gate. A silent register_component line in the runner is not a valid way to grow the set, because it grows the unit-tier runtime profile with no review.

timer: stays in the set as a deterministic in-process source. It carries no producer, so it is inert to the send-point interception in section 5. A timer: route that never quiesces is a test-authoring concern (bound it with repeatCount), not a reason to evict the component.

3. The creep rule (the test that rejects new components)

For any proposed unit-tier capability, apply this test:

Does this capability require an inbound stimulus other than direct:, or a component outside the closed lean set? If yes, it belongs in the integration tier, not in the camel test lean boot.

This is the test that rejects http: in the lean boot (rc-5s8c). http: is both outside the closed set and an external transport stimulus. It fails the test on both clauses. The rule is orthogonal to set membership: a component in the set (for example seda:) passes the set clause, and a route driven only by a direct: producer passes the stimulus clause, even when that route reads from seda:.

4. Integration tier — SKETCH (non-binding)

This section is a non-binding sketch. It records intended direction so that the boundary in section 1 has a defined other side. It does not ratify vocabulary, assertion shape, or API. The binding integration-tier contract requires a future ADR. Do not cite this section as settled contract.

Intended shape, for orientation only:

  • Embedded mode only. The integration tier boots a full runtime in-process (bundles, Camel.toml) and drives routes through real adapters.
  • Standalone mode is out of scope. Testing a separately deployed camel run process over IPC is out of scope until the frozen no-IPC invariant is explicitly amended by a future ADR. The 2026-08-18 ruling rejected inventing an IPC control surface for test assertions; that rejection stands here.
  • Citrus-inspired vocabulary (send / receive-with-timeout / sleep / validate against logical endpoints), used as inspiration, not conformance (ADR-0046). receive-with-timeout assertions sit at transport boundaries.
  • Partner behavior simulation (rc-i2qf, re-parented under epic rc-kk69) is integration-tier work.

5. AdviceWith — staging context (enabled here, not implemented)

This ADR enables route interception (AdviceWith). It does not implement it. Interception stays a data-plane Tower decoration. It is never routed through the RuntimeBus or a RuntimeQuery; that path breaks the ADR-0002 / ADR-0045 control-plane ceiling (the 2026-08-18 ruling, fact 2). The intercept is baked into the compiled-steps snapshot (ADR-0042 / ADR-0004), so it applies atomically and is not mutated after context.start().

Stage A — boot-time interception (camel-core). Boot-time InterceptRules plus a Tower send-point wrapper installed before context.start(). Match is exact-URI, first-match-wins. Two divert kinds:

  • divert (WireTap-style): the intercept copies the exchange to a mock sink as an outcome-isolated side-effect. A failure in the mock copy MUST NOT corrupt the real path's PipelineOutcome (ADR-0024). The copy is a side branch, exactly like WireTap.
  • skip: full producer replacement. The real producer never runs.

Stage A send-point scope includes seda: send-side (see section 6). It excludes seda: consumer-side and any post-queue assertion semantics (also section 6).

Stage B — declarative intercepts. An intercept block in *.test.yaml (rc-7f0n), building on the reserved test-suffix contract (ADR-0062).

Stage C — deprecation of inline to: mock: in production routes. A scope-aware lint that warns, then errors, mirroring the ADR-0045 §4 discipline (rc-car5). Inline mock: stays legitimate in pure test-fixture routes that camel run never loads. Migration is lazy. There is no flag-day.

No new crate is introduced by any stage (ADR-0055 publish topology).

6. The seda send-side carve-out (pinned, fenced — not revocable)

The maintainer requires seda: send-side interception inside Stage A scope. This deviates from the original ruling, which excluded seda: until demand appeared. Demand has appeared (maintainer instruction, 2026-08-23), and the mechanics are verified safe:

  • to: seda:x enqueues from the producer's call(): tx.send().await under blockWhenFull=true, else tx.try_send (crates/components/camel-component-seda/src/lib.rs:761-831). The producer is a Service<Exchange> wrapped in BoxProcessor::from_fn (crates/components/camel-component-seda/src/lib.rs:514-528). Wrapping that producer is mechanically identical to wrapping any other producer.

The carve-out is pinned and fenced, not a revocable knob. Fenced OUT of scope, as hard boundaries:

  • seda: consumer-side interception (from: seda:). Consumer forwarding runs in a separate background task (forward_envelope, lib.rs:727); it is not a producer send point.
  • Fanout-partial semantics across the subscribers map. Fanout is all-or-nothing at the producer and has no single interception point that represents "one subscriber".
  • Post-queue (processed-side) assertion semantics under divert. A divert records the exchange pre-queue (before tx.send). A test that asserts processed-side state under divert observes intake, not consumer output. This gap is why the fence exists.

The carve-out is not revocable by convenience. A future change that wants consumer-side, fanout-partial, or post-queue seda interception amends this ADR, the same gate as any lean-set change. Removing the send-side carve-out passes the same gate. Revocability would reintroduce the ambiguity that the closed-list discipline exists to remove.

Consequences

Positive

  • The boundary is a named test, not a habit. camel test cannot silently grow into a second runtime.
  • AdviceWith has a staging frame that never touches the control plane. Each stage lands on its own review, with divert error-isolated per ADR-0024.
  • The seda send-side carve-out is explicit and fenced, so the safe part ships without dragging in the unsafe part.

Negative

  • The closed set forces an ADR amendment for any new unit-tier component. This is intended friction. It is the cost of keeping the lean boot lean.
  • The integration tier stays a sketch, so integration testing has no bound contract until a future ADR lands. Accepted: binding it now would ratify vocabulary before the design work is done.

Known unit-tier gaps (follow-ups under epic rc-7roi, not fixed here)

These are current lean-boot limits. This ADR names them so they are tracked, and does not specify their solutions:

  • rc-07qh: the lean boot registers no bean registry, so routes with bean: steps fail under camel test.
  • rc-66c5: the delivery loop discards producer replies (producer.oneshot(), runner.rs:168), so InOut routes cannot assert replies.
  • rc-24e5: no explicit dataformat registration in the lean boot (investigation).

Amendment (2026-08-24): the rc-07qh (bean registry) gap was closed by bean-test-registry, and the rc-66c5 (reply capture) gap was closed by reply-capture. Stage C's warn-phase lint (rc-car5) completes the §5 warn phase: camel lint now warns R-MOCK-IN-PRODUCTION on inline mock: sends in route files, with fixture-path and test-document exemptions.

Alternatives considered

  • http: in the lean boot (rc-5s8c): rejected by the creep rule. It is outside the closed set and an external transport stimulus.
  • AdviceWith through the RuntimeBus / RuntimeQuery: rejected. It breaks the ADR-0002 / ADR-0045 control-plane ceiling and forces endpoint state into the projection read model (2026-08-18 ruling, fact 2).
  • Porting the Java AdviceWith API shape verbatim: rejected. Camel is inspiration, not conformance (ADR-0046).
  • arming / lazy-recording of mock traffic: rejected. It subsidizes the anti-pattern of inline mock: in production routes instead of retiring it.
  • A record=false URI parameter: rejected. It is a per-endpoint knob for a route-level concern.
  • A [components.mock] config knob: rejected. It is a config surface for a decision that belongs in the route or test document.
  • Standalone integration mode (test a deployed camel run over IPC): rejected here. Out of scope until the no-IPC invariant is explicitly amended.

Concession

Bounded-retention hardening in camel-mock is permissible as pure hardening if a live production memory cost appears. This is a cap on retained exchanges, nothing more. It MUST NOT introduce record semantics or any inspection channel. The sink identity from the 2026-08-18 ruling holds.

Self-grill record

Questions generated:

  1. [glossary] Does "two-tier" or "creep rule" collide with an existing CONTEXT-MAP term or an established ADR concept?
  2. [sharpen] "Inbound stimulus" is fuzzy. Does adding seda: to the closed set contradict the creep rule, given from: seda: is itself an inbound stimulus?
  3. [scenario] Under divert, what does a post-queue seda assertion observe, and does that break the mock-sink contract?
  4. [cross-ref] Does the seda producer actually wrap like any other producer, and is timer: truly inert to send-point interception?

Answers (with citations):

  1. [glossary] No collision. CONTEXT-MAP has no "two-tier" or "creep rule" entry; the control-plane ceiling it does define (CONTEXT-MAP.md:132, Synchronous-projection CQRS, ADR-0002 / ADR-0045) is the ceiling this ADR respects, not one it renames. The intercept-as-snapshot claim aligns with PipelineOutcome (CONTEXT-MAP.md:156, ADR-0024).
  2. [sharpen] No contradiction. Set membership and the creep rule are orthogonal. seda: is IN the closed set, so it passes the set clause; a route driven by a direct: producer that then sends to seda: uses no stimulus other than direct:. http: fails both clauses; that is why the rule rejects it. Wording sharpened to "external transport inbound stimulus."
  3. [scenario] A divert records the exchange pre-queue, before tx.send (crates/components/camel-component-seda/src/lib.rs:761-831). A processed-side assertion under divert would observe intake, not consumer output. This does not break the sink contract; it is fenced OUT explicitly in section 6 so no test relies on it.
  4. [cross-ref] Verified. The seda producer is a Service<Exchange> returned as BoxProcessor::from_fn (crates/components/camel-component-seda/src/lib.rs:514-528); wrapping it is identical to wrapping any producer. timer: carries no producer (it is a from: source), so a send-point wrapper never sees it — inert, no scope surprise.

Open question escalated to the human, resolved at ratification (2026-08-23): whether the seda carve-out should be revocable or permanently pinned-and-fenced. Ruling: permanently pinned-and-fenced. Send-side is IN; consumer-side, fanout-partial, and post-queue are OUT. Any change to the fence, in either direction, requires an ADR amendment, the same gate as a lean-set change. A revocable carve-out would create two contract classes inside one ADR, one gated by amendment and one revocable by convenience, and would reintroduce the ambiguity the closed-list discipline removes.

Outcome: confirm (seda carve-out pinned-and-fenced; open question resolved at ratification, see above) Self-grill mode: self-grill-proposals skill

ADR-0065: Cache Payload Offload

Date: 2026-08-24 Status: Accepted Cross-references: ADR-0023, ADR-0033, ADR-0056, ADR-0063

Decision

Decision 1: decorator over the backend, index in backend, blob on disk

DiskOffloadRepository (crates/camel-core/src/cache/disk_offload.rs:62) wraps any Arc<dyn CacheRepository>. It is a decorator, not a backend. The wrapped backend stores a small index entry with an emptied bytes field and a payload_path. The payload bytes live in one blob file under payload_dir.

The payload travels opaquely through the trait. The decorator intercepts set, get, and peek_stale, and delegates every other method. One insertion point serves both persistent backends, per the service-seam reasoning of ADR-0063. CacheEntry gains payload_path: Option<String> (crates/camel-api/src/cache.rs:23). The field serializes with #[serde(default)], so JSON stored by older binaries still deserializes. Context wiring registers the decorator under the bare backend's name, so route steps select it unchanged.

Decision 2: file-first write order with unique per-attempt tmp names

set writes the blob before it stores the index entry. The write opens a unique per-attempt tmp name with create_new, calls sync_all, then renames onto the final name within the same directory. Same-directory rename is atomic on POSIX. The parent-directory fsync is best effort, warns once, and is ignored on failure. A crash before rename leaves a .tmp orphan. It never leaves an index row that points at missing bytes.

Decision 3: self-die filenames and the death epoch

Blob names are {blake3-128hex(key)}.{death_epoch}.{blake3-128hex(payload || content_type-discriminant)}.blob (blob_filename, crates/camel-core/src/cache/disk_offload.rs:479). The content fingerprint hashes the payload bytes followed by the one-byte discriminant of the ContentType enum, which separates the domains (content_fingerprint, crates/camel-core/src/cache/disk_offload.rs:471).

The death_epoch is effective_expires_at + stale_retention + payload_sweep_interval as unix seconds. The sweep-interval grace keeps each file alive at least as long as any inner sweeper or server-side deadline keeps the index row. Residual tick lag is a documented MISS+WARN degradation.

Entries written without a TTL get expires_at = now + payload_max_ttl fabricated on the stored entry, so index and file share one death timeline. The default cap is 720h (30 days).

Two writers that store identical content under the same key produce the same filename, which is coherent. Any difference in content or death time yields a distinct filename. The surviving index row references its own blob. No cross-writer pairing of one writer's bytes with another writer's metadata can occur. A 128-bit collision would yield a complete-but-stale entry or a miss, never a torn entry. Orphaned blobs reclaim themselves at their encoded epoch.

Decision 4: inline fallback on blob-write failure

If the blob write fails (ENOSPC, EIO), the decorator stores the unstripped entry inline, warns, and returns Ok(()). The cache EIP fails the pipeline on a set error, so the decorator must not add a new route-failure mode. Errors from the wrapped backend still propagate.

Decision 5: MISS+WARN for a dead file, Err for failing storage (Contract C1)

The read path holds zero expiry logic. The in-band expiry check stays in the wrapped backend. When an entry carries payload_path, the decorator sanitizes the name (sanitize_blob_name, crates/camel-core/src/cache/disk_offload.rs:499): the path must resolve to a direct child of payload_dir. Separators and .. are rejected, so a corrupt or foreign row cannot trigger an arbitrary file read.

A missing blob (sweep lag, NFS skew, crash window) returns Ok(None) with a WARN. An I/O failure on an existing blob (EIO, EACCES) returns Err, per Contract C1: a failing disk is a storage failure, not a miss.

Decision 6: standalone sweeper with ENOENT as success

A standalone tokio task sweeps the payload directory (spawn_sweeper, crates/camel-core/src/cache/disk_offload.rs:619). It unlinks blobs whose encoded death epoch has passed and reclaims stale .tmp files (sweep_payload_dir, crates/camel-core/src/cache/disk_offload.rs:560). Unlink of an absent file counts as success, so concurrent replicas sweep without coordination. The task stops on the context shutdown token, and Drop aborts it (crates/camel-core/src/cache/disk_offload.rs:432). No sweeper exists under inline mode.

Decision 7: fail-closed config matrix

Four cache_repo fields govern offload: payload ("inline" default, "disk"), payload_dir, payload_sweep_interval (default 1h), and payload_max_ttl (default 720h). Validation rejects payload = "disk" on the memory backend, payload = "disk" without a non-empty payload_dir, and any payload field set under inline mode or the memory backend (crates/camel-config/src/config.rs:1806, crates/camel-config/src/config.rs:1899). Malformed or zero intervals fail with an error that names the field. payload_dir has no default: the operator states where the blobs live. ${env:} strict interpolation applies.

Rejected alternatives

Per-backend offload options

Rejected: the same logic would be triplicated across backends that already diverge on scan and delete.

Compact binary codec (bincode, base64)

Rejected as a substitute: a codec fixes the JSON x4 bloat of a Vec<u8> but keeps the full dataset in backend RAM. It also does not unlock replicas > 1 on the redb single-writer lock. Kept as a separate follow-up.

Trait widening (keys(prefix)) and directory-per-prefix layout

Rejected: self-die filenames make eager file deletion unnecessary. Purge is index purge plus asynchronous reclaim.

payload_min_size threshold

Rejected: every payload in the target workload is about 50 KB. Additive later if a small-entry consumer appears.

Context

Problem

The cache backs the tile proxy for emergency-services WMS, WMTS, and radar delivery (7 sources, about 8k tiles per source). It is a resilience asset: when an upstream fails, the stale tile is served. The hard requirement is having the tile, not latency. No backend satisfies replicas > 1 with a full dataset and bounded RAM:

  • redb is embedded and takes a single-writer file lock. It needs a read-write-once volume, and no RWX or NFS sharing. Each replica re-warms its own copy.
  • redis is shared, but the whole dataset lives in RAM. CacheEntry serializes as JSON, so a Vec<u8> payload becomes an integer array. A 50 KB tile occupies about 200 KB of redis RAM.
  • memory is volatile.

A small, shared, durable index plus payload files on cheap storage resolves the tension.

Forces

  • Operator-owned placement. Routes, EIPs, and backend choice stay untouched. The operator only decides where the blob lives.
  • Fail-closed culture. The config matrix validates at startup (ADR-0033).
  • Unknown-outcome honesty. Contract C1 (ADR-0023) governs the read paths: a dead file is a miss, a failing disk is an error.
  • No new failure mode. A full disk must degrade the cache, not break the route.

Consequences

Rollback requires a cache clear

A rollback to a binary without offload reads an offloaded index row and serves empty bytes. Clear or re-seed the cache across a rollback.

NFS caveats

A local volume is preferred. On NFS, fsync durability is mount-dependent, and rename plus create_new semantics can leave short-lived .tmp files under load. The sweeper reclaims them.

Stats report index-side accounting only

stats delegates to the wrapped backend. Offloaded entries contribute an emptied bytes field: redb sums entry bytes, so each offloaded entry contributes 0, and redis reports None for the sum. Blob bytes never appear.

Portability

Offloaded entries are unreadable by consumers that do not share payload_dir. Context build emits one startup WARN that names the resolved directory (crates/camel-config/src/context_ext.rs:296).

Multi-replica on RWX volumes

With the redis backend, the index is shared and the blobs live on one RWX volume. Concurrent writers to the same key are last-index-wins. The surviving row references its own blob, and the loser reclaims at its death epoch.

Retention and TTL changes apply to future writes only

Every blob's death epoch is computed at set() time with the retention values in force at that moment, and the sweeper deletes by the filename epoch alone — runtime config never takes part in deletion. Changing stale_retention, payload_max_ttl, or payload_sweep_interval therefore never applies retroactively:

  • Lowering stale_retention gives no immediate disk relief. Existing blobs die at their baked epoch, so the orphan window after a purge grows temporarily. Relief comes only from clear() or from waiting out the old cycle.
  • Raising stale_retention opens a redb-only transient window where a row outlives its blob: the row is reclaimed at expires_at + new_retention (redb recomputes with the runtime value), while the blob dies at its baked epoch. Reads in between are a MISS with WARN. The window is nominally bounded by new_retention − old_retention − payload_sweep_interval (the grace baked into the blob), plus up to one redb sweep tick before the row goes. It closes by itself when the redb sweep removes the row — cold keys heal without churn. Pair a large raise with clear() when stale-serve continuity matters during the transition. Redis has no such window: the key expires at the immutable EXAT set at write time, which is the same death line the blob already encodes.
  • payload_max_ttl changes affect only the expiry fabricated for future ttl = None writes; existing blobs keep their baked epochs.
  • payload_sweep_interval changes affect only the grace of future writes plus the runtime sweep cadence and .tmp GC age.
  • Mixed-config replicas sharing one payload_dir are safe: the sweeper is filename-driven, so different retention settings only shift windows, never correctness.

The sweeper logs one INFO line per pass with live and reclaimed blob counts and bytes, so operators can watch the volume during any of these transitions.

Load-bearing citations

File:lineElement
crates/camel-api/src/cache.rs:18CacheEntry
crates/camel-api/src/cache.rs:23payload_path: Option<String>, #[serde(default)]
crates/camel-core/src/cache/disk_offload.rs:62DiskOffloadRepository decorator
crates/camel-core/src/cache/disk_offload.rs:479fn blob_filename self-die name format
crates/camel-core/src/cache/disk_offload.rs:471fn content_fingerprint domain-separated blake3-128
crates/camel-core/src/cache/disk_offload.rs:490fn parse_death_epoch
crates/camel-core/src/cache/disk_offload.rs:499fn sanitize_blob_name direct-child guard
crates/camel-core/src/cache/disk_offload.rs:560sweep_payload_dir: death-epoch unlink, tmp GC
crates/camel-core/src/cache/disk_offload.rs:619spawn_sweeper standalone task
crates/camel-core/src/cache/disk_offload.rs:432impl Drop aborts the sweeper
crates/camel-config/src/config.rs:709-730the four payload config fields
crates/camel-config/src/config.rs:1806memory-backend payload rejection
crates/camel-config/src/config.rs:1899disk-mode fail-closed matrix
crates/camel-config/src/context_ext.rs:296-330wrap-on-disk wiring and portability WARN
crates/camel-test/tests/cache_payload_offload.rslive redis offload integration suite

ADR-0066: Metrics Collector Binding and Lifetime Contract

Date: 2026-08-28 Status: Accepted Origin: bd rc-hrm1.8 (epic rc-hrm1), expert ruling N7 Amends: ADR-0012 Cross-refs: ADR-0012, ADR-0013, ADR-0041, ADR-0044, ADR-0045, ADR-0052

Context

The 2026-08-26 metrics audit (epic rc-hrm1) found two structural defects in collector wiring. First, with_tracer_config snapshotted a collector before backend lifecycle registration. A prometheus-only context captured NoOpMetrics and exported nothing (rc-cizb). Second, the first-registered OTel collector won the route path. A later Prometheus registration overwrote a CamelContext.metrics slot that no route or component read.

The fix landed as metrics-handle-late-binding (8c876d54): one MetricsHandle per context, backed by ArcSwap, resolved after backend registration. This ADR pins that contract and the lifetime rules that follow from it. It also records the error-accounting amendments to ADR-0012 that the dashboard-observability change introduces.

Decision

Decision 1: one late-bound collector slot per context

Each CamelContext owns exactly one MetricsHandle (crates/camel-api/src/metrics.rs:110). The handle is an ArcSwap<CollectorSlot> that seeds itself with NoOpMetrics. Consumers may hold the handle before any real collector exists. Calls before registration are safe no-ops. The handle is resolved after backend registration. This supersedes the collector snapshot inside set_tracer_config that produced rc-cizb.

Decision 2: registration composes; order is irrelevant

MetricsHandle::register composes the new collector over the stored one via CompositeMetricsCollector (crates/camel-api/src/metrics.rs:132, :240). Registration never replaces. The same Arc registered twice is a no-op (Arc::ptr_eq dedupe), so a call site that wires one collector through two builder paths does not double-count. Composition is order-independent, so registration order is irrelevant.

Decision 3: multi-backend fan-out composes

OTel and Prometheus registered simultaneously both receive every emission. No collector silently wins the route path. The composite delegates every trait method to each member, including the error-semantics methods added by dashboard-observability (increment_retry_attempt, increment_circuit_breaker_rejection).

Decision 4: tracer.enabled gates spans ONLY

[observability.tracer] enabled controls span creation only. It does not gate metric emission. A prometheus-only context (tracer off) still collects route and component metrics. Prometheus enablement implies the tracer pipeline, so the non-disableable error family is always exported (effective_tracer_config, crates/camel-config/src/context_ext.rs:962).

Decision 5: the error family is the only non-disableable family

The ADR-0012 error family (MetricsCollector::increment_errors, crates/camel-api/src/metrics.rs:10) is always emitted. No [observability.metrics] lever can disable it. The exchange counter, duration histogram, and component-operations families are gateable independently. The pipeline tracer never gates increment_errors (crates/camel-core/src/shared/observability/adapters/tracer.rs:159).

Decision 6: retry accounting (amends ADR-0012)

One increment_errors per exhausted NetworkRetryPolicy sequence, executed by the policy helpers themselves (retry_async, crates/components/camel-component-api/src/network_retry.rs:245; retry_async_cancelable, :349). Call sites do not increment on their own Err arm for attempts the helper retries. Cancellation is not failure: a cancelled sequence emits no error. Per-attempt telemetry lives on camel_retry_attempts_total{scheme,operation}.

Decision 7: breaker rejections are not errors (amends ADR-0012)

Open-breaker fast-fails (CamelError::CircuitOpen, classified "circuit_open", crates/camel-api/src/error.rs:174) count on camel_circuit_breaker_rejections_total{route}. The pipeline tracer skips increment_errors for error_class == CIRCUIT_OPEN (crates/camel-core/src/shared/observability/adapters/tracer.rs:178-179). Callers still receive CamelError::CircuitOpen unchanged. Only metric routing changes.

Decision 8: rejection-counter unit is readiness probes

The rejection counter counts readiness probes, not logical sends. A parked caller retries poll_ready on backoff, so one open breaker produces about one rejection per backoff interval per parked send. The unit is pinned: camel_circuit_breaker_rejections_total measures probe pressure, not dropped work. Alerting must scale thresholds by the probe rate, not by the send rate.

Decision 9: helper-owned exhaustion errors use the operation label

Helper-owned exhaustion errors place the OPERATION in the first-arg (route) label position of camel_errors_total. The helper has no route scope, so the shape is increment_errors(operation, "e:{scheme}:{operation}"). Dashboards keying on route must expect component-operation pseudo-routes there, for example e:container:events-connect.

Decision 10: registration-order contract for gauges

Identity gauges (build info, uptime) fire pre-registration at build time. They are re-published on registration, so a late-registered collector still sees them. Route-state is transition-driven and does NOT replay to collectors registered after transitions fired. The canonical path registers pre-start (configure_context) and is e2e-proven. Post-start registration shows uptime without route inventory until the next transition. Queue-depth self-heals via 250ms samplers.

Decision 11: sampling cadence

SeriesCadence
camel_uptime_seconds60s refresh task
SEDA queue depth250ms sampler
Aggregator queue depth250ms sampler
Aggregator TTL sweepttl / 2, minimum 50ms
Resequencer queue depthpost-accept only

The resequencer publishes after each accept. Timeout-release staleness is a known ceiling: a batch released by gap timeout is not re-sampled until the next accept.

Decision 12: double-count contract

Facade failures land on e:{component}:{operation}. Retained component-specific error labels MUST never equal that string. A true double-count is one series incremented twice for one failure. Dashboards summing the whole camel_errors_total family count each component failure twice (uniform series plus specific series). This is intended per D5 and stated here so the 4.2 audit table records it.

Decision 13: helper and facade label collision

retry_async with metrics = Some emits e:{scheme}:{operation}, byte-identical to the facade label when scheme == component (the opensearch shape). Today no boundary wires BOTH the helper (metrics = Some) AND the facade: the Some helper sites (container events-connect/logs-connect, sql pool-init) do not use the facade, and the facade sites (opensearch, and the rest of the Phase-4 sweep) pass the helper None. A component that adopts the facade at an operation boundary where retry_async runs with metrics = Some would same-series double-count. Wiring BOTH at one boundary is FORBIDDEN. Choose one error owner per boundary. A component that wants per-attempt telemetry AND facade outcome semantics must use distinct operation labels — the shared series cannot carry both.

Decision 13a: third-backend registration

Registration composes (D2) and fan-out is order-independent, so a third backend needs no code change: register it and every emission site fans out to it. Delegation cost grows linearly with member count — one dyn dispatch per member per call — which is the accepted price of the multi-backend contract.

Decision 14: vocabulary asymmetry

ComponentOperations
kafkaconsume, produce
rediscommand (producer only)

Kafka emits both consume and produce. Redis emits command (producer) only. The redis consumer stays outside the family vocabulary. D5 names the producer operation only.

Decision 15: recording-double debt

About ten recording doubles exist across crates, growing per trait method. Consolidation is bd-tracked (rc-4dvi). This ADR records the intent: the doubles are known, counted, and scheduled for consolidation, not silent drift.

Rejected alternatives

Per-backend collector slots

Rejected: a slot per backend re-introduces the winner-takes-all bug. One composed slot is the contract.

Snapshot at config time

Rejected: this is the rc-cizb failure mode. Late binding is the fix.

Consequences

Alert thresholds recalibrate

camel_errors_total counts drop by the retry factor and by breaker rejection rate. Alert thresholds calibrated to inflated counts fire less. The merge commit states this.

Trait growth is additive

Five new default methods on MetricsCollector keep out-of-tree implementors compiling. The composite delegates all of them.

Cardinality is bounded

camel_component_operations_total is bounded by closed label sets. Components opt in with the components lever, default off.

Load-bearing citations

File:lineElement
crates/camel-api/src/metrics.rs:110MetricsHandle (ArcSwap slot)
crates/camel-api/src/metrics.rs:132register composes
crates/camel-api/src/metrics.rs:240CompositeMetricsCollector
crates/camel-core/src/context.rs:53metrics: Arc<MetricsHandle>
crates/camel-config/src/context_ext.rs:962effective_tracer_config
crates/camel-core/src/shared/observability/adapters/tracer.rs:178-179circuit_open exclusion
crates/camel-api/src/error.rs:174CIRCUIT_OPEN
crates/components/camel-component-api/src/network_retry.rs:245retry_async
crates/components/camel-component-api/src/network_retry.rs:349retry_async_cancelable
crates/components/camel-container/src/lib.rs:1545-1562helper-owned exhaustion
crates/components/camel-kafka/src/consumer.rs:591e:kafka:recv-exhaustion
crates/components/camel-kafka/src/consumer.rs:386,410("kafka","consume")
crates/components/camel-kafka/src/producer.rs:282("kafka","produce")
crates/components/camel-redis/src/producer.rs:21-23("redis","command")
crates/camel-processor/src/aggregator.rs:371TTL sweep ttl / 2, min 50ms
crates/camel-processor/src/aggregator.rs:31250ms queue-depth sampler
crates/components/camel-component-seda/src/lib.rs:43250ms queue-depth sampler
crates/camel-processor/src/resequencer/mod.rs:202-211post-accept publish

Gauge A/B lever cost (bench-era-2)

The request-path cost of keeping the metric families (memory gauges) ON was measured by an A/B lever study on the http-server cell: ratio 0.9890, 95% CI [0.9785, 1.0126], UNPAIRED cross-run arms — cost unresolved from zero, at most about 1% at this resolution. Canonical benchmark runs therefore keep gauges ON. Full protocol, quiet-host gates, and interpretation: docs/benchmarks/history/2026-08-29-benchmark-v4-addendum.md.

ADR-0067: JMS Message-Type Forwarding Policy

Date: 2026-08-29 Status: Accepted Origin: bd rc-41h3 (epic rc-41h3), Phase 3 Cross-refs: ADR-0032

Context

The JMS bridge forwards broker messages to a bytes-only proto. The proto carries a body and headers. It has no branch for the JMS message types ObjectMessage, MapMessage, or StreamMessage. Today JmsConsumer.convertMessage falls through for these types and produces an empty body. This ADR pins that behavior as policy.

Decision inputs

Input 1: current empty-body behavior

The fall-through path produces an empty body with headers preserved. Under AUTO_ACKNOWLEDGE, receipt acknowledges the message (JmsConsumer.java:132). No body accessor is invoked (property accessors still run for headers and content-type).

Input 2: Java-serialization gadget risk

ObjectMessage.getObject() executes attacker-controlled readObject. Broker messages are exchange data under ADR-0032. They are untrusted and adversary-controlled. A policy of never deserializing keeps the bridge out of the deserialization attack surface.

Input 3: MapMessage flattening rejected

Flattening a MapMessage to a JSON body is rejected for now. JMS map values are constrained to primitives, String, and byte[]. Faithful flattening still forces decisions the bytes-only proto cannot express today:

  • numeric type preservation: the int vs long vs double distinction is lost in JSON numbers;
  • byte[] representation: base64 vs hex, and how it is versioned;
  • null-value semantics: JSON null vs an absent key;
  • a canonical media type and versioning for the synthesized body.

With no consumer demand, each decision is an unforced compatibility commitment. Flattening stays revisitable.

Decision

Forward ObjectMessage, MapMessage, and StreamMessage with an empty body. Preserve headers. Receipt acknowledges under AUTO_ACKNOWLEDGE. Never invoke the body accessors on these types.

Content producers must use BytesMessage or TextMessage to carry a body.

Consequences

No deserialization attack surface

The bridge never calls getObject() or any other body accessor. The Java-serialization gadget risk from Input 2 does not reach the bridge.

Content producers use Bytes or Text

Producers that need a body must use BytesMessage or TextMessage. Object, Map, and Stream messages arrive empty-bodied.

Flattening is revisitable

MapMessage flattening stays open. A future ADR can revisit it when a consumer demands it and the proto can express the constraints from Input 3.

ADR-0068: MCP Registry Owner-Liveness Entries

Date: 2026-08-29 Status: Accepted Origin: bd rc-apvm Cross-refs: ADR-0007, ADR-0060

Context

The MCP server role registers one tool or resource route per consumer start. Each entry maps a name or URI to a route. The registries are process-global singletons (ADR-0060). A consumer that dies without a stop leaves its entries behind. The entries are dead: their channels never deliver again.

A dead entry blocks a legal takeover. A restart of the same route hits the duplicate guard and fails. The dead entry also holds a catalog cap slot. Listings advertise a tool that can never answer.

Decision inputs

Input 1: lease or heartbeat rejected

A lease or heartbeat renews liveness on a timer. It fixes every crash shape, but needs a renewing task and TTL tuning. A restart faster than the TTL still hits the duplicate guard. Heartbeats are self-liveness machinery. ADR-0007 forbids consumers from self-supervising. Rejected.

Input 2: pid-scoped keys rejected

Pid-scoped keys would key entries by process id. The registry is an in-memory process-global singleton. There is no cross-process path. The reported case is same-process. Rejected as moot.

Input 3: sender.is_closed() rejected

sender.is_closed() is true only when the bridge died. On drop-without-stop the bridge detaches and parks on rx.recv() forever. The channel stays open. Rejected as insufficient.

Input 4: Drop-guard rejected

A Drop-guard on the consumer covers drop and abort. The runtime takes the bridge handle, so Drop cannot abort the detached bridge. It still needs owner-conditional unregister to avoid deleting a successor's entry. Redundant once replace-on-conflict exists. Rejected.

Decision

McpConsumer::start() mints an Arc<()> token and stores it in Running. Each registry entry and route security plan carries a Weak<()> view of that token. The token dies when the consumer dies: task abort, unwind, or plain drop.

register() replaces a duplicate entry whose owner token is dead. A live owner keeps today's rejection. Before cap enforcement, register() prunes every dead-owner entry, so a dead entry under any name releases its slot. resolve() and list_ready() skip and remove dead-owner entries. The unregister used by stop() and failure cleanup is owner-conditional. unregister_owned removes an entry only when the caller's token matches the entry's token (Weak::ptr_eq). A late stop of a dead owner cannot delete a replacement's entry.

McpBindSecurity plans take the same discipline. register_plan is owner-scoped: a plan held by a live owner is kept, a dead owner's plan is replaced. The unregister used by stop() and failure cleanup is owner-conditional. unregister_plan_owned removes a plan only when the caller's token matches the entry's token (Weak::ptr_eq). plan_for ignores plans whose owner is dead.

Consequences

Dead entries stop blocking takeover

A restart of a dead route replaces the stale entry. The duplicate guard rejects only live-owner duplicates. A failed duplicate start cannot remove or overwrite the incumbent consumer's plan.

Dead entries release cap slots

The prune-before-cap sweep reclaims slots without waiting for an unrelated list or resolve operation. The sweep is bounded by the cap itself.

Listings stop advertising dead tools

list_ready prunes dead-owner entries before listing. Dead tools and resources disappear from tools/list and resources/list.

In-flight requests fail cleanly

A request that resolved an entry before its owner died sends into a dead channel. It returns a clean MCP error. The route body never runs.

Plan removal cannot open an auth hole

A missing plan means unauthenticated pass-through. A late stop of a dead owner cannot remove a live replacement's plan. Dispatch stays authenticated.

No new supervision machinery

There are no timers, heartbeats, or new config knobs. ADR-0007 keeps its posture: consumers do not self-supervise.

ADR-0069: Integration-Tier Testing Contract

  • Status: Accepted (human-ratified 2026-09-03; e_opus + e_gpt BLESS-WITH-FIXES, fixes applied). Amended 2026-09-06: section 13 added (flake taxonomy and test-design rules R1-R7, bd rc-jwp3; ADR-0070 carries the staged-listener application of R2).
  • Date: 2026-09-03
  • Supersedes: none. Binds the sketch in ADR-0064 section 4.
  • Epic: rc-kk69. Authoring path: human grill + ste-writing (same path as ADR-0064, per rc-379d precedent). Not a conductor-light change.

Context

ADR-0064 fixed the two-tier testing boundary. It named the integration tier as a non-binding sketch: full runtime boot, real adapters, receive-with-timeout assertions at transport boundaries. This ADR converts that sketch into binding contract.

Three inputs shaped the decisions below:

  1. Two expert consultations (e_opus, e_gpt) over the code, the ADRs, and the bd history. Both returned GO-WITH-CONDITIONS. All rulings were verified against source files.
  2. A grill session with the human on 2026-09-03. Seven questions, all sealed.
  3. Verified demand. The HTTP bridge header corruption (rc-eoft, rc-f0cn), the consumer readiness defect (rc-w1u9, since closed through Explicit startup and mark_ready), and the WS reconnect work (rc-cl7, rc-39d6) are real regressions that hand-written tests did not catch.

The pain is concrete. crates/camel-test/tests/ holds roughly 30 hand-written integration tests. They probe for free ports, sleep fixed intervals, and assert by hand. The scenario runner replaces that pattern with declarative documents.

Decision

1. One format, derived tier

All test documents are .test.yaml. There is no second schema and no integration-specific suffix.

A document's tier is a pure function of its content. No field declares the tier. A declaration field can only repeat what the machine computes, or contradict it. The contradiction class is removed by not having the field.

The function is total through a conservative default:

tier(document):
  1. The document has a `scenario:` section          -> FULL
  2. Else, compute the component closure over:
       the parsed RouteDefinitions from any route source
       (routeFiles, routeFilesFromRoot, or inline routes),
       with nested steps traversed recursively,
       MINUS endpoints replaced by `intercepts`,
       PLUS the schemes named in `inputs` and `expects`:
       closure within {direct, log, mock, seda, timer} -> LEAN
       any non-lean literal, placeholder-in-scheme,
       or dynamic dispatch step                        -> FULL

Dynamic dispatch steps are recipient_list, routingSlip, dynamic_router, and toD-style targets computed from the exchange at run time. Their target scheme is not knowable before run time, so they force FULL.

scenario: forces FULL without condition. A "lean scenario" would need an action interpreter inside the lean boot. That is runtime-profile creep, which ADR-0064 fences. Unit documents already have an action vocabulary: inputs, expects, intercepts.

This conforms to ADR-0064 in spirit. That ADR fixes the boundary by inbound stimulus plus runtime profile. It does not fix the boundary by file name. Content-derived tiering measures the real stimulus. A label only claims it.

The lean registry stays byte-identical to today. Tiering routes documents to a boot. It never grows the lean set. The creep rule and its amendment gate stay in force without change.

2. Mixed vocabulary is forbidden in v1

A document with scenario: must not declare inputs, expects, or intercepts. The runner rejects such a document at load time.

Reasons: one vocabulary per tier, and per document. intercepts exists to fake transports in the lean boot. The full boot has the real transport. If a demand for mixing emerges, reopen this rule with evidence.

The full boot also registers mock, direct, and log. The ban is for clarity, not for lack of capacity.

camel-lint does not parse test documents today. When it does, it becomes the static enforcement surface for this rule and for tier derivation. Until then, the runner enforces at load time.

3. Filters, not modes

camel test --unit runs only lean documents. camel test --integration runs only full-tier documents. Default camel test runs everything, each document at its derived tier.

Tier filters are symmetric and exclude by scope:

  • A nonmatching document found through directory expansion is excluded from the run.
  • A nonmatching document named explicitly on the command line fails with tier-filter-collision. The explicit name is the assertion.
  • Supplying both --unit and --integration is misuse, exit 2.

A CI job for the fast suite pins the fleet to lean through the filter, without any per-document field.

A name or pattern filter may come later. It will compose with the tier filters as one filter surface. A --watch mode is filed separately (rc-hi9y) and is out of scope here.

4. Environment parity and hermeticity

Today camel run resolves ${env:NAME} and ${env:NAME:-default} in Camel.toml and in route URIs. camel test resolves nothing. The full-boot test path closes this gap by construction: both commands boot through the same composition root, so the resolver behaves the same.

Hermeticity is primary. The test document is the source of truth, not the CI machine.

  • An optional env: section in the document fixes fixture values for the scenario. The resolver reads these first.
  • Ambient environment inheritance is off by default.
  • A document may list specific variables that pass through from the ambient environment.
  • CAMEL_PROFILE is pinned per document. An ambient profile would break hermeticity.
  • The document env: section does not couple to the CAMEL_* config-override allowlist. That allowlist governs TOML overrides. It is a different resolver from the ${env:} route placeholder path.

The current resolvers read process-global state directly. The harness must not mutate the process environment: concurrent documents, and a future parallel, would race on it. Instead, the boot path receives an explicit layered environment source: document env first, allowlisted ambient second, defaults third, otherwise unresolved. The pinned profile is passed the same way. This layered source is an input to the DSL and config loaders, not a process-global rewrite.

Known limitation, recorded when this ADR was written: resolve_tree_walk visited only string leaves, so placeholders in int-typed TOML fields did not resolve. Solved by the typed env probe (rc-45xig, rc-v1sw — landed 2026-09-10): the provenance-tracking interpolation and smallest-first typed probe coerce clean-integer defaults at integer-typed positions in both the DSL YAML and camel-config TOML arms. Ports in URIs resolve today.

5. Partner-side assertions are the only normative proof

The harness owns a listener on the other side of the wire. For an outbound route, the harness binds an HTTP server on 127.0.0.1:0 and validates what the route producer sends. For an inbound route, a harness client drives the real consumer and validates the wire response. What arrives there is the proof: bytes, headers, status, timing.

A proxy route whose upstream query varies per request has no literal arrival lane a receive can name, so its scenario asserts the harness record through a validate whose partner URI self-declares the harness reference in object form (provisioning: harness on an http endpoint plus a partners: entry naming the URI) rather than matching a send/receive reference; the sacrificial-receive workaround is forbidden by design.

Transport interception and mock: expectations are secondary diagnostics. They never produce a green integration result. Mocks and interception are unit tier tools by ADR-0064 design.

Loopback on 127.0.0.1:0 inside one process does not violate the no-IPC invariant. The invariant forbids a test-control channel to a deployed camel run process. Loopback traffic is the subject under test.

The readiness prerequisite is satisfied by rc-w1u9. CamelContext::start() waits until the HTTP consumer binds or fails: the consumer opts into ConsumerStartupMode::Explicit and calls mark_ready() after bind. The current signal does not expose an address selected from port 0. Inbound scenarios therefore use an explicitly configured loopback port in v1. OS-selected consumer ports require a separate operator-facing bound-address API, filed on its own merits. The integration tier consumes only operator-facing signals.

6. Core purity fences

camel-core is the engine. The testing program does not touch it.

  1. No crate the testing program introduces may appear in camel-core [dependencies] or [dev-dependencies]. The ADR-0055 lint machinery enforces this.
  2. No virtual clock in core. Integration deadlines use real monotonic time. Paused Tokio time stays a unit-harness concern.
  3. No observability tap or test-only event surface in core. Readiness (rc-w1u9) is an operator signal designed on its own merits.
  4. Tier derivation lives outside core. The function reads DSL and lint surfaces. Core has no concept of tiers.
  5. Partner readiness polling wants a "consumer bound" event. That want is the rc-w1u9 temptation. The fix stays an operator signal, not a test callback. The harness consumes the readiness signal only through the same public surface an operator uses: health or readiness state, or the bound address a future boot handle reports (the WebSocket side of that address surface has since landed: ServerRegistry::get_or_spawn_with_listener. ADR-0070 generalizes the full staged-listener surface and its rulings). It never subscribes to a core-internal event or a callback added for the test. If partner readiness needs more than the operator surface exposes, the gap is an operator-facing engine feature, filed on its own merits. It is not a harness hook into core.
  6. Bugs the tier exposes are engine bugs. They are filed and fixed in the domain on their own merits.

Any new core API proposed for testability must answer one question: would this API exist without tests? If not, it does not land.

7. Failure taxonomy

Exit codes are inherited from camel test today: 0 all pass, 1 any failure, 2 any parse or misuse error. The split is epistemic:

Exit 1, the scenario ran and the system under test failed it:

  • receive-timeout: nothing reached the partner before the deadline.
  • validation-mismatch: the message arrived and failed the validator.
  • (moved to exit 2, see below)

Exit 2, the scenario never got a meaningful answer:

  • scenario-var-unresolved (moved from exit 1, rc-whof): a referenced variable was never set. An unset variable is an authoring bug — the scenario never got a meaningful answer, whether the defect is caught statically at load or at run time.
  • doc-validation: mixed vocabulary, broken grammar.
  • tier-filter-collision: an explicitly named document did not match the tier filter.
  • partner-bind-failure: the harness could not bind its listener.
  • partner-startup-failure: the partner listener bound but its handler failed to start.
  • action-transport-failure: a send or receive action failed at the transport before any assertion ran.
  • infra-unavailable: an adapter needs a broker or Docker that is absent. The error names the requirement. It never hangs.
  • full-boot-failure: the embedded boot failed.
  • shutdown-failure: teardown of the boot or a partner timed out or erred after the verdict was recorded.

Every adapter operation carries a deadline, not only infra-unavailable. A cancelled parallel sibling reports as cancelled, not as a verdict failure.

infra-unavailable fails loud by default. With demand-gated adoption, a silent skip would hide the demand signal itself. A skip mechanism, if ever needed, is a separate decision.

8. Demand gate and activation order

The tier activates adapters only when a concrete regression justifies them.

Update 2026-09-05: the demand signal arrived, so the camel-cli binary now enables integration-http by default. The gate itself is unchanged: the adapter still compiles only behind the Cargo feature, featureless builds keep the infra-unavailable path, and the e2e suites that spawn the real binary stay behind the non-default itest-e2e test feature so the default suite keeps its runtime and composition.

  1. HTTP, both directions. Outbound bridge and proxy regressions justify it (rc-eoft, rc-f0cn). The rc-w1u9 readiness work is already satisfied.
  2. SQL, as the sql: scenario action against named datasources (rc-25lup.1, rc-25lup.2). It landed 2026-09-08.
  3. WS, after the consumer-client role lands (rc-39d6).
  4. gRPC is a loopback candidate. It needs no Docker. It activates on demand.
  5. Kafka, JMS, and other broker adapters wait for an adapter-specific regression.

Each adapter is a Cargo feature. There is no all-components feature. CI runs a dedicated integration-http job with path filters. A dedicated integration-sql job proves that the sql feature stands alone: it builds camel-cli with --no-default-features --features integration-sql,itest-e2e and runs the scenario e2e suite, so the full-boot path runs without integration-http. Broker scenarios run in an isolated or scheduled job. Loopback tests carry no #[ignore] marker. That is the ADR-0054 rule, not a new one. The loopback budget is seconds.

9. Partner provisioning sources

The grammar names three sources for a partner endpoint address. The axis is who owns the lifecycle.

  1. harness: the harness binds an in-process listener on 127.0.0.1:0. This is the only source implemented in v1.
  2. testcontainer: the harness manages an ephemeral container and destroys it with the scenario. Reserved grammar value. The v1 runner rejects it as unsupported.
  3. user-provided: the document receives an address through a variable or passthrough environment value. How that infrastructure exists is not the harness's concern. Docker Compose, a CI service container, or a staging broker are all the same to the grammar. Reserved grammar value in v1. The runner rejects it as unsupported until an adapter activation needs it.

The harness source also backs a validate self-declaration: a partner validate URI may name no send/receive reference when the object target form carries provisioning: harness on an http endpoint and a partners: entry names the URI. Proxy routes whose query varies per request use this channel, since those routes have no literal arrival lane (section 5) and a sacrificial receive is forbidden by design.

The system under test is always the embedded boot. The harness never drives a deployed camel run. A live camel run --watch next to a test run is two independent processes. They share nothing. Port conflicts are the only interaction, and section 4 covers them: URI ports resolve through ${env:}.

10. Crates

Two new crates, both depending on core, never the reverse.

camel-bundles owns the component-bundle registration cascade extracted from camel run, plus the lifecycle handle. The name is the project's own noun: the crate's sole responsibility is running ComponentBundle::register_all. camel-config keeps context composition. The extraction does not re-home it.

The shared boot boundary is enumerated, not blanket. camel-bundles owns the bundle registration and the lifecycle handle with explicit shutdown(). The handle owns the bridge cleanup and the JMS and CXF pool teardown. The CLI keeps the watcher, signal handling, the second-Ctrl+C path, the conditional exec guard, and operator logging. Security setup, bind acknowledgements, datasources, and startup checks move to the shared boot when, and only when, both consumers need the same semantics. Until then each path states what it owns. The handle type is BootHandle, following the ...Handle suffix precedent.

camel run and the harness both register bundles through this one cascade. Feature flags for bundles forward from the CLI and from the harness into camel-bundles.

camel-integration-test owns the scenario model, the parser, the action executor, validators, partner adapters, and the Rust API. camel-cli depends on it and provides a thin command adapter. camel-test stays unchanged: it is the publish-order leaf sink, and ADR-0055 forbids publishable dependencies on it.

11. Citrus divergences

Citrus is inspiration, not authority. This is the ADR-0046 rule applied to Citrus.

Adopt: ordered action lists. Logical endpoints bound to typed transport drivers. send, receive with a mandatory deadline, sleep, validate. Scenario variables with extraction. Exact body, header, and status validators.

Defer: iterate. Validator registries. A negative expect-timeout action. Protocol-specific action families. Structured parallel with sibling cancellation on failure. The cancellation semantics are sealed. The timing is deferred until a scenario demands it.

Reject, binding:

  • Citrus conformance in files, API, or literal semantics. Assertion translation is re-derivation, not porting. This is the ADR-0046 anti-pattern.
  • XML, Spring Bean, JUnit, and TestNG formats and runner coupling. This runtime is embedded Rust.
  • Standalone mode against a deployed camel run. The frozen no-IPC invariant stands.
  • Universal symmetric client-server endpoints. Each adapter declares the roles it supports.
  • repeat-on-error. It masks non-determinism and subverts the conditional determinism of ADR-0054.

12. bd hygiene

rc-i2qf closes with a reason, not a supersede. Its acceptance criterion is already satisfied by the recorded rejection: a producer is a write/send sink. Partner reply behavior belongs to the typed partner adapters in camel-integration-test. camel-component-mock does not change.

13. Flake taxonomy and test-design rules

Added 2026-09-06 (bd rc-jwp3, epic rc-99d5). Two escalation reviews (e_opus advisory, e_gpt adversarial counter-review) produced the adjudicated text below. The counter-review's corrected rules are the normative wording. The full reviews are local-only working documents. The durable adjudication record is bd rc-99d5. The wording below is self-contained.

13.1 Taxonomy

Seven flake classes. Cite these tags in bd issues:

  • unbounded-wait: a test awaits externally driven progress without a deadline (loop, retry, receive, lock, JoinHandle).
  • port-toctou: a bind-inspect-close-rebind race around ephemeral ports. Governed by ADR-0070.
  • pooled-race: a raw test server closes a connection while a pooled client reuses it.
  • global-state: statics or environment mutation contaminates a later test in the same process.
  • platform-timing: an OS-dependent race window. A detector class, not a cause.
  • sleep-as-sync: sleep stands in for synchronization. The test assumes state after N milliseconds. A wait_until barrier on an observable state replaces it. Post-start sleeps are vestigial since the rc-w1u9 explicit handshake.
  • runner-pollution: orphan processes or firewall residue from earlier CI steps.

Honest expectations, from the counter-review's audit. The rules below prohibit named anti-patterns, bound hangs in gating jobs, and removed the two known races (rc-y24l, rc-u3aw). No rule makes any class structurally impossible. A renamed helper or an assembled raw response can still evade a scanner. Retries that fail on flaky, weekly macOS coverage, and job-level ceilings remain necessary layers.

13.2 Rules

R1 no-unbounded-wait. A test operation that waits for externally driven progress MUST have a deadline at its call site, or use an audited bounded helper. This covers network I/O, channel receive, lock acquisition, process exit, readiness polling, and JoinHandle waits. A long-lived service loop MAY run without an internal deadline only when the test spawns it, owns its handle, bounds every readiness assertion, and bounds teardown. A source exception MUST carry // allow-test-wait: <reason>. Enforcement is a narrow AST lint over known wait calls (syn, already an xtask dependency). It does not claim complete proof. R6 remains the mandatory backstop.

R2 no-free-port. Test infrastructure MUST NOT select an address through bind, inspect, close, and rebind. The component that owns the bind MUST accept port zero, retain the live listener, and report its bound address through a production or operator-facing API. A child process MAY bind port zero and report the address to its parent. A reservation socket is not an ownership handoff. The counter-review held this rule BLOCKED until a bound-address API existed. ADR-0070 is that API. Four applications landed. Every test binary and itest suite now stages listeners. The named exceptions carry their own bd issues: rc-1dgvg (in-lib residue), rc-s7dyw (external-process handoff), plus ADR-0070's reserved-address and oneshot-placeholder exceptions.

R3 no-raw-http-test-server. Tests of outbound HTTP client behavior MUST use a real loopback server that implements the connection semantics the scenario requires. Prefer axum, Hyper, or wiremock. Use oneshot only for server-handler behavior below the network boundary. Raw TCP is allowed for malformed-message, framing, disconnect, and other protocol-fault tests. Such a server MUST implement either one-response-and-close semantics or a complete request loop for persistent connections. A literal scanner flags suspicious fixtures. It cannot enforce protocol correctness.

R4 no-env-mutation-unguarded. Tests MUST inject configuration directly when the API permits it. A test of process-environment behavior MUST run in a dedicated child process with an explicit environment. In-process mutation is a documented legacy exception. It MUST use one crate-wide RAII guard, one lock, and MUST restore the prior value. Async or multi-threaded mutation is forbidden unless all readers and writers are proven to use that same lock. The lint recognizes the canonical guard type, not variable names.

R5 no-unignored-loopback. No new rule. ADR-0054 and cargo xtask lint-ignore already enforce the stricter closed vocabulary with the bidirectional allowlist. That contract stands unchanged.

R6 per-test-timeout. Per-test ceilings are required. Nextest provides them and isolates each test in its own process. Adopt nextest first for --workspace --lib. Container and bridge suites stay on their current cargo invocations until migration measurements pass. Job-level timeout-minutes remain mandatory for every job. Note the semantics: slow-timeout { period = "30s", terminate-after = 3 } terminates after about 90 seconds.

R7 quarantine. Normal CI MAY retry for diagnosis only when flaky-result = "fail" keeps a pass-on-retry red. A confirmed flaky test may enter a checked-in quarantine registry that names its exact test ID, bd issue, owner, and ISO expiry date. A gating xtask lint MUST reject missing, malformed, or expired entries. A separate non-gating job MAY run quarantined tests with retries. Maximum lifetime is 14 days. #[ignore] and name suffixes are not quarantine mechanisms.

13.3 Enforcement status and pilot evidence

The rules are normative from this amendment. Their scanners land incrementally under tracked bd issues (structural lint work: rc-3lx2). Partial enforcement is the honest state, per 13.1.

The scenario-tier pilot of 2026-09-05 (bd rc-enbw, timeout.sh migration eval on 0.40.0) supplies the first empirical support: both central timeout asserts of timeout.sh replicate cleanly through client-side send and receive with a deadline. Two cautions travel with that evidence. First, partner receive does not yet record responseTimeout-aborted requests (bd rc-kcli), so deadline proofs do not rest on partner-side abort accounting until it closes. Second, the pilot also requested a minimum-elapsed not-before-X assertion (bd rc-1alu). Section 2's vocabulary ban governs format mixing, not assertion growth. New assertion kinds enter through their own changes.

Consequences

Positive

  • One test document format across the route lifecycle.
  • The tier boundary stays machine-checked with no label field to drift.
  • Engine defects get an honest detector without engine pollution.
  • The env gap between camel run and camel test closes by construction.

Negative

  • The tier function must stay correct as the DSL grows. New dynamic-dispatch steps must register as FULL-forcing.
  • Content-derived tier removes the file name as tier metadata. CI selects through the tier filters, not a glob. The runner's tier report records each document's tier for audit.
  • Two new crates raise the publish surface.

Alternatives considered

  • A separate *.integration.test.yaml schema. Rejected in grill. Citrus separates because it is an external framework against a deployed system. The harness here is first-party and embedded. The separate schema also duplicated routeFiles references across two files per route.
  • A declared tier: field. Rejected. Redundant or contradictory, never informative. The filter flag carries the assertion role.
  • Growing the lean boot with more components. Rejected by ADR-0064 creep rule. Unchanged here.
  • Interception or mock expectations as integration proof. Rejected. rc-w1u9 shows the in-process view lies about readiness. Only the wire is honest.

Self-grill record

  • Grill session 2026-09-03, seven questions, all sealed by the human.
  • Expert consultations: e_gpt (first round, 8 rulings), e_opus (verdict and P1-P4 adjudication, both code-verified). e_gpt second round did not respond; e_opus arbitration covered the naming deadlock.
  • Divergence labels for ADR-0046 bookkeeping: unified format vs Citrus file separation (divergence), filter flags vs mode flags (divergence), repeat-on-error reject (divergence), scenario-implies-FULL (pin-invariant).

ADR-0070: Staged Listeners for Port-Deterministic Tests

Status

Accepted (2026-09-06). Generalizes ADR-0069 §6.5. The capability spec openspec/specs/staged-listener-binding/spec.md is normative for the contract details.

Context

The epic rc-99d5 taxonomy names the port-toctou flake class. A test binds port 0, reads the assigned port, and drops the listener. It then hands the bare port number to a component that binds later. Between the drop and the real bind, any process can claim the port. The camel-ws hang (bd rc-y24l) burned a CI runner for six hours through this class. The original inventory counted 104 probe callsites.

The fix pattern is now landed in every inbound transport. Four applications, each reviewed and merged:

ComponentSurfaceChange
camel-component-grpcGrpcConsumer::start_with_listener (consumer.rs:434), server-side get_or_spawn_with_listener (server.rs:187)pre-epic
camel-component-wsServerRegistry::stage_listener / get_or_spawn_with_listener (lib.rs:132, :295), WsConsumer::start_with_listener (lib.rs:1336)bd rc-9xsv
camel-component-httpServerRegistry::stage_listener / get_or_spawn_with_listener (lib.rs:886, :853)bd rc-h0aw
camel-test residue (http_static 20 sites staged, ws_security 8 placeholder)support helpers + constant placeholdersbd rc-yorz9
camel-component-wasmstaged_listener::stage_listener (staged_listener.rs:37), consumed at the source bind sitebd rc-wgba

The camel-test itest binaries acquire ports through two helpers, stage_http_listener and stage_ws_listener (bd rc-h0aw). The wasm test binaries use stage_wasm_source_listener (bd rc-wgba).

Decision

  1. Test port acquisition SHALL come from a staged bound listener. The test binds {host}:0, parks the listener with the component, and reads the actual port from it. Bind-read-drop probes are forbidden. A route that later binds the same port receives the held socket. No window exists in which the port is free.
  2. Every application follows one contract (the capability spec carries the scenario detail):
    • Exact (host-string, port) key. No DNS or wildcard normalization. 0.0.0.0 and 127.0.0.1 are different keys.
    • One-shot consumption. The consumer removes the entry when it takes the listener.
    • Empty by default. The unstaged path is behaviorally compatible with the pre-change path. An added map lookup is the only internal difference.
    • Deterministic conflicts. A staged entry on the same port under a different host string fails the consumer before any bind with staged listener conflict on port {p}: staged under host {h}, requested {r}. Duplicate staging fails with listener already staged for {h}:{p}. These strings are contractual. A silent fresh bind would risk EADDRINUSE flakiness, so the failure is explicit instead.
    • One consumption point. Registries consume inside the one-shot init winner (the OnceCell closure). The wasm source consumer consumes at its single bind site. Both close the two-callers race by construction.
  3. Consumption ordering. A consumer consults the staged map only after config agreement validation and security gates. In wasm the consumption point sits after the operator/guest bind agreement and the ADR-0061 exposure gate. A refused route never consumes a staged slot. Staging never bypasses a gate.
  4. Production-surface ruling (papal e_opus reviews of rc-h0aw and rc-wgba): listener injection on a COMPONENT registry is the bound-address operator surface class. ADR-0069 §6.5 blesses get_or_spawn_with_listener as that class. stage_listener belongs to the same class. The ADR-0069 §6 fence forbids test-only event surfaces in camel-core. No staged API exists in core, and none may.
  5. The ADR-0069 gate question for new core APIs is "would this API exist without tests?" For component staged surfaces the answer is yes. An operator can pre-bind a socket and hand it to the component. External supervisors do this today through FD inheritance.
  6. Exception for asserted-unbound addresses. A test that proves an address has no listener (connect-refused assertion) cannot stage. A staged listener is bound by definition. Such a test names a fixed reserved address, 127.0.0.1:1. The canon spec carries this exception in the no-port-probes requirement. 6a. Exception for placeholder ports. The exception applies ONLY where the request is served by tower::ServiceExt::oneshot in process AND the address is never bound nor dialed. Such a test performs no port acquisition, so a constant placeholder replaces the probe (bd rc-yorz9, ws_security_test). No staging applies, because no acquisition happens. A test that binds or dials never qualifies.
  7. No reset API. Staged keys are ephemeral ports that never repeat within a process. Each test binary is a fresh process. Unconsumed slots from refusal-path tests stay inert until process exit. camel-http, camel-ws, and camel-component-wasm all ship without reset, by the same rationale.
  8. New inbound transports MUST offer the staged surface at design time. The surface is part of the transport's operator contract, not a retrofit.

Consequences

  • The port-toctou class is structurally eliminated from every test binary and itest suite. grep -rn 'fn free_port' crates/ returns zero matches. The final camel-test residue (two local helpers missed by the rc-h0aw inventory) was removed under bd rc-yorz9 during this ADR's review. Two named residues remain under their own bd issues: the camel-http in-lib helper setup_consumer_on_free_port (crates/components/camel-http/src/lib.rs:7387, 8 sites, bd rc-1dgvg) and the camel-bridge Quarkus env handoff (bd rc-s7dyw, an external process that cannot consume a staged listener).
  • Process-global staged maps are accepted test-tier state. They are empty in production and hold no task references.
  • The CLONE-FIXTURE pattern is the sanctioned way to hold two handles for one socket in a test: std bind, try_clone(), set_nonblocking(true), tokio::net::TcpListener::from_std. Tokio has no try_clone.
  • ADR-0069 §6.5 anticipated "the bound address a future boot handle reports". The staged-listener surface is that address surface. Port acquisition needs no separate boot handle.
  • A future transport that binds without a staged surface reintroduces the class. Review must cite this ADR when rejecting such a design.

ADR-0071: HTTP Outbound URL Policy (Query Composition and CamelHttpUri Fence)

Status

Accepted (2026-09-06). Implements the query-composition half of the http-contract-surface change for the camel-http producer; the capability spec openspec/changes/http-contract-surface/specs/http-url-resolution/spec.md is normative for the scenario detail. Companion to ADR-0032 (exchange-data trust boundary) and ADR-0034 (control-bus authorizedRoutes fence precedent).

Context

Three bd findings name the outbound URL as a contract surface under ADR-0032, which defines exchange headers as untrusted, adversary-controlled data:

  • rc-rbfxq — a CamelHttpUri exchange header replaces the entire outbound URL — scheme, host, path, query — before SSRF validation, with no allowlist tying it to the endpoint's configured host. SSRF default (allow_internal=false) blocks private addresses only, so an attacker who can set CamelHttpUri redirects the producer to any public host. The e_glm counter-report lowered it to p2 (defense-in-depth, not default-path exploitability): reachable only when a route step explicitly copies untrusted data into an exactly-cased CamelHttpUri header. But the contract inconsistency with ADR-0034 stands — control-bus fenced the same header-driven control-target class with authorizedRoutes, while camel-http had no fence.
  • rc-k3pir — default inbound reflection is undocumented: the consumer installs CamelHttpPath/CamelHttpQuery from the inbound wire request (lib.rs:1821-1825), and a non-bridged producer consumes both by default, so from: http://.../in -> to: http://upstream/api appends the inbound path and query to the upstream URL with zero route steps. Through the rc-69fie else-if, the inbound query silently replaced operator-configured query_params.
  • rc-69fie — a CamelHttpQuery header present on an exchange carrying endpoint-configured query params took the header branch and silently discarded the configured params — no merge, no warning. Under ADR-0032 this is worse: the untrusted exchange header fully replaced trusted operator config.

ADR-0032 establishes that no untrusted exchange datum may drive a control plane, numeric, or resource decision, or an executable/interpretable sink, without validation, bounding, or a capability check. The outbound URL is a resource decision. ADR-0034 supplies the fence precedent: control-bus fenced its header-driven route target with a mandatory authorizedRoutes allowlist that fails closed when absent.

Decision

  1. Outbound query composition (rc-69fie). When the exchange carries a CamelHttpQuery header, the producer composes the outbound query from the arm-specific higher-precedence source followed by the header pairs whose keys are absent from that set; the higher-precedence source wins any key collision.

    • Base arm (no CamelHttpUri): the higher-precedence source is the endpoint query — raw_query (consumed option keys filtered) plus programmatic query_params.
    • Override arm (CamelHttpUri present): the higher-precedence source is the override URI's own query. The endpoint base query does NOT ride an override — the override remains untrusted exchange data under ADR-0032, so trusted operator config never rides an untrusted override.
    • CamelHttpPath applies to the path component before query composition in both arms.
    • Header pair bytes are carried verbatim; a raw byte forbidden in a query component inside a header value produces a resolve error naming the offending byte, never a re-encoding (Wave-A law, raw-preserving serializer).
    • A present-but-empty CamelHttpQuery is a no-op: the higher-precedence source is emitted unchanged with no additional ? marker.
    • Override-URI merge fix: when an override URI carries its own query and the exchange also carries CamelHttpQuery, the two merge at pair level — override pairs first (winning collisions), header pairs appending for absent keys — instead of concatenating a second ? marker.
    • Deliberate divergence from Apache Camel. Apache Camel applies the CamelHttpQuery header verbatim, replacing the endpoint query entirely (header-wins-verbatim). This ADR composes instead, so collisions resolve to the higher-precedence (operator) source: the endpoint config in the base arm, the override URI's own pairs in the override arm. The divergence is explicit and test-pinned.
  2. Default inbound reflection retained (rc-k3pir, Apache Camel parity). Consumer-installed CamelHttpPath/CamelHttpQuery ride the outbound URL by default and compose per rule 1. The plain-proxy shape keeps working; the operator query pair is not replaced by reflected inbound data.

  3. Opt-in allowedUriHosts fence (rc-rbfxq). The endpoint URI accepts a comma-separated allowedUriHosts option: exact hosts, each optionally host:port.

    • Bracketed IPv6 literals compare in canonical form; DNS names compare case-insensitively; a host-only entry permits any port; a host:port entry matches only the override's effective port (explicit port, or the scheme default: 443 https / 80 http).
    • A malformed entry — a segment carrying a path or userinfo, an entry the url crate rejects, or a declared option that yields zero valid entries after trimming/dropping empty comma segments — fails endpoint creation.
    • An armed fence fails closed: a CamelHttpUri override resolving to a host not matching any entry, or failing to yield a host, is a resolve error, and the rejected URL is rendered only through the diagnostics redaction path (ADR-0051).
    • An unarmed endpoint (option absent) keeps the pre-fence override behavior unchanged. The option is consumed: it never appears in the outbound query.
  4. Compatibility exception versus ADR-0034. Unlike control-bus's MANDATORY authorizedRoutes fence (which fails closed when absent), the camel-http fence is opt-in: unarmed endpoints keep the pre-fence override behavior. This is a deliberate compatibility trade-off — defense-in-depth hardening, not incident response — preserving Apache Camel override semantics for operators who did not ask for the fence.

Consequences

  • Migration for Apache Camel users relying on header-wins-verbatim: their CamelHttpQuery headers now COMPOSE. Collisions resolve to the higher-precedence source — the endpoint config in the base arm, the override URI's own pairs in the override arm — and only absent keys append. A route that previously overwrote a static operator pair must delete that pair from the endpoint URI or drop the header.
  • Fence adoption path for routes that copy untrusted inbound data into CamelHttpUri: declare allowedUriHosts with the exact target hosts. An armed fence turns a redirect-to-any-public-host into a redacted resolve error. The fence is enforced at override resolution time only: redirect hops under followRedirects (off by default) are NOT fence-checked and rely on the existing per-hop SSRF validation and cross-origin credential stripping.
  • Reflection is now documented as the default, next to bridgeEndpoint, rather than an undocumented side effect.
  • Wave-A law continues to govern authored and header query bytes: the raw-preserving serializer carries them verbatim, forbidden bytes error naming the byte, and the redaction path protects credentials in any diagnostic rendering of a rejected URL.
  • Contract surface updated in crates/components/camel-http/CONTEXT.md; future bug reports about outbound URL/query behavior check the composition and fence rules there first.

ADR-0072: Test Pyramid v2 (Shared Matcher Algebra)

Status

Proposed (2026-09-06). Supersedes ADR-0069 in part: vocabulary ownership only. ADR-0069's grammar rules, tier derivation, and verdict taxonomy stand unchanged; this ADR does not amend them. Epic rc-8zau7; e_opus consultation ses_f880eca32ffeCbIky4WBYVB71w. The pure-crate carve this ADR ratifies landed in the same change (tasks 1.1-1.2 of shared-matcher-core).

Context

The two test tiers speak different assertion vocabularies with the same intent (epic rc-8zau7). The scenario tier (ADR-0069) owns a full matcher grammar after waves A-D: count bounds, path filters, query-subset matching, value expectations. The unit tier's expects is endpoint-to-count only (ADR-0064). Capability gravity inverted the test pyramid: authors chose the scenario tier because the unit tier was mute, not because they wanted real wire.

That grammar is welded to the scenario harness. Wave D landed the algebra inside camel-integration-test (document.rs, runner/partner_validate.rs, runner.rs), surrounded by harness concerns: HttpWireRequest observation, PartnerRouter, redaction-coupled diagnostics (ADR-0051). Left there, the algebra cannot serve the unit tier without dragging those concerns into it. Extracting it while wave D is fresh prevents the weld from hardening.

Two facts frame the placement choice:

  • The B2 precedent (rc-6bsf): e_opus ruled that "a new shared crate is over-engineering" and named camel-config as the natural home. That ruling addressed boot sharing, where camel-config already held the dependency edges.
  • ADR-0055 publish topology: a crate with zero camel-* dependencies is topologically free. It publishes first, before every consumer. No cycle risk.

The testing story is also spread across four ADRs — ADR-0064 (contract), ADR-0069 (crate layout), ADR-0055 (publish leaf), ADR-0070 (staged listeners) — plus the camel test command and the dual-use lean components. Coherent, but undocumented in one place. Section 5 closes that gap.

Decision

1. Placement: a dedicated camel-matchers crate

The shared assertion algebra lives in its own crate, crates/camel-matchers, in the foundational band below camel-api, alongside the pure libs. The crate is one named concept with zero ambiguity.

The B2 precedent was weighed and rejected for this case. rc-6bsf applied where a natural home already existed for boot sharing: camel-config was the home of the shared boot edges. Here the natural home IS the vocabulary. Hosting matchers inside a config crate is the semantic accretion that makes a 60+ crate workspace feel disorganized. The precedent does not transfer.

The crate-count cost is acknowledged and accepted. The cost of a crate is semantic confusion, not the number. This crate adds no confusion: its name and charter are one concept. The workspace is 60+ crates; this is one more, justified on its own terms.

Rejected hosts:

  • camel-core / camel-api: runtime pollution with test vocabulary.
  • camel-test: would invert the dependency direction. The scenario kit (camel-integration-test) would depend on the unit-tier kit.

ADR-0055 topology applies cleanly: zero camel-* dependencies means the crate publishes first, lint-publish-cycles passes trivially, and no consumer waits on it.

2. Purity rule: types and pure functions, nothing else

The crate is types plus pure functions. It carries zero camel-* dependencies.

Allowed foundational third-party dependencies: regex, serde_json, form_urlencoded. Nothing else.

The crate has:

  • No harness types.
  • No wire types (HttpWireRequest has no home here).
  • No redaction (the ADR-0051 law stays in the tier kits).
  • No async runtime (no tokio).
  • No Cargo features.

This buys a crate that camel-test (unit tier), camel-integration-test (scenario tier), and camel-cli can consume without dragging harness or wire concerns into one another.

3. One algebra, per-tier grammar, per-tier observation

"Same verbs, different subjects." Both tiers' assertion vocabularies deserialize to, and call into, the same core types. The algebra is one; the subjects are per-tier.

Per-tier grammar. Document formats stay per-tier. The ADR-0069 §2 mixing ban stands: a scenario: document declares no inputs/expects/ intercepts, and the runner rejects the mix at load time. Grammars are never unified.

Per-tier observation. What gets matched stays per-tier. The scenario tier matches recorded wire: HttpWireRequest, lane keys, wire fidelity. The unit tier matches in-process Exchange projections. These do not unify: wire fidelity, lane keys, and redaction have no Exchange analog.

Parameterize the algebra, not the observation. Never introduce one observation trait. The carve in this change is the pattern: matching_count takes an iterator of projected (method, path_and_query) tuples. Each tier projects its own observation into those tuples at the call site. The scenario tier maps its wire records; a future unit tier maps its Exchange projections.

4. Staged direction (future changes, not this one)

This ADR records the approved sequence. None of the steps below is this change; this change is the pure carve only.

  • Step 2: unit-tier expects growth. The unit tier grows body and header matchers in camel-test, consuming the crate.
  • Step 3: observational probes. Step identity arrives through to: mock:probe-N probes, registry-only. These are observational and legal today.
  • Mutating weaving stays gated. Skip and replace processors land only as a lean-set change per the ADR-0064 §5 AdviceWith Stage A/B frame. Observation is free; mutation is gated.
  • Wire timeouts are never virtualized. ADR-0069 §6 stands: no virtual clock in core. Paused Tokio time stays a unit-harness concern.
  • recipient_list and dynamic-dispatch force-FULL stays static. A runtime-verified closure would destroy pre-boot tier selection. The tier function remains a pure function of document content.

5. Testing-surface map

One map of the test surfaces. Each row names the surface, its role in the pyramid, and the ADR that governs it.

SurfaceRole in the pyramidGoverning ADR
camel-testUnit-tier kit: CamelTestContext, mock access, Tokio time controlADR-0064 (unit tier), ADR-0055 (publish leaf), ADR-0070 (staged-listener helpers)
camel-integration-testScenario-tier kit: scenario model and parser, partner adapters, embedded FULL bootADR-0069
camel-matchersShared assertion algebraThis ADR (0072)
camel-cli test commandRunner, tier derivation, tier filtersADR-0069 §1 and §3, ADR-0064
camel-bundlesShared boot installers: bundle registration cascade, BootHandleADR-0069 §10
mock, direct, seda, timer, logDual-use lean runtime components: the lean boot registers them; they are runtime components, not test-only cratesADR-0064 §2 (closed lean set, creep-rule amendment gate), ADR-0055

This map closes the documentation gap. The story previously sat in ADR-0064, ADR-0069, ADR-0055, and ADR-0070, plus the camel-cli command and the component crates. This section is the single reference.

Consequences

Positive

  • Two tiers share one semantic core without sharing grammar or observation.
  • The unit tier can grow its expects vocabulary from the crate (step 2) without touching the scenario tier.
  • The crate publishes first under ADR-0055 topology; no consumer waits on it.
  • The test surfaces have one documented map.

Negative

  • One more crate in a 60+ workspace. Accepted and recorded in section 1.
  • Grammar and observation duplication between tiers is deliberate and stays. Each tier keeps its own document formats and its own subjects.
  • This ADR is Proposed. Section 4 records direction, not landed contract. Each step lands as its own change.

Alternatives considered

  • camel-config as host (rc-6bsf B2 precedent). Rejected in section 1. The precedent addressed boot sharing; the vocabulary has no natural home in a config crate.
  • camel-core / camel-api as host. Rejected: runtime pollution with test vocabulary.
  • camel-test as host. Rejected: inverts the dependency direction.
  • One observation trait over both tiers. Rejected in section 3. HttpWireRequest and Exchange projections do not unify.
  • Unified grammar across tiers. Rejected: ADR-0069 §2 stands.

Amendment 1 — 2026-09-07 — shared-algebra consumption

The Context statement that the unit tier's expects is endpoint-to-count only overstated the gap. camel-mock already carried the full seven-key matcher vocabulary over Body: its grammar mirrored the mock-testkit rules, and the scenario tier's wave-D grammar mirrored the same keys. The two tiers spoke one vocabulary all along.

The real defects were the duplicated ad-hoc algebra and the missing upper bounds. Two copies of the matching logic lived in two crates, and neither supported a bounded count. The gap was not vocabulary; it was a shared core and a count bound.

Step 2 (change expects-matcher-growth) is the worked example of per-tier observation this ADR prescribes. camel-mock delegates its string and json verbs to the shared core through the text_only and json_value projections. The unit tier projects its own observation into the core at the call site instead of unifying it with the scenario tier's wire records.

The same step completes the count bounds. CountBound carries the state, maxCount joins the grammar, and an explicit maxCount: 0 asserts absence over the post-settle snapshot. The count vocabulary that the original Context credited only to the scenario tier now lands in the unit tier too.

One programmatic note: the mock's expectation state now carries a single count bound per endpoint. Programmatic setters keep that rule — a later expect_bound replaces an earlier one (the document grammar always rejected setting two bounds together).

The Decision sections stand unchanged. This amendment corrects the Context's framing; it does not revise the placement, purity, or staged- direction decisions.

Amendment 2 — 2026-09-07 — step 3 delivery shape

Step 3 of the staged direction delivered the observational probes this ADR prescribed. The observational weave itself predated the ADR: intercepts with divertCopyTo landed 2026-08-23 (the declarative-intercepts change), so a probe endpoint was reachable from any route send before this ADR recorded the step. The genuine gap was the cross-endpoint arrival-order assertion.

Step 3 lands as divert-copy probes plus the arrival-sequence assertion. camel-mock stamps a component-wide, strictly-increasing arrival index on every recorded exchange. sequence: evaluates a filtered complete interleaving over the listed endpoints: the arrivals at those endpoints, projected in global arrival order, must equal the declared list exactly, while arrivals at unlisted endpoints are ignored. Retention is bounded and the arrival indices truncate in lockstep with the retained exchanges.

The ADR-0064 section 5 gate comes to this in substance: skipTo exists only as a test-document construct and never in the production route DSL. Observation is free; mutation stays gated. The Decision sections stand otherwise unchanged.

ADR: Default-strict REST content negotiation (415/406)

  • Status: Accepted — spec + plan blessed in the conductor-light flow (2026-09-11); archived with change add-rest-strict-negotiation.

  • Ruling source: e_opus sealed ruling, amended by the user fact ZERO ADOPTION — no retrocompat constraints. Do not re-derive compat.

  • bd: rc-hlb1q (REST DSL v2 L2).

Context

The REST DSL already declares per-operation media contracts: consumes / produces on RouteDslRestOperation, validated at lowering time (crates/camel-dsl/src/rest.rs, lower_operation: binding json must declare JSON-family media via is_json_media_type, binding raw may declare any valid type/subtype via is_valid_media_declaration; both helpers are private in rest.rs:461-476). Nothing enforces these declarations at runtime:

  • The HTTP consumer routes by method+path only: crates/components/camel-http/src/registry.rs:51 (rest_endpoints: Vec<RestEndpoint<T>>, rest_match.rs:30 pub struct RestEndpoint<T> carries method and path only). Media is invisible to routing.
  • Request Content-Type lands in the request StreamMetadata (camel-http/src/lib.rs, axum handler ~1560) and in the exchange headers the consumer receive loop installs; Accept travels as a plain exchange header (see the forwarding pin test at camel-http/src/lib.rs ~4120).
  • Errors from the route pipeline flow back through the finalizer pipeline_error_to_reply (camel-http/src/lib.rs:3334), which maps typed CamelError variants to statuses (401/403/400/400/503, else 500).

So a text/plain POST against an application/json operation today fails late (unmarshal 400) after the body stream may already have been partially consumed, and Accept is ignored entirely.

Decision

D1 — Enforcement point: header-gate processor, injected by lowering

A ContentNegotiationProcessor in crates/camel-processor/src/ content_negotiation.rs implements the standard Tower service shape (BoxProcessor = tower::util::BoxCloneSyncService<Exchange, Exchange, CamelError>, crates/camel-api/src/processor.rs:51). REST lowering (crates/camel-dsl/src/rest.rs, lower_operation) injects it as the FIRST lowered step — before the UnmarshalStep that request binding pushes today (rest.rs ~338) and before every other step.

Cycle-free construction (dependency fact). camel-dsl depends on camel-processor (Cargo.toml; compile.rs already references camel_processor:: types), so the processor MUST NOT call back into camel-dsl — that would be a crate cycle. The split:

  • The processor in camel-processor is a header-reading shell. It extracts Content-Type and Accept from exchange headers (case-insensitive lookup) and delegates the decision to a check closure injected at construction: `Arc<dyn Fn(Option<&str>, Option<&str>) -> Result<(), CamelError>
    • Send + Sync>(content-type, accept). It NEVER polls, wraps, replaces, or caches the body (L3 pin: noStreamCacheService, no consumption). On Okit passes the exchange through untouched; onErr` it fails the exchange with the typed error.
  • camel-dsl/src/compile.rs constructs that closure over the media.rs matcher and a MediaContract — the declared consumes/produces parsed ONCE at compile time, not per request. All media semantics (parsing, matching, essence rule, q-factors) stay in crates/camel-dsl/src/media.rs (D3), one home shared with the lowering-time declaration validation.

Wiring uses the existing lowering-only step pattern (SetHeaderIfAbsent precedent: #[serde(skip_deserializing)] on the RouteDslStep arm, route_ast.rs:515): the lowering pushes the step, and the DSL→declarative conversion — centralized in yaml.rs::route_dsl_to_declarative_route, which the JSON authoring path reuses (json.rs imports it; JSON is a full-DSL authoring format per ADR-0026) — maps it to a DeclarativeStep (model.rs). compile.rs then constructs the processor and emits BuilderStep::Processor(camel_api::OpaqueProcessor(..)) (construction precedents in compile.rs ~1005-1292). camel-core needs NO change — BuilderStep::Processor already accepts pre-built Tower services (crates/camel-core/src/lifecycle/application/route_definition.rs:62).

The camel-http registry and rest_match stay MEDIA-BLIND. The only camel-http change is the finalizer mapping (D2).

D2 — Typed errors, mapped by the HTTP finalizer

crates/camel-api/src/error.rs (CamelError is #[non_exhaustive], line 78) gains:

  • UnsupportedMediaType { consumed: String, declared: String } → HTTP 415
  • NotAcceptable { accept: String, produced: String } → HTTP 406

classify() (line 177) gains categories unsupported_media_type and not_acceptable; variant_name() (line 214) gains the arms (the defining-crate exhaustive match makes omission a compile error). pipeline_error_to_reply (camel-http/src/lib.rs:3334) maps the two variants to 415/406 with JSON error bodies, mirroring the existing TypeConversionFailed → 400 pattern. This is the ONLY camel-http change.

D3 — In-tree parser, no new dependency

crates/camel-dsl/src/media.rs (new module) hosts the RFC 7231/9110 subset. The private helpers split_media_base, is_valid_media_declaration, is_json_media_type MOVE here from rest.rs (keeping their current call sites via pub(crate)), and the module adds the negotiation parser, budget ~150-250 LoC:

  • Media type: type/subtype with optional +suffix; tchar validation for token characters; case-insensitive comparison.
  • Parameters: parse and skip all except q (Accept side); q defaults to 1.0 when absent; q=0 means explicit reject.
  • Wildcards */* and type/*: valid ONLY as Accept entries. A wildcard Content-Type is malformed (D5).
  • Charset and every other parameter are ignored for matching.

media.rs also hosts the match entry the injected closure calls: check_request(content_type: Option<&str>, accept: Option<&str>, contract: &MediaContract) -> Result<(), CamelError>, where MediaContract is the parsed declaration pair built once at compile time. The shell in camel-processor holds no media knowledge of its own — only the closure and the header extraction (D1).

MediaContract sides are Option<MediaRange>: a declaration that is not a CONCRETE type (raw-mode lowering's tchar-only check can admit */*, which is token-valid) yields None for that side — meaning permissive, exactly the ruling's "undeclared = naturally permissive". This keeps parse_contract infallible (no unwrap under lint pressure) and gives degenerate declarations the only semantics they can honestly carry. The contract also retains the trimmed ORIGINAL declaration strings (consumes_declared/produces_declared) because the error payloads echo them and parsed ranges are lowercased and param-stripped.

Body-lessness travels on the step: ContentNegotiationStep carries check_content_type: bool, set by lowering from verb_has_body (rest.rs ~456), because the compile layer sees only the step — no verb context (compile.rs ~990). The compiled closure maps the Content-Type argument to None when the flag is false; the Accept-side gate always runs.

Escape hatch: adopt the mediatype crate ONLY if the matcher exceeds ~350 LoC with residual bugs; that path requires a workspace review (MSRV, cargo audit, q-factor matching must be exposed).

D4 — Default-strict: no opt-in surface

No flag, no strict field, no permissive matrix. Enforcement exists exactly where declarations exist; undeclared media naturally means permissive. REST operations always carry consumes/produces after lowering defaults (both default to application/json), so lowered REST operations are strict by default. Non-REST routes (direct, component consumers) never receive the step and are unaffected.

v1 compatibility is BEHAVIORAL, not byte identity of the lowered sequence: the delta renames the canon requirement to "v1 behavioral compatibility for omitted binding" and modifies the JSON/raw pipeline requirements to carry the negotiation prefix. For requests whose Content-Type satisfies the declared consumes and whose Accept admits the declared produces, observable behavior (statuses, response bytes) is unchanged; mismatched requests move from late 400/200 to early 415/406 — the L2 contract. The v1 pin suites remain the internal regression net, with their lowered-sequence assertions updated to include the prefix (and nothing else).

D5 — Malformed-header direction (PINNED)

  • Malformed Accept → treat as */* (permissive). If any entry of the comma-separated list fails to parse, the whole header is treated as */*.
  • Malformed Content-Type on an operation with declared consumes (strict side) → 415. A wildcard in Content-Type is malformed.

Asymmetry is deliberate: Accept failures must not lock clients out of representations they can consume; an unverifiable Content-Type against a declared contract must fail closed.

D6 — Matching semantics

One function, used by both sides, media_satisfies(candidate, declared):

  • Normalize case; compare type/subtype equality; ignore parameters except q.
  • Essence rule (L1): if BOTH candidate and declared are JSON-family (application/json or any +json suffix — the existing is_json_media_type test), they satisfy each other. This makes application/vnd.api+json satisfy consumes: application/json and symmetrically satisfy Accept: application/json against produces: application/json.
  • Accept-side wildcards: */* satisfies any declared; type/* satisfies any declared with the same type.
  • Media-range precedence (RFC 7231 §5.3.2): among Accept entries that satisfy the declaration, the most specific governs — exact type/subtype over JSON-essence match over type/* over */* — and the request is acceptable only when the governing entry has q > 0. Thus Accept: application/json;q=0, */*;q=1 against produces: application/json is an explicit reject (406): the exact entry outranks the accepting wildcard. When several entries tie at the governing specificity (application/json;q=0, application/json;q=1), the LOWEST quality governs — fail closed, deterministic.
  • Content-Type side: no wildcards (malformed per D5).

Gate semantics (fail fast, first step):

  • 415: request carries a Content-Type that does not satisfy declared consumes — only for verbs with a body (verb_has_body, rest.rs:456); body-less verbs (GET/DELETE) skip the request check. Absent Content-Type is permissive (v1 unmarshal path decides).
  • 406: NO Accept entry with q > 0 satisfies declared produces. Absent Accept is permissive (RFC default). q=0 on the only matching entry is an explicit reject → 406.
  • No server-driven selection: the gate accepts or rejects; the single declared produces is what the route emits.

Alternatives considered

  • Enforce in camel-http routing (media-aware registry): rejected — bloats the media-blind RestEndpoint<T> matching surface and only sees media AFTER route match; the ruling pins registry media- blindness.
  • Enforce in the finalizer by inspecting exchange state: rejected — too late (body may be consumed), and couples transport to DSL declarations it cannot see.
  • Parser in camel-api (below both crates): rejected — would split media knowledge across crates: declaration validation stays in camel-dsl lowering while runtime matching lived in camel-api, duplicating the essence rule in two homes. The injected-closure split keeps every media decision in media.rs.
  • mediatype crate from the start: rejected — the subset fits ~150-250 LoC in-tree; a new dependency needs workspace review. Escape hatch documented in D3.
  • Opt-in strict flag: rejected — ZERO ADOPTION; a default-off flag enforces nothing and a default-on flag IS default-strict with extra surface.

Consequences

  • Lowered REST pipelines gain one leading step; v1 pin tests are updated to assert the prefix (sequence assertions only) and stay green as the internal regression net; well-formed requests keep byte-identical responses.
  • L1 (binding: raw) and L3 (streaming) pin batteries stay green: the gate is header-only and never touches Body::Stream, metadata, or StreamCacheService injection.
  • variant_name()/classify() updates are compile-enforced in camel-api (variant_name_tests).
  • doTry catch-by-variant users can catch the two new variant names; no existing matcher changes (additive arms only).

Test strategy

  • media.rs unit table: tokens, suffixes, params, q, wildcards, case, malformed inputs (both pinned directions).
  • Processor unit tests (Tower oneshot): 415/406 emission, pass- through identity, zero body polls (poll-recorder stream, L3 style).
  • Lowering tests: step order (negotiation first, before unmarshal), both bindings, body-less verbs.
  • Compile tests: BuilderStep::Processor emission with expected declarations.
  • camel-http e2e: 415/406 status + JSON error body through the axum handler; wildcard/multi-accept/q=0 matrices; malformed directions; v1/L1/L3 pin suites stay green.

API reference

Rust API documentation lives in cargo doc, not in this guide. Read the rustdoc for canonical type signatures, trait contracts, and method details.

Read the API docs locally

Build and open the rustdoc for the whole workspace from the repository root:

cargo doc --open

For a single crate, target it by name. The --no-deps flag skips dependencies and builds faster:

cargo doc -p camel-api --no-deps --open

The local build always matches your checkout. Use it when you edit the code or track a branch that is not released yet.

Read the API docs online

Published crates are on docs.rs. docs.rs builds the latest released rustdoc for each crate:

  • camel-api - core types: Exchange, Message, Body, CamelError, Processor, BoxProcessor, PipelineOutcome, the CQRS bus traits, 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 docs/AGENTS.md. Read it before you write prose.

Structural checks

The mdBook build is the structural check for the guide. Run it before you commit:

nix shell nixpkgs#mdbook -c mdbook build docs

The build verifies every include directive, link, and page. It does not assess prose quality. Prose quality depends on the ste-writing skill and human review. ADR citation validity and glossary consistency with the Key Terms in CONTEXT-MAP.md are also review-enforced; no xtask lint covers them.

Two-source rule

Every durable claim in the guide cites a source it can defend. The two acceptable sources are a crate CONTEXT.md (the crate authority) or an ADR in docs/adr/ (the decision record). A claim with no citation is speculation.

Define each domain term once on its canonical page. Link to it from everywhere else. Do not re-explain. If two pages both explain the Exchange model, one of them is wrong.

Publishing

A GitHub Actions workflow publishes docs/book from main. An administrator sets the Pages source to GitHub Actions once under Settings, then Pages. Generated HTML is not committed. The output directory is disposable, so future release-versioned books can stage without changing the source chapter URLs.