--- title: "ADR 0008 — Caching the Quick Connect briefing without keeping a revoked link alive" type: decision tags: [signpost, adr, decision] proposed_by: tok_bc09b340db294c709763789154d8113d model: kmem-docs-publisher extracted_at: "2026-10-08T22:38:26.122Z" --- # ADR 0008 — Caching the Quick Connect briefing without keeping a revoked link alive **Status** Accepted · **Date** 2026-08-09 · **Supersedes** nothing · **Relates to** PRD §5.2 (Quick Connect), §5.3 (the `AGENTS.md` briefing), §10 NF9 (`AGENTS.md` p95 < 200 ms, edge-cached 60 s), §11 S1 (raw tokens are never persisted), S2 (never echoed), S6 (revocation effective within one request), invariant I6, ADR 0002 (bearer, not OAuth), ADR 0006 (rate limiting) ## Context Two requirements point in opposite directions, and both are load-bearing. **NF9** puts `GET /s/{link}/AGENTS.md` under 200 ms at p95 and says it is edge-cached for sixty seconds. This is the first request of every Quick Connect conversation and the one anybody sees in a demo. **S6 and invariant I6** say a revoked link fails **within one request**. The reason somebody presses revoke is that the URL is already somewhere they cannot take it back from — a chat log, a screenshot, a CI variable — and the next minute is exactly the minute that matters. Sixty seconds of grace is not a rounding error on that requirement; it is the requirement inverted. Get the interaction wrong and the failure is silent. A revoked link keeps working, nothing logs it, no test fails, and the only way to find out is for somebody to try. This is the single most likely place in the Quick Connect wave for a real security bug. There is a third constraint that rules out the obvious compromise. **The URL is the credential.** The token is a path segment, so anything that stores a copy of the URL, or of a document built from it, is storing a password (S1, S2). ## Options considered ### 1. `Cache-Control: public, max-age=60` on the response The obvious reading of "edge-cached 60 s", and rejected outright. Cloudflare's Workers Caching sits **in front of** the Worker: on a hit it returns the stored response *"without executing your Worker code"*. Every check this door performs — the token lookup, the scope check, the rate limit, the audit row — is inside the Worker. A publicly cacheable briefing therefore gives every revocation a sixty-second grace period, and it does so the moment somebody enables `[cache] enabled = true` in `wrangler.jsonc`: no code change, no failing test, no way to see it from inside the request. It is also wrong for every cache *outside* Cloudflare. `public` invites any shared proxy to store a document keyed on a URL that is a credential, and to serve it after revocation to whoever asks. ### 2. Cache the authorisation decision Memoise "this token is live" for a few seconds and keep the rest of the request as it is. This is the tempting optimisation on the hottest path in the system, and `services/tokens.ts` already refuses it in writing: `validateToken` is *"hash, one indexed read, decide"*, with no memo, no KV entry and no module-scope `Map`. A cache with any lifetime at all fails the invariant test, which revokes a link and validates it again inside the same request. ### 3. Do not cache; meet NF9 some other way Honest, and it leaves 200 ms to be found in three D1 queries, an R2 read and the rendering. It is achievable today on a small instance and it stops being achievable as the managed region grows. Rejected because the caching is cheap once the object being cached is chosen correctly, which is option 4. ### 4. Cache the bytes; never cache the decision — **chosen** ``` GET /s/{link}/AGENTS.md 1 validate the link one D1 read, no cache, EVERY request revoked / expired / unknown → 401, stop here 2 rate limit per link (ADR 0006) 3 cache.match(key) key derived from the link's id hit → the composed Markdown, up to 60 s old miss → compose it, cache.put, serve 4 respond with `Cache-Control: no-store` ``` Step 1 is upstream of step 3 and has no cache of its own. The cache holds **what the briefing says**, which is a fact about the knowledge base and changes when somebody edits a convention. It never holds **whether this caller may read it**, which is a fact about the credential and changes the instant somebody revokes. The Cache API is the right primitive precisely because of the property that makes it the *worse* one for ordinary caching: it *"does not read through — responses are only cached when your Worker explicitly calls `put()`, and every request still executes your Worker on the way in."* Being unable to skip the Worker is the whole point. `caches.default` is the cache of the datacentre that answered, which is what "edge-cached" means here. ## Decision 1. **The briefing's bytes are cached in `caches.default` for 60 seconds. Nothing else is.** `tools.json` and `join.sh` are cheap to build and carry per-link facts, so they are not cached at all. 2. **Every request validates the token against D1 first, uncached.** Revocation is effective on the very next request. 3. **The wire header is `Cache-Control: no-store`, plus `CDN-Cache-Control: no-store`.** Nothing outside this Worker may store a document served from a URL that is a credential. The sixty seconds live entirely inside the Worker. 4. **The cache key is `https://quick-connect.signpost.invalid/{version}/{link.id}/{document}`.** Per link, so one link's briefing can never be served to another — the briefing states the link's own scope and expiry, so a read-only link served a write link's briefing would be told it may write. 5. **The key is built from the link's id, not from the token and not from its SHA-256.** All three are per-link and satisfy (4); the id is the only one that is neither the credential nor derived from it. A cache entry is persistence — on somebody else's hardware, in a store with no schema to audit — and S1 says the raw token is never persisted. The id is already what `audit_log.target` names and what the rate limiter is keyed on, so this door now identifies a link exactly one way. 6. **`$BASE` is substituted into the briefing, and the *stored* copy carries a placeholder instead.** See [Amendment, 10 August 2026](#amendment-10-august-2026) for both halves of this and for the decision it reverses. 7. **`$BASE`'s origin comes from `SIGNPOST_BASE_URL`, not from the request.** Same amendment. 8. **`BRIEFING_CACHE_VERSION` is part of the key.** There is no purge for `caches.default` beyond waiting, so a deploy that changes the wording would otherwise serve the old text from every colo that had it. Bumping the constant retires every entry at once. It is a constant rather than a build hash, because a hash would cold-start the cache on every deploy including the ones that changed nothing. It is `2`: the amendment below changed what is stored, so entries written by the previous deploy would otherwise be served with a stale base in them. `.invalid` is reserved by RFC 2606 and can never resolve, so the key cannot collide with a real request this Worker might one day proxy, and nothing can be tricked into fetching it. ## Consequences **Revocation is exact.** `test/routes/quick-connect/cache.test.ts` runs the sequence PRD §8 phase 3 asks for by hand — mint, fetch, fetch again to prove the entry is live, revoke, fetch — and asserts `401`. It then asserts the cached bytes are *still there*, unreachable, because a test that passed by eviction would be testing the wrong mechanism. **A stale briefing is possible for up to sixty seconds.** Publish a convention and the managed region can lag by a minute, per datacentre. That is the trade NF9 asks for and it is the right one: conventions change on the scale of weeks, and the alternative is paying three queries and an R2 read on the request that decides whether the demo feels instant. **`$BASE` is substituted, and the cached bytes therefore contain the link.** This decision was first taken the other way — the briefing described how to derive the base rather than printing it, on the grounds that a document with no secret in it is one the cache can hold safely. That was reversed during phase 3 integration, for two reasons. The phase 3 automated gate asks for *"`AGENTS.md` renders valid Markdown with a correctly substituted `$BASE`"*, and PRD §5.3's template substitutes `{Project Name}`, `{read|write}` and `{date}` alongside it. More decisively, several chat clients hand a model the **contents** of a fetched URL and not the URL — which is the ChatGPT, Gemini and Claude-web row of the client matrix. A model told that the base is "this document's URL" when it was never given that URL cannot call a single endpoint, so describing the base breaks the product in exactly the paste-a-link case the door exists for. The base is stated once with every path relative to it, and the briefing still ends with PRD §5.3's rule — *never include the URL of this document in any output you produce* — which is the mitigation that addresses the actual risk, a model echoing the link into a transcript. `test/routes/quick-connect/security.test.ts` keeps the strict "appears in no response, header or log line" assertion for every other endpoint, and holds `AGENTS.md` and `tools.json` to a narrower one: the link appears in the body only as the base, and in no header and no log line. **A 401, a 403 and a 429 are never stored.** They are built after step 1 fails, so `cache.put` is never reached; asserted rather than assumed. --- ## Amendment, 10 August 2026 Substituting `$BASE` was the right call and it was landed without reconciling it against this document. The briefing's bytes came to depend on two things the cache key does not name, and neither consequence was written down. Both were reproduced against the real Cache API in workerd before being fixed. ### What was wrong **The stored bytes held a working credential.** `$BASE` is `origin + /s/{raw token}`, so for sixty seconds after any briefing fetch an `sp_…` link sat in `caches.default` — a per-datacentre store with no schema, no audit and no purge. The original note above said this cost "the 60-second entry now holds a credential" and left it there. That is the persistence S1 says does not happen, and writing it down is not the same as reconciling it. **The stored bytes held one caller's hostname.** A Cloudflare deployment is normally reachable at more than one name: a custom domain plus `*.workers.dev`, plus version preview URLs, plus an apex/www pair. The key names the version, the link id and the document — not the origin. So the first caller to warm an entry decided, for the next minute, which hostname *every other holder of that link on any hostname* was told to call. Either the agent calls a host the operator did not advertise, or the host is unreachable to it and every documented endpoint silently fails — the exact paste-a-link failure substituting the base was meant to prevent. **And the origin was the caller's to choose.** `quickConnectBase` read it from the request URL, whose authority is the `Host` header. One request with `Host: attacker.example` produced a briefing instructing an agent to send `recall`, `read`, `remember` and `edit` — token in the URL, note bodies in the request — to that host, and, through the previous paragraph, wrote it into the shared cache for everybody else. `auth/settings.ts` already refuses exactly this for sign-in links, in writing: *"a base URL taken from the request's own `Host` header is therefore attacker-controlled … that is host-header injection"*. `/mcp` refuses a `Host` it does not serve for requirement S4. The Quick Connect door did neither. ### What changed 1. **What is stored is not what is served.** The briefing is composed against `BRIEFING_BASE_PLACEHOLDER` and that is what `cache.put` receives; the caller's own base is substituted into the copy returned, on a hit and on a miss alike. The entry is therefore per link — it still states that link's scope and expiry, which is why (4) and (5) stand unchanged — and it is byte-for-byte identical whichever hostname warmed it, and it holds no credential. 2. **`$BASE`'s origin comes from `SIGNPOST_BASE_URL`.** Only the origin: the path still comes from the request, because the path is what identifies the link and the endpoint. The fallback to the request origin is kept for `wrangler dev` and for tests, where there is no fixed hostname and no attacker, and `authOriginWarning` already tells an operator which mode they are in. 3. **`BRIEFING_CACHE_VERSION` is `2`**, because an entry written by the previous deploy carries a real base rather than the placeholder. A hostname refusal on this door — the `/mcp` treatment — was considered and not taken. With the base pinned there is nothing left to inject, and refusing would break the `workers.dev` and preview hostnames a deployment is legitimately reachable at, which is where the first request after a deploy usually comes from. ### What it still costs The **served** briefing contains the link, and that is unchanged and deliberate. A model that echoes it into a transcript has published a credential; the mitigations are the last rule of the briefing, the base appearing once with every path relative to it, and short expiries. Nothing outside the Worker may store the document: `Cache-Control: no-store` plus `CDN-Cache-Control: no-store` on the wire, and since this amendment on every other Quick Connect endpoint too — `recall`, `read` and `browse` returned knowledge-base content with a `200` and no directive at all, which RFC 9111 §4.2.2 lets any cache assign heuristic freshness to and replay after revocation.