rototo
DocsReference
Reference

The Expression Language

Two places in a package ask a question about the runtime: a qualifier's when ("is this a premium user?") and a variable rule's when or query ("does this rule apply?"). Both are written in the same little expression language, and this page is the whole language in one sitting.

If you've ever written a CEL expression, this will feel familiar - it is a subset of CEL under the hood. But you don't need to know CEL to read on. The expressions look a lot like a condition in any programming language: comparisons, && and ||, a few handy functions.

when = '(context.user.tier == "premium")'

That's a real qualifier condition. It reads "the user's tier is premium," and when that's true, the qualifier matches. Let's unpack what an expression can actually reach.

The three things an expression can read

An expression can only look at three roots. That's it - three names, and everything hangs off them. Keeping the list short is what makes expressions easy to reason about and easy for lint to check.

context - the facts your app passed in

context is the bundle of request-time facts your application hands to rototo: who the user is, where they're coming from, what's in their cart. You reach into it with dots:

when = '(context.request.country in ["DE","FR","ES","IT","NL","SE"])'

The shape of context isn't a free-for-all - it's pinned down by your evaluation-context schema. If you read context.user.tier but the schema never declared it, lint tells you, so a typo here doesn't quietly become "always false" in production.

entry - the catalog entry in front of you

entry only shows up inside a query (more on those below). When you're filtering a catalog, entry is the one entry currently being looked at, and you read its fields the same dotted way:

query = "entry.enabled == true"

Outside of a query, entry doesn't exist - there's no entry to talk about.

env - what rototo provides

env is the stuff rototo fills in for you. It has a small, fixed set of members:

What you can't read

Anything outside those three roots is rejected at lint time. Two cases come up most:

This is a feature, not a nuisance: catching a bad reference while you're editing beats discovering it when a rule silently never matches.

The operators you'd expect

Comparisons, logic, and membership all work the way you'd guess:

What you wantWrite it
Equal / not equal==, !=
Ordering<, <=, >, >=
And / or / not&&, ||, !
Is it in a list?context.region in ["us","eu"]
Is this item in an array field?"admin" in context.user.roles
Does a field exist?has(context.user.tier)

You can index with dots (context.user.tier) or with brackets when a key has funny characters (context["account.plan"]).

A few things are not in the language, because the CEL subset leaves them out: no loops, no comprehensions, no assigning to things, no defining your own functions. Expressions are meant to ask a question, not run a program. And because lint knows your context schema, it'll also catch type mismatches - like comparing a string field against a number.

Built-in functions

On top of the operators, there's a set of functions for the comparisons that come up over and over. Several have both a camelCase and a snake_case spelling (and sometimes a short alias) - pick whichever reads best to you; they do the same thing.

You want to check…Functions
Text starts with somethingstartsWith / starts_with / prefix
Text ends with somethingendsWith / ends_with / suffix
Text (or a list) contains somethingcontains
Text matches a patternmatches / regex, or glob for glob-style
Version comparisonsemver
A deterministic rollout bucketbucket (see below)
An IP is in a rangecidr / inCidr / in_cidr
A value is present / absentpresent / missing
Reach a nested path, or get a sizepath, size
Time comparisonstimeAfter, timeBefore, timeBetween, timeAtOrAfter, timeAtOrBefore (and their snake_case forms)

The time functions pair naturally with env.now when you want a condition that's true only after a date, or only within a window.

Buckets: gradual rollouts that stay put

The one function worth its own section is bucket, because it's how you ship something to "10% of users" and have that 10% stay the same 10% from one request to the next.

when = '(bucket(context.user.id, "checkout-redesign-2026-05", 0, 1000))'

You call it bucket(value, salt, start, end). Here's the idea:

So 0, 1000 is "the first 1,000 slots out of 10,000" - 10%. The hashing is deterministic and side-effect-free: the same user id and salt always land in the same slot, so a user who's in the rollout stays in it, and the same user doesn't flip in and out between requests.

The salt is what lets you run independent rollouts. Change the salt and you get a fresh, unrelated 10% - so two different features rolling out to "10%" don't hit the exact same users.

Queries: picking catalog entries with an expression

The last place expressions show up is a query, and it's only for variables typed list<catalog:...> - a variable whose value is a list of catalog entries. Instead of hardcoding which entries go in the list, a query describes them, and rototo keeps every entry the query says yes to.

[[resolve.rule]]
query = "entry.channel == context.channel && entry.active == true && env.qualifier[\"premium\"]"

rototo runs that expression once per catalog entry. For each entry, entry is that entry, context and env are the same as everywhere else, and if the whole thing comes out true, the entry makes the list. The result is every matching entry, in order.

A simpler one:

query = "entry.enabled == true"

That's "give me every enabled entry." Queries read the same roots as a when (context, entry, env.qualifier[...], env.now) and use all the same operators and functions - the only new thing is that entry is now in play.

A note on stability

The expression engine is a pinned version of the CEL implementation rototo builds on, and that's on purpose: the exact parsing and evaluation behavior is part of rototo's contract, not an implementation detail that drifts under you. An expression that resolves a certain way today resolves the same way tomorrow.