Evidence: 2 file:line witnesses as of 2026-08-18. Confidence: derived, written from the seams and the estate's failures rather than from citations; treat each rule as a design call and argue with it. What changes it: the first module built against this doc (
../PLAN.mdforge-1) and its instance breakdown.
Not asking the same expensive question twice: an LLM call, a scrape, a research pass, a vendor lookup. Three layers exist in the estate for different reasons, and the one the owner asked about (cross-service caching across apps, with the Redis already up on Render) does not exist yet.
Audience: anyone whose payload spends money on a call whose answer could be reused.
What the instances do#
| versable-runner | speedway | walmart-mvp | |
|---|---|---|---|
| result cache | GCS-backed KV keyed by pipeline input (app/gcs_cache.py, LazyKVBackend("enhancement"), plus services_cache for part-type and attribute agents) | scrape cache, cross-workspace on purpose: SERP results in Firestore with 7-day TTL, page text in the object store with a 1-month lifespan enforced at read and by a bucket lifecycle rule (app/lib/scrapecache.server.ts:1-11) | none for results |
| hot-path cache | none | in-memory TTL microcache with single-flight for the session, user, workspace, membership chain before every render (app/lib/microcache.server.ts:1-10) | none |
| Redis | none | none | queue (arq) and the fleet-wide vendor rate-limit slots (ratelimit.py), not results |
| scope | per module deployment | per app | per app |
The rule#
Cache by content, scope by what the content is, state the TTL, and never let a cache change an answer's shape or hide its provenance.
Three layers, three reasons#
| Layer | Keyed by | Lives | Purpose |
|---|---|---|---|
| result cache | a hash of the capability, its version, the variant, the reference-data versions, and the item's relevant input fields | object storage or Redis, per module or shared | skip a paid call whose inputs have not changed |
| vendor cache | the outbound request (a URL, a SERP query, a model prompt) | shared across modules that call the same vendor | skip a scrape or a lookup any module already did |
| hot-path cache | whatever an app reads before every request | in-process, seconds of TTL, single-flight | speed, not money; app-side |
The result cache is the runner's (01-runner-and-payload.md: the payload
gets a cached(key, compute) verb, and never opens a cache client itself).
The vendor cache is a shared service or a shared Redis, and it is what
"cross-service caching" means. The hot-path cache is an app concern and
speedway's microcache is the model.
Scope follows the content#
speedway's rule is adopted verbatim: "a page's content is not workspace data". A scraped page, a SERP result, a model's answer to a tenant-free prompt are cross-tenant by nature and cached without a tenant in the key. An answer that embeds tenant data (copy generated from a customer's template) is tenant-scoped in the key or not cached across tenants at all. The rule of thumb: if the cached value could reveal one tenant's data to another, the tenant is in the key.
The key includes everything that would change the answer#
Capability id and version, variant, judge, reference-data versions, model name, and the input fields the capability declares as cache-relevant in its manifest. A cache hit after a taxonomy snapshot bump is a wrong answer with a fast response time; the version in the key is what prevents it.
Provenance survives a hit#
An outcome served from cache says so (cached: true, cache_key,
cached_at) and still records a usage event (meter cache.hit, cost zero)
so stats and bills see it. A caller can pass settings.cache: bypass on a
job to force fresh computation, and the manifest says whether the
capability supports it.
TTLs are stated and enforced by the store#
Every cache has a TTL in the manifest and a matching expiry on the store (Redis TTL, a bucket lifecycle rule). Enforcing at read time alone, as speedway does for page text in addition to the lifecycle rule, is fine as a belt; the store's rule is the braces.
Cross-service caching, on Redis or not#
The owner named the Redis on Render already used for App V5 caching. A shared vendor cache is the natural first tenant of a shared Redis, with two cautions:
- a Render Redis reached from GCP crosses clouds on every hit; for a scrape cache whose alternative is a paid Oxylabs call that is fine, for a hot-path cache it is not
- Redis is a cache, not a store; anything that must survive an eviction (results, usage) lives in object storage or a database, and the Redis layer sits in front
Whether the shared cache is one Redis, a small cache module with its own surface, or per-cloud Redis instances with the same key scheme is a deployment decision left open until a second module needs it. The key scheme and the scope rule above are what make any of the three work.
What a cached answer tells its consumer#
Marking an outcome cached is the floor, not the contract. An outcome served
from cache carries a cache block, and a consumer can render it without
knowing which module produced it:
| field | meaning |
|---|---|
hit | whether this outcome came from cache |
key_version | the capability and reference-data versions the key was built from, so a consumer can see WHY something did or did not hit |
stored_at | when the value was written |
expires_at | when the store will drop it, so a consumer can show remaining life rather than a raw TTL |
age_s | how old the value is at the moment it was served |
scope | tenant, capability, or shared, matching the key scheme above |
A miss carries the same block with hit: false, because the absence of a hit
is itself information and a consumer that only sees the block on hits cannot
tell a miss from a module that never reports.
Owner ruling, 2026-08-22: preview runs reuse their outputs, and the cache metadata is "useful later" beyond that. The immediate case is a preview of one to five rows whose result the eventual job should not pay for twice; the durable case is a consumer that can explain to a person why a run was instant or why it cost money.
Do-nots#
- Do not put a tenant's data in a cross-tenant cache key, and do not leave the tenant out of a key when the value carries tenant data.
- Do not cache without the capability version and reference-data versions in the key. A stale hit is a wrong answer.
- Do not serve a cached outcome without marking it cached.
- Do not let a payload open a cache client. The runner provides the verb.
- Do not treat Redis as durable storage.
- Do not ship a cache without a TTL the store enforces.
- Do not report a cache hit as a bare boolean. Carry the block, including on a miss.
- Do not omit the key's version inputs from what the consumer sees. A hit nobody can explain is a hit nobody can trust.