The Expression Language
A few places in a package ask a question about the runtime: a variable rule's
when ("does this rule apply?"), a catalog query's filter ("does this
entry belong in the result?"), a layer's unit ("what value do we hash for
this request?"), and an allocation's eligibility ("is this unit enrolled?").
All of them 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 rule condition. It reads "the user's tier is premium," and when that's true, the rule matches. Let's unpack what an expression can actually reach.
The five things an expression can read
An expression can only look at five roots. That's it - five names, and everything hangs off them. Keeping that set 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 catalog query's filter or sort (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:
filter = "entry.enabled == true"
Outside of a query, entry doesn't exist - there's no entry to talk about.
variables - other variables' resolved values
variables reads the resolved value of another variable, by its id. Write it
with a dot (variables.premium_users) or with brackets
(variables["premium_users"]). Ids are snake_case, so the dot form always
works for a plain id; brackets are what you need for a namespaced id like
variables["payments/retry_limit"], since / can't appear in a dot path.
This is how a named condition gets reused. Define the condition once as a bool
variable (a "condition variable": type = "bool", default false, a rule that
sets it true), and every other rule can lean on it by name:
when = '(variables["premium_users"]) && (variables["beta_rollout_bucket"])'
The referenced variable resolves lazily, against the same context, and the
result is memoized for the rest of that one resolution - so ten rules reading
variables["premium_users"] cost one evaluation, and they all see the same
answer. A chain of variables referencing each other is fine; a cycle is not.
Lint catches cycles at edit time (rototo/variable-reference-cycle), and
resolution refuses them too.
lists - a named set of legal values
lists reads the member set of a list you declared under lists/<id>.toml.
Write it with a dot (lists.plan_tiers) or with brackets for a namespaced id
(lists["geo/regions"]). The value is the plain array of members, so the natural
use is a membership test:
when = 'context.account.plan_tier in lists.plan_tiers'
The point is naming the set instead of restating it. Without this, every rule
that cares about "a real plan tier" carries its own copy of
["free", "team", "business"], and adding a tier means hunting them all down.
With lists.plan_tiers, the list file is the one place the set lives, and the
rules follow it.
Because the member set is an ordinary value, the collection tools work on it too:
size(lists.plan_tiers) or a .exists(...) comprehension are fine. Referencing
a list the package doesn't declare is a lint error
(rototo/expression-unknown-list), and lint also checks that the context path
you're testing has a type the list's members could actually match.
env - what rototo provides
env is the stuff rototo fills in for you. It has a small, fixed set of members:
-
env.now- the current time, as an RFC3339 string. It's captured once at the start of a resolution, so every mention ofenv.nowin that one resolution sees the exact same instant. No risk of two checks disagreeing because a millisecond ticked over. -
env.resolving.variable- the id of the variable being resolved right now. This one is special: it only works inside a[[trace]]policy in the manifest. A rule can't read it, and that's deliberate - a condition has to be a function of the request, not of who happens to be asking.[[trace]] when = 'env.resolving.variable == "checkout_redesign" && context.user.id == "tester-123"'
What you can't read
Anything outside those five roots is rejected at lint time. Two cases come up most:
- The retired qualifier spellings,
qualifier["id"]andenv.qualifier["id"]. Qualifiers were dissolved into condition variables - writevariables["id"]instead, and lint's error points you there. - Made-up
envmembers likeenv.regionor a typo likeenv.noww. If it isn't one of the members above, lint stops you.
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 want | Write it |
|---|---|
| Equal / not equal | ==, != |
| Ordering | <, <=, >, >= |
| And / or / not | &&, ||, ! |
| Is it one of these? | 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 assigning to things, no defining your own functions.
(Comprehension macros like .exists(...) are fine - they ask a question over
a collection, they don't run a program.) 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 something | startsWith / starts_with / prefix |
| Text ends with something | endsWith / ends_with / suffix |
| Text (or an array) contains something | contains |
| Text matches a pattern | matches / regex, or glob for glob-style |
| Version comparison | semver |
| A deterministic rollout bucket | bucket (see below) |
| An IP is in a range | cidr / inCidr / in_cidr |
| A value is present / absent | present / missing |
| Reach a nested path, or get a size | path, size |
| Time comparisons | timeAfter, 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:
- It takes your
value(usually a stable id like the user id) and asaltstring, hashes them together, and lands on a number from 0 to 9999 - think of it as which of 10,000 slots this value falls into. - It returns true when that number is in the range
[start, end)- start included, end excluded.
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.
bucket is the inline form, good for a one-off condition inside a single
rule. When several variables need to read one shared assignment - an
experiment driving the layout, the copy, and the CTA together - that's a layer
and method = "allocation", covered below and on the
concepts page.
Queries: picking catalog entries with an expression
Expressions also show up in a catalog query - a variable whose
[resolve] block declares method = "query" and reads its value straight out
of a catalog's entries. Which entries match is often a fact the entries
already carry, so instead of hardcoding the answer in the variable, the query's
filter describes it:
[resolve]
method = "query"
from = "notifications"
filter = 'entry.channel == context.channel && entry.active == true && variables["premium_users"]'
rototo runs the filter once per entry in the from catalog. For each entry,
entry is that entry, and context, variables, and env are the same as
everywhere else. If the whole thing comes out true, the entry stays.
A simpler one:
filter = "entry.enabled == true"
That's "keep every enabled entry." A query can also order the survivors with a
sort expression - evaluated once per entry, it produces the sort key rather
than a true/false answer - and trim the result with order and limit. The
exact field list, and the single-entry form where the top sorted entry wins,
live in the package format reference. From the
expression language's side there are just two slots: filter asks a question
like a when does, sort produces a value. Both read the same roots
(context, entry, variables[...], env.now) and use all the same
operators and functions - the only new thing is that entry is now in play.
Layers: unit and eligibility
The last two slots live in a layer file - the shared bucket lines behind
method = "allocation" variables (the concepts page tells
that story). A layer has a unit expression, and each of its allocations can
have an eligibility expression:
unit = "context.user.id"
[[allocation]]
id = "cta_copy_test"
eligibility = '!variables["enterprise_accounts"]'
unit produces the value that gets hashed onto the layer's buckets - usually
a stable id. It may read context only: no variables, no entry. That
restriction is what keeps assignment a pure function of the request, so the
same user lands on the same bucket every time.
eligibility asks a question like a when does: is this unit enrolled in the
allocation at all? It can read context and variables[...] - handy for
keeping a whole named condition's worth of users out of an experiment - but
not entry. There's no catalog entry in play here, so entry doesn't exist,
same as outside a query.
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.