-->
Building a CEL engine for .NET
Building a CEL engine for .NET<br>Why I wrote Celly — a from-scratch, conformance-passing CEL implementation for .NET — and the protovalidate library that fell out of it<br>July 18, 2026<br>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']<br>EVALUATED AGAINST CHANGING DATA
{ age: 25, country: 'US' }<br>{ age: 16, country: 'US' }<br>{ age: 30, country: 'FR' }
allow<br>deny<br>deny
THE SAME ENGINE RUNS IN
Kubernetes<br>Envoy<br>Cloud IAM<br>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...