Scopes in the schema
GraphQL only For plain HTTP, partition with vary rules on identifying-callers.
The scope argument of @cacheControl says who may be served the
entry a response is stored in. You write it on the types and fields it is true of, and GraphPilot
works out the audience of each response from what the query selected.
| Scope | Who shares the entry |
|---|---|
PUBLIC, or no scope at all | Every caller |
A named scope, such as ORGANIZATION | Every caller with the same values for the vary rules it lists |
PRIVATE | Every caller with the same cache. value, which should be one person |
PUBLIC is the default
Section titled “PUBLIC is the default”An annotation with no scope is PUBLIC, as in Apollo Server. A schema written for Apollo
Server’s plugin caches the same way behind GraphPilot: @cacheControl(maxAge: 300) on its own
stores one entry that every caller is served, whether or not the request carried a credential.
type Query { # The same answer for everybody. No scope written, so PUBLIC: stored once. publicHoliday: Holiday @cacheControl(maxAge: 3600)
# Per caller, so say so: one entry per caller, and only if # cache.policy.private resolves for the request. viewer: User @cacheControl(maxAge: 60, scope: PRIVATE)}That makes the default the line to review. A field whose answer depends on who asks has to say
so; left unscoped, it is shared. A field hint that states no scope keeps its return type’s
scope; see a field hint overrides only the arguments it sets. The
CACHE_POLICY_SCHEMA log line’s scope_defaulted = true marks responses shared by default.
Declaring a named scope
Section titled “Declaring a named scope”Most real data lives between PUBLIC and PRIVATE. A deployment list is identical for every
member of an organization. Scoped PRIVATE, five colleagues opening the same page produce five
entries and five origin requests; a named scope stores it once per organization.
You add a value to your own CacheControlScope enum and name the vary rules it partitions on. The
schema names the audience; your service configuration defines what each name is derived from.
directive @vary(names: [String!]!) on ENUM_VALUE
enum CacheControlScope { PUBLIC PRIVATE ORGANIZATION @vary(names: ["x-org"]) ORGANIZATION_ROLE @vary(names: ["x-org", "x-role"])}
type Deployment @cacheControl(maxAge: 300, scope: ORGANIZATION) { id: ID! createdAt: String!}[[cache.policy.vary]]name = "x-org"value = 'auth:idp.org_id'scope_only = true
[[cache.policy.vary]]name = "x-role"value = 'auth:idp.role'scope_only = trueEvery response that selects a Deployment is now stored for five minutes and served only to
callers whose verified token carries the same org_id. Both rules are marked
scope_only, so ORGANIZATION partitions
only on the rules it names: configuring x-role for a different scope does not key this entry on
the role too, and it stays stored once per organization. Without scope_only, every configured
rule partitions every response, and this entry would be stored once per organization and role.
The names in @vary are header names, matched case-insensitively against your
[[cache.policy.vary]] entries.
Three rungs in one service runs this shape against a live service with four tokens: two colleagues, one of them again as a viewer, and someone from another organization.
A schema that declares only PUBLIC and PRIVATE needs no @vary anywhere and behaves exactly
as before. Moving a type from PRIVATE to a named scope only ever reduces the number of entries.
The exact definition of @vary and its rules are on the directive
reference, which also covers renaming
it.
How scopes compose across a query
Section titled “How scopes compose across a query”scope is a single value per type or field. What accumulates across the fields a query selects
is the set of names they partition on, unioned:
| Scope | Partitions on |
|---|---|
PUBLIC, or no scope | nothing |
PRIVATE | your cache. value |
ORGANIZATION | x-org |
ORGANIZATION_ | x-org and x-role |
The table assumes every rule is marked scope_only, as in the example above. A rule without it
keys every response on top of what its scope names, PUBLIC included; see vary
rules.
Adding a name to an entry’s key narrows the set of requests it can answer, so a union of names
is an intersection of audiences. A query selecting one ORGANIZATION field and one PRIVATE
field is keyed on both and served only to a caller who matches both, which is one person. One
PRIVATE field anywhere in the selection makes the whole response private. PUBLIC contributes
nothing, so it can never widen what a sibling field narrowed. A field returning a union or an interface
counts the scope of every member as well, whichever one the query selects; see a field returning
a union or interface.
When your origin sends Cache-Control too, the header decides how long, and the scope’s
names still key the entry. A request a named scope partitions carries a token, so the header has
to say the response may be shared: a Deployment scoped ORGANIZATION behind an origin
answering Cache-Control: s-maxage=60 (or max-age=60, public) is stored for 60 seconds. On this
path every configured vary rule keys the entry as well, so with x-role configured as above it is
stored once per organization and role. A bare max-age=60 does not say the response may be
shared: it is filed per caller under cache.policy.private, or not cached, as authenticated
requests describes. The rule below applies on
this path as well.
The runtime rule: only names GraphPilot derives itself
Section titled “The runtime rule: only names GraphPilot derives itself”A name counts for a request only when both hold:
- GraphPilot owns the name. A
[[cache.policy.vary]]entry defines it, or it is in the reservedgp-namespace GraphPilot injects its own buckets into. - The header is present on the request.
Either failure refuses the store with CACHE_SKIPPED_PRIVATE_WITHOUT_DISCRIMINATOR; the response
still reaches your client in full. The first condition is what makes the second trustworthy: a
configured name cannot be forged, because GraphPilot removes whatever the client sent under it
from the incoming request and re-derives the value from your expression. A caller whose token carries no org_id
therefore gets a response that is not stored, never somebody else’s entry.
That guarantee ends where your expression reads from the caller. A scope standing for an
organization, a customer, a role or a permission has to read from auth:; see a value is only
as trustworthy as its source.
An expression that resolves to the empty string satisfies the rule; see an empty value is a
value.
What the deployment build reports
Section titled “What the deployment build reports”The runtime refusal keeps the cache safe but is silent: you would learn about a misspelled name
from a hit rate of zero. So the deployment build reports what it can see, in the portal under
Schema → Findings and Config → Findings (see findings) and in the
output of gpilot deploy:
| The deployment build found | What to do |
|---|---|
A @vary name no [[cache. entry defines | Add the entry. |
A @vary name inside the reserved gp- namespace | Rename it, and the entry with it. A gp- name is treated as GraphPilot’s own, so it is not checked against your configuration, and pass_ cannot work for it. |
A @vary name whose entry is not a legal HTTP header name | Correct the entry’s name. GraphPilot skips such an entry, so the header is never derived. |
A [[cache. entry whose name is not a legal HTTP header name, referenced or not | Correct it. The entry is dead on every path. |
An entry no schema references is not reported: it may exist for your origin, which receives
the value through pass_to_origin. Such an entry still partitions every response, or, marked
scope_only, every response whose lifetime your origin’s Cache-Control decided.
These findings are advisory and do not fail a deployment. Treat one as the reason your cache is empty, not as a warning you can defer.
A scope value that is neither PUBLIC nor PRIVATE and carries no usable @vary (missing, an
empty names list, or not a list of strings) resolves to PRIVATE and raises a finding.
Private is the narrowest bucket the author could have meant, so this cannot widen the audience. A
misspelled value (scope: ORGANISATION against an enum that does not declare it) is rejected by
ordinary GraphQL validation first.
Is your field really organization-uniform?
Section titled “Is your field really organization-uniform?”scope: ORGANIZATION asserts that the response depends on nothing narrower than the
organization. It is the one assertion GraphPilot cannot check for you.
Belonging is rarely the only condition. An administrator and a read-only member, or someone whose
access to one project was revoked last week, see different amounts of the same organization. A
deployment list every engineer may read and a member without deployment access may not, scoped
ORGANIZATION, is filled by the first engineer and then read by that member. Fields that are
blanked or redacted for some members have the same shape.
Write down everything the visibility of these rows and fields depends on, and key the scope on every one of them. There are two ways to spell organization plus role:
- Separate names,
@vary(names: ["x-org", "x-role"]), as above. A token missing either claim produces no entry at all, so this shape fails closed. The audience is composed at each annotation, though, and naming onlyx-orgon a type whose rows depend on a permission is a leak nothing reports. - One combined name whose rule joins both claims (
auth:idp.org_id + ":" + auth:idp.role, see vary rules), referenced as@vary(names: ["x-audience"]). The audience is defined once and a schema author cannot under-specify it, andpass_to_origincan hand your origin the same value. A token missing either claim derives nothing, with+and&&alike, so this shape fails closed as well.
Prefer the combined name when the audience is fixed and your origin needs it too. Prefer separate names when different types need different subsets.
If visibility depends on the identity itself, a row owned by one user, then PRIVATE is the only
correct scope. A role bucket would be the same trap one level up.
A per-user scope that keeps the organization readable
Section titled “A per-user scope that keeps the organization readable”PRIVATE keys on cache.policy.private, and Insights records that value only as a keyed digest,
so per-user traffic cannot be grouped by organization there. A named scope can be per user and
still show the organization, by joining the organization claim with a
pseudonym of the user:
enum CacheControlScope { PUBLIC PRIVATE ORGANIZATION_USER @vary(names: ["x-org-user"])}
type Me @cacheControl(maxAge: 60, scope: ORGANIZATION_USER) { id: ID! favorites: [Product!]!}[[cache.policy.vary]]name = "x-org-user"value = "auth:idp.org_id + ':' + pseudonymize(auth:idp.sub)"scope_only = true
[telemetry]vary_values = "clear"Every caller still gets an entry of their own, because the user is part of the value. For a
caller in org-acme, Insights shows the segment as 8:org-acme:43: followed by the pseudonym:
the organization in clear, then the user as 43 characters that group one person’s requests
without naming them. The length prefixes are there because the value combines two sources; see
vary rules.
auth:idp.sub in the rule | pseudonymize(auth:idp. in the rule | |
|---|---|---|
| The rule derives, and the entry is partitioned on | 8:org- | 8:org- and Ben’s pseudonym |
Insights, vary_values = "pseudonymized" (default) | hmac-sha256: and 16 hex digits | hmac-sha256: and 16 hex digits |
Insights, vary_values = "clear" | 8:org- | 8:org- and Ben’s pseudonym |
Entries and hit rate are the same in both columns: one person always derives the same pseudonym.
Under the default the organization is no more readable than under PRIVATE, so the setting is what
makes this scope worth having. Every place pseudonymize is accepted, and what each setting
records, is on keeping identifiers out of
Insights.
scope_onlyis what keeps this toMe. Without it the rule partitions every response, so every product in the catalog would be stored once per signed-in user.vary_values = "clear"applies to every rule of the service, not only this one. Check that no other rule’s value is something you would not store in clear, and read storing values in clear is your choice.- It fails closed. A token without
org_idorsubderives nothing, so the response is not stored.
The same scope beside an organization and a role scope, end to end, is in partitioning your cache.
Why the definition lives in the configuration
Section titled “Why the definition lives in the configuration”The annotation references a vary rule by name and carries no expression of its own, on
purpose. Your origin can already partition on a derived header through its Vary header, so an
axis defined in the SDL as well would have two definitions that can drift apart silently. And an expression
that decides who shares an entry is a security control: in gpilot.toml it is reviewed with your
service configuration, not written into a schema assembled from several teams’ subgraphs.