KVS build-cache KEY contract — content-addressed build-artifact cache over the kvs CAS

ratified

One line. Define exactly what hashes into a build-cache entry so a cache hit is provably equivalent to a fresh build. A wrong key is worse than no cache — it serves a stale artifact as fresh (silent poisoning). This RFC fixes the key's completeness before any code (287a, design-first; Quality > Speed).

Ratified 2026-06-27 (owner). The §3 key field set and the §5 completeness directive (over-include-never-under-include + continuous parity canary) are accepted; the §7 store reuses the kvs CAS (#206/#208) and §8 shares one input-digest with hub-RFC-003. 287b is now open for implementation.

1. Why this is design-first

287 has three levers (content-addressed cache, isolated runners, fair

concurrency). Lever 1 — the cache — has one catastrophic failure mode: an incomplete key. If any input that affects the output is omitted from the key, two builds with different real inputs collide on the same key and the cache returns the wrong artifact as if it were correct. That is undetectable at use time and corrupts everything downstream (a poisoned compiler stage poisons every binary built from it). No amount of runner isolation or scheduling fixes a poisoned cache. So the key contract is ratified before the store is built, the same discipline applied to KVS-RFC-003 slice-d (get the model right, then code).

This RFC is the Stack-wide, remote/shared generalization of the per-tool key already ratified for one component in design-RFC-009 Option A (the design-gen content-hash manifest). design-RFC-009 R3 explicitly scoped out a distributed build cache ("single-machine incremental is enough until KDS exceeds 1000 pages");

287 is exactly that distributed layer for the whole monorepo. This contract

makes design-gen's per-tool manifest one conforming instance of a universal key.

2. Soundness precondition — producer determinism (inherited, not re-stated)

A cache is only as sound as the determinism of the thing it caches. policies/reuse/build-tooling-first.kmd §1 already mandates, for every build-time producer: identical input → identical output, hash-stable, with these sources of non-determinism forbidden in mainline output: wall-clock time, random seeds, map/dict iteration order, filesystem walk order. This RFC does not re-declare those rules — it depends on them. A producer that is not deterministic per §1 is ineligible for the cache (its outputs would differ run-to-run, so either every store is a false key or hits return garbage).

Corollary — the key never includes a forbidden input. Time, hostname, build user, absolute paths, and PIDs MUST NOT appear in the key (including them causes false misses, never poisoning, but defeats the cache). Absolute paths are normalized to repo-relative before hashing.

3. The key

A cache entry is key → manifest → {blob digests}. The key is:

key = H(
    schema_version          # this contract's version (§6) — global partition
  ‖ target_id               # WHAT: <module-path> · <output-kind> · <variant>
  ‖ source_digest           # merkle of the declared input fileset (content, not mtime)
  ‖ toolchain_digest        # the actual producer binary identity (§4)
  ‖ flags_env_digest        # canonical build flags + allowlisted env (§4)
  ‖ deps_digest             # merkle of the cache keys of declared upstream artifacts
  ‖ render_version          # per-producer semantic-bump escape hatch (§6)
)

H = the kvs object-plane hash (SHA-256 today, agile per stack-RFC-006). = concatenation of length-prefixed, canonically-serialized fields (no ambiguous delimiters; field order fixed by this list).

Field semantics

Field What it captures Failure if omitted
schema_version the contract itself a contract change silently reuses old entries → poisoning
target_id output identity: module path · kind (go-pkgkoda-stage1rust-crateog-image…) · variant (locale, GOOSGOARCH triple) two distinct outputs share a key → poisoning
source_digest content hash of every file in the target's declared input set, walked in sorted order; a merkle root, not a flat concat an edited source returns the pre-edit artifact → poisoning
toolchain_digest the producer's binary identity — go build-id + GOROOT digest, the koda stage0 binary digest, rustc -Vv + sysroot digest, clang --version + resource-dir digest. Prefer the binary digest over a version string (two builds of the same version can differ) a compiler upgrade serves artifacts from the old compiler → poisoning
flags_env_digest canonicalized flags (-tags, -trimpath, opt level, target triple) + the allowlisted env vars that affect output (CGO_ENABLED, GOFLAGS, GOOS/GOARCH, KODA_*) a flag change serves the wrong-flags artifact → poisoning. Allowlist, not denylist — see §5
deps_digest merkle of the cache keys (not just versions) of declared upstream artifacts, making the key a DAG: a rebuilt dependency invalidates every dependent transitively a changed dep serves an artifact linked against the old dep → poisoning
render_version producer-owned integer, bumped when output semantics change in a way the inputs above cannot express (e.g. a codegen template embedded in the producer) a behavior change the inputs don't see reuses stale output → poisoning

This is deliberately the same shape as design-RFC-009 Option A's key(spec, locale, kind) (source ‖ frontmatter ‖ i18n ‖ template digest ‖ asset digest ‖ generator.go_digest ‖ render.version) — generalized so generator.go_digest becomes toolchain_digest, the per-kind template/asset digests fold into source_digest+render_version, and deps_digest is added for cross-artifact chains that a single-tool generator didn't need.

4. Toolchain & env — capturing the producer, not a label

The two most common real-world poisoning vectors are a toolchain upgrade and an env var that silently steers codegen. Both are addressed by capturing identity, not labels:

  • Toolchain: hash the producer's content where feasible (the compiler

    binary's build-id / digest), not just --version. A nightly that keeps the same version string but changes codegen must still invalidate.

  • Env: the key consumes an allowlist of output-affecting variables per

    producer kind, declared in the producer's cache adapter. Everything else is excluded (so TERM, SSH_*, PWD, HOSTNAME never cause misses). The allowlist is part of the producer's contract and is itself covered by schema_version (changing the allowlist is a schema event).

5. Completeness discipline — the central rule

The two-sided risk:

  • Under-inclusion → false HIT → poisoning. Catastrophic, silent. *ever

    acceptable.*

  • Over-inclusion → false MISS → cache underused. Safe, just slower. Tunable

    later.

Therefore the contract's prime directive: *when uncertain whether an input affects the output, include it.* Err toward over-inclusion; never under-include. Three structural guards make this enforceable rather than aspirational:

  1. Declared input sets, allowlisted env. A producer declares its inputs and

    its output-affecting env explicitly (no "hash the whole CWD" and no implicit ambient env). Declaration is reviewable; drift is a schema_version bump.

  2. Continuous parity canary (the safety net). A configurable fraction of

    builds run both cached and cold, and their output digests are compared (exactly design-RFC-009 R6's bit-for-bit parity check, run continuously in prod rather than once at ratification). A mismatch = a key-completeness bug = page + auto-disable the affected target_id class (fail safe to cold builds). This converts "did we forget an input?" from an unknowable into a monitored, alarmed signal — and is why it is safe to grow the cache incrementally.

  3. Lowest-risk first consumer. 287b wires Go's GOCACHE first precisely

    because Go already content-addresses its build inputs correctly; it validates the store/transport against a producer whose key correctness is independently trusted, before we hand-roll a key for koda stages (287c).

6. Versioning & invalidation

  • schema_version (global) is part of every key, so bumping it cleanly

    partitions the entire keyspace — old entries become unreachable without deletion (content-addressed immutability, §7). Bump on any change to this contract: field set, serialization, hash function, or a producer's declared input/env allowlist.

  • render_version (per producer) invalidates one producer's entries for a

    semantic change its inputs can't express.

  • There is no time-based invalidation of correctness — a hit is correct

    forever for its key. TTL exists only for GC (§7), never as a correctness crutch.

This is distinct from specs/cache-purge/contract.kmd, which governs HTTP/edge cache purge (a <meta> tag for browser/CDN freshness). That is a different cache in a different layer; this RFC governs the build-artifact cache. The two share no mechanism — noted here so they are never conflated.

7. Put/Get interface over the kvs CAS

The store reuses the existing kvs CAS seam (#206 block-merge / #208 CAS-on-ref) — no new substrate (design-RFC-009-style manifest, generalized; reuse-first):

Get(key)            -> (manifest, hit)        # key is the address; pure lookup
Put(key, manifest)  -> ()                      # write-once
manifest = { target_id, key, schema_version, blobs: [{path, blob_digest}], created_lsn }
  • Blobs are content-addressed by their own digest and stored once (dedup

    across keys, across runners, across dev machines — one warm cache serves everyone, the #287 goal). The manifest maps a key → the set of output blob digests; the two-level indirection means identical outputs from different keys share storage.

  • Write-once / collision = alarm. A Put for an existing key whose manifest

    would differ is a contradiction: same key, different output ⇒ a key-completeness bug. The store rejects the divergent write and raises the same alarm as the §5 canary. (Identical re-Put is a no-op.)

  • Namespacing: keys are partitioned by schema_version and by tenant

    (policies/multi-tenant-by-default.kmd) so no cross-tenant artifact leakage and a schema bump is a clean partition boundary.

  • GC / eviction is LRU over blob digests, refcounted by live manifests, with

    a TTL floor — and is purely a space concern, never a correctness one (an evicted entry just becomes a cold rebuild). Eviction never deletes a blob a live manifest references.

8. Relationship to build attestation (hub-RFC-003)

The key's input set — source_digest, toolchain_digest, flags_env_digest, deps_digestis the SLSA provenance "materials + builder + parameters" that hub-RFC-003 attests. They MUST be computed once and shared: a cache Put can co-emit (or anchor) the attestation from the same input digests. Building a second, independent input-hashing path for attestation would risk the two disagreeing — the cache says "same inputs," the attestation says "different" — which is its own class of bug. One input-digest, two consumers (cache key + provenance predicate).

9. Slices (this RFC ratifies 287a)

  • 287a — this contract. Output = the key schema (§3–6) + the Put/Get interface

    (§7). No code until ratified.delivered by this RFC.

  • 287b — CAS build-store + Go GOCACHE backend. Implement §7 over the kvs CAS;

    wire GOCACHE as the first consumer (its trusted hashing de-risks §5). Stand up the §5 parity canary here, before any hand-rolled key.

  • 287c — koda stage cache. The motivating worst case (per-generation

    make clean): cache stage0stage1 .asm.o keyed per §3. Highest single win; its key correctness leans on the canary proven in 287b.

  • 287d — isolated/warm runners (cpusets). Consumes

    infra-RFC-002-self-hosted-runner-pool (the runner/isolation side lives in infra; the 2026-06-24 aivoice#064 starvation incident is the motivation).

  • 287e — fair concurrency / queue. Per-runner limits over 287d's runner model.

10. Acceptance for ratification — ✅ MET (ratified 2026-06-27)

  1. Owner sign-off on the §3 key field set and the §5 completeness directive

    (over-include-never-under-include + continuous parity canary).

  2. Confirmation that the §7 store reuses the kvs CAS (#206/#208), not a new

    substrate.

  3. Confirmation that §8 shares one input-digest with hub-RFC-003 (no second

    hashing path).

  4. On ratification: status: ratified, then 287b opens for implementation.

11. Open questions

  • Canary fraction & placement: what % of builds run dual (cached+cold), and is

    the comparison in-runner or a post-hoc job over the CAS? (Cost vs detection latency — start high in soak, lower once a target_id class is trusted.)

  • toolchain_digest granularity: binary digest is strongest but a multi-hundred-MB

    toolchain is expensive to hash per build — cache the toolchain digest itself keyed by its on-disk path+mtime+size (a local, lower-stakes cache)?

  • deps_digest for non-Koder deps: vendored modules hash by content; how are

    system libraries (libc, system clang) pinned into the key without a full sysroot hash? (Likely: a declared sysroot manifest, bumped via render_version.)