ADR-0047: MiniJinja Template Rendering Language
Date: 2026-07-18 Status: Accepted (implementation pending in bd rc-ao5) Issue: bd rc-ao5 Amends: none Cross-refs: ADR-0030 (Exchange-aware extension hooks), ADR-0033 (fail-closed security defaults), ADR-0039 (configurable resource caps), ADR-0046 (Apache Camel as design input, not conformance authority)
Context
rc-ao5 began on 2026-05-31 as an HTML/SSR processor for HTTP routes. The
2026-07-18 investigation found a broader structured-output gap: LLM prompts,
HTML, email/documents, HTTP producer bodies, and OpenSearch JSON DSL all need
conditional sections, loops, filters, and multiline output. LLM is a strong
existing consumer because the producer builds prompts and message history from
the Exchange (crates/components/camel-component-llm/src/producer.rs:164-186, 228-252) and the rendered prompt affects cache identity
(crates/components/camel-component-llm/src/producer_cache.rs:266-325).
OpenSearch is a weaker consumer because rendered JSON still requires an
explicit JSON unmarshal before its producer accepts the body
(crates/components/camel-opensearch/src/producer/mod.rs:287-298).
Inline source is sufficient for prompts, JSON snippets, and HTML fragments, but
not for the original full-page SSR case. A page containing shared layout,
styles, navigation, and footer must remain an external resource. Consequently,
the inline Language is one delivery slice of rc-ao5, not by itself completion
of the original use case.
This is not a missing Step or DSL shape. transform: is already an alias for
set_body:, and SetBodyConfig already accepts generic language and source
(crates/camel-dsl/src/route_ast.rs:426-464). The canonical API model is
LanguageExpressionDef { language, source }
(crates/camel-api/src/declarative.rs:16-25). Existing simple:, rhai:,
jsonpath:, and xpath: fields are compatibility conveniences, not a pattern
to extend.
Simple remains the right language for interpolation and predicates. It has
comparison plus binary and/or, but no unary not token or production
(crates/languages/camel-language-simple/src/parser.rs:38-42,69-81,379-421),
and its evaluator deliberately excludes arithmetic and string-concatenation
operators (crates/languages/camel-language-simple/src/evaluator.rs:84-93). A
rendering language fills a different role: producing structured text with
blocks, loops, filters, macros, and context-aware escaping.
The proposal is ADR-worthy: backend and crate boundaries are costly to reverse, the Language/Component split is non-obvious, and viable engines and placements have materially different security and lifecycle properties.
Decision
Adopt Option C, staged.
Phase 1: inline minijinja Language
Add crates/languages/camel-language-minijinja, backed by MiniJinja. It
implements the existing Language, Expression, and Predicate contracts
(crates/languages/camel-language-api/src/lib.rs:21-67). Route usage is:
set_body:
language: minijinja
source: |-
{% autoescape "html" %}
<h1>Hello {{ headers.name }}</h1>
{% endautoescape %}
No template: field and no template: Step are added. Runtime route
resolution calls create_expression/create_predicate once and stores the
compiled object (crates/camel-core/src/lifecycle/adapters/step_resolution.rs:59-86);
the MiniJinja AST/environment therefore belongs to that compiled object and is
reused for every Exchange.
Phase 1 accepts inline, configuration-authored source only. It does not install
a template loader and cannot read files, resolve URIs, or perform network I/O.
Rendering is CPU-local. MiniJinja 2.21 exposes a synchronous render API; that is
compatible with the async Language SPI because Phase 1 performs no blocking
I/O. Async resource acquisition must not be hidden inside Expression::evaluate.
Template source is never selected from an Exchange body, header, or property.
Escape-mode contract
There is no global HTML default. Each rendering expression must explicitly
select its output context with a top-level MiniJinja autoescape block:
"html", "json", or "none". Phase 1 rejects a rendering template without
an explicit top-level mode. none is an explicit operator decision, not an
implicit fallback. URL fragments use MiniJinja's urlencode filter; there is no
generic shell or SQL escaping mode.
This keeps the generic language/source DSL unchanged while preventing an HTML
default from corrupting JSON, URL, prompt, or plain-text output. It also avoids
extension-based inference: inline templates have no trustworthy filename.
Security model
- Template source comes from route configuration only. Message headers cannot
replace source or select a template resource. An external source URI declared
on a template Component Endpoint is configuration; bytes arriving through an
inbound Message are not. Apache Camel's FreeMarker, Mustache, and Velocity
components use the same default
(
allowTemplateFromHeader=false) because header-selected templates cross an untrusted-data boundary. - Undefined values are errors. The language configures MiniJinja strict
undefined behavior; missing
body, header, or property paths fail evaluation rather than silently rendering an empty string. - Template context exposes only
body,headers, and Exchange properties. CamelContext, registries, filesystem, network, environment variables, and arbitrary host objects are not exposed. - Template source bytes, serialized context bytes, execution fuel, recursion
depth, and output bytes all have non-zero finite defaults. Source/context
limits apply before compilation or rendering; fuel does not substitute for
either.
Body::Streamcontext is rejected with guidance to addstream_cache, whose materialization is independently bounded (crates/camel-processor/src/stream_cache.rs:37-54). Limit exhaustion fails startup, reload, or evaluation; it never truncates and reports success. Limits are configurable with bounded defaults, following ADR-0033 and ADR-0039. MiniJinja'sfuelfeature supplies per-render instruction accounting and its environment supplies a recursion limit; rust-camel supplies bounded input accounting and an output writer. - Loader, dynamic evaluation, and host-capability features remain disabled.
This mirrors Rhai's separation of unconditional capability sandboxing from
configurable DoS limits (
crates/languages/camel-language-rhai/src/lib.rs:6-43, 45-76).
Phase 2: external-template Component
Full-page SSR confirms demand for external templates. Add
crates/components/camel-template under bd rc-64if, linked
discovered-from:rc-ao5; rc-ao5 is not complete until this slice is delivered.
The Component owns route-declared URI/file loading, include resolution, path
policy, bounded source acquisition, compiled-template caching, invalidation, and
hot reload. It renders the current Exchange as context without replacing the
body with template bytes first.
Compiled state is bounded by entry count and total source bytes. Normal requests reuse it without parsing; only a changed resource builds replacement state. Compilation occurs off the active snapshot, and a successful complete build is the sole swap point.
The Endpoint resolves its resource URI at route compilation/startup. Incoming
body, headers, and properties cannot override that URI. Reload reads a complete
bounded snapshot, compiles and validates it, then swaps only a fully compiled
template set; failure preserves the prior set, matching the TLS reload pattern
(crates/components/camel-component-api/src/tls_source.rs:137-140,
crates/components/camel-component-grpc/src/tls_reload.rs:40-60).
MiniJinja's current extension point is Environment::set_loader, a synchronous
loader callback with environment caching. It is useful inside the future
Component, but it is not an async Source trait and does not by itself solve
TOCTOU, traversal policy, cache invalidation, or lifecycle. Those concerns are
why external loading does not belong in the Language crate.
Composing camel-file with pollEnrich remains useful for ordinary content
enrichment but is not the template-loading contract. camel-file returns a
single-consumption Body::Stream (crates/components/camel-file/src/poll_logic.rs:375-422),
and pollEnrich serializes access to one mutable PollingConsumer
(crates/camel-processor/src/content_enricher.rs:86-97). The default strategy
replaces the original body while retaining its headers/properties
(crates/camel-processor/src/enrichment_strategy.rs:24-36); there is no
USE_ORIGINAL DSL strategy
(crates/camel-core/src/lifecycle/adapters/step_resolution.rs:175-185). This
composition would require body preservation, stream_cache, runtime template
compilation, and unverifiable data-provenance assertions. The Component avoids
all five concerns.
Backend selection
MiniJinja is selected because it provides Jinja2-compatible syntax, dynamic Serde-compatible values, strict undefined behavior, configurable autoescape, per-render fuel, recursion limits, a loader extension point for Phase 2, and a small dependency/compile-time goal. These directly match dynamic Exchange data and the Rhai sandbox precedent.
Current evidence does not support two rationales from the initial review:
MiniJinja 2.21 is not async-native, and its public API does not expose a
Source trait. Neither is required by this decision: inline render is
synchronous and loader I/O belongs to the future Component. The previously
stated “same maintainer as Askama” claim is also not used as decision evidence.
Non-goals
- SQL text rendering.
camel-sqlalready binds named, expression, positional, and expanding-IN parameters (crates/components/camel-sql/src/query.rs:37-52,248-265). Rendering SQL text would replace parameter binding with injection-prone string construction. - Replacing Simple. Simple remains the default lightweight interpolation and predicate language.
- Replacing
camel-xsltorcamel-xj. They retain their XML/XSLT and XML/JSON bridge-based transformation contracts (crates/components/camel-xslt/src/producer.rs,crates/components/camel-xj/src/producer.rs). - External includes, inheritance, or hot reload in the Language crate. These require the Phase 2 Component. Inline macros remain available.
- Treating shell command construction as a safe rendering target. No context-free escaping rule can make arbitrary shell text safe.
Consequences
Positive
- One Language crate unblocks LLM prompts, HTML fragments, and inline email/document output; HTTP and OpenSearch can opt in without component coupling. The Component completes full-page SSR.
- Existing
set_body/transformandlanguage/sourcecontracts remain canonical. No schema, AST, or Step proliferation occurs. - Templates compile at route compilation, so syntax and explicit escape-mode failures prevent route startup rather than appearing on first traffic.
- Capability isolation and resource limits are explicit, testable contracts.
Negative
- External templates require Phase 2, so users see a Language crate for inline rendering and a Component crate for resource lifecycle.
- Strict undefined values and mandatory escape declarations reject templates accepted by permissive Jinja deployments; migration is intentional.
- Output-byte accounting needs a rust-camel bounded writer in addition to MiniJinja's built-in fuel and recursion controls.
Neutral
minijinjabecomes a workspace dependency and its selected features become part of dependency and MSRV governance.- Rendered JSON remains text until an explicit unmarshal step converts it to a structured body.
Alternatives Considered
- Add
template:toSetBodyConfig— rejected. It duplicates canonicallanguage/sourceand perpetuates legacy per-language convenience fields. - Add a
template:Step — rejected. Body-value transformation already belongs toset_body/transform; a second Step creates duplicate compiler, schema, and runtime paths. - Tera backend — rejected. Tera's public render path is synchronous and it lacks MiniJinja's per-render fuel control. MiniJinja is also synchronous in current releases, so synchrony alone is not the discriminator; enforceable execution bounds and lean embedding are.
- Handlebars backend — rejected. Its logic-light model is useful for simple substitution but requires custom helpers for transformations naturally expressed by Jinja filters and expressions. That weakens portability for the LLM/document use cases.
- Askama backend — rejected. Askama derives a template implementation for a compile-time Rust struct or enum. Route-authored templates and dynamic Exchange maps require runtime compilation and runtime-shaped context.
- Put rendering in
BeanProcessor— rejected.BeanProcessordispatches a named method with parameters (crates/camel-bean/src/processor.rs:5-22); it is not the expression-language registry or compilation boundary. - Put rendering in WASM — rejected. It adds serialization/ABI crossings and a broader capability policy to a deterministic in-process text operation. WASM remains available when isolation or non-Rust template code is itself a requirement.
- Put external loading in the Language crate — rejected. Filesystem and URI access add async I/O, traversal policy, TOCTOU behavior, cache invalidation, and reload lifecycle. A loader callback is not a lifecycle boundary.
- Load template bytes with
camel-file+pollEnrich, then render from the Exchange — rejected as the template contract.file:Endpoint paths denote directories and exact selection usesfileName(crates/components/camel-file/src/lib.rs:563-580); the returned body is a single-consumption stream, default enrichment replaces application data, and current DSL exposes onlyuseEnrichedBody/throwOnNoPoll. More importantly, bodies and properties have no intrinsic trust label. A route can copy adversarial Message data into either, so compile-time validation oftemplate_from: property.xwould assert provenance the runtime does not track (crates/camel-api/src/exchange.rs:54-65,115-123).
Self-grill Record
Questions generated:
- [glossary] Does “template” conflict with an existing domain term?
- [sharpen] Are rendering and external resource loading one responsibility?
- [scenario] What happens when one global HTML autoescape policy renders JSON, a prompt, or plain text?
- [cross-ref] Does the current DSL already express this operation, and when is source compiled?
- [cross-ref] Do the recorded MiniJinja API claims match the current public API?
Answers (with citations):
- [glossary] Yes. The route AST already uses “templates” for route-definition
expansion (
crates/camel-dsl/src/route_ast.rs:17-21). This ADR uses rendering Language for inline evaluation and external-template Component for resource lifecycle, avoiding a third undifferentiated “template” concept (CONTEXT-MAP.md:7-13,20-23). - [sharpen] No.
Languagecreates compiled expressions/predicates and evaluates them against an Exchange (crates/languages/camel-language-api/src/lib.rs:21-67); Components own URI-scheme integration and lifecycle (CONTEXT-MAP.md:8,21-23). The proposal is refined into two phases and two crate boundaries. - [scenario] HTML escaping changes quotes, angle brackets, and ampersands; that
can invalidate JSON or alter prompt/plain-text semantics. Conversely,
unescaped HTML permits markup injection. Mandatory per-expression
html/json/nonedeclaration resolves both cases; URL fragments require a context-specific filter. MiniJinja exposesAutoEscape::Html,AutoEscape::Json, andAutoEscape::None(MiniJinja 2.21 API, References). - [cross-ref] Yes.
SetBodyConfig.language/sourcealready exists (crates/camel-dsl/src/route_ast.rs:450-464), and route resolution invokescreate_expression/create_predicatebefore execution (crates/camel-core/src/lifecycle/adapters/step_resolution.rs:59-86). New DSL fields and Steps are dropped. - [cross-ref] Partly. Current MiniJinja has synchronous
Template::renderandEnvironment::set_loader, not an async render API or publicSourcetrait. Fuel, recursion, strict undefined, and explicit autoescape are present. The decision is refined to rely only on verified APIs and to keep loader I/O in Phase 2 (MiniJinja 2.21 API, References).
Outcome: refine — retain MiniJinja and the staged decision; make escape-mode
selection enforceable, separate rendering from resource lifecycle, and remove
unsupported async-native, Source-trait, and shared-maintainer rationales.
Self-grill mode: self-grill-proposals skill
Revision self-grill record (2026-07-18)
Questions generated:
- [glossary] Is an external template resource the same concept as a dynamic Exchange-sourced template?
- [sharpen] Can acquisition, compilation, and rendering share the Language SPI without hiding I/O or provenance?
- [scenario] Does
pollEnrichpreserve application data while supplying a reusable exact-file template under concurrent HTTP traffic? - [cross-ref] Can the DSL and Language SPI represent
template_fromandcontext_fromtoday? - [cross-ref] Does
camel-fileprovide template hot reload?
Answers (with citations):
- [glossary] No. A route-declared Component URI is configuration; an Exchange
body/header/property is runtime Message data. Exchange properties are merely
processor-to-processor storage, not a trust domain
(
crates/camel-api/src/exchange.rs:54-65,115-123). - [sharpen] No.
Language::create_expressionaccepts one static script andExpression::evaluatereceives an Exchange (crates/languages/camel-language-api/src/lib.rs:21-25,50-54). Resource I/O, cache lifecycle, and atomic reload remain Component responsibilities. - [scenario] No.
UseEnrichedBodyreplaces the original body (crates/camel-processor/src/enrichment_strategy.rs:24-36), file content is a single-consumption stream, and the shared poller is mutex-serialized (crates/camel-processor/src/content_enricher.rs:86-97).USE_ORIGINALis not supported. - [cross-ref] No.
SetBodyConfigandLanguageExpressionDefcarry only the existing staticlanguage/sourceform (crates/camel-dsl/src/route_ast.rs:450-464,crates/camel-api/src/declarative.rs:16-25). Adding two MiniJinja-specific fields would reverse the no-DSL-proliferation decision. - [cross-ref] No.
FilePollingConsumerrescans onreceiveand keeps a local seen set (crates/components/camel-file/src/polling_consumer.rs:55-92);ModificationDetectingStreamonly reports a file changed during one read (crates/components/camel-file/src/poll_logic.rs:28-93). It does not compile, atomically swap, or lifecycle-manage template sets.
Outcome: drop dynamic Exchange-sourced Phase 1; refine Phase 2 from optional
future work to the required external-template slice of rc-ao5.
Self-grill mode: self-grill-proposals skill
References
crates/languages/camel-language-api/src/lib.rs:21-67crates/languages/camel-language-rhai/src/lib.rs:6-76crates/camel-dsl/src/route_ast.rs:426-464,500-514crates/camel-api/src/declarative.rs:16-25crates/camel-core/src/lifecycle/adapters/step_resolution.rs:59-86crates/components/camel-component-llm/src/producer.rs:164-186,228-252crates/components/camel-component-llm/src/producer_cache.rs:266-325crates/components/camel-opensearch/src/producer/mod.rs:287-298crates/components/camel-sql/src/query.rs:37-52,248-265crates/components/camel-file/src/lib.rs:563-580crates/components/camel-file/src/polling_consumer.rs:55-92crates/components/camel-file/src/poll_logic.rs:28-93,375-422crates/camel-processor/src/content_enricher.rs:86-97crates/camel-processor/src/enrichment_strategy.rs:24-36crates/camel-processor/src/stream_cache.rs:37-54- MiniJinja 2.21 crate API
- MiniJinja
EnvironmentAPI - MiniJinja
TemplateAPI - Tera 2.0
TeraAPI - Handlebars 6.4
HandlebarsAPI - Askama 0.16 crate API
- Apache Camel FreeMarker component
- Apache Camel Mustache component
- Apache Camel Velocity component
docs/adr/0030-exchange-aware-dataformat-hooks.mddocs/adr/0033-security-defaults-fail-closed-startup-validation.mddocs/adr/0039-configurable-loop-iteration-cap.mddocs/adr/0046-apache-camel-inspiration-not-conformance.md
Revisions
- 2026-07-18: Confirmed external full-page templates as required scope;
rejected Exchange-sourced dynamic templates and promoted the external-template
Component from optional follow-up to required
rc-ao5delivery.