Skip to content

Policy expressions

Several settings in your configuration file (gpilot.toml) take an expression rather than a value:

SettingWhat the expression produces
cache.policy.publicThe value the shared entry is partitioned by.
cache.policy.privateThe value a private entry is partitioned by.
cache.policy.vary[].valueThe value written onto the request as a header.
cache.policy.bypassA truth: whether to skip the cache for this request.
client.profile[].whenA truth: whether this request came from that declared client.
client.profile[].versionThe version that client reported.
response.headers.rules[].when, response.cookies.rules[].whenA truth: whether that conditional response filter applies to this request.
telemetry.recordA truth: whether this request is recorded in Insights.

They all use the same small language, described completely below. It has no variables, no loops and no access to the response: an expression reads the request and returns a string, or returns nothing.

An expression reads from exactly four places.

SourceSyntaxReads
Claimsauth:<provider>.<path>A claim from a credential a provider already verified. The path may be nested: auth:idp.org.id. A claim whose name is a URL is quoted: auth:idp.'https://acme.example/roles'. A string reads as itself, a number or a Boolean as its JSON text (42, 1.5, true), so auth:idp.is_admin == 'true' matches a Boolean claim. null, an object and an array resolve to nothing.
Headersheader:<name>A request header, case-insensitively. A repeated header gives its first value, and a value with bytes outside printable ASCII resolves to nothing.
Cookiescookie:<name>A cookie from the request’s Cookie header, the first one of that name, matched case-sensitively: the whole value after the first =, so token=abc== resolves to abc==, with surrounding whitespace and quotes removed. name= resolves to the empty string.
Query parametersquery:<name>One query parameter, percent-decoded, with + read as a space. The first occurrence of a repeated name.

header:, cookie: and query: names may contain letters, digits, _, - and .. An auth: path is steps separated by .; a step is letters, digits, _ and -, or any name in single or double quotes (auth:idp.'https://acme.example/roles'), which is one step whatever it contains and escapes like a string literal. An empty step does not parse.

Loosest binding first:

OperatorSpellingMeaning
Or||, OREither side.
And&&, ANDBoth sides.
Not!, NOTInverts a truth. An expression that could not be evaluated has no truth to invert, so negating it does not produce one. See ! and a missing value.
Comparison==, !=, <, <=, >, >=== and != compare two operands as strings; the other four compare them as numbers. See comparisons are not all alike.
Concatenation+Joins operands into one string, skipping the ones that do not resolve, except in a cache key, where every operand is required.

Parentheses group. All six comparison operators share one precedence level and chain left to right. Comparison binds tighter than && and || and looser than +, so auth:idp.role == 'admin' || auth:idp.sub == 'u1' reads as two comparisons joined by an or.

+ and && both join strings, and differ on what is missing

Section titled “+ and && both join strings, and differ on what is missing”

As a value, && concatenates too. Outside the cache key the two differ on a missing part:

  • + keeps going. auth:idp.org + ':' + auth:idp.role on a token carrying no role resolves to org_7: rather than to nothing.
  • && requires every operand. The same expression written auth:idp.org && ':' && auth:idp.role resolves to nothing when the role is absent.

In the three values that partition the cache, cache.policy.private, cache.policy.public and every [[cache.policy.vary]] value, a chain of + and && is derived more strictly, so that two different callers can never end up with the same key:

  • Every operand is required. The chain resolves to nothing as a whole when any operand does, for + as well as &&. A caller missing a claim is then not cached, or shares the variant without the header, rather than sharing a partition keyed on what was left.
  • Each source keeps its boundary. When two or more operands read the request, each of them is written as its length in UTF-8 bytes, a colon, and the value. Literals stay as you wrote them. auth:idp.org + ':' + auth:idp.role for org org_7 and role admin derives 5:org_7:5:admin, so org a:b with role c (3:a:b:1:c) and org a with role b:c (1:a:3:b:c) can no longer meet on a:b:c.

A chain with a single source, such as 'tenant-' + auth:idp.org, derives the same text as before (tenant-o7), and so does an expression with no + or &&. A literal prefix such as 'v2/' + ... still works as a cache buster. || still yields the first side that resolved, each side derived by these rules. A chain inside a function argument or a comparison is that function’s input and keeps the plain meaning above. Conditions (when, bypass, telemetry.record) and [[client.profile]].version are not cache keys and keep it too. See what every expression resolves to for measured examples.

A function whose result is a yes-or-no answer is called a predicate below. It resolves to the string true or false, which is what a truth context reads back.

FunctionResolves toNothing when
lower(s), upper(s)s, case-foldeds is absent
starts_with(s, prefix)predicateeither argument is absent
ends_with(s, suffix)predicateeither argument is absent
contains(s, needle)predicateeither argument is absent
len(s)the number of characterss is absent
split(s, separator, index)the field at index, counting from 0s or the separator is absent, the separator is empty, the index is not a non-negative number, or there is no such field

Case folding keeps a partition from splitting on casing your callers do not control: lower(header:x-customer-id).

split is how a composite header becomes its parts:

split(header:user-agent, '/', 1) # 'gp-cli/0.8.0' gives '0.8.0'

An index past the end resolves to nothing rather than to an empty string, so a vary key built from it fails to build instead of collapsing every caller into one shared bucket.

FunctionResolves toNothing when
in(x, [a, b, c])predicate: does x equal any candidatex is absent

Every argument after the first is a candidate, and a list contributes its elements, so in(x, ['a', 'b']) and in(x, 'a', 'b') are the same expression. A candidate that resolves to nothing is skipped: one unreadable alternative does not make the rest undecidable.

in(header:x-region, ['eu', 'us'])
FunctionResolves to
semver_gte(a, b), semver_gt(a, b), semver_lte(a, b), semver_lt(a, b), semver_eq(a, b)predicate: a against b, compared as versions

Both sides are read as semantic versions, so 2.10.0 is newer than 2.9.0, and 1.0.0-rc.1 is older than 1.0.0. A missing component reads as zero, so 2.9 is 2.9.0. Build metadata does not change a version: 1.0.0+ci.7 and 1.0.0 are the same release and clear the same floor.

Anything that is not a version (v2.9.0, latest, a word) resolves to nothing, not to “older than the floor”, so a floor written as semver_gte(...) never admits it. Under ! it does match, like everything else that could not be evaluated: !semver_lt(header:x-app-version, '2.0.0') matches a request whose version does not parse. See ! and a missing value.

Together with split, that covers the client whose version is only in its User-Agent:

[[client.profile]]
name = "gp-cli"
when = "starts_with(header:user-agent, 'gp-cli/') && semver_gte(split(header:user-agent, '/', 1), '0.7.0')"
version = "split(header:user-agent, '/', 1)"
FunctionResolves toNothing when
pseudonymize(s)a keyed pseudonym of s: 43 characters of unpadded base64urls is absent

pseudonymize replaces a value with a keyed digest (HMAC-SHA256) made with a secret key of your service that neither you nor your origin holds. Equal inputs give equal pseudonyms within the service, so a partition built on one still groups the same callers, while the cache, a plain origin and your request telemetry never see the value itself. The same input gives a different pseudonym on another service, and the pseudonym is not the v1= digest an anonymized origin receives.

Hash the part that identifies a person and keep the rest readable:

[[cache.policy.vary]]
name = "x-org-user"
value = "auth:idp.org_id + ':' + pseudonymize(auth:idp.sub)"
  • Only in the values that partition the cache. pseudonymize is accepted in cache.policy.public, cache.policy.private and cache.policy.vary[].value. Anywhere else the deployment is refused: a truth would compare the pseudonym with a value nobody can know, and client.profile[].version is read as a version.
  • A pseudonym is never replaced by the value itself. The cache, a plain origin and your request telemetry see the pseudonym, not the input.
  • Insights stores a digest of the pseudonym. With telemetry.vary_values at its default, a vary rule’s value is stored as its own hmac-sha256: digest, so a pseudonym is stored hashed again. Requests of one segment still group together, but the stored value is not the pseudonym a plain origin received. With vary_values = "clear", Insights stores the pseudonym as it is.
LiteralExample
String'admin', "admin"
Integer5, -1
Decimal1.5
Booleantrue, false
List['eu', 'us']

Strings take single or double quotes; \', \", \\, \n and \t are the escapes. They resolve in one left-to-right pass, and a backslash before any other character is kept as written ('a\qb' is a\qb). So 'a\\n' is a, a backslash and n, never a newline.

A list is not a value. It exists for in, and evaluating one anywhere else resolves to nothing rather than quietly becoming part of a cache key.

true and false are lowercase whole words (TRUE, False and trueish do not parse). As a truth each is decided for every request and never undecidable, so an expression that is only a literal answers the same way for all traffic: telemetry.record = "false" records nothing. As a value each is its own spelling, the string a comparison resolves to, so header:x-flag == true is a text match against "true". Requires a runtime that ships them; older runtimes refuse the expression at deploy.

== and != compare the two values as strings. <, <=, > and >= compare them as numbers, and if either side is not a number, the comparison resolves to nothing, which is not a match.

header:x-count > 5 # true when the header is 42
header:x-ratio >= 1.5 # decimals work
header:x-plan > 'gold' # nothing: neither side is a number
header:x-app-version >= '2.9.0' # nothing, for every version ever sent

The last line is the one that costs people an afternoon. It parses, it deploys, and it never matches, because 2.9.0 is not a number. Nothing is logged, because an expression that resolves to nothing is an ordinary “did not match” rather than an error.

Ordering is deliberately not a string comparison. Lexically '2.10.0' >= '2.9.0' is false, the opposite of what anyone writing it means, so a version floor built that way would refuse your newest release. Compare versions with semver_gte instead, which reads both sides as versions.

Whole numbers compare exactly, including ones past the signed 64-bit range, so an id orders correctly against its neighbor. A decimal, an exponent, or an integer longer than 39 digits moves both sides to floating point, where two neighboring values can round onto each other.

A source that is not present on the request resolves to nothing, and that propagates:

  • a && b resolves to nothing if either part does.
  • a + b resolves to whichever parts did resolve, and to nothing only if none did. In a cache key it resolves to nothing if either part does; see combining sources in a cache key.
  • a || b resolves to b if a does not, and to nothing if neither does.
  • A comparison whose operands do not both resolve has no answer: it is neither a match nor a failure to match, and that is a third state the rest of this page calls undecidable.

For the settings that produce a value, nothing resolved is the whole story:

SettingAn expression that resolves to nothing
publicThe entry is not partitioned by it.
privateA private response (origin Cache-Control: private, or a schema PRIVATE scope) is not cached, reported as CACHE_SKIPPED_PRIVATE_WITHOUT_DISCRIMINATOR: there is no way to keep one caller’s copy from another’s, so it fails closed. An unmarked response to a credentialed request is refused as CACHE_SKIPPED_CREDENTIALED_REQUEST. A public response is unaffected and cached as usual.
vary[].valueNo header is written. A request without a credential is stored as the variant without it; one with a credential is not stored (CACHE_SKIPPED_PRIVATE_WITHOUT_DISCRIMINATOR).
client.profile[].versionThe client is recognized, with no version recorded against it.

For the settings that produce a truth, the undecidable case is decided by the setting itself, and each one picks the direction that withholds rather than grants. See a request the expression cannot decide.

! inverts whether an expression matched, and an expression that could not be evaluated for want of a source never matched or failed to match. There is nothing there to invert, so ! passes the undecidable state through rather than turning it into a match:

!starts_with(header:user-agent, 'gp-cli/') # undecidable for a request with no User-Agent
!(auth:idp.tier == 'free') # undecidable for every request without a token

This is what makes the two spellings of one condition one condition. Both of

auth:idp.role != 'admin'
!(auth:idp.role == 'admin')

are undecidable for a caller with no role, so they decide alike. They are the same expression to anyone reading them, and the language agrees.

header:x asks whether the caller sent it, and that question always has an answer: a match when it resolved to anything other than the empty string or the exact string false, a no otherwise. The same reading applies to any value read as a truth, so a header sent as x-flag: false is a no, and x-flag: 0 is a match. So !header:x-preview is exactly the absence test it looks like, and a bare source is what lets you decide the missing case yourself:

auth:idp.tier && !(auth:idp.tier == 'free') # false for a caller with no tier
!auth:idp.tier || !(auth:idp.tier == 'free') # true for a caller with no tier

An operand that cannot change the answer does not need one:

ExpressionResult
false && undecidableno match
true && undecidableundecidable
true || undecidablematch
false || undecidableundecidable

Either order: undecidable && false is also no match.

Every setting that asks for a truth says what an undecidable expression means for it, and each picks the safe direction for what it does:

SettingAn undecidable expression
cache.policy.bypassDoes not bypass. The request is cached like any other.
client.profile[].whenDoes not name the client. It stays unknown, and client.unknown decides it.
response.*.rules[].whenApplies the rule. Its hide or only list is enforced.
telemetry.recordRecords the request. It is kept in Insights like any other.

The split is not an inconsistency. A setting whose match grants something, the origin or a named profile with its own limits, withholds it from a request carrying none of the context the expression names. A setting whose match takes something away, a hide list, takes it away: a filter that cannot tell whether a condition held must not be the one that reveals. A setting whose match keeps data, telemetry.record, keeps it: an expression over a header some callers omit must not make their traffic disappear from Insights.

To decide it in the expression instead of leaving it to the setting, say which way you mean, using a bare source as shown above.

Because the expression alone does not say whether the setting’s direction is what you meant, the deployment reports a warning for every ! applied to a predicate over a source nothing has required to be present, on the settings that ask for a truth. The warning names the setting, the expression and the sources, and shows both spellings that decide:

`client.profile[0].when` (`!(auth:idp.tier == 'free')`) negates a predicate over
`auth:idp.tier`, so it has no answer for a request that carries no such value: the predicate
cannot be evaluated there, and negating something undecidable leaves it undecidable. Those
requests are decided by this key's default instead of by the expression: a `cache.policy.bypass`
does not bypass them, a `[[client.profile]].when` leaves them an unknown client, a
`[[response.headers.rules]].when` applies the rule and hides, a `telemetry.record` records them.
If that is what you meant, nothing needs to change. If not, say what those requests should do by
requiring the value before negating it, writing `auth:idp.tier && ` in front of the negation, as
in `header:x && !starts_with(header:x, 'prefix')`, or by admitting them explicitly with
`!header:x || ` in front of it.

The guard in front of the negation names your sources; the two header:x spellings after it are fixed examples. For the expression above, the admitting spelling is !auth:idp.tier || .

It never fails the deployment. If the setting’s direction is what you meant, leave the expression as it is: it deploys and behaves exactly as written.

ExpressionWarnedWhy
!starts_with(header:user-agent, 'gp-cli/')yesThe function cannot be evaluated without the header.
!(auth:idp.tier == 'free')yesThe comparison cannot be evaluated without the claim.
header:x || !starts_with(header:x, 'a')yesA bare source on the other side of || is not a guard: the right side runs exactly when the header is missing.
header:user-agent && !starts_with(header:user-agent, 'gp-cli/')noThe conjunction requires the header first. Either side of && works.
!auth:idp.role || auth:idp.role != 'admin'noNo predicate is negated.
!auth:idp.role || !(auth:idp.role == 'admin')noThe missing case already matches through the left side.
!header:x-previewnoNegating a bare source is the absence test.
auth:idp.role != 'admin'noNo ! to look at, though it is undecidable for the same callers.
public = "!starts_with(header:x, 'a')"noA setting that asks for a value, where nothing is undecidable.

Only a bare source counts as a guard. starts_with(header:x, 'a') && !ends_with(header:x, 'b') is still warned about: not every predicate over a source requires it (+ keeps whichever side resolved, in skips a missing candidate), and the check does not assume a guard it cannot see.

The != row is worth being straight about. The warning looks for a !, and auth:idp.role != 'admin' carries none, so it draws no warning even though it is now exactly the same expression as !(auth:idp.role == 'admin') and undecidable for exactly the same callers. A check that reported every undecidable expression would report almost all of them, since any comparison against a claim is undecidable for a caller without it. So this one flags the spelling most likely to be misread, not every case: treat a clean deployment as “nothing obvious”, not as “every expression decides every request”.

The last row is a different case. A setting that resolves a value has no undecidable state at all: ! there yields the string "true" whenever its operand did not resolve, and that string is what the setting uses. There is no direction standing in for a missing answer, so there is nothing for this warning to be about, and requiring the value first would change what the setting resolves to rather than pin down an answer it was missing. What ! does cost in a vary value, folding every caller missing the source into one bucket with everyone the predicate was false for, is a cardinality question.

bypass, client.profile[].when, the when of a conditional response filter and telemetry.record are evaluated as truths; the rest are evaluated as values. This is not interchangeable, and it is the one place the language can surprise you.

Which one applies is decided by the setting you write the expression under, not by anything in the expression itself. The same text can mean two things in two places.

As a value, && and || combine strings. As a truth, they combine truths, over the three states described under ! and a missing value. Reading a value expression’s output as a boolean would grant a bypass on the very expressions written to withhold it, so GraphPilot evaluates the two differently rather than converting between them. auth:idp.role == 'admin' as a value resolves to the string "false" for a caller who is not an admin, which is a value that resolved; as a truth it is a no.

Write bypass as a predicate. A service has one bypass key, so these are alternatives:

[cache.policy]
bypass = "header:x-preview-token" # presence check
# bypass = "auth:idp.role == 'editor'" # or a verified claim
# bypass = "header:x-preview-token && auth:idp.role == 'editor'" # or both

An expression is parsed when the service is deployed, and the whole expression must parse: a typo fails rather than silently becoming a shorter, different expression. That matters most for bypass, where a truncated predicate would be weaker than the one you wrote.

An expression that parses is still refused where it calls pseudonymize outside the values that partition the cache, and the error names every setting that does.

An expression that parses can still draw a warning for an unguarded !. Warnings are listed in the deployment’s diagnostics and never fail it.

Everything here reads what the caller sent, and anything that reaches a cache key multiplies the number of entries your service holds. split(header:user-agent, '/', 1) as a vary value partitions your cache by every client version in the wild.

Prefer a predicate that collapses many values into one (semver_gte(...), in(...)) over a value that carries them all.

Everything in this section is measured against the evaluator rather than described, and kept in step with it by an automated test.

The caller sending these requests carries a verified idp token with sub=u1, role=editor, tier=pro, org.id=o7, the headers user-agent: gp-cli/0.8.0, x-org: acme, x-blank: (empty), x-count: 42, x-version: 2.10.0, the cookies session=abc; theme=dark, and the query string ?lang=de&lang=fr&empty=.

A dash means the expression resolved to nothing.

ExpressionAs a valueAs a truth
auth:idp.role"editor"match
auth:idp.org.id"o7"match
auth:idp.missing–no
header:x-org"acme"match
header:x-blank""no
header:x-absent–no
cookie:session"abc"match
query:lang"de"match
query:empty""no

A header the caller sent blank resolves to the empty string, which is a value that resolved and a truth that is no. A repeated query parameter gives the first occurrence.

The whole expression must parse, and a typo fails the deployment rather than quietly becoming a shorter one.

Expression
'a literal'resolves to "a literal"
"double quoted"also a string literal, though in TOML you would have to escape the quotes
trueresolves to "true", and as a truth matches every request
falseresolves to "false", and as a truth matches no request
auth:idp.role = 'editor'does not parse. Equality is ==
len(header:x-org) > 2resolves to "true"; a bare number is a valid operand

The last column is what the same expression derives as a cache key (private, public, a vary value).

ExpressionAs a valueAs a truthAs a cache key
auth:idp.org.id + ':' + auth:idp.role"o7:editor"match"2:o7:6:editor"
auth:idp.org.id + ':' + auth:idp.missing"o7:"match–
header:x-absent + header:x-org"acme"match–
'tenant-' + auth:idp.org.id"tenant-o7"match"tenant-o7"
auth:idp.role && auth:idp.tier"editorpro"match"6:editor3:pro"
auth:idp.missing && auth:idp.tier–no–
auth:idp.missing || auth:idp.tier"pro"match"pro"
auth:idp.missing || header:x-absent–no–
ExpressionAs a valueAs a truth
auth:idp.role == 'editor'"true"match
auth:idp.role == 'admin'"false"no
auth:idp.missing == 'admin'–undecidable
auth:idp.missing != 'admin'–undecidable
header:x-count > '40'"true"match
header:x-count > '50'"false"no
header:x-org > '40'–undecidable
header:x-version >= '2.9.0'–undecidable

The last two are the reason ordering is numeric and only numeric: acme and 2.10.0 are not numbers, so the comparison has no answer rather than a lexical one. Compare versions with semver_gte and its siblings.

Note the second row. A comparison that is false still resolved, to the string "false". That is why a value expression’s output cannot be read as a truth: as a value it is there, as a truth it is a no.

ExpressionAs a valueAs a truth
lower(auth:idp.role)"editor"match
upper(header:x-org)"ACME"match
len(header:x-org)"4"match
len(header:x-absent)–undecidable
starts_with(header:user-agent, 'gp-cli/')"true"match
starts_with(header:x-absent, 'gp-cli/')–undecidable
ends_with(header:x-org, 'me')"true"match
contains(header:user-agent, 'cli')"true"match
split(header:user-agent, '/', 1)"0.8.0"match
split(header:user-agent, '/', 9)–undecidable
in(auth:idp.role, ['editor', 'admin'])"true"match
in(auth:idp.role, ['admin'])"false"no
in(auth:idp.missing, ['admin'])–undecidable
semver_gte(header:x-version, '2.9.0')"true"match
semver_lt(header:x-version, '2.9.0')"false"no
semver_gte(header:x-absent, '2.9.0')–undecidable
semver_gte('latest', '2.9.0')–undecidable
pseudonymize(auth:idp.sub)"azOiNEJTZ9KbWv1yQsvn34ZfGCsHSeYbH73G4fLf32Y" (under an example key)match
pseudonymize(auth:idp.missing)–undecidable

semver_gte(header:x-version, '2.9.0') is a match where header:x-version >= '2.9.0' is undecidable, on the same header and the same request. That is the whole reason the semver_* family exists.

ExpressionAs a valueAs a truth
!auth:idp.role"true"no
!auth:idp.missing"true"match
!header:x-blank"true"match
!(auth:idp.missing == 'admin')"true"undecidable
auth:idp.tier && !(auth:idp.tier == 'free')"protrue"match
!auth:idp.tier || !(auth:idp.tier == 'free')"true"match

As a cache key, the fifth row derives "3:pro4:true"; every other row derives what it resolves to as a value.

The same expressions, for a caller who sent none of it

Section titled “The same expressions, for a caller who sent none of it”

Every source resolves to nothing and is a no, as you would expect. What is worth looking at is where that does not simply propagate:

ExpressionAs a valueAs a truth
auth:idp.org.id + ':' + auth:idp.role":"match
'tenant-' + auth:idp.org.id"tenant-"match
auth:idp.role == 'editor'–undecidable
!(auth:idp.missing == 'admin')"true"undecidable
!auth:idp.role"true"match
auth:idp.tier && !(auth:idp.tier == 'free')–no
!auth:idp.tier || !(auth:idp.tier == 'free')"true"match

The first two rows are the + trap again, now with nothing in front of the separator at all. As a cache key both resolve to nothing, so the trap is closed where it would share an entry. The last two are the guarded and the admitting spelling doing exactly what they are for: one excludes this caller, the other includes them, and neither leaves the answer to the setting.

One entry per person, which is what private normally wants:

[cache.policy]
private = "auth:idp.sub || cookie:session_id"

One shared entry per customer, with the customer taken from a verified claim:

[cache.policy]
public = "lower(auth:idp.org.id)"

A composite partition, where both parts must be present or the expression resolves to nothing:

[cache.policy]
public = "auth:idp.org.id && '-' && auth:idp.locale"

Both claims are written with their length (2:o7-2:de for org o7 and locale de), so no pair of claims can derive another pair’s key. In a cache key + behaves the same way here: the expression resolves to nothing unless both claims are present.

A vary header your origin can partition on without learning the value:

[[cache.policy.vary]]
name = "x-segment"
value = "auth:idp.plan"
pass_to_origin = "anonymized"

A preview bypass for editors only:

[cache.policy]
bypass = "auth:cms.role == 'editor'"

See identifying callers for what each setting then does with the value.