Policy expressions
Several settings in your configuration file (gpilot.toml) take an expression rather than a value:
| Setting | What the expression produces |
|---|---|
cache. | The value the shared entry is partitioned by. |
cache. | The value a private entry is partitioned by. |
cache. | The value written onto the request as a header. |
cache. | A truth: whether to skip the cache for this request. |
client. | A truth: whether this request came from that declared client. |
client. | The version that client reported. |
response., response. | A truth: whether that conditional response filter applies to this request. |
telemetry. | A 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.
Sources
Section titled “Sources”An expression reads from exactly four places.
| Source | Syntax | Reads |
|---|---|---|
| Claims | auth:<provider>. | A claim from a credential a provider already verified. The path may be nested: auth:idp.. A claim whose name is a URL is quoted: auth:idp.. 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. |
| Headers | header:<name> | A request header, case-insensitively. A repeated header gives its first value, and a value with bytes outside printable ASCII resolves to nothing. |
| Cookies | cookie:<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 parameters | query:<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.
Operators
Section titled “Operators”Loosest binding first:
| Operator | Spelling | Meaning |
|---|---|---|
| Or | ||, OR | Either side. |
| And | &&, AND | Both sides. |
| Not | !, NOT | Inverts 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.roleon a token carrying no role resolves toorg_7:rather than to nothing.&&requires every operand. The same expression writtenauth:idp.org && ':' && auth:idp.roleresolves to nothing when the role is absent.
Combining sources in a cache key
Section titled “Combining sources in a cache key”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.rolefor orgorg_7and roleadminderives5:org_7:5:admin, so orga:bwith rolec(3:a:b:1:c) and orgawith roleb:c(1:a:3:b:c) can no longer meet ona: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.
Functions
Section titled “Functions”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.
| Function | Resolves to | Nothing when |
|---|---|---|
lower(s), upper(s) | s, case-folded | s is absent |
starts_with(s, prefix) | predicate | either argument is absent |
ends_with(s, suffix) | predicate | either argument is absent |
contains(s, needle) | predicate | either argument is absent |
len(s) | the number of characters | s is absent |
split(s, separator, index) | the field at index, counting from 0 | s 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.
Membership
Section titled “Membership”| Function | Resolves to | Nothing when |
|---|---|---|
in(x, [a, b, c]) | predicate: does x equal any candidate | x 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'])Versions
Section titled “Versions”| Function | Resolves 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)"Pseudonyms
Section titled “Pseudonyms”| Function | Resolves to | Nothing when |
|---|---|---|
pseudonymize(s) | a keyed pseudonym of s: 43 characters of unpadded base64url | s 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.
pseudonymizeis accepted incache.policy.public,cache.policy.privateandcache.policy.vary[].value. Anywhere else the deployment is refused: a truth would compare the pseudonym with a value nobody can know, andclient.profile[].versionis read as a version. - A pseudonym is never replaced by the value itself. The cache, a
plainorigin and your request telemetry see the pseudonym, not the input. - Insights stores a digest of the pseudonym. With
telemetry.vary_valuesat its default, a vary rule’s value is stored as its ownhmac-sha256:digest, so a pseudonym is stored hashed again. Requests of one segment still group together, but the stored value is not the pseudonym aplainorigin received. Withvary_values = "clear", Insights stores the pseudonym as it is.
Literals
Section titled “Literals”| Literal | Example |
|---|---|
| String | 'admin', "admin" |
| Integer | 5, -1 |
| Decimal | 1.5 |
| Boolean | true, 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.
Comparisons are not all alike
Section titled “Comparisons are not all alike”== 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 42header:x-ratio >= 1.5 # decimals workheader:x-plan > 'gold' # nothing: neither side is a numberheader:x-app-version >= '2.9.0' # nothing, for every version ever sentThe 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.
Resolving to nothing
Section titled “Resolving to nothing”A source that is not present on the request resolves to nothing, and that propagates:
a && bresolves to nothing if either part does.a + bresolves 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 || bresolves tobifadoes 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:
| Setting | An expression that resolves to nothing |
|---|---|
public | The entry is not partitioned by it. |
private | A private response (origin Cache-Control: private, or a schema PRIVATE scope) is not cached, reported as CACHE_: 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_. A public response is unaffected and cached as usual. |
vary[].value | No header is written. A request without a credential is stored as the variant without it; one with a credential is not stored (CACHE_). |
client. | The 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.
! and a missing value
Section titled “! and a missing value”! 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 tokenThis 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.
A bare source is never undecidable
Section titled “A bare source is never undecidable”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&& and || over three values
Section titled “&& and || over three values”An operand that cannot change the answer does not need one:
| Expression | Result |
|---|---|
false && undecidable | no match |
true && undecidable | undecidable |
true || undecidable | match |
false || undecidable | undecidable |
Either order: undecidable && false is also no match.
A request the expression cannot decide
Section titled “A request the expression cannot decide”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:
| Setting | An undecidable expression |
|---|---|
cache. | Does not bypass. The request is cached like any other. |
client. | Does not name the client. It stays unknown, and client. decides it. |
response. | Applies the rule. Its hide or only list is enforced. |
telemetry. | Records 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.
The deploy warns about an unguarded !
Section titled “The deploy warns about an unguarded !”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 predicatecannot be evaluated there, and negating something undecidable leaves it undecidable. Thoserequests 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 byrequiring the value before negating it, writing `auth:idp.tier && ` in front of the negation, asin `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.
| Expression | Warned | Why |
|---|---|---|
!starts_ | yes | The function cannot be evaluated without the header. |
!(auth:idp.tier == 'free') | yes | The comparison cannot be evaluated without the claim. |
header:x || !starts_ | yes | A bare source on the other side of || is not a guard: the right side runs exactly when the header is missing. |
header:user- | no | The conjunction requires the header first. Either side of && works. |
!auth:idp. | no | No predicate is negated. |
!auth:idp. | no | The missing case already matches through the left side. |
!header:x- | no | Negating a bare source is the absence test. |
auth:idp.role != 'admin' | no | No ! to look at, though it is undecidable for the same callers. |
public = "!starts_ | no | A 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.
Values versus truths
Section titled “Values versus truths”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 bothErrors
Section titled “Errors”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.
Cardinality
Section titled “Cardinality”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.
What every expression resolves to
Section titled “What every expression resolves to”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.
Sources
Section titled “Sources”| Expression | As a value | As a truth |
|---|---|---|
auth:idp. | "editor" | match |
auth:idp. | "o7" | match |
auth:idp. | – | no |
header:x-org | "acme" | match |
header:x- | "" | no |
header:x- | – | 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.
What does not parse
Section titled “What does not parse”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 |
true | resolves to "true", and as a truth matches every request |
false | resolves to "false", and as a truth matches no request |
auth:idp.role = 'editor' | does not parse. Equality is == |
len(header:x-org) > 2 | resolves to "true"; a bare number is a valid operand |
Joining values
Section titled “Joining values”The last column is what the same expression derives as a cache
key (private, public, a vary value).
| Expression | As a value | As a truth | As a cache key |
|---|---|---|---|
auth:idp. | "o7:editor" | match | "2:o7:6:editor" |
auth:idp. | "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. | – | no | – |
auth:idp. | "pro" | match | "pro" |
auth:idp. | – | no | – |
Comparisons
Section titled “Comparisons”| Expression | As a value | As 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.
Functions
Section titled “Functions”| Expression | As a value | As a truth |
|---|---|---|
lower(auth:idp. | "editor" | match |
upper(header:x- | "ACME" | match |
len(header:x- | "4" | match |
len(header:x- | – | undecidable |
starts_ | "true" | match |
starts_ | – | undecidable |
ends_with(header:x-org, 'me') | "true" | match |
contains(header:user- | "true" | match |
split(header:user-agent, '/', 1) | "0.8.0" | match |
split(header:user-agent, '/', 9) | – | undecidable |
in(auth:idp. | "true" | match |
in(auth:idp.role, ['admin']) | "false" | no |
in(auth:idp.missing, ['admin']) | – | undecidable |
semver_ | "true" | match |
semver_ | "false" | no |
semver_ | – | undecidable |
semver_gte('latest', '2.9.0') | – | undecidable |
pseudonymize(auth:idp. | "azOiNEJTZ9KbWv1yQsvn34ZfGCsHSeYbH73G4fLf32Y" (under an example key) | match |
pseudonymize(auth:idp. | – | 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.
Negation
Section titled “Negation”| Expression | As a value | As a truth |
|---|---|---|
!auth:idp. | "true" | no |
!auth:idp. | "true" | match |
!header:x- | "true" | match |
!(auth:idp.missing == 'admin') | "true" | undecidable |
auth:idp. | "protrue" | match |
!auth:idp. | "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:
| Expression | As a value | As a truth |
|---|---|---|
auth:idp. | ":" | match |
'tenant-' + auth:idp.org.id | "tenant-" | match |
auth:idp.role == 'editor' | – | undecidable |
!(auth:idp.missing == 'admin') | "true" | undecidable |
!auth:idp. | "true" | match |
auth:idp. | – | no |
!auth:idp. | "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.
Worked examples
Section titled “Worked examples”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.