Expressions
About 3829 wordsAbout 13 min
2026-09-24
Every operator in the policy language: its precedence, the operand types it accepts and what it evaluates to.
- Expressions appear in
whenconditions,assertconditions,letbindings, param defaults, policy invocation arguments and decision payloads. - Every expression has a static type that the compiler knows before evaluation.
- Nothing converts between types implicitly. The types are on Types.
Why the operators look the way they do: Why the language looks like this.
Operator precedence
From lowest to highest:
| Level | Operators | Associativity | Notes |
|---|---|---|---|
| 1 | or | left | Short-circuits |
| 1 | xor | none | Evaluates both sides; doesn't mix with or without parentheses |
| 2 | and | left | Short-circuits |
| 3 | not | prefix | Unary |
| 4 | == != < <= > >= | none | Strictly typed; no implicit coercion |
| 4 | in, not in | none | Element in list, substring in string |
| 4 | all in, any in | none | List subset and list intersection |
| 4 | one in, exclusive in | none | Exactly one, or at most one, element of a list is in another |
| 4 | has | none | Map contains all given pairs, or a key |
| 4 | like, matches | none | Glob and RE2 regex; the pattern must be a literal |
| 5 | ?? | right | Default for optional values |
| 6 | + - | left | Numbers, durations, timestamps |
| 7 | -, present | prefix | Unary minus; presence of an optional |
| 8 | .field ?.field [key] f(args) | left (postfix) | Field access, optional chaining, map or list index, host function call |
- Parentheses override precedence.
- Quantifiers (
any x in xs: ...,all x in xs: ...) and filters (filter x in xs: ...) aren't in the table. They're prefix forms whose body extends as far right as possible; see Quantifiers and Filters. - Level-4 operators are non-associative.
a < b < c,a == b == canda in b == care compile errors; add parentheses. xorshares level 1 withorbut can't be chained or mixed with it.a xor b xor canda or b xor care compile errors; add parentheses.
| Expression | Parses as |
|---|---|
not a == b | not (a == b) |
not "admin" in actor.roles | not ("admin" in actor.roles); prefer "admin" not in actor.roles |
owner ?? "unknown" == "team-a" | (owner ?? "unknown") == "team-a" |
a ?? b + c | a ?? (b + c) |
Boolean operators
and, or, xor and not take bool operands and produce bool.
- There's no truthiness.
when approvers { ... }is a compile error becauseapproversis alist<string>, not abool. andandorshort-circuit and evaluate left to right. The right operand ofa and bisn't evaluated whenais false, so a list index or host function call on the right can't raise a runtime error when the guard on the left fails.xoris true when exactly one of its two operands is true. It doesn't short-circuit: both operands are evaluated, and either can raise a runtime error.xortakes exactly two operands. Chaining it is a compile error; for "exactly one of several", useone in.
a | b | a xor b |
|---|---|---|
false | false | false |
false | true | true |
true | false | true |
true | true | false |
release.hotfix xor release.scheduledComparison
| Operators | Operand types |
|---|---|
== != | bool, int, float, string, duration, timestamp, decision, an enum |
< <= > >= | int, float, duration, timestamp |
- Both operands must have the same type.
3 == 3.0is a compile error (intagainstfloat), and so isrelease.soak == 30(durationagainstint). - String comparison is case-sensitive:
"Prod" == "prod"is false. - Comparing an optional (
?T) value is a compile error until it's unwrapped with??. ==and!=don't apply to lists, maps or structs. For lists, use the membership and set operators.!=isn't defined for lists, soxs != []is a compile error that suggestsany x in xs: true; see Test whether a list is empty.- Enums aren't ordered either.
<on an enum is a compile error, whatever the declaration order. - Strings aren't ordered.
<on strings is a compile error. To compare versions, declare a host function in the kind; see Compare versions.
release.soak >= min_soak
service.tier == criticaldeploy/production.sigil:7:6: error: `==` needs operands of the same type, found duration and int
|
7 | when release.soak == 30 {
| ^^^^^^^^^^^^^^^^^^
= help: a bare number is never a duration; write a literal like `30m`
deploy/production.sigil:3:6: error: `!=` isn't defined for list<string>
|
3 | when actor.regions != [] {
| ^^^^^^^^^^^^^^^^^^^
= help: lists have no `!=`; to test that `actor.regions` isn't empty, write `any x in actor.regions: true`, or call a host function such as `len` if the kind declares onePlanned
Host-ordered types would let < compare values such as versions directly.
Membership: in and not in
The type of the right-hand side picks the meaning of in:
| Form | Left type | Right type | True when |
|---|---|---|---|
x in xs | T | list<T> | xs contains an element equal to x |
s in t | string | string | s is a substring of t |
"deployer" in actor.roles // list element
"payments" in service.name // substring
service.tier in [critical, standard] // list of enum values- An enum value is tested against a list only. There's no substring form for enums.
- A map key is tested with
has, never within."env" in service.labelsis a compile error that suggestsservice.labels has "env". x not in yis exactlynot (x in y).- The parser reads
not inas one operator whennotfollows an operand, and as unarynotwhen it starts an expression.
List set operators
all in, any in, one in and exclusive in take two lists of the same element type and produce bool.
| Form | True when | Empty left side |
|---|---|---|
a all in b | every element of a is in b (subset) | true |
a any in b | at least one element of a is in b (intersection is non-empty) | false |
a one in b | exactly one distinct element of a is in b | false |
a exclusive in b | at most one distinct element of a is in b | true |
split(service.labels["regions"], ",") all in actor.regions
actor.teams any in service.owners
["prod-admin", "prod-auditor"] exclusive in actor.roles
[read, write, admin] one in outcomeexclusive inis true whenbholds none or one of the listed values, and false when it holds two or more.one inalso requires one to be present.- With two elements,
[a, b] one in xsis(a in xs) xor (b in xs). one inandexclusive incount distinct elements ofathat appear inb. Repeats don't count twice on either side:["x", "x"] exclusive in ["x"]and["x", "y"] exclusive in ["x", "x"]are both true.- With a one-element list on the left,
exclusive inis always true andone inmeansin. No lint flags either yet.
Whether [] all in b should stay vacuously true is an open question.
Map containment: has
has takes a map on the left and either a map or a key on the right.
| Form | Right type | True when |
|---|---|---|
m has {k: v, ...} | map<K, V> | every pair on the right is in m with an equal value |
m has k | K | m has key k |
service.labels has {
"app.kubernetes.io/managed-by": "argocd",
"platform.example.com/lifecycle": "ga",
}
service.labels has "app.kubernetes.io/managed-by"- The right-hand map doesn't have to be a literal.
- On a map keyed by an enum, the key is a bare value:
quotas has critical. - An empty right-hand map makes
hastrue. m has kis the only way to test for a key. Neitherinnornot inapplies to maps; writenot m has k, which parses asnot (m has k).
Pattern matching: like and matches
Both take a string on the left and a pattern on the right.
- The pattern must be a string literal, plain or raw, optionally in parentheses. It compiles once, when the policy compiles.
- A pattern built from an expression, even a
letthat holds a literal, is a compile error.
| Operator | Syntax | Matches | Invalid pattern |
|---|---|---|---|
like | glob: * is any run of characters, including none; ? is exactly one character; every other character, [ and \ included, matches itself | the whole string; * crosses / and . | impossible |
matches | Go RE2 regular expression | anywhere in the string, as Go's regexp.MatchString; anchor with ^ and $ for the whole string | compile error |
service.name like "payments-*"
service.labels["team"] matches `^team-[a-z]+$` // a raw string avoids double escaping- A glob has no character classes or escapes. Use
matchesfor anything richer. - RE2 runs in linear time in the input length. Why that matters: Halting by construction.
Optional default
a ?? b unwraps an optional, with b as the default when a is absent.
// assuming the kind declares `ticket: ?string` on Release
release.ticket ?? "none"a ?? brequiresaof optional type?Tandbof typeT. The result isT: the value ofaif present, otherwiseb.bis only evaluated whenais absent.??is right-associative:a ?? b ?? cmeansa ?? (b ?? c)and works whenaandbare?TandcisT.??on a value that isn't optional is a compile error.- Struct types have no literal, so the only default for an optional struct (
?Release) is another value of that struct type, such as an input:(parent_release ?? release).soak. Its fields are usually read with optional chaining instead.
Optional chaining
x?.name reads a field of an optional struct. If x is absent, the result is absent; otherwise it's the field of the struct inside. The result is optional and is unwrapped with ??:
// assuming the kind declares `release: ?Release`
release?.soak ?? 5mA chain is a run of .name, ?.name and [index] that no parentheses break. A ?. makes the rest of its chain optional, as in TypeScript. When a ?. finds its operand absent, nothing after it in the chain runs, so nothing after it can fail:
// Release declares `parent: ?Commit`;
// Commit declares `author: Actor` and `merged_by: ?Actor`
release.parent?.author.name ?? "" // ?string, then string; `author` needs no `?.`
release.parent?.author.roles[5] ?? "" // no index error when there's no parent
release.parent?.merged_by?.name ?? "" // `merged_by` is optional itself, so it needs its own `?.`- The type of a chain with a
?.in it is its last link's type made optional. A last link that's already optional stays?T; optionals don't nest. - A
?.only skips what comes after an absent value. A link that's optional itself needs its own?.:release.parent?.merged_by.nameis a compile error that suggests?.name. - Parentheses end a chain.
(release.parent?.author).namereads a field of a?Actorand is a compile error. ?.on a value that can't be absent is a compile error:service?.namesuggestsservice.name.?.reads struct fields only. There's no?[, because a kind can't declare an optional list or map. A chain that ends at a list or map field is optional:release.parent?.author.rolesis a?list<string>, unwrapped with?? [].- Optional chaining can't tell an absent struct from a present one whose field is zero: with
release?.soak ?? 0s, both give0s.presentcan.
Presence: present
present x is true when the optional x holds a value and false when it's absent. It tells absence apart from a zero value.
when not present release { deny(reason: no_release) }
when present release.ticket { ... } // an empty ticket is present
when present release.parent?.merged_by { ... } // any optional, including a chain- The operand must be optional.
presenton a value that can't be absent is a compile error. - It binds like unary minus, to one operand chain.
present release.parent and xmeans(present release.parent) and x.present release.ticket ?? ""is a compile error:presentapplies first and yields abool. - It only tests. Inside
when present release { ... },releaseis still optional and its fields are still read with?.. There's no flow typing.
Arithmetic
Binary + and - are left-associative and defined only for these combinations:
| Left | Operator | Right | Result |
|---|---|---|---|
int | + - | int | int |
float | + - | float | float |
duration | + - | duration | duration |
timestamp | + - | duration | timestamp |
timestamp | - | timestamp | duration |
release.soak + 2h >= min_soak
now - release.built_at > 2h // assuming an input `now` and a field `built_at`, both timestamps- Unary
-applies toint,floatandduration. - The table is complete. Any other combination, including
+on strings or lists, is a compile error that says so. - There's no
*,/or%. - Integer and duration overflow is a runtime error, not a wrap-around.
Field access, indexing and calls
These are postfix and bind tightest.
| Form | Reads | Rules |
|---|---|---|
x.field | a field of a struct value | A field the struct type doesn't declare is a compile error |
common.name | a let through a whole-file import such as use deploy.common | See Policy files |
m[k] | a map value | k must have the map's key type. A missing key yields the value type's zero value, like Go: service.labels["absent"] is "" |
xs[i] | a list element | i is an int. An index out of range, including a negative one, is a runtime error |
f(a, b) | a host function declared with fn in the kind | Arguments are positional; their count and types must match the signature |
deploy/production.sigil:9:16: error: unknown field "teir" on type Service
|
9 | when service.teir == critical { approve(reason: release_manager) }
| ^^^^
= help: did you mean "tier"? Service declares: name, tier, owners, labelsHost function calls:
- There are no built-in functions.
split,lenand every other function exist only when the kind declares them. - Calling a name the kind doesn't declare is a compile error. Policies can't define functions.
- A host function isn't a value; its bare name without parentheses is a compile error.
- Host functions must be pure.
- An error returned by a host function is a runtime error.
Quantifiers
any r in actor.roles: r like "sre-*"
all r in actor.roles: r != "admin"| Form | True when | Empty list |
|---|---|---|
any x in xs: body | body holds for at least one element | false |
all x in xs: body | body holds for every element | true |
- The range
xsmust be a list. Quantifying over a map is a compile error. - The body must be
bool. xis bound to each element in turn, has the list's element type, and is only visible inside the body.xfollows the no-shadowing rule: naming it after an input, param, let, imported name or host function is a compile error.- Evaluation stops at the first element that decides the result.
- A quantifier starts an expression; the binary
all inandany infollow an operand. The parser tellsall r in xs: ...froma all in bby that position; see Grammar.
The body extends as far right as possible. To end a quantifier early, wrap it in parentheses:
any r in actor.roles: r like "sre-*" and eligible
// parses as
any r in actor.roles: (r like "sre-*" and eligible)
(any r in actor.roles: r like "sre-*") and eligiblesigil fmt adds parentheses around every quantifier body whose top level is and, or or xor. They don't change how it parses.
Filters
filter a in approvers: a != requestor.name
filter r in actor.roles: r like "prod-*"filter x in xs: body keeps the elements of xs for which body holds.
- The result has the type of
xs: filtering alist<string>gives alist<string>. - Elements that pass keep their order. An element that's in the list twice and passes is kept twice.
- When none passes, the result is the empty list.
- A filter never stops early. Its body runs for every element.
- The quantifier rules apply; see Quantifiers.
- As the operand of an operator, a filter needs parentheses.
- A filter or a quantifier can't be an invocation argument; arguments are bound when the policy compiles.
"prod-admin" in (filter r in actor.roles: r like "prod-*")
(filter r in actor.roles: r like "prod-*") all in allowed_roles
let approvers = filter a in managers: a != requestor.nameFor the approver recipe, including what to do when the filter leaves nobody, see Keep the requestor off the approvers.
Enum values
service.tier == critical
service.tier in [standard, internal]
let fallback = internal
let plan = Plan.standard // with `enum Plan: standard | premium` declared tooAn enum value is written as its bare name, or qualified with its enum's name, Tier.critical. A bare name that isn't a local name (a let, param, import, quantifier or filter variable, input, host function or decision) resolves against the kind's enums:
| Form | Rule | Example |
|---|---|---|
| 1. Expected type | Where the context expects an enum E, ?E, list<E> or a map keyed by E, and E declares the name, it's E's value | service.tier == standard |
| 2. Unique enum | Otherwise, when exactly one enum declares the name, it's that enum's value | let t = critical |
| 3. Ambiguous | Otherwise it's a compile error; qualify the name | let p = standard, with standard in Tier and Plan |
| Qualified | E.x is always E's value x, whatever the context | let p = Plan.standard |
The contexts that expect a type are the ones that give an empty [] its type:
- the other operand of
==,!=,in,not in, the list operators,hasand??, - another element of the same list or map literal,
- the declared type of a param default, payload field, host function parameter, invocation argument or map index key.
Rules:
- When the expected enum doesn't declare the name, the error names the enum, suggests the closest value and lists the declared ones, instead of reporting an undefined name.
Tier.critcalgets the same error. - A qualified value must still have the type its context expects:
service.tier == Plan.standardis a compile error. - A string literal is never an enum value:
service.tier == "critical"is a compile error. - A policy's own names can't share an enum value's name or an enum's name, except in a document pinned below the version that added it; see Identifiers.
- The
reason:of a decision constructor names one of that decision's reasons and never resolves to an enum value. It takes no qualified form; see The reason.
deploy/production.sigil:9:24: error: Tier has no value `critcal`
|
9 | when service.tier == critcal
| ^^^^^^^
= help: did you mean `critical`? Tier declares: critical, standard, internal
deploy/production.sigil:5:9: error: `standard` is a value of Plan and Tier
|
5 | let p = standard
| ^^^^^^^^
= help: write `Tier.standard` or `Plan.standard`Why values are bare by default, and when to qualify them: Why enum values are bare names.
Decision values and outcome
Inside an assert condition, a policy can test what evaluation decided.
| Operand | Type | Meaning |
|---|---|---|
approve | decision | the decision with any reason |
approve.release_manager | decision | the decision with that reason only |
outcome | list<decision> | each distinct decision and reason the host gets back, in the kind's declaration order |
assert("sod_customer_dev", [customer_data_writer, development_environment_writer] exclusive in outcome)
assert("rm_needs_ticket", approve.release_manager not in outcome or present release.ticket)- A decision's bare name is a value; a constructor always has parentheses.
approve in outcomeis never a call. - Decision values and
outcomeexist only insideassertconditions. when deny == approveis a compile error that points at the constructor form.outcomein awhencondition or aletis a compile error: "outcomecan only be read in an assert condition".- In a
collect onekind,outcomeholds exactly one element: the winner or the default. - In a collecting kind,
outcomeholds every outcome that fired, or the default if the kind declares one and nothing fired. - Membership over
outcomematches: a bare decision is inoutcomewhen any of its reasons is, and a qualified one only when that reason is. ==and!=between two decision values are exact:approve == approve.lgtmis false.- Decision values can be compared with
==and!=, tested withinand the list operators, and collected in lists, and nothing else. - A decision can't be a map value, because it has no zero value for a missing key:
{"a": approve}is a compile error. - To read what a decision carries, go through its candidates.
- When asserts run: Assertions. Why
outcomeis readable only there: Asserts and decisions.
Candidates
outcome.<decision> is the list of that decision's candidates the host gets back, each with the decision's payload fields and its reason. Every field is checked against the kind.
assert("no_self_review", all r in outcome.review: requestor.name not in r.approvers)
assert("short_admin_grants", all g in outcome.admin: g.ttl <= 8h)- A reason after the decision narrows the list:
outcome.review.manager_approval. - On one candidate,
r.reasonis a decision value with its reason, sor.reason == review.manager_approvalandr.reason in [review]both work. - A kind can't declare a payload field called
reason. A decision without a payload gives candidates that only havereason. allover no candidates is true.- A policy can range over candidates with
any,allorfilterand read each one's fields, and nothing else. Indexing the list, comparing candidates with==orin, and putting one into a list or map literal are compile errors that say so. - A filter over candidates is a list of candidates, with the same rules.
- Candidates only exist in
assertconditions.
The list holds exactly the candidates the host acts on, the ones Decision[T].MatchAll returns in Go:
| Kind | outcome.<decision> holds |
|---|---|
collect one | At most one: the winner, or the default when nothing fired and the default is of that decision. Under precedence, a candidate that lost to a higher rank isn't in it |
| collecting | Every candidate of the decision at the top rank, after equal ones fold. Two reviews with different approvers are both there |
Why guardrails use all, and why candidates have no equality or order: Asserts and decisions.
Evaluation order
- Operands are evaluated left to right.
and,or,??and quantifiers skip work they don't need. Anything they skip can't raise a runtime error.- A filter runs its body for every element, so a body that raises a runtime error for any element fails the filter.
- Expressions have no side effects and host functions are pure, so evaluation order is otherwise unobservable.
