# 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.

Source: https://tokentriage.com/docs/build/caching-scope

---
import Callout from '../../../components/Callout.astro';
import Details from '../../../components/Details.astro';
import NextSteps from '../../../components/NextSteps.astro';

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

```yaml
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:

```text
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:

```text
warning: cache scope is global — all callers share entries; enable ledger.dimensions for tenant isolation
```

<Callout type="warning">
This is a warning, not an error, so a deployment can start in this state. Enable
`ledger.dimensions` before you enable the cache in any environment with more than one customer in
it. Both shipped example configs turn dimensions on for this reason.
</Callout>

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](/docs/build/cost-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](/docs/build/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:

```json
"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.

<Details summary="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.

</Details>

## 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.

<NextSteps items={[
  {title: "How a request becomes a cache key", href: "/docs/build/caching-keys", description: "Where the scope prefix sits, and what the fingerprint covers instead."},
  {title: "Turn on business dimensions", href: "/docs/build/cost-dimensions", description: "How tenant, user and feature values reach a request in the first place."},
  {title: "Operating the cache", href: "/docs/build/caching-operations", description: "Purging by scope, and what that can and cannot reach."}
]} />
