Cache scope and isolation
The five scope dimensions, what each buys in isolation, what each costs in hit rate, and the setting that silently disables them all.
On this page
A cache with no scope is a cache where any caller can be served any other caller’s answer. That is usually catastrophic and occasionally fine, and the difference is a decision you have to make rather than one a default can make for you.
Scope is how you make it. It contributes nothing to whether two requests would produce the same answer, which is the fingerprint’s job, and everything to who is allowed to share one.
How scope works
The scope key is hashed into a prefix that sits in front of every storage key and every recall index partition. An entry written under scope A cannot be addressed by a probe under scope B. There is no post-hoc filter to get wrong, and no query that returns an entry the caller then has to be denied.
The trade-off is direct. Every dimension you add multiplies the number of partitions, and hit rate
falls with it. Scoping by trace gives near-perfect isolation and almost no reuse, because a trace
rarely asks the same question twice.
The five dimensions
cache:
scope: [tenant] # the shipped default
| Dimension | Partitions by | Reach for it when |
|---|---|---|
tenant |
the tenant on the request | you are multi-tenant, which is most deployments |
user |
the end user | answers are personalised, or one user’s question should not reach another |
feature |
the calling feature | two features share a model but must not share answers |
trace |
the agent run | reuse should not cross runs; expect very few hits |
upstream-key |
the serving virtual key’s id | key holders must not share entries |
The list is closed; anything else is a validation error naming the index:
cache.scope[1]: "region" is not one of tenant|user|feature|trace|upstream-key
Order matters to the hash but not to the meaning: the dimensions are joined in the order you declare them, so reordering the list repartitions the store the same way adding one does.
cache.scope is global. It applies to every rule, and it is restart-required, because it changes
the identity of every entry.
The setting that turns this off
Scope values come from the request’s business dimensions, which are off unless you enable them.
With ledger.dimensions.enabled: false, tenant resolves to an empty string for every request,
scope: [tenant] collapses to a single partition, and every caller shares every entry.
Validation says so rather than guessing:
warning: cache scope is global — all callers share entries; enable ledger.dimensions for tenant isolation
The structural guarantee and the configuration are separate claims. Scope A cannot serve scope B’s entry; that holds regardless. Whether your tenants map to distinct scopes depends on dimensions being on and the headers being sent. See turning on business dimensions for how those values arrive.
Per-key isolation is opt-in
Adding upstream-key folds the serving virtual key’s id — the key’s name, never its secret — into
the scope prefix. Without it, all keys share one partition, which is the behaviour every deployment
had before virtual keys existed.
Two boundaries apply even once you add it:
Anonymous callers share one bucket. A request with no resolved virtual key carries an empty key id, so every such request lands in the one empty-key partition and they share entries with each other. That bucket is still separated from every keyed partition; the dimension does not subdivide anonymous traffic.
It scopes recall, not authorisation. The dimension decides which entries a probe can reach. It is not an access check, and it does not enforce which models or providers a key may use. That is governance’s job, and governance runs before the cache lookup, so a request that would be turned away does not reach a stored answer.
Reading scope off a row
The row carries the first seven characters of the scope hash, so partitions are comparable without any tenant identifier appearing in the log:
"scope_sha7": "799cda7",
Two rows with the same value were looked up in the same partition. Two rows with different values could not have shared an entry no matter what they contained. That single field answers most isolation questions faster than reasoning about the config does.
Why a raw tenant id is not in the row
The scope key is a hash of the dimension pairs, and only its first seven characters are published. A decision log shipped to a shared analytics store therefore carries no customer identifier from the cache path, while still supporting the two questions an operator asks of it: were these two requests in the same partition, and how many partitions are in play.
What can go wrong
Adding a dimension looks like a cache failure. Hit rate drops to zero for the warm-up period,
because every existing entry is under the old prefix. Nothing is lost; nothing is reachable either.
Expect a cold start whenever you change cache.scope.
Scoping by user on a support assistant destroys the reason you turned the cache on. If the
value of your cache is that a thousand people ask the same question, scoping per user means a
thousand misses. Scope by tenant and rely on the fingerprint to keep different questions apart.