Skip to content

CEL Rules Reference

Sharp Ninja edited this page May 16, 2026 · 1 revision

CEL Rules Reference

Each flag rule carries a when field that contains a CEL expression. CEL (Common Expression Language) is a formally specified, sandboxed expression language originally designed for Google's policy and admission-control systems (Kubernetes admission webhooks, Envoy, Cloud Armor, Cerbos). The SharpNinja FeatureFlags SDK implements a supported subset of CEL tailored for flag rule evaluation.


Why CEL

CEL was chosen over alternatives such as JSONLogic for the following reasons, documented in the planning artifact:

  • Formal grammar - CEL has a complete published grammar. The parser produces a well-defined AST with deterministic precedence rules.
  • Deterministic evaluation - Given the same input, CEL always produces the same output. There is no implicit type coercion that can silently change outcomes between versions.
  • Sandbox safety - CEL expressions cannot perform I/O, allocate unbounded memory, or call arbitrary functions. The supported function set is closed and declared explicitly.
  • AOT compatible - The evaluator is a tree-walking interpreter over parsed AST nodes. It uses no Reflection.Emit, no dynamic code generation, and no runtime expression compilation. It is fully compatible with iOS AoT compilation and NativeAOT.
  • Sub-millisecond compile-and-cache - Each expression is parsed once and cached by text. Subsequent evaluations skip parsing entirely.
  • Proven scale - CEL is production-proven in Kubernetes, Envoy, and Cloud Armor.

Supported Types

CEL type JSON manifest source Examples
boolean true, false true, false
string Quoted string literal 'us', "alpha"
number Integer or decimal literal 42, 3.14, -1
list List literal or context value [1, 2, 3], user.roles
map Map literal or context value {"key": value}, context.metadata
null null literal null

All arithmetic uses decimal arithmetic internally. There is no distinction between integer and floating-point at the expression level; both JSON integer and number flag values map to the CEL number type.


Operators

Comparison

Operator Description Example
== Equal user.region == 'us'
!= Not equal tier != 'free'
< Less than score < 50
<= Less than or equal score <= 100
> Greater than version > 5
>= Greater than or equal score >= 80

String comparison uses ordinal byte-order comparison. Number comparison uses decimal arithmetic. Comparing a string to a number is a type error.

Logical

Operator Description Example
&& Logical AND user.region == 'us' && score >= 50
|| Logical OR tier == 'gold' || tier == 'platinum'
! Logical NOT !feature.disabled

&& and || require both operands to be boolean. ! requires a boolean operand.

Arithmetic

Operator Description Example
+ Addition or string concatenation score + 5, user.first + '-' + user.last
- Subtraction or numeric negation total - discount, -1
* Multiplication quantity * price
/ Division total / count
% Modulo index % 2

Division and modulo by zero throw an evaluation error. + concatenates strings when either operand is a string.

Membership

Operator Description Example
in Tests whether a value appears in a list user.role in ['driver', 'dispatcher']

The right-hand side of in must be a list or a context value that holds a list. Strings are not enumerable and cannot be used as the right-hand side of in.

Ternary

condition ? thenValue : elseValue

The condition must be a boolean expression. The then and else branches may return any type, but both branches should return the same type when used inside a boolean predicate. Ternary is right-associative.

score >= 50 ? 'high' : 'low'
(score >= 80 ? (tier == 'gold' ? true : false) : false) == true

Member Access

Dot notation

Access a field of a context value or nested map using .:

user.region
user.role
context.metadata.tenantId

If user is set in the evaluation context as a dictionary, user.region reads the value at key "region".

Index notation

Access a map or list value by key or index using [...]:

user["region"]
tags[0]
{"min": 1, "max": 10}["min"]

String keys look up map entries. Integer indexes access list elements (zero-based). Out-of-range integer indexes throw an evaluation error.


Literals

Map literals

{"key": value, "other": value2}

Map keys must be string expressions. Values may be any type.

{"region": "us", "tier": "gold"}["region"] == "us"
[1, 2, 3].filter(n, {"min": 1, "max": 2}["min"] <= n) .exists(n, n == 1)

List literals

[value1, value2, value3]

Elements may be of any type. Lists are the operand for macros and the right-hand side of in.

["driver", "admin"]
[1, 2, 3]

Built-in Macros

Macros operate on lists. They use the syntax list.macroName(variable, expression) where variable is a locally-scoped loop variable bound to each element.

Macros are limited to 512 iterations per evaluation. Exceeding this limit throws an evaluation error.

exists

Returns true if the predicate is true for at least one element.

user.roles.exists(role, role == 'admin')
tags.exists(t, t == 'beta-tester')

all

Returns true if the predicate is true for every element. Returns true for an empty list.

permissions.all(p, p != 'root')

filter

Returns a new list containing only elements for which the predicate is true.

scores.filter(s, s > 50)
tags.filter(t, t != 'internal')

filter returns a list and is typically chained with another macro:

scores.filter(s, s > 50).exists(s, s > 80)

map

Returns a new list by applying the expression to each element.

tags.map(t, t)
roles.map(r, r == 'admin')

map returns a list. It is typically chained with another macro.

exists_one

Returns true if the predicate is true for exactly one element.

assignments.exists_one(a, a.primary == true)

Custom Functions

The following functions are defined by the SharpNinja FeatureFlags SDK. No other function names are accepted; calling an unknown function is a syntax error.

semver_compare(a, b)

Compares two semantic version strings. Returns a negative integer if a < b, zero if a == b, and a positive integer if a > b. Both arguments must be non-empty strings parseable as semantic versions (e.g. "2.3.4", "v1.0.0-beta.1").

semver_compare(SemanticVersion, '2.3.0') >= 0
semver_compare(SemanticVersion, '3.0.0') < 0

Aliases accepted: version_compare, semverCompare.

semver_satisfies(version, constraint)

Returns true if version satisfies the given constraint. The constraint is a comparison operator followed by a version string. Supported operators: >=, <=, ==, !=, >, <. A bare version without an operator defaults to ==.

semver_satisfies(SemanticVersion, '>=2.3.0')
semver_satisfies(SemanticVersion, '<3.0.0')
semver_satisfies(SemanticVersion, '!=2.0.0')

Alias accepted: semverSatisfies.

bucket(discriminator, threshold)

Assigns the current evaluation to a deterministic numeric bucket between 0 and 100 (exclusive) and returns true if the bucket value is less than threshold.

The bucket is computed by hashing the concatenation of ProductId, ReleaseId, FlagKey, and the string representation of discriminator using FNV-1a 64-bit. Because all four inputs are fixed for a given (flag, user), the assignment is stable across repeated evaluations. See ADR-001 for the hash algorithm decision record.

threshold must be between 0 and 100 inclusive. A threshold of 0 means no one is included; 100 means everyone is included.

bucket(UserId, 10)     -- 10% rollout keyed on UserId
bucket(TenantId, 50)   -- 50% rollout keyed on TenantId
bucket(UserId, 100)    -- all users

Calling bucket(discriminator) with one argument returns the raw numeric bucket value (a decimal between 0 and 100). This is useful for debugging or for constructing range-based rules:

bucket(UserId) >= 25 && bucket(UserId) < 75

Aliases accepted: percentage, percentage_bucket, percentageBucket.


Evaluation Context Variables

Context variables are supplied by the calling application using EvaluationContext.Builder(). Any string key may be used. The following well-known keys are recognized by the SDK and resolved automatically.

Key Source Type Description
ProductId Manifest + build string The product identifier from the manifest. Also available as productId.
ReleaseId Manifest + build string The release identifier from the manifest. Also available as releaseId.
FlagKey Runtime string The key of the flag currently being evaluated. Also available as flagKey.
SemanticVersion Application string The semantic version of the running build. Used with semver_compare and semver_satisfies.
ReleaseChannel Application string The release channel (e.g. canary, beta, stable).
ReleaseBuild Application string The build identifier from CI.
Environment Application string The deployment environment name.
TenantId Application string The tenant identifier for multi-tenant deployments.

Any additional key-value pair set via EvaluationContext.Builder().Set(name, value) is available in rules by name. Nested context values (e.g. a user object containing a dictionary) are accessible via dot notation or index notation.

EvaluationContext context = EvaluationContext.Builder()
    .Set("user", new Dictionary<string, object?> {
        ["region"] = "us",
        ["role"]   = "dispatcher",
        ["roles"]  = new[] { "driver", "admin" },
    })
    .Set("SemanticVersion", "2.4.1")
    .Set("TenantId", "acme-corp")
    .Build();

Type Safety Rules

The validator enforces type consistency at manifest validation time:

  • The when expression must evaluate to boolean. Expressions that statically infer to a non-boolean type (e.g. a bare string literal 'not-a-boolean') are rejected with error code FFMANIFEST_RULE_WHEN_TYPE.
  • The rule value must match the flag's declared type. A flag of type boolean cannot have a rule value of "yes".
  • Logical operators (&&, ||, !) require boolean operands.
  • Arithmetic operators (-, *, /, %) require numeric operands.
  • The + operator is valid for number + number (addition) or any combination involving a string (concatenation).
  • Comparison operators (<, <=, >, >=) require both operands to be the same type (both numeric or both string).
  • Macro predicates (exists, all, exists_one) must evaluate to boolean.
  • Map literal keys must be string expressions.

When a sub-expression's type cannot be determined statically (because it reads from a context variable), the validator defers the check to runtime.


Forbidden Constructs

The following are not supported in v1 and are rejected at validation time:

  • User-defined functions - Only semver_compare, semver_satisfies, and bucket are recognized. Any other function call is a syntax error.
  • I/O or side effects - CEL expressions are pure functions over their inputs. There is no file access, network access, or mutable state.
  • String enumeration - Strings cannot be used on the right-hand side of in or as the target of a macro. Only lists are enumerable.
  • Division or modulo by zero - These throw an evaluation error at runtime.
  • Index out of bounds - Accessing a list element beyond its length throws an evaluation error.

Example Rule Expressions

Simple equality check

{ "when": "user.region == 'us'", "value": false }

Multi-condition AND

{
  "when": "user.region == 'us' && tier == 'enterprise'",
  "value": true
}

Role membership check

{ "when": "user.role in ['driver', 'dispatcher']", "value": true }

List membership macro (user has a specific role)

{ "when": "user.roles.exists(r, r == 'admin')", "value": true }

Percentage rollout (50% of users by UserId)

{ "when": "bucket(UserId, 50)", "value": "on" }

Semantic version gate (enable for builds >= 2.3.0)

{ "when": "semver_satisfies(SemanticVersion, '>=2.3.0')", "value": true }

Semantic version comparison returning an integer

{ "when": "semver_compare(SemanticVersion, '2.3.0') >= 0", "value": true }

Platform targeting by environment

{ "when": "Environment == 'Production'", "value": false }

Tenant targeting

{ "when": "TenantId == 'acme-corp'", "value": "custom-theme" }

Ternary for conditional string selection (used inside a boolean comparison)

{ "when": "(score >= 50 ? 'high' : 'low') == 'high'", "value": "high" }

Combined semver + role + score rule

{
  "when": "user.region == 'us' && score + 5 >= 15 && user.role in ['driver', 'dispatcher'] && user.roles.exists(role, role == 'admin') && semver_satisfies(SemanticVersion, '>=2.3.0')",
  "value": true
}

Filter and exists chain

{ "when": "scores.filter(s, s > 50).exists(s, s > 80)", "value": true }

Map literal index access

{ "when": "{\"a\": 1, \"b\": 2}[\"a\"] == 1", "value": true }

Clone this wiki locally