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

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 .wasm module produced by it
  • ferricel-core: the pure Rust crate used by ferricel CLI. 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

FeatureStatus
Integer arithmetic (+, -, *, /, %) with overflow detectionSupported
Floating-point arithmeticSupported
Unsigned integer (uint) arithmeticSupported
Boolean logic (&&, ||, !) with short-circuit evaluationSupported
Comparison operators (==, !=, <, <=, >, >=)Supported
String operations (+, size, contains, startsWith, endsWith, matches)Supported
Bytes operationsSupported
List literals, indexing, membership (in)Supported
Map literals, field access, indexingSupported
Conditional expressions (? :)Supported
Null handling and null propagationSupported
Type conversions (int(), uint(), double(), string(), bytes(), bool())Supported
Timestamp and Duration typesSupported
Timestamp/Duration arithmetic and field accessorsSupported
size() functionSupported
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 accessSupported
Protobuf wrapper type semantics (google.protobuf.*Value)Supported
dyn() type erasureSupported
type() introspectionSupported

cel-go Extension Libraries

ExtensionFunctionsStatus
Bindingscel.bind(var, init, body)Supported
Encodersbase64.encode, base64.decodeSupported
Mathmath.greatest, math.leastSupported
math.bitOr, math.bitAnd, math.bitXor, math.bitNotSupported
math.bitShiftLeft, math.bitShiftRightSupported
math.ceil, math.floor, math.round, math.truncSupported
math.abs, math.signSupported
math.isInf, math.isNaN, math.isFiniteSupported
math.sqrtSupported
StringscharAt, indexOf, lastIndexOfSupported
lowerAscii, upperAscii, trimSupported
replace, split, substring, joinSupported
reverse, strings.quoteSupported
format (string interpolation)Supported
Listsslice, flatten, distinct, reverseSupported
sort, sortBySupported
first, lastSupported
lists.range(n)Supported
Setssets.contains, sets.equivalent, sets.intersectsSupported
TwoVarComprehensionsall(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
Regexregex.replace, regex.extract, regex.extractAllSupported
Protosproto.getExt, proto.hasExtNot supported

Kubernetes CEL Extensions

Ferricel also supports the Kubernetes CEL validation libraries:

ExtensionStatus
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 SuiteSuccessfulFailedSkipped
basic4120
bindings_ext800
block_ext18811
comparisons40600
conversions10900
encoders_ext400
fp_math3000
integer_math6400
lists3900
logic3000
macros24600
macros4400
math_ext19900
namespace1130
network_ext6900
optionals7000
parse1287417
string_ext21600
string5100
timestamps7600
type_deduction17129
Total16768857

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.

ParameterTypeDescription
lenusizeNumber 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.

ParameterTypeDescription
leveli32Log level threshold (see table below). Values outside the valid range are clamped.

The log levels are:

ValueLevel
0Debug
1Info (default)
2Warn
3Error

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.

ParameterTypeDescription
bindingsi64Packed 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.

ParameterTypeDescription
bindingsi64Packed 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.

ParameterTypeDescription
ptri32Offset in Wasm linear memory of a UTF-8 JSON-encoded LogEvent object.
leni32Byte 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).

ParameterTypeDescription
packedi64Packed 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.

ParameterTypeDescription
requesti64Packed 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:

FieldNameVersion
languageCEL(empty)
processed-byferricelcrate 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 nameContentProduced by
ferricel.cel-sourceThe original CEL expression[compile()]
ferricel.vap-sourceThe full ValidatingAdmissionPolicy serialized as YAML[compile_vap()], [compile_vap_from_policy()]
ferricel.extensionsJSON array of host extensions used by this moduleall 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 run CLI — simple command-line execution; does not support host extensions.
  • ferricel-core Rust 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:

  1. Compile time — declare the extension so the compiler emits a real host call instead of a static no matching overload error.
  2. Runtime — register an implementation that the Wasm module invokes via the cel_call_extension host import.

ferricel supports two kinds of host extensions:

  • Flat extensions — standalone functions like math.abs(x) or kw.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:

SegmentDescription
namespaceOptional dot-separated namespace prefix (e.g. math in math.sqrt).
functionFunction name (the last dot-separated segment).
styleOne of global, receiver, or both (see below).
arityTotal number of arguments the host receives, including the receiver for receiver-style calls.

Calling styles:

StyleInvocation form
globalfunc(args) or ns.func(args)
receivervalue.func(extra_args) — receiver is always args[0]
bothBoth 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(),
}
}
FieldDescription
functionFull dotted CEL function name.
state_keysJSON keys under which each positional argument is stored. The number of keys determines the expected arity.
output_typeType 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,
}
}
FieldDescription
functionMethod name.
input_typeExpected "__type__" tag of the receiver. Used for compile-time disambiguation.
state_keysJSON keys for each positional argument. Determines arity.
output_typeType tag for the output map.
accumulateWhen 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" }
}
FieldDescription
functionMethod name.
input_typeExpected "__type__" tag of the receiver.
state_keyField name of the nested map in the state object.
output_typeType tag for the output map.

Note: the choice between Chain and MapEntry is made by the extension author, not inferred. The CEL call site looks identical — .annotation("env", "prod") could be either — but the semantics differ: Chain stores arguments under fixed positional keys; MapEntry uses 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(),
}
}
FieldDescription
functionMethod name.
input_typeExpected "__type__" tag of the receiver.
extra_arg_keysKeys for extra positional arguments folded into the map before the host call. Empty for zero-arg terminals.
host_namespaceNamespace in the ExtensionCallPayload sent to the host.
host_functionFunction 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:

  1. 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 whose input_type matches.

  2. 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 by state_keys.len() for Chain steps and extra_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:

reasonHTTP code
Forbidden403
Unauthorized401
RequestEntityTooLarge413
Invalid or unset422

Evaluation Order

The compiled module enforces the standard Kubernetes VAP evaluation order:

  1. matchConditions — evaluated in declaration order. If any condition evaluates to false, the policy does not apply to this request: the module returns {"accepted": true} immediately (a skip, not a rejection). Remaining matchConditions and all validations are not evaluated.

  2. variables — evaluated in declaration order. Each result is stored under variables.<name> and is immediately accessible to subsequent variables expressions and to all validations.

  3. validations — evaluated in declaration order. The first expression that evaluates to false causes 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:

FeatureStatusNotes
failurePolicy: IgnoreOut of scopeThis has to be handled by the host.
auditAnnotationsNot implementedRequires a separate compilation path and an additional field in the response JSON.
matchConstraintsOut of scopeThis 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

BindingRequired when…
objectAlways (the resource being admitted)
oldObjectPolicy expressions reference oldObject
requestPolicy expressions reference request
namespaceObjectPolicy expressions reference namespaceObject
paramRefparamKind 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.k8s API 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:

KeySet by chain stepNotes
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.