Skip to content
Harness Engineering2026-09-157 min readRev. 2026-09-30

Key a Cached Answer to Its Reader's Current Access, Not Just the Query

An answer cache keyed on the query serves a revoked reader their old answer. Add an authorization revision to the key, recheck before release, test the race, and count what per-reader keys cost in tokens.

Key takeaways

  • Prompt caching reuses computation inside one provider workspace; an answer cache reuses a decision about who may read what, and fails differently.
  • A key built from the query and document text alone serves a revoked reader the answer they can no longer see.
  • An authorization revision in the key turns revocation into a cache miss, but the release path must still recheck, because a revocation can land mid-request.
  • Per-reader keys cost generations: 200 readers of one answer is 200 model calls instead of one; keying by permission class can bring it back to a handful.
  • Test the race, not only the before-and-after sequence.

Rod Rivera

Author

Key a Cached Answer to Its Reader's Current Access, Not Just the Query

Rod's note: read with a pencil; the margins are for you.

Nothing changed except who may know

A research assistant answers "summarize last night's eval sweep" for Ana. The answer draws on an eval report, d3, that only the eval team may read. The system caches the answer so the next person to ask gets it without another retrieval and model call.

Then Ana moves teams, and her access to d3 is revoked. She asks the same question, word for word. The question text has not changed. The document has not changed. If the cache key was hash(query), the system finds a hit and hands back the old answer. Retrieval was correct. Authorization was not, and no amount of re-embedding would notice.

Two caches that look alike

Prompt caching is a provider feature that reuses the computation behind a repeated prefix of input tokens. Anthropic's prompt caching documentation describes a default 5-minute lifetime and states that caches are isolated between organizations and, on the Claude API, per workspace. That isolation separates your workspace from others. It knows nothing about the people inside your application.

Answer caching is something you build. You store a finished response so a repeated question skips retrieval, reranking and generation. The moment the key is only the question, you have promoted a cost optimization into an authorization decision. Your application has to enforce who may read the cached answer, on the hit path as well as the miss path.

A key that carries authority

Widen the key so it identifies the answer's authority as well as its content, and keep a current check before release. One design, offered to build and test rather than as a measured result:

Key componentWhat it capturesWhy it is there
principal_id or permission classWho is askingTwo readers may be entitled to different answers
authorization_revisionA counter bumped on every access changeRevocation becomes a key mismatch instead of a manual purge
document_revisionsVersion of every document the answer drew onA content change invalidates dependent answers
normalized_queryThe question, plus conversation context that changes its meaningThe thing being asked
generator_revisionModel and prompt versionAn answer from a retired prompt does not survive the upgrade
python
import hashlib

auth_revision = {"ana": 7, "bo": 3}  # bumped by the system that grants and revokes
cache = {}
generations = 0


def cache_key(principal, query, doc_revisions, generator="summarizer-v4"):
    deps = ",".join(sorted(doc_revisions))
    raw = f"{principal}|{auth_revision[principal]}|{query}|{deps}|{generator}"
    return hashlib.sha256(raw.encode()).hexdigest()[:12]


def answer(principal, query, doc_revisions):
    global generations
    key = cache_key(principal, query, doc_revisions)
    if key in cache:
        return "hit", key
    generations += 1  # stands in for retrieval, reranking and a model call
    cache[key] = f"answer drawn from {doc_revisions}"
    return "miss", key


print(answer("ana", "eval summary", ["d3:v2"]))
print(answer("ana", "eval summary", ["d3:v2"]))
auth_revision["ana"] += 1  # Ana loses access to d3
print(answer("ana", "eval summary", ["d3:v2"]))
print("generations:", generations)
text
('miss', 'ff83b37bea79')
('hit', 'ff83b37bea79')
('miss', '1eed9d01504a')
generations: 2

I ran it with Python 3.12 on September 30, 2026. The revocation bumped Ana's revision from 7 to 8, so the old entry no longer matches and nobody had to find and delete it. A miss is not enough on its own: the regenerated answer must come from retrieval that applies current permissions, which will no longer return d3. Filtering retrieval by permission before the prompt covers that half. The fixture passes the same dependency list only to keep the example short.

The cache key gains an authorization dimension. Query text; Cache key lookup; principal + auth revision; Hit: recheck authority before release; Miss: retrieve with current permissions; New answer checked under captured revision
The cache key gains an authorization dimension

What the key costs, in tokens

Safety here is paid for in generations, and you can count them. Say 200 researchers each ask for the same sweep summary, and one answer costs 8,000 input tokens of retrieved context plus 500 output tokens: 8,500 tokens per generation.

  • Key on the query only: 1 generation, 8,500 tokens. Unsafe.
  • Key per principal: 200 generations, 200 × 8,500 = 1,700,000 tokens.
  • Key per permission class: if those 200 people fall into 3 distinct sets of readable documents for this corpus, 3 generations, 3 × 8,500 = 25,500 tokens.

Multiply by your model's per-token prices to turn tokens into dollars. The middle line is 200 times the first, and the third recovers most of the saving. It is safe when two readers in a class can read exactly the same documents in scope, because then retrieval returns the same passages for both. Weaker sharing rules, such as "B may read everything this answer cited," are also safe for access but can cost recall: B might have been entitled to a better document that A could not see.

The part a static test misses: revocation mid-flight

Ask, revoke, ask again tests only a revocation that completes between requests. Real systems have requests in flight. Picture Ana's request past retrieval, holding a generated answer, about to be cached and returned, when an administrator revokes her access and her revision moves from 8 to 9.

python
def admit(pending, current_revision):
    # Run inside the same transaction or lock as the revocation write.
    return "release" if pending["auth_revision"] == current_revision else "authorization_changed"


pending = {"principal": "ana", "auth_revision": 8, "answer": "fixture answer"}
print(admit(pending, auth_revision["ana"]))
auth_revision["ana"] += 1  # a revocation commits while the answer is pending
print(admit(pending, auth_revision["ana"]))
text
release
authorization_changed

The comparison is one line. The discipline is where it runs: immediately before the answer is released or written to the cache, not when the request started. A check only at the start passes this case, because Ana still had access then. And the admission step and the revocation write need a shared transaction, lock or equivalent, or a revocation can commit between the check and the release. Bytes already sent to the reader cannot be recalled; streaming responses need their own policy.

A revocation commits before a stale response seeks atomic admission. Capture revision 7; Commit revocation at revision 8; Atomically check and admit revision 7 response; Refuse: authority revision changed
A revocation commits before a stale response seeks atomic admission

Keep a positive control: if the revision does not change, the pending answer must be released. A suite that tests only the revoked path can pass a system that refuses everyone. And check scope: invalidation for Ana's revocation should touch Ana's entries, not every entry that mentions d3, unless the change is document-wide, such as d3 being deleted.

A dictionary on your laptop is not your cache

The fixture proves the comparison. It proves nothing about a shared Redis instance, an edge cache or an in-process LRU shared across worker threads under real concurrency. Test the real store with the pause-revoke-resume sequence below.

Widen the key

Add the principal or permission class and an authorization revision to whatever key you build from the query.

Give the revision one owner

The system that grants and revokes access increments the counter, in the same transaction as the access change. The cache layer never infers it.

Recheck at release

Compare the captured revision with the current one at the last moment before returning or caching, under the coordination the revocation also uses.

Test the race

Pause a request after retrieval, revoke, resume, and confirm the answer is withheld and not cached. Run the unrevoked control in the same suite.

Quick check — A request generated its answer at authorization revision 8. Before release, access is revoked and the revision becomes 9. What should happen to the pending answer?

Check the decision

Why this matters in a lab

Inference cost is a design constraint on any deployed agent, and caching is the first lever people reach for. Applied-AI work asks you to pull it without breaking access control. A strong interview answer separates provider prompt caching from application answer caching, puts authority in the key, rechecks at release, and then does the arithmetic above: what per-reader keys cost in generations, and how permission classes win most of it back. That combination of a correctness argument and a token count is what a reviewer looks for in a work sample.

What would prove this design wrong

If a fresh request after revocation, with no timing involved, still returns the old answer, the key has failed; that is the cheap deterministic test. If the paused-request test releases the answer, the recheck is in the wrong place or not coordinated with the revocation. If the permission-class key ever serves an answer built from a document one member of the class cannot read, your class definition is wrong.

Nothing here was run against a production cache; the fixtures show the logic, and the token numbers are arithmetic on stated assumptions.

Read Chapter 5: what the model sees, and a memory that forgets

The chapter treats memory as choosing what reaches the model, measures retrieval, and advances a context revision when something must be forgotten.

Learn the mechanism. Test the claim.

Join the Prof Rod newsletter for one educational lesson a week, with worked examples attached. It is free to register for and separate from the Zero Employee community.