Files
everyinc--compound-engineer…/docs/solutions/best-practices/cache-invalidation-input-set-completeness.md
2026-07-13 12:20:01 +08:00

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
Building an optimization cache whose stale result could change a downstream decision or output
Invalidating a cache by checking whether a set of "input" files changed
Deciding what counts as a cache-busting change vs. an ignorable one
cache
invalidation
correctness
freshness
git
stale-data

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:

  1. 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).
  2. 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.
  3. Catch new (untracked) inputs. git status --porcelain --untracked-files=all surfaces a newly-added manifest as ??; without --untracked-files=all git collapses a fully-untracked new directory to ?? dir/ and hides the manifest inside.
  4. Count both endpoints of a rename. R old -> new must invalidate on the source too — a profile input renamed away (package.json -> pkg.json) otherwise drops its invalidation signal.
  5. 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
  • docs/solutions/skill-design/cross-skill-shared-cache-primitive.md — the cache this rule shipped on
  • docs/solutions/best-practices/predictable-tmp-cache-ownership-check.md — a separate safety property of the same cache
  • AGENTS.md "Shared Repo-Grounding Profile Cache"