5.5 KiB
title, date, category, module, problem_type, component, severity, applies_when, tags
| title | date | category | module | problem_type | component | severity | applies_when | tags | |||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| A correctness cache needs a COMPLETE, schema-derived invalidation input set | 2026-06-29 | docs/solutions/best-practices/ | repo-grounding-cache | best_practice | tooling | high |
|
|
A correctness cache needs a COMPLETE, schema-derived invalidation input set
Context
We cached a question-agnostic "project profile" (stack, deps, license, conventions, topology) keyed by git <root-sha>/<head-sha>, and reused it only when the working tree was "clean enough." The cardinal rule: the cache is an optimization that must never serve a stale profile that changes a skill's output. "Clean enough" was implemented as a delta check — reuse the entry unless a profile-input path is dirty (git status --porcelain). The whole correctness of the scheme rests on the profile-input set being complete.
The first version's input set was a hand-picked, JS/Python/Go/Ruby-centric allowlist of manifest filenames. Adversarial review found whole ecosystems missing — .NET (*.csproj, *.sln), Swift/iOS (Package.swift, Podfile), Deno, modern Python (uv.lock, pdm.lock), C/C++, Gradle version catalogs. In any of those repos, editing a manifest at an unchanged HEAD would not invalidate, and the cache would serve a profile with the old stack/deps — a silent cardinal-rule break.
Guidance
When a cache must never change an output, treat the invalidation input set as a completeness obligation, not a convenience list:
- Derive the input set from the cached schema's actual sources, conservatively, as a superset. Every field the cache stores must trace to the files that produce it; if a file feeds the schema, a change to it must invalidate. Over-matching costs a re-derive (cheap); under-matching serves stale (a correctness break).
- Span ecosystems, not just the ones in front of you. A hardcoded allowlist will omit the language you don't use today. Use suffix matching for project-file families (
.csproj/.fsproj/.sln) and cover the mainstream package managers, deploy descriptors, and CI configs. - Catch new (untracked) inputs.
git status --porcelain --untracked-files=allsurfaces a newly-added manifest as??; without--untracked-files=allgit collapses a fully-untracked new directory to?? dir/and hides the manifest inside. - Count both endpoints of a rename.
R old -> newmust invalidate on the source too — a profile input renamed away (package.json -> pkg.json) otherwise drops its invalidation signal. - Don't cache cheap-to-recompute, churny, correctness-sensitive data at all. The
docs/solutions/index is re-globbed fresh every run rather than cached: a directory listing is ~free, the consuming match reads files fresh anyway, and caching it risked serving a stale match (e.g. missing a just-written learning). Caching it AND invalidating on every write would defeat the cache exactly in the compounding loop. Glob-fresh wins over both alternatives.
Why This Matters
A cache that "usually" invalidates is worse than no cache: it is correct in testing (you test the ecosystems you use) and silently wrong in production for someone else's stack. The failure is invisible — there's no error, just a stale answer fed into a decision. The completeness of the input set is the single load-bearing guarantee; everything else (keying, atomic writes, TTL) is secondary to it.
The general principle: bias every ambiguous case toward over-invalidation. A needless re-derive is a few seconds; a served-stale profile is a wrong verdict, plan, or review.
When to Apply
- The cached value, if stale, would change a downstream decision (a verdict, a plan, a generated artifact) — not just a perf metric.
- You invalidate by "did these inputs change?" rather than by content hash.
- The set of inputs spans formats/ecosystems you can't fully enumerate from the current repo.
For a pure latency cache where a stale value is merely slower-correct (not wrong), this rigor is overkill — bound it by TTL and move on.
Examples
# Under-complete (silently serves stale in a .NET / Swift / Deno repo):
_MANIFEST = {"package.json", "go.mod", "Cargo.toml", "Gemfile", "pyproject.toml", ...}
def is_input(p): return os.path.basename(p) in _MANIFEST
# Complete superset: span ecosystems + suffix-match project files + untracked + rename
_MANIFEST = { ...JS, Go, Rust, Ruby, Python(+uv/pdm), PHP, JVM(+catalogs),
Swift/iOS(Package.swift, Podfile), .NET(packages.config, *.props),
Deno, C/C++, Haskell... }
_PROJECT_SUFFIXES = (".csproj", ".fsproj", ".vbproj", ".sln", ".cabal")
def is_input(p):
b = os.path.basename(p)
return (b in _MANIFEST or b.endswith(_PROJECT_SUFFIXES)
or (("/" not in p) and b in _ROOT_DOCS)
or p.startswith((".cursor/", ".github/workflows/", ".circleci/")))
# git status --porcelain --untracked-files=all -> catches new ?? manifests
# rename "old -> new" -> append BOTH paths
Related
docs/solutions/skill-design/cross-skill-shared-cache-primitive.md— the cache this rule shipped ondocs/solutions/best-practices/predictable-tmp-cache-ownership-check.md— a separate safety property of the same cache- AGENTS.md "Shared Repo-Grounding Profile Cache"