Introduction
Ferricel compiles CEL (Common Expression Language) expressions into WebAssembly modules.
The produced .wasm files can then be executed in any Wasm runtime.
Components
Ferricel provides the following components:
ferricel: a CLI tool that can be used to either compile or run a.wasmmodule produced by itferricel-core: the pure Rust crate used byferricelCLI. It can be used to embed a compiler or a runtime inside of your Rust program
The next sections cover in depth how to handle compilation of CEL expressions and how to run them.
Spec
The “Wasm Spec” section illustrates the low level details of the WebAssembly modules produced by the ferricel compiler.
Compilation
The ferricel CLI tool can be used to build a CEL program to WebAssembly.
You can find more details here.
CEL Compliance
Ferricel targets full compliance with the CEL specification and the cel-go extension libraries. Conformance is validated against the official CEL conformance test suite.
Specification Coverage
The next sections outline which parts of the different CEL specifications are currently supported by the ferricel compiler.
Core CEL Specification
| Feature | Status |
|---|---|
Integer arithmetic (+, -, *, /, %) with overflow detection | Supported |
| Floating-point arithmetic | Supported |
Unsigned integer (uint) arithmetic | Supported |
Boolean logic (&&, ||, !) with short-circuit evaluation | Supported |
Comparison operators (==, !=, <, <=, >, >=) | Supported |
String operations (+, size, contains, startsWith, endsWith, matches) | Supported |
| Bytes operations | Supported |
List literals, indexing, membership (in) | Supported |
| Map literals, field access, indexing | Supported |
Conditional expressions (? :) | Supported |
| Null handling and null propagation | Supported |
Type conversions (int(), uint(), double(), string(), bytes(), bool()) | Supported |
| Timestamp and Duration types | Supported |
| Timestamp/Duration arithmetic and field accessors | Supported |
size() function | Supported |
Single-variable comprehensions (all, exists, exists_one, map, filter) | Supported |
Logical error propagation through && / || | Supported |
Optional types (optional.of, optional.none, .orValue, .value) | Supported |
| Protocol Buffer message construction and field access | Supported |
Protobuf wrapper type semantics (google.protobuf.*Value) | Supported |
dyn() type erasure | Supported |
type() introspection | Supported |
cel-go Extension Libraries
| Extension | Functions | Status |
|---|---|---|
| Bindings | cel.bind(var, init, body) | Supported |
| Encoders | base64.encode, base64.decode | Supported |
| Math | math.greatest, math.least | Supported |
math.bitOr, math.bitAnd, math.bitXor, math.bitNot | Supported | |
math.bitShiftLeft, math.bitShiftRight | Supported | |
math.ceil, math.floor, math.round, math.trunc | Supported | |
math.abs, math.sign | Supported | |
math.isInf, math.isNaN, math.isFinite | Supported | |
math.sqrt | Supported | |
| Strings | charAt, indexOf, lastIndexOf | Supported |
lowerAscii, upperAscii, trim | Supported | |
replace, split, substring, join | Supported | |
reverse, strings.quote | Supported | |
format (string interpolation) | Supported | |
| Lists | slice, flatten, distinct, reverse | Supported |
sort, sortBy | Supported | |
first, last | Supported | |
lists.range(n) | Supported | |
| Sets | sets.contains, sets.equivalent, sets.intersects | Supported |
| TwoVarComprehensions | all(i, v, pred), exists(i, v, pred), existsOne(i, v, pred) | Supported |
transformList(i, v, [filter,] expr) | Supported | |
transformMap(i, v, [filter,] expr) | Supported | |
transformMapEntry(i, v, [filter,] mapExpr) | Supported | |
| Regex | regex.replace, regex.extract, regex.extractAll | Supported |
| Protos | proto.getExt, proto.hasExt | Not supported |
Kubernetes CEL Extensions
Ferricel also supports the Kubernetes CEL validation libraries:
| Extension | Status |
|---|---|
IP address functions (ip(), isIP(), family(), etc.) | Supported |
CIDR functions (cidr(), isCIDR(), containsIP(), etc.) | Supported |
URL functions (url(), isURL(), getHost(), etc.) | Supported |
Quantity functions (quantity(), isQuantity(), add(), sub(), etc.) | Supported |
Semver functions (semver(), isSemver(), major(), minor(), patch(), etc.) | Supported |
Format validation (format.named(), format.dns1123Label(), etc.) | Supported |
List extensions (isSorted(), sum(), min(), max()) | Supported |
Regex extensions (find(), findAll()) | Supported |
Conformance Tests
This is an overview of the current status of the conformance tests:
| Test Suite | Successful | Failed | Skipped |
|---|---|---|---|
| basic | 41 | 2 | 0 |
| bindings_ext | 8 | 0 | 0 |
| block_ext | 18 | 8 | 11 |
| comparisons | 406 | 0 | 0 |
| conversions | 109 | 0 | 0 |
| encoders_ext | 4 | 0 | 0 |
| fp_math | 30 | 0 | 0 |
| integer_math | 64 | 0 | 0 |
| lists | 39 | 0 | 0 |
| logic | 30 | 0 | 0 |
| macros2 | 46 | 0 | 0 |
| macros | 44 | 0 | 0 |
| math_ext | 199 | 0 | 0 |
| namespace | 11 | 3 | 0 |
| network_ext | 69 | 0 | 0 |
| optionals | 70 | 0 | 0 |
| parse | 128 | 74 | 17 |
| string_ext | 216 | 0 | 0 |
| string | 51 | 0 | 0 |
| timestamps | 76 | 0 | 0 |
| type_deduction | 17 | 1 | 29 |
| Total | 1676 | 88 | 57 |
Wasm Spec
This chapter provides the low-level details about the WebAssembly module produced by the ferricel compiler.
This information is useful if you plan to load the .wasm modules directly
without using the helpers provided by the ferricel-core crate.
Data Exchange
The host and the Wasm guest exchange data using the JSON format.
The host reads and writes data to the WebAssembly linear memory.
Several functions pass or return memory regions through a single i64 value.
The rest of this document refers to this value as “packed pointer”.
The encoding packs a pointer and a byte length into the two 32-bit halves:
bits 0–31 → pointer (offset into Wasm linear memory)
bits 32–63 → byte length
In pseudo-code:
packed = (len as i64) << 32 | (ptr as i64)
ptr = (packed & 0xFFFF_FFFF) as u32
len = (packed >> 32) as u32
Memory Management
Important
The code produced by the compiler uses a leaking allocator: memory is never released during evaluation.
Given the short lifetime of a CEL program, this is not an issue in practice.
Do not reuse an instantiated WASM module for multiple evaluations—its memory usage will keep growing. Instead, instantiate a new WASM module for each evaluation.
Exported Functions
cel_malloc(len: usize) -> *mut u8
Allocates len bytes in Wasm linear memory and returns a pointer to the
allocated buffer.
| Parameter | Type | Description |
|---|---|---|
len | usize | Number of bytes to allocate. |
Returns a pointer to the newly allocated buffer within Wasm linear memory.
The allocator is a bump-pointer arena: dealloc is a no-op and memory is
released only when the host drops the Wasm instance. Hosts must call this
function to obtain a valid buffer before writing input data (e.g. bindings)
into Wasm memory.
Use this function to load into the instantiate Wasm module the data to be processed.
cel_set_log_level(level: i32)
Sets the minimum log level for messages emitted via env::cel_log.
| Parameter | Type | Description |
|---|---|---|
level | i32 | Log level threshold (see table below). Values outside the valid range are clamped. |
The log levels are:
| Value | Level |
|---|---|
0 | Debug |
1 | Info (default) |
2 | Warn |
3 | Error |
Messages below the configured level are suppressed and env::cel_log will not
be called for them.
evaluate(bindings: i64) -> i64
Evaluates the compiled CEL expression using JSON-encoded variable bindings.
| Parameter | Type | Description |
|---|---|---|
bindings | i64 | Packed pointer to a UTF-8 JSON object mapping variable names to their values. |
The bindings value points to the data previously loaded via cel_malloc.
Returns a packed i64 pointing to a UTF-8 JSON string that contains the
result of the CEL expression.
If the expression produces a runtime error (overflow, divide-by-zero, unbound variable, etc.) the module traps and the host receives an error from the call.
evaluate_proto(bindings: i64) -> i64
Evaluates the compiled CEL expression using Protobuf-encoded variable bindings.
| Parameter | Type | Description |
|---|---|---|
bindings | i64 | Packed pointer to a serialized ferricel.Bindings protobuf message. |
Returns a packed i64 pointing to a UTF-8 JSON string that contains the
result of the CEL expression (same format as evaluate).
Runtime errors cause a trap, identical to evaluate.
Imported Functions
The module imports three functions from the env module. All three must be
provided (or stubbed) by the host at instantiation time.
env::cel_log(ptr: i32, len: i32)
Called by the runtime to emit a structured log event.
| Parameter | Type | Description |
|---|---|---|
ptr | i32 | Offset in Wasm linear memory of a UTF-8 JSON-encoded LogEvent object. |
len | i32 | Byte length of the JSON payload. |
The LogEvent object is a JSON structure like:
{
"level": "error",
"message": "division by zero",
"file": "main.rs",
"line": 42,
"column": 15,
"extra": { ... }
}
The level field is one of "error", "warn", "info", or "debug".
The extra field is optional and contains arbitrary key-value pairs.
A host that does not need log output can satisfy this import with a no-op function.
env::cel_abort(packed: i64)
Called by the runtime when a fatal runtime error occurs (e.g. divide-by-zero, integer overflow, unbound variable).
| Parameter | Type | Description |
|---|---|---|
packed | i64 | Packed pointer to a UTF-8 error message. |
The host implementation is expected to surface the error message and return an
error to the caller (e.g. by trapping or returning Err).
env::cel_call_extension(request: i64) -> i64
Invokes a host-provided extension function by name.
| Parameter | Type | Description |
|---|---|---|
request | i64 | Packed pointer to a UTF-8 JSON-encoded extension call request. |
The request JSON has the structure:
{
"namespace": "math",
"function": "greatest",
"args": [10, 20, 15]
}
Where namespace is optional (may be null), function is the function name, and args is an array of JSON-encoded CEL values.
Returns a packed i64 pointing to a UTF-8 JSON string containing the result. The response is a serialized CelValue:
{
"type": "int",
"value": 20
}
The Wasm module calls this import whenever a compiled CEL expression invokes a
function that was registered as a host extension at compile time. The host is
responsible for dispatching the call to the correct implementation based on the
namespace and function fields in the request. If no extensions are
used, this import can be satisfied with a stub that traps.
Note
This import is only present if the compiled CEL expression uses host extensions.
Producers Metadata
Each compiled module includes a standard WebAssembly
producers
custom section that records which tools produced it. The section has no
semantic effect on execution and can be safely stripped, but it is useful for
debugging and toolchain analytics.
Ferricel adds the following entries:
| Field | Name | Version |
|---|---|---|
language | CEL | (empty) |
processed-by | ferricel | crate version (e.g. 0.2.0-rc.1) |
These are merged with entries already present in the embedded runtime template (contributed by the Rust toolchain), so the final section typically contains:
language:
Rust
CEL
processed-by:
rustc: 1.95.0 (59807616e 2026-04-14)
walrus: 0.26.1
ferricel: 0.2.0-rc.1
Inspecting the section
Use wasm-tools to print the
producers section in a human-readable form:
wasm-tools metadata show policy.wasm
Alternatively, wabt’s wasm-objdump
can dump the raw bytes of the section:
wasm-objdump -s -j producers policy.wasm
Source Custom Sections
Each compiled module embeds the original source and a manifest of host
extensions as raw UTF-8 custom sections, making it possible to recover this
information from a .wasm file without any external metadata.
| Section name | Content | Produced by |
|---|---|---|
ferricel.cel-source | The original CEL expression | [compile()] |
ferricel.vap-source | The full ValidatingAdmissionPolicy serialized as YAML | [compile_vap()], [compile_vap_from_policy()] |
ferricel.extensions | JSON array of host extensions used by this module | all compile paths |
ferricel.extensions section
The ferricel.extensions section contains a JSON array of objects, sorted by
(namespace, function), listing every host extension that the module may call
at evaluation time:
[
{ "namespace": null, "function": "abs" },
{ "namespace": "kw.k8s", "function": "get" },
{ "namespace": "kw.k8s", "function": "list" },
{ "namespace": "kw.net", "function": "lookupHost" }
]
namespace is null for flat (non-namespaced) extensions. The section is
always present; it is an empty array [] when the module uses no host
extensions.
The section records extensions that may be called — due to CEL’s short-circuit
operators (&&, ||), an extension in the list might not be invoked for every
evaluation. A host should use the list to decide which extension implementations
to register, not as a guarantee that all listed extensions will be called.
Read the section at runtime with ferricel_core::extensions_used:
#![allow(unused)]
fn main() {
use ferricel_core::extensions_used;
let wasm = std::fs::read("policy.wasm")?;
for ext in extensions_used(&wasm)? {
println!("{}/{}", ext.namespace.as_deref().unwrap_or("(none)"), ext.function);
}
}
Inspecting source sections
# Print the CEL expression embedded in a compiled module
wasm-objdump -s -j ferricel.cel-source policy.wasm
# Print the VAP YAML embedded in a compiled module
wasm-objdump -s -j ferricel.vap-source policy.wasm
# Print the extensions manifest
wasm-objdump -s -j ferricel.extensions policy.wasm
With wasm-tools, the raw UTF-8 content can be extracted directly:
wasm-tools dump policy.wasm | grep -A1 "ferricel.cel-source"
Inspecting a Module
The ferricel inspect command reads all embedded metadata and prints it in a
human-readable form with syntax highlighting:
ferricel inspect policy.wasm
Output (with color):
Module: policy.wasm
Source (ValidatingAdmissionPolicy):
apiVersion: admissionregistration.k8s.io/v1
...
Host extensions (may be called):
- kw.k8s/get
- kw.net/lookupHost
Exports: cel_malloc, evaluate, evaluate_proto
Producers:
language: CEL, Rust
processed-by: rustc 1.95.0, walrus 0.26.1, ferricel 0.2.0-rc.1
The source is syntax-highlighted: YAML for VAP modules, CEL for plain modules. The theme is chosen automatically based on the terminal background (light or dark), using Solarized Light or Solarized Dark respectively.
For machine-readable output, use --json:
ferricel inspect --json policy.wasm
Disable color with --no-color (e.g. for CI or piping):
ferricel inspect --no-color policy.wasm
Compiling a CEL Program to Wasm
The ferricel build command compiles a CEL expression into a self-contained
WebAssembly module.
The compiler removes all unused runtime functions from the final Wasm module.
Beyond that, it performs no additional optimizations. For smaller or faster
binaries, you can post-process the output with wasm-opt.
Basic Usage
Pass a CEL expression directly with --expression:
ferricel build --expression '1 + 1' --output result.wasm
Or provide the expression in a file with --expression-file:
ferricel build --expression-file my_program.cel --output result.wasm
The two flags are mutually exclusive. If --output is omitted, the module is
written to final_cel_program.wasm in the current directory.
Host Extensions
Host extensions allow the compiled CEL expression to call functions implemented by the host at evaluation time. See the Host Extensions chapter for full documentation, including CLI flags, the Rust API, and builder chains.
Defining a CEL container
A CEL container acts as a namespace for type name resolution. When a container is set, unqualified type names are resolved relative to it first.
For example, with --container google.protobuf you can write Timestamp
instead of google.protobuf.Timestamp in your CEL expression:
ferricel build \
--expression 'Timestamp{seconds: 0}' \
--container google.protobuf \
--proto-descriptor descriptor.pb \
--output result.wasm
Using Proto
To use Protocol Buffer message types in a CEL expression, provide a compiled
descriptor set with --proto-descriptor. The flag can be repeated to merge
multiple descriptor files:
ferricel build \
--expression 'req.user_id != ""' \
--proto-descriptor api.pb \
--output result.wasm
Generate a descriptor set from your .proto files with protoc:
protoc --descriptor_set_out=api.pb --include_imports api.proto
Multiple descriptor files are merged automatically:
ferricel build \
--expression 'a.field == b.field' \
--proto-descriptor types_a.pb \
--proto-descriptor types_b.pb \
--output result.wasm
Running a CEL program compiled to Wasm
There are two ways to run a CEL program compiled to Wasm:
ferricel runCLI — simple command-line execution; does not support host extensions.ferricel-coreRust crate — more flexible; supports host extensions and custom configurations.
Using the ferricel CLI
Here’s a simple CEL program. Save it to a file named validate-balance.cel:
account.balance >= transaction.withdrawal
|| (account.overdraftProtection
&& account.overdraftLimit >= transaction.withdrawal - account.balance)
Compile it to a Wasm module:
ferricel build --expression-file validate-balance.cel -o validate-balance.wasm
Create a bindings file named validate-balance.bindings.json with the input data:
{
"account": {
"balance": 500,
"overdraftProtection": true,
"overdraftLimit": 1000
},
"transaction": {
"withdrawal": 700
}
}
Run the compiled Wasm module with the bindings:
ferricel run \
--bindings-file validate-balance.bindings.json \
validate-balance.wasm
Output:
true
Alternatively, pass bindings as an inline JSON string:
ferricel run \
--bindings-json '{"account":{"balance":500,"overdraftProtection":true,"overdraftLimit":0},"transaction":{"withdrawal":700}}' \
validate-balance.wasm
Output:
false
Using the ferricel-core Rust crate
The ferricel-core crate provides programmatic control over compilation and
evaluation. This is useful when you need to evaluate many expressions, integrate
CEL into a larger system, or use host extensions.
Basic evaluation
Compile a CEL expression and evaluate it with bindings:
#![allow(unused)]
fn main() {
use ferricel_core::{compiler, runtime};
let wasm = compiler::Builder::new()
.build()
.compile("x * 2 + 1")?;
let result = runtime::Builder::new()
.with_wasm(wasm)
.build()?
.eval(Some(r#"{"x": 10}"#))?;
assert_eq!(result, "21");
}
The eval method accepts JSON-encoded variable bindings and returns a
JSON-encoded result string.
Host extensions
Register host-provided functions that the CEL expression can call. Extensions are declared at compile time and implemented at runtime:
#![allow(unused)]
fn main() {
use ferricel_core::{compiler, runtime};
use ferricel_types::extensions::ExtensionDecl;
let abs_decl = ExtensionDecl {
namespace: None,
function: "abs".to_string(),
receiver_style: false,
global_style: true,
num_args: 1,
};
let wasm = compiler::Builder::new()
.with_extension(abs_decl.clone())
.build()
.compile("abs(x)")?;
let result = runtime::Builder::new()
.with_extension(abs_decl, |args| {
let n = args[0].as_i64().unwrap_or(0);
Ok(serde_json::Value::Number(n.abs().into()))
})
.with_wasm(wasm)
.build()?
.eval(Some(r#"{"x": -42}"#))?;
assert_eq!(result, "42");
}
For full documentation on flat extensions, dotted namespaces, and builder chains, see the Host Extensions chapter.
Logging
Configure logging during evaluation with a custom logger and log level:
#![allow(unused)]
fn main() {
use ferricel_core::runtime;
use ferricel_types::LogLevel;
use slog::{Drain, Logger, o};
let decorator = slog_term::PlainSyncDecorator::new(std::io::stderr());
let drain = slog_term::FullFormat::new(decorator).build().fuse();
let logger = Logger::root(drain, o!());
let result = runtime::Builder::new()
.with_logger(logger)
.with_log_level(LogLevel::Debug)
.with_wasm(wasm)
.build()?
.eval(Some(r#"{"x": 10}"#))?;
}
Custom Wasmtime configuration
For advanced use cases, provide your own wasmtime::Engine with custom settings
(e.g. fuel metering, epoch interruption):
#![allow(unused)]
fn main() {
use ferricel_core::{compiler, runtime};
use wasmtime::{Config, Engine as WasmEngine};
let mut config = Config::new();
config.consume_fuel(true);
let wasm_engine = WasmEngine::new(&config)?;
let wasm = compiler::Builder::new().build().compile("1 + 1")?;
let result = runtime::Builder::new()
.with_engine(wasm_engine)
.with_wasm(wasm)
.build()?
.eval(None)?;
}
Protobuf bindings
For type-safe variable bindings that preserve full fidelity (bytes, uint,
timestamp, duration, etc.), use eval_proto with protobuf-encoded bindings:
let result = engine.eval_proto(&bindings_proto_bytes)?;
This avoids the JSON round-trip and preserves exact type information.
Memory management and performance
Important
CEL programs compiled to Wasm use a leaking allocator: memory is never deallocated during evaluation. This is not an issue in practice because CEL expressions are short-lived.
However, the consequence is that each evaluation starts with a fresh Wasm instance. This ensures no memory carries over between calls and prevents unbounded memory growth.
To optimize performance despite this, the Builder uses wasmtime::InstancePre
to pre-link the module at build time. This amortizes compilation cost:
Builder::build()parses the Wasm bytes once and pre-links all host functions.- Each
eval()call only pays the cost of instantiation.
For workloads with many evaluations, creating a single Engine and reusing it
across multiple eval() calls is the recommended pattern.
Host Extensions
Host extensions allow compiled CEL expressions to call functions implemented by the host at evaluation time. Extensions follow a two-phase model:
- Compile time — declare the extension so the compiler emits a real host
call instead of a static
no matching overloaderror. - Runtime — register an implementation that the Wasm module invokes via
the
cel_call_extensionhost import.
ferricel supports two kinds of host extensions:
- Flat extensions — standalone functions like
math.abs(x)orkw.net.lookupHost(host). - Builder chains — fluent APIs like
kw.k8s.apiVersion("v1").kind("Pod").list()where intermediate calls accumulate state into a map and a terminal call invokes the host.
Flat Extensions
CLI
Extensions can be declared inline with --extensions (repeatable):
ferricel build \
--expression 'math.sqrt(x)' \
--extensions math.sqrt:global:1 \
--output result.wasm
Or in a JSON file with --extensions-file:
ferricel build \
--expression 'math.sqrt(x)' \
--extensions-file extensions.json \
--output result.wasm
The two flags are mutually exclusive.
--extensions format
Each --extensions value follows the pattern [namespace.]function:style:arity:
| Segment | Description |
|---|---|
namespace | Optional dot-separated namespace prefix (e.g. math in math.sqrt). |
function | Function name (the last dot-separated segment). |
style | One of global, receiver, or both (see below). |
arity | Total number of arguments the host receives, including the receiver for receiver-style calls. |
Calling styles:
| Style | Invocation form |
|---|---|
global | func(args) or ns.func(args) |
receiver | value.func(extra_args) — receiver is always args[0] |
both | Both of the above |
Examples:
--extensions abs:global:1 # abs(x) — 1 arg
--extensions math.sqrt:global:1 # math.sqrt(x) — namespace "math", 1 arg
--extensions math.pow:global:2 # math.pow(base, exp) — 2 args
--extensions reverse:receiver:1 # x.reverse() — receiver counts as the 1 arg
--extensions greet:both:2 # greet(name, lang) and name.greet(lang)
--extensions-file format
The file must contain a JSON array of extension declaration objects:
[
{ "namespace": "math", "function": "sqrt",
"global_style": true, "receiver_style": false, "num_args": 1 },
{ "namespace": null, "function": "reverse",
"global_style": false, "receiver_style": true, "num_args": 1 },
{ "namespace": null, "function": "greet",
"global_style": true, "receiver_style": true, "num_args": 2 }
]
Rust API
Use ExtensionDecl to declare the extension at compile time, and
with_extension to provide the implementation at runtime:
#![allow(unused)]
fn main() {
use ferricel_core::{compiler, runtime};
use ferricel_types::extensions::ExtensionDecl;
// Declare the extension at compile time
let abs_decl = ExtensionDecl {
namespace: None,
function: "abs".to_string(),
receiver_style: false,
global_style: true,
num_args: 1,
};
// Compile the CEL expression with the extension
let wasm = compiler::Builder::new()
.with_extension(abs_decl.clone())
.build()
.compile("abs(x)")?;
// Register the implementation at runtime
let result = runtime::Builder::new()
.with_extension(abs_decl, |args| {
let n = args[0].as_i64().unwrap_or(0);
Ok(serde_json::Value::Number(n.abs().into()))
})
.with_wasm(wasm)
.build()?
.eval(Some(r#"{"x": -42}"#))?;
assert_eq!(result, "42");
}
The extension declaration specifies the function signature at compile time (for validation), and the implementation is provided at runtime. The host is responsible for marshalling JSON values to and from the extension function.
Dotted namespaces
Namespaces can contain dots, e.g. kw.net.lookupHost. The compiler resolves
the full dotted target name from the CEL AST, so both single-segment (math)
and multi-segment (kw.net) namespaces work identically:
#![allow(unused)]
fn main() {
let decl = ExtensionDecl {
namespace: Some("kw.net".to_string()),
function: "lookupHost".to_string(),
receiver_style: false,
global_style: true,
num_args: 1,
};
}
Note: extensions declared at compile time but not implemented by the host produce a runtime error when the expression is evaluated.
Builder Chains
Builder chains model fluent APIs where intermediate method calls accumulate
state into a map and a terminal call invokes the host with the accumulated
state. This is the pattern used by cel-go libraries like kw.k8s and
kw.sigstore.
How it works
At runtime, each intermediate builder object is a CelValue::Object (map)
with a reserved "__type__" key that tracks the builder’s current type. For
example, after kw.k8s.apiVersion("v1").kind("Pod").namespace("default") the
map is:
{
"__type__": "kw.k8s.Client",
"apiVersion": "v1",
"kind": "Pod",
"namespace": "default"
}
When a terminal method like .list() or .get("nginx") is called, the host
receives this map as the single argument of the extension call.
Declaring a builder chain
A builder chain is declared with BuilderChainDecl, which contains a list of
BuilderStep variants. Register it on the compiler builder with
with_builder_chain:
#![allow(unused)]
fn main() {
use ferricel_core::compiler;
use ferricel_types::extensions::{BuilderChainDecl, BuilderStep};
let chain = BuilderChainDecl {
steps: vec![
BuilderStep::Entry { /* ... */ },
BuilderStep::Chain { /* ... */ },
BuilderStep::Terminal { /* ... */ },
],
};
let wasm = compiler::Builder::new()
.with_builder_chain(chain)
.build()
.compile("my.api.start('val').method('arg').run()")?;
}
Step types
Entry
A global entry-point that starts a new chain. Called as a dotted global
function (e.g. kw.k8s.apiVersion("v1")).
#![allow(unused)]
fn main() {
BuilderStep::Entry {
function: "kw.k8s.apiVersion".into(),
state_keys: vec!["apiVersion".into()],
output_type: "kw.k8s.ClientBuilder".into(),
}
}
| Field | Description |
|---|---|
function | Full dotted CEL function name. |
state_keys | JSON keys under which each positional argument is stored. The number of keys determines the expected arity. |
output_type | Type tag written to "__type__" in the output map. |
Chain
A receiver-style chaining step that stores positional arguments under fixed keys. Supports single-arg and multi-arg steps.
#![allow(unused)]
fn main() {
// Single-arg: .kind("Pod")
BuilderStep::Chain {
function: "kind".into(),
input_type: "kw.k8s.ClientBuilder".into(),
state_keys: vec!["kind".into()],
output_type: "kw.k8s.Client".into(),
accumulate: false,
}
// Multi-arg: .keyless("https://issuer", "user@example.com")
BuilderStep::Chain {
function: "keyless".into(),
input_type: "sig.VerifierBuilder".into(),
state_keys: vec!["issuer".into(), "subject".into()],
output_type: "sig.KeylessVerifier".into(),
accumulate: false,
}
}
| Field | Description |
|---|---|
function | Method name. |
input_type | Expected "__type__" tag of the receiver. Used for compile-time disambiguation. |
state_keys | JSON keys for each positional argument. Determines arity. |
output_type | Type tag for the output map. |
accumulate | When true, values are appended to an array under each key instead of overwriting (e.g. repeated .fieldMask() calls). |
MapEntry
A receiver-style step that inserts a runtime key/value pair into a nested map. Always takes exactly 2 arguments: arg0 is the map key, arg1 is the value. Repeated calls merge into the same nested map.
#![allow(unused)]
fn main() {
// .annotation("env", "prod") → annotations["env"] = "prod"
BuilderStep::MapEntry {
function: "annotation".into(),
input_type: "sig.VerifierBuilder".into(),
state_key: "annotations".into(),
output_type: "sig.VerifierBuilder".into(),
}
}
After .annotation("env", "prod").annotation("team", "sec"), the state map
contains:
{
"__type__": "sig.VerifierBuilder",
"annotations": { "env": "prod", "team": "sec" }
}
| Field | Description |
|---|---|
function | Method name. |
input_type | Expected "__type__" tag of the receiver. |
state_key | Field name of the nested map in the state object. |
output_type | Type tag for the output map. |
Note: the choice between
ChainandMapEntryis made by the extension author, not inferred. The CEL call site looks identical —.annotation("env", "prod")could be either — but the semantics differ:Chainstores arguments under fixed positional keys;MapEntryuses arg0 as a dynamic key into a nested map and accumulates across repeated calls.
Terminal
A terminal step that invokes the host with the accumulated state map. Extra positional arguments (if any) are folded into the map before the call.
#![allow(unused)]
fn main() {
// Zero-arg terminal: .list()
BuilderStep::Terminal {
function: "list".into(),
input_type: "kw.k8s.Client".into(),
extra_arg_keys: vec![],
host_namespace: "kw.k8s".into(),
host_function: "list".into(),
}
// One-arg terminal: .get("nginx") — folds "name" into the map
BuilderStep::Terminal {
function: "get".into(),
input_type: "kw.k8s.Client".into(),
extra_arg_keys: vec!["name".into()],
host_namespace: "kw.k8s".into(),
host_function: "get".into(),
}
}
| Field | Description |
|---|---|
function | Method name. |
input_type | Expected "__type__" tag of the receiver. |
extra_arg_keys | Keys for extra positional arguments folded into the map before the host call. Empty for zero-arg terminals. |
host_namespace | Namespace in the ExtensionCallPayload sent to the host. |
host_function | Function name in the ExtensionCallPayload sent to the host. |
The host must also register a flat ExtensionDecl for each terminal so the
runtime can dispatch the call:
#![allow(unused)]
fn main() {
let ext = ExtensionDecl {
namespace: Some("kw.k8s".to_string()),
function: "list".to_string(),
global_style: false,
receiver_style: false,
num_args: 1, // the accumulated map is the single argument
};
}
Disambiguation
When multiple chains register the same method name (e.g. both kw.sigstore
and kw.crypto define a .verify() terminal), the compiler disambiguates at
compile time using two criteria:
-
Receiver type — each step declares an
input_type. The compiler tracks the static__type__of the receiver expression through the chain and selects the step whoseinput_typematches. -
Argument count — steps with the same method name and receiver type but different arities (e.g.
.githubAction("owner")vs.githubAction("owner", "repo")) are distinguished by the number of positional arguments, determined bystate_keys.len()for Chain steps andextra_arg_keys.len()for Terminal steps.
If disambiguation produces zero or more than one candidate, the compiler reports an error.
Complete example
This example declares a small query builder chain, compiles an expression that uses it, and evaluates it with a host implementation that receives the accumulated state map:
#![allow(unused)]
fn main() {
use ferricel_core::{compiler, runtime};
use ferricel_types::extensions::{BuilderChainDecl, BuilderStep, ExtensionDecl};
// A tiny query API: query.field("age").between(18, 65).count()
let chain = BuilderChainDecl {
steps: vec![
BuilderStep::Entry {
function: "query.field".into(),
state_keys: vec!["field".into()],
output_type: "query.Builder".into(),
},
// Multi-arg step: stores both bounds under "min" and "max".
BuilderStep::Chain {
function: "between".into(),
input_type: "query.Builder".into(),
state_keys: vec!["min".into(), "max".into()],
output_type: "query.Range".into(),
accumulate: false,
},
BuilderStep::Terminal {
function: "count".into(),
input_type: "query.Range".into(),
extra_arg_keys: vec![],
host_namespace: "query".into(),
host_function: "count".into(),
},
],
};
// Flat extension decl for the terminal
let count_ext = ExtensionDecl {
namespace: Some("query".to_string()),
function: "count".to_string(),
global_style: false,
receiver_style: false,
num_args: 1,
};
// Compile
let wasm = compiler::Builder::new()
.with_builder_chain(chain)
.build()
.compile("query.field('age').between(18, 65).count()")?;
// Run
let result = runtime::Builder::new()
.with_extension(count_ext, |args| {
// args[0] is the accumulated state map:
// { "__type__": "query.Range",
// "field": "age", "min": 18, "max": 65 }
let map = &args[0];
assert_eq!(map["field"], "age");
assert_eq!(map["min"], 18);
assert_eq!(map["max"], 65);
// A real host would run the query and return the matching row count.
Ok(serde_json::json!(42))
})
.with_wasm(wasm)
.build()?
.eval(None)?;
}
For a real-world example, see the built-in kw.k8s builder chain
used by ValidatingAdmissionPolicy support.
Inspecting Used Extensions
The compiler embeds a ferricel.extensions custom section into every compiled
Wasm module. It contains a JSON array listing every host extension the module
may call at evaluation time — one entry per unique (namespace, function)
pair, sorted and deduplicated.
[
{ "namespace": null, "function": "abs" },
{ "namespace": "kw.k8s", "function": "get" },
{ "namespace": "kw.net", "function": "lookupHost" }
]
The section is always present; it is an empty array [] for modules that use no
host extensions.
Note: because CEL’s
&&and||operators do not short-circuit at compile time, an extension listed here may not be called for every evaluation. The list records what the module can call, not what it will call.
Reading the section from Rust
Use ferricel_core::extensions_used to read the section back:
#![allow(unused)]
fn main() {
use ferricel_core::extensions_used;
let wasm = std::fs::read("policy.wasm")?;
for ext in extensions_used(&wasm)? {
println!("{}/{}", ext.namespace.as_deref().unwrap_or("(none)"), ext.function);
}
}
Returns an empty Vec if the section is absent (e.g. modules produced by an
older version of ferricel).
Reading the section from the command line
Use ferricel inspect for a human-readable view of all embedded metadata,
including the extensions list with syntax-highlighted source:
ferricel inspect policy.wasm
Or for just the raw extensions JSON:
wasm-objdump -s -j ferricel.extensions policy.wasm
For the full specification of all custom sections ferricel embeds, and
documentation of the ferricel inspect command, see the
Wasm Spec chapter.
Kubernetes ValidatingAdmissionPolicy Support
ferricel can compile a Kubernetes
ValidatingAdmissionPolicy
(VAP) into a self-contained WebAssembly module. The host that runs the Wasm module is
responsible for supplying Kubernetes data, like the namespace object and param resources,
via bindings and extensions.
This is a ferricel-core library feature. Enable it with the k8s-vap Cargo
feature.
Response Shape
The module’s evaluate export returns a JSON object.
Request accepted:
{ "accepted": true }
Or, on rejection:
{ "accepted": false, "message": "too many replicas", "code": 422 }
The message field comes from the failing validation’s message field, or from
its messageExpression if one is specified. If neither is set, a default
message is generated from the validation expression text.
The code field is derived from the validation’s reason field:
reason | HTTP code |
|---|---|
Forbidden | 403 |
Unauthorized | 401 |
RequestEntityTooLarge | 413 |
Invalid or unset | 422 |
Evaluation Order
The compiled module enforces the standard Kubernetes VAP evaluation order:
-
matchConditions— evaluated in declaration order. If any condition evaluates tofalse, the policy does not apply to this request: the module returns{"accepted": true}immediately (a skip, not a rejection). RemainingmatchConditionsand allvalidationsare not evaluated. -
variables— evaluated in declaration order. Each result is stored undervariables.<name>and is immediately accessible to subsequentvariablesexpressions and to allvalidations. -
validations— evaluated in declaration order. The first expression that evaluates tofalsecauses the module to return a rejection response. Remaining validations are not evaluated.
Known Limitations
The following VAP features are not yet implemented or are not part of ferricel’s scope:
| Feature | Status | Notes |
|---|---|---|
failurePolicy: Ignore | Out of scope | This has to be handled by the host. |
auditAnnotations | Not implemented | Requires a separate compilation path and an additional field in the response JSON. |
matchConstraints | Out of scope | This is a server-side filter applied by the API server, not a CEL expression. The compiled module does not enforce it. |
Compiling a ValidatingAdmissionPolicy to Wasm
From YAML
Pass the raw YAML string to compile_vap:
#![allow(unused)]
fn main() {
use ferricel_core::compiler::Builder;
let yaml = std::fs::read_to_string("policy.yaml")?;
let wasm_bytes = Builder::new().build().compile_vap(&yaml)?;
}
The YAML must contain exactly one ValidatingAdmissionPolicy document.
From a Parsed Policy
The ValidatingAdmissionPolicy type is defined by the
k8s-openapi crate.
#![allow(unused)]
fn main() {
use ferricel_core::compiler::Builder;
use k8s_openapi::api::admissionregistration::v1::ValidatingAdmissionPolicy;
let policy: ValidatingAdmissionPolicy = /* ... */;
let wasm_bytes = Builder::new().build().compile_vap_from_policy(&policy)?;
}
Example
The following policy enforces a maximum replica count read from a ConfigMap
parameter resource:
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicy
metadata:
name: "demo-policy.example.com"
spec:
failurePolicy: Fail
paramKind:
apiVersion: v1
kind: ConfigMap
matchConstraints:
resourceRules:
- apiGroups: ["apps"]
apiVersions: ["v1"]
operations: ["CREATE", "UPDATE"]
resources: ["deployments"]
variables:
- name: replicas
expression: "object.spec.replicas"
validations:
- expression: "variables.replicas <= int(params.data.maxreplicas)"
message: "The number of replicas must be less than or equal to the configured maximum"
Compile it to a Wasm module:
#![allow(unused)]
fn main() {
use ferricel_core::compiler::Builder;
let yaml = std::fs::read_to_string("policy.yaml")?;
let wasm_bytes = Builder::new().build().compile_vap(&yaml)?;
}
Running a Compiled ValidatingAdmissionPolicy
Use runtime::Builder exactly as you would for any other compiled CEL module.
Pass the variable bindings as a JSON object:
#![allow(unused)]
fn main() {
use ferricel_core::{compiler::Builder, runtime};
let wasm_bytes = Builder::new().build().compile_vap(&yaml)?;
let result_str = runtime::Builder::new()
.with_wasm(wasm_bytes)
.build()?
.eval(Some(&bindings_json))?;
let result: serde_json::Value = serde_json::from_str(&result_str)?;
// result["accepted"] == true / false
}
Required Bindings
| Binding | Required when… |
|---|---|
object | Always (the resource being admitted) |
oldObject | Policy expressions reference oldObject |
request | Policy expressions reference request |
namespaceObject | Policy expressions reference namespaceObject |
paramRef | paramKind is set (see below) |
object, oldObject, and request correspond directly to the fields of the
Kubernetes
AdmissionReview
request object.
Kubernetes Resource Fetching
Params
When a policy sets paramKind, the compiled module fetches the referenced
resource itself at evaluation time by calling a host-provided kw.k8s.get
extension. The host does not supply params directly in the bindings.
The module reads paramRef.name and paramRef.namespace from the bindings at
runtime and forwards them to the host as part of the request map (see below).
The result is stored in params and made available to all variables and
validations expressions.
The host must supply paramRef in the bindings:
{
"paramRef": { "name": "my-params", "namespace": "default" },
"object": { ... }
}
And register a kw.k8s.get implementation on the runtime builder:
#![allow(unused)]
fn main() {
use ferricel_core::{compiler::Builder, runtime, compiler::vap};
let wasm_bytes = Builder::new().build().compile_vap(&yaml)?;
let result_str = runtime::Builder::new()
.with_wasm(wasm_bytes)
.with_extension(vap::kw_k8s_get_extension(), |args| {
// args[0] is the accumulated request map (see shape below)
let map = &args[0];
let name = map["name"].as_str().unwrap();
let namespace = map["namespace"].as_str().unwrap();
let api_version = map["apiVersion"].as_str().unwrap();
let kind = map["kind"].as_str().unwrap();
// Fetch from Kubernetes and return the resource as a JSON value.
let resource = fetch_from_k8s(api_version, kind, namespace, name)?;
Ok(resource)
})
.build()?
.eval(Some(&bindings_json))?;
}
Fetching Data from the Kubernetes API
The
kw.k8sAPI is implemented as a builder chain. See the Host Extensions chapter for general documentation on declaring and consuming builder chains.
Policy variables (and other expressions) can call kw.k8s directly to fetch
arbitrary resources. The API mirrors the
kw.k8s Kubernetes library
provided by the Kubewarden CEL policy:
kw.k8s
.apiVersion(<string>) → kw.k8s.ClientBuilder
.kind(<string>) → kw.k8s.Client
.namespace(<string>) → kw.k8s.Client (optional)
.labelSelector(<string>) → kw.k8s.Client (optional)
.fieldSelector(<string>) → kw.k8s.Client (optional)
.fieldMask(<string>) → kw.k8s.Client (optional, repeatable)
.get(<string>) → dyn (host call — returns one resource)
.list() → dyn (host call — returns a list)
Example — fetch a ConfigMap in a variable, then check a field in a validation:
// variables entry
kw.k8s.apiVersion('v1').kind('ConfigMap').namespace('default').get('my-config')
// validation expression
variables.cfg.data.allowedTeam == request.userInfo.groups[0]
Host Extension Request Map
When a kw.k8s.get or kw.k8s.list terminal is called, the host receives a
single argument — a JSON object containing the accumulated builder state:
| Key | Set by chain step | Notes |
|---|---|---|
apiVersion | .apiVersion() | Always present |
kind | .kind() | Always present |
namespace | .namespace() | Present only if .namespace() was called |
labelSelector | .labelSelector() | Present only if called |
fieldSelector | .fieldSelector() | Present only if called |
fieldMasks | .fieldMask() | Array; present only if called |
name | .get(<name>) | Present only for get terminal |
Register the extensions using the helpers from ferricel_core::compiler::vap:
#![allow(unused)]
fn main() {
use ferricel_core::compiler::vap;
// For policies that call .get(...)
runtime::Builder::new()
.with_extension(vap::kw_k8s_get_extension(), |args| { ... })
// For policies that call .list()
runtime::Builder::new()
.with_extension(vap::kw_k8s_list_extension(), |args| { ... })
}
Example
This example mirrors the scenario from the
Kubewarden CEL policy README:
a policy that enforces a maximum replica count read from a ConfigMap parameter resource.
The ValidatingAdmissionPolicyBinding
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicyBinding
metadata:
name: "demo-policy-binding.example.com"
spec:
policyName: "demo-policy.example.com"
validationActions: [Deny]
paramRef:
name: "my-params"
namespace: "default"
parameterNotFoundAction: Deny
matchResources:
namespaceSelector:
matchLabels:
environment: test
The ConfigMap Parameter Resource
apiVersion: v1
kind: ConfigMap
metadata:
name: my-params
namespace: default
data:
maxreplicas: "5"
The Incoming Deployment
This is the resource being admitted:
apiVersion: apps/v1
kind: Deployment
metadata:
name: my-app
namespace: default
spec:
replicas: 3
selector:
matchLabels:
app: my-app
template:
metadata:
labels:
app: my-app
spec:
containers:
- name: my-app
image: my-app:latest
Rust Integration
#![allow(unused)]
fn main() {
use ferricel_core::{compiler::{Builder, vap}, runtime};
// The host extracts these from the AdmissionReview and the PolicyBinding.
let bindings = serde_json::json!({
"paramRef": { "name": "my-params", "namespace": "default" },
"object": object_json,
});
let wasm_bytes = Builder::new().build().compile_vap(vap_yaml)?;
let result_str = runtime::Builder::new()
.with_wasm(wasm_bytes)
.with_extension(vap::kw_k8s_get_extension(), |args| {
// The module calls this to fetch the ConfigMap.
// In production, make a real Kubernetes API call here.
let map = &args[0];
assert_eq!(map["apiVersion"], "v1");
assert_eq!(map["kind"], "ConfigMap");
assert_eq!(map["name"], "my-params");
assert_eq!(map["namespace"], "default");
Ok(serde_json::json!({
"apiVersion": "v1",
"kind": "ConfigMap",
"metadata": { "name": "my-params", "namespace": "default" },
"data": { "maxreplicas": "5" }
}))
})
.build()?
.eval(Some(&bindings.to_string()))?;
let result: serde_json::Value = serde_json::from_str(&result_str)?;
assert_eq!(result["accepted"], true); // replicas 3 <= maxreplicas 5
}
Compiler Architecture
Ferricel compiles CEL expressions into self-contained WebAssembly modules. The process involves two distinct Rust artifacts: the runtime and the compiler.
The Runtime
The runtime crate is a Rust library compiled to wasm32-unknown-unknown.
It provides the low-level functions that a compiled CEL program calls at
execution time, like memory allocation, value serialization, arithmetic helpers,
string operations, and so on.
Because the target is bare Wasm (no WASI, no OS), it is entirely self-contained.
The Compiler
ferricel-core contains the compiler. It parses the CEL source using the
parser that is part of the cel crate.
The parser produces a typed AST, which the compiler then traverses, emitting
WebAssembly instructions.
The WebAssembly instructions are produced using the walrus
crate.
Rather than generating a Wasm module from scratch, the compiler loads the
pre-embedded runtime.wasm module and injects the compiled CEL program into it.
A dead-code-elimination pass (based on walrus::passes::gc)
then removes any runtime functions the program does not call, keeping output
files small.
The result is a single .wasm file that is fully self-contained and can be
executed anywhere a WebAssembly runtime is available.