Siddhartha Bariker


Building a CEL engine for .NET
Why I wrote Celly — a from-scratch, conformance-passing CEL implementation for .NET — and the protovalidate library that fell out of it
July 18, 2026
tech

TL;DR — I built Celly, a native C# implementation of Google’s Common Expression Language. It passes 100% of the official conformance suite, and along the way I ended up shipping the first protovalidate library for .NET. It’s on NuGet, and the docs are at bsid.io/celly.

ONE RULE, WRITTEN ONCE user.age >= 18 && user.country in ['US','CA'] EVALUATED AGAINST CHANGING DATA { age: 25, country: 'US' } { age: 16, country: 'US' } { age: 30, country: 'FR' } allow deny deny THE SAME ENGINE RUNS IN Kubernetes Envoy Cloud IAM protovalidate

What’s CEL, and why did I care?

CEL (Common Expression Language) is a small, safe expression language from Google. You’ve used something built on it even if you’ve never heard of it — it’s the language behind Kubernetes admission policies, Envoy’s RBAC rules, Google Cloud IAM conditions, and gRPC’s protovalidate. The idea is simple: let people write expressions like

request.auth.groups.exists(g, g == "admin") || resource.owner == request.auth.subject

as data — strings in a config file — and evaluate them safely at runtime. No arbitrary code execution, guaranteed to terminate, strongly typed.

Why CEL, and not just a scripting language?

Any time you need logic to live outside your compiled code — “which requests are allowed,” “when does this alert fire,” “is this payload valid” — you’ve got a few options, and most of them are traps:

  • Hardcode it. Now every rule change is a code change, a review, and a deploy. Your rules move at the speed of your release process, not the speed of the business.
  • Embed a real scripting language (Lua, JavaScript, Python). Congratulations, you’ve handed arbitrary code execution to whoever writes the rules — while (true), reading the filesystem, exhausting memory. A rule is now an attack surface.
  • Invent your own mini-language. Now you maintain a parser, a type checker, and a spec forever, and it works in exactly one service.

CEL is the sweet spot, and it’s popular for deliberate reasons:

  • It always terminates. CEL is not Turing-complete, on purpose. No unbounded loops, no recursion — evaluation is bounded. That one property is why Google, Kubernetes, and Envoy are comfortable running untrusted, user-supplied expressions directly in the request path. A rule literally cannot hang your server.
  • It’s safe by construction. No I/O, no side effects, no ambient authority. An expression can only look at the data you hand it and return a value. The worst a malicious rule can do is be wrong.
  • Rules are data, not code. Expressions are just strings — they live in a database, a Kubernetes CRD, a config map — and they change without a redeploy. Product can ship a new policy in seconds.
  • Write once, run anywhere. The same expression means the same thing in Go, C++, Java, Rust — and now .NET. Declare a validation rule in your .proto and every service, in every language, enforces it identically. That’s the entire premise of protovalidate.
  • Fast and typed. Compile once, evaluate millions of times. The type checker catches mistakes before a rule ever ships, and evaluation is a tight walk over pre-resolved data.

The trick is that CEL gives you just enough language to express real logic and deliberately withholds everything that would make it dangerous. That restraint is the whole point — and it’s why it keeps showing up under the hood of infrastructure you already use.

There are official implementations for Go, C++, Java, and Rust. But not for .NET. The options that existed were partial ports, and none of them passed the conformance suite. So if you were on .NET and wanted CEL, you were kind of stuck.

I wanted it. So I decided to write it.

The one rule I gave myself

The tempting shortcut is to take the Go implementation, compile it to WebAssembly, and call into it from C#. Or ship a native binding. That “works,” but now your pure managed library drags a WASM runtime or a native .so behind it, and you inherit a whole class of packaging and platform problems.

So I made one rule: pure managed C#. No WASM, no cgo, no Go-compiled artifacts. Everything — the lexer, the recursive-descent parser, the type checker, the tree-walking evaluator — written from scratch in C#. If it can’t be done in managed code, it doesn’t ship.

That turned out to be the right constraint, but it made the correctness bar much higher. You can’t lean on someone else’s engine; you have to actually get every detail right yourself.

Proving it actually works

Here’s the thing about claiming “complete CEL support” — it’s a claim you have to be able to prove. CEL is full of subtle traps:

  • Cross-type numeric comparisons (1 == 1u == 1.0) on a single number line, without ever casting through a double and losing precision above 2^53.
  • size(), charAt, substring are Unicode code-point based, but .NET strings are UTF-16 — so surrogate pairs will silently break naive implementations.
  • Timestamps need nanosecond precision, but .NET ticks are 100ns.
  • string(1e23) has to format exactly like Go’s strconv, down to the scientific notation.
  • && and || are commutative and error-absorbing — false && error is false, not an error.

Every one of those is a place to be quietly wrong. So from the very first commit, Celly was wired to the official cel-spec conformance suite — the same 2,456 tests that cel-go, cel-cpp, and cel-java run against. I used a ratcheting skip-list: a failing test could be listed and ignored, but the moment it started passing, the build failed until I removed it from the list. Progress could only ever go up, and CI stayed green the whole way.

Today that list is empty. 2,456 / 2,456. And because “trust me, it’s 100%” isn’t good enough, I verified it three independent ways: the repo test suite, a fully-separate audit that pulls Celly from NuGet and converts the test data with a different tool, and a differential fuzzer that generates tens of thousands of random expressions and compares Celly’s output against cel-go byte-for-byte. Zero divergences.

Show me the code

using Celly;
using Celly.Checking;
using Celly.Types;

var env = CelEnv.Create(new CelEnvSettings
{
    Declarations = [new VariableDecl("request", CelType.Map(CelType.String, CelType.Dyn))],
});

var program = env.Compile("request.user.startsWith('admin-') && size(request.groups) > 0");

var result = program.Eval(new Dictionary<string, object?>
{
    ["request"] = new Dictionary<string, object?>
    {
        ["user"] = "admin-alice",
        ["groups"] = new[] { "ops" },
    },
});
// result => true

Compile once, evaluate many times — the pattern every policy engine wants. The compiled program is immutable and thread-safe, so one instance serves every request.

'x + 1 > 3'CompileCelProgramEval(data)result lex · parse · check · plan (once)reused · thread-safe · many ×

Making it fast

A policy engine compiles a rule once and then evaluates it on every request — sometimes millions of times a second. So the number that actually matters isn’t how fast you parse; it’s steady-state evaluation.

Celly turned out to be the fastest CEL engine on .NET — roughly 1.2× faster than the next-best managed option and several times faster than the rest. But the surprise was this: on comprehension-heavy expressions it’s faster than cel-go itself, the reference Go implementation (~15µs vs ~22µs on a 100-element filter/map). A managed tree-walker beating native Go wasn’t on my bingo card.

A few decisions got it there:

  • Compile once, evaluate many. Parsing, type-checking, and planning all happen up front and produce an immutable CelProgram. Evaluation is a walk over that pre-built plan, where overloads are already resolved and constant sub-expressions already folded — so the hot path does almost no bookkeeping, just the real work. The program is immutable and thread-safe, so one instance serves every thread with zero per-thread setup.

  • The rope trick. CEL comprehensions (map, filter) build their result by repeatedly doing accumulator + [element]. Do that literally and you copy the whole list every iteration — O(n²), and it’s exactly where comprehension cost piles up. Celly uses a rope-style list that makes append O(1), turning the accumulation back into O(n). That single change is why the comprehension benchmark beats the Go reference.

  • Extensions ride the same path. The string and math extension functions aren’t a bolted-on layer with a dispatch tax — they plug into the same tree-walk as the built-ins. On an extension-heavy expression, Celly comes out about 2× faster than cel-go.

And turning on all nine extension libraries costs nothing at eval time: a simple expression runs in ~125ns whether the environment has zero libraries or all of them, because loading libraries is a one-time compile step and the hot path only touches the functions your expression actually uses.

The part I didn’t plan: protovalidate

Once the core engine was solid, I noticed something. protovalidate — buf’s successor to protoc-gen-validate — is how you validate protobuf messages against rules declared right in the .proto:

string email = 1 [(buf.validate.field).string.email = true];
int32 age = 2 [(buf.validate.field).int32 = {gte: 0, lte: 150}];

It has runtimes for Go, Java, Python, C++… and none for .NET.

And here’s the neat part: protovalidate’s rules are themselves written in CEL, embedded as options inside the proto. Which meant I already had the hard part — a conformant CEL engine. All that was left was reading those rules via reflection and evaluating them.

.protoValidatorCelly engineViolations buf.validate rules (CEL)reads rules by reflectionthe rules ARE CELfield · rule · message

“All that was left” turned into a real grind — protovalidate has its own conformance suite of 2,872 cases covering every scalar rule, well-known types, maps, oneofs, editions, the works. But I drove it to green the same way: run buf’s official conformance runner, fix what fails, repeat. It’s now at 2,872 / 2,872, and it’s on NuGet as Celly.Protovalidate. As far as I know, it’s the first protovalidate implementation for .NET.

var validator = new Validator(Person.Descriptor.File);
foreach (var violation in validator.Validate(person))
    Console.WriteLine($"{violation.RuleId}: {violation.Message}");

Try it

dotnet add package Celly                 # the CEL engine
dotnet add package Celly.Protobuf        # if your data is protobuf
dotnet add package Celly.Protovalidate   # protobuf validation

If you’re on .NET and you’ve ever wanted CEL — for a rules engine, feature flags, authorization policies, or protobuf validation — I’d love for you to give it a spin.

Looking to hire an engineer who knows both hardware and software? Reach me at [email protected].