§ 17 — Cache semantics
17. Cache semantics
Normative. This chapter defines the abstract cache contract, the integrity rule for restoring artifacts from a content-addressable store, the discovered-inputs mechanism, the test-unit caching contract, and the portability invariants that allow a cache to travel between machines and workspace relocations.
17.1. Cache semantics (abstract)
A conforming implementation MAY maintain a build cache that avoids re-executing a work unit whose observable inputs are unchanged since the last successful completion of that unit. The Standard specifies the cache abstractly; the hash function, on-disk layout, eviction policy, and storage medium are implementation-defined (§ 0.3).
The observable inputs of a work unit are those of its properties that are derived from the Cookfile source or from the register-phase record. They comprise, at a minimum:
- The command text or Lua chunk that the unit will execute.
- The list of input paths declared for the unit.
- The list of output paths declared for the unit.
- The recipe’s resolved environment (§ 21).
- For a unit produced by a
gather <probe>source (§ 8.2), the materialised data member: the current iteration element rendered in a canonical, key-sorted form. Each per-member unit’s fingerprint therefore depends on its own member’s content — independent of the whole source value — so editing one member re-runs only that unit. The source probe itself is re-observed on every invocation and is never cached (§ 22.5.8); what this observable keys is the member, so a member whose content did not move keeps its unit’s key however the observation was arrived at.
Note (informative). An implementation MAY realise the materialised-member observable (5) by folding the canonical member rendering into the unit’s command-text contribution (observable 1) rather than as a distinct fingerprint field; the two are observably equivalent because both make the member part of the inputs the fingerprint depends on. The reference implementation takes this approach: the codegen attaches the canonical member to each fan-out unit, which is mixed into the command hash at registration.
A conforming implementation MAY additionally consider the content of the declared input files, the modification times of the declared output files, and the content of the recipe’s input set. It MUST NOT consider wall-clock time, process identifiers, or any state not derivable from the sources above. An implementation MAY exclude from the resolved-environment observable a set of environment variables whose values are known not to affect work-unit output (e.g., ambient session state, credentials unrelated to the build product); the specific set is implementation-defined.
When the cache indicates that a unit is up-to-date, the implementation MAY skip executing the unit and MUST treat it as successfully completed for the purposes of the dependency graph. When the cache indicates that a unit is stale or absent, the implementation MUST execute the unit and, on success, MAY record the unit’s completion in the cache for subsequent runs.
Truthful cache reporting (CS-0248). A unit MUST be reported and counted as cached only when this invocation skipped its observation or execution by serving a result recorded by an earlier invocation. Work performed during this invocation is not a cache hit merely because a later phase reuses its result. In particular, a probe value synthesised without a VM and a probe value produced by this invocation’s registration pre-pass (§ 22.5.8) are both current observations: their probe nodes MUST be reported as completed, not cached.
A unit for which the register-phase record disables caching — for example, a bare shell step or an @ step, whose registration form sets the cache flag to false — MUST be executed on every run of the containing recipe.
17.1.1. The single cache key
A cacheable work unit — one whose register-phase record enables caching (§ 17.1) — has exactly one cache key. Declaring outputs is not the criterion (CS-0186): it decides what a hit gives back, per § 17.1.1.1 below, not whether there is a hit to give. The key is a content-addressed digest:
key = H( declared input contents, command text / Lua chunk, declared output paths, consulted environment values, declared probe values )
where:
- declared input contents are the contents of the unit’s
gather(§ 8.2), its depfile-discovered inputs (§ 17.3), and the module sources its body loaded (§ 17.1.1.3), content-hashed; - command text / Lua chunk is observable 1 of §17.1;
- declared output paths is observable 3 of §17.1;
- consulted variable values are the string forms (§ 5.3) of the declared variables the unit reads —
$<NAME>sigils and thevar.NAMEstatic reads of § 17.1.2; - declared probe values are the canonical values (§ 22.5.5) of every probe in the unit’s effective seal set — the probes the author sealed the unit on (the disposition surface of §8.4.3, an additive set with no project-default and no narrowing form).
A non-empty effective seal set is sufficient to mint this key even when the unit declares no input, output, or materialised data member (§ 17.1.1.4).
The key MUST NOT include any determinant the author did not declare. In particular, host or machine identity — target triple, libc version, locale — is NOT folded into the key unless the author declared it as a probe and sealed on it (seal host). The engine infers no machine-identity, tool, or environment determinant of its own; it caches over exactly what the author declared.
Project identity (CS-0196). A unit’s key additionally incorporates a project identity: exactly the configured project name ([cloud] project in the reference implementation’s .cook/cloud.toml), or the empty string when none is configured. A conforming implementation MUST NOT derive this segment from the checkout’s location — a directory name, an absolute path, or anything else that varies between clones of one project — because two checkouts of the same workspace under different directory names MUST compute the same keys and share entries; a location-derived segment forecloses exactly the cross-machine portability the single key exists to provide. Configuring a project name is the author’s declaration that this project’s artifacts are its own: two projects configured with distinct names MUST NOT serve each other’s entries even for otherwise-identical units. (Before CS-0196 the reference implementation carried an empty segment on every registered unit regardless of configuration, and its cache_ignore_env denylist likewise never reached unit keys; both now take effect at registration. Unconfigured keys are unchanged.)
The local cache and the shared (cross-machine) store are addressed by this same key. There is no separate “shared” key and no two-tier loose/strict split. A consumer reuses a stored artifact for a key K if and only if it computes the same K, which holds if and only if every real determinant of the unit was declared. The cache is trustworthy exactly when serving a stored artifact for K — instead of re-running the unit — would produce a byte-equivalent artifact in the consumer’s current context. Soundness therefore rests on author-declared determinants; under-declaration is the cache-correctness failure mode, observed and classified by the divergence oracle (the shadow-divergence tool surfaced by the reference implementation as cook cache verify, which classifies a divergence as an under-keyed determinant versus benign non-determinism). A unit may further opt out of sharing (cache locally only) or require fetch-only service through its disposition (§8.4.3).
Note (informative). The reference implementation realises the declared-probe-values determinant as a single
seal_contributiondigest — the xxh3_64 of the unit’s effective seal set rendered as sortedkey\0<canonical-value-bytes>records (the canonical value bytes are those of § 22.5.5). It is folded into the one cache key alongside the command and consulted-environment contributions. Because a probe’s value is only available once the probe has run, the fold happens at execute-phase — after each sealed probe’s value has materialised (§ 22.5.8); the seal key set itself is register-time data carried on the unit’s cache metadata, and a sealing unit therefore depends on its sealed probes. An empty seal set contributes the zero digest, so an unsealed unit’s key is unchanged by this determinant. This realisation also bumps the reference implementation’s cache schema version (a one-shot rebuild on the upgrade boundary, per § 17.5). (CS-0107.)
Content validation MUST detect changed bytes even when a writer preserves the file’s modification time, for both equal-length and different-length replacements. This applies to declared inputs, recorded discovered inputs, loaded module inputs, and normal output-drift checks. A metadata shortcut MUST use evidence sufficient to distinguish those writes; equal modification time alone, even paired with length, is insufficient. The existing empty-input marker rule and record-disposition output authority are unchanged. (CS-0254.)
Note (informative). The reference implementation persists full modification time, length, Unix change time, inode, and device as local validation evidence. It observes metadata before hashing bytes, refreshes the evidence after a same-content touch, and re-reads content when the platform supplies no sufficiently discriminating identity. This evidence is not part of portable content keys. The index schema is bumped to reject old mtime-only records; the per-run stat memo still shares one observation across repeated paths until Cook writes.
Note (informative). A local cache index entry is recorded or refreshed only by the unit that actually executed: the reference implementation writes a unit’s fingerprint entry on the executed-unit commit path (after the unit’s command ran against the recorded input contents), refreshes recorded mtimes on a cache hit only after verifying content-hash equality, and prunes entries during stale-output reconciliation (§ 17.7). Operations that register, plan, or explain — registration passes,
cook why(§ 17.1.6), and test scoping — never record fingerprints for units they did not execute. Consequently a reported hit always corresponds to a prior genuine execution of the same command over the same input contents.
17.1.1.1. Effect kind, and the identity of an output-less unit
Normative (CS-0186). A unit’s effect kind says what a cache hit gives back, and is a function of its declaration:
- a unit declaring one or more output paths produces: a hit restores their bytes;
- a unit declaring none observes: a hit replays its verdict.
The effect kind MUST be derived from the declaration and MUST NOT be recorded on the cache record. A recorded kind can contradict the declaration it was derived from, and there is then no ground on which to prefer either. For the same reason a conforming implementation MUST reject a record whose evidence contradicts its declaration in the one direction that is a contradiction: a unit declaring no outputs MUST NOT have recorded any. The converse is not a contradiction, because a declared terminal output (§ 17.6) may legitimately resolve to zero files.
Being output-less is independent of being a test (§ 22.1, CS-0185). An implementation MUST NOT infer either from the other.
Identity. Within a local index, a producing unit is identified by a declared output path, which CS-0169 makes claimable by at most one unit (§ 17.5 rule 2). An observing unit has none, and MUST instead be identified by a digest of its declaration — the input paths it declares and the command text or Lua chunk it will execute. That digest MUST NOT include:
The contents of its declared inputs. Identity answers which unit; the recorded determinants and input records answer whether it may be replayed (§ 17.2). An identity that moved with the contents would never be found twice, so no record would ever be available to compare against, every invalidation would present as a first-ever build, and an implementation offering § 17.1.6 could report a key but never a cause.
The recipe name, or the step’s source position. § 17.4 forbids these in the key, and the prohibition binds the identity equally: moving a unit within a recipe, or a recipe between Cookfiles, MUST NOT bust its cache.
Any further distinguishing tag. Where two units in one index are separated by no determinant their declarations carry, they MUST share one record. This is CS-0169’s rule reaching the case it did not cover — an identity is claimable by at most one unit — and replaying either unit’s record for the other cannot be observed.
A unit’s effective seal key set (§ 8.4.3) is such a determinant, and the identity MUST separate two otherwise-identical units declared under different recipe seal sets. The sealed probes’ values are execute-phase (§ 17.1.1) and so cannot enter an identity, which is register-phase; if the key set is excluded as well, a test in a recipe containing
seal toolchainand the same test in a recipe with no seal declare one identity and share one record — whose recorded seal contribution then disagrees with one of them on every comparison, so each invalidates the other every run. That is not a false green but permanent churn, which is the outcome CS-0169 exists to refuse. (CS-0186.)A conforming implementation MUST NOT let an observing unit’s identity collide with a producing unit’s declared output path within one index. The means of keeping them apart is implementation-defined.
In the reference implementation the identity is composed by
cook_contracts::cache::local_key, as a pure function of the declaration in the crate that holds contract data rather than in the phase that first calls it (informative). Exclusion 2 is what that placement buys: the composing function takes the recipe name and the Cookfile path and uses neither, so “moving a recipe between Cookfiles MUST NOT bust its cache” is a property visible in one signature rather than a discipline spread across the register phase. The two identity spaces are kept apart by writing the digest in a form no workspace-relative output path takes.What a hit replays. An observing unit’s hit reproduces its verdict, and that verdict is a pass: § 17.4 rule 2 admits only a passed outcome to the cache, so the existence of a record is the verdict.
The observation (CS-0189). CS-0186 left open, for every unit kind alike, whether a hit should also reproduce what the run printed. It should, and § 17.1.5.1 says how. A conforming implementation records an observation for every unit it records at all, producing and observing alike, and a hit MAY reproduce it. Two constraints hold whether or not an implementation offers replay:
- it MUST NOT report a replayed duration as though it were measured;
- it MUST NOT present a replayed unit’s captured output as though the unit had produced it in this run.
These are the same two constraints CS-0186 stated, and they are not withdrawn by an observation existing. What changes is that they now bound a thing implementations have, rather than standing in for one they do not.
17.1.1.2. Declared inputs: paths and patterns
Normative (CS-0186). A unit’s declared input set (§ 17.1 observable 2) is a list of entries, and every entry is one of exactly two things:
- a path, naming one file whose content the unit reads;
- a pattern, whose expansion against the working tree the unit reads — a glob or a build-owned directory entry in the sense of § 17.6.
Which of the two an entry is MUST be settled when the unit is registered, and MUST be carried on the declaration. An implementation MUST NOT decide it afterwards by inspecting how the entry is spelled.
The two are not distinguishable by spelling, and this is why the rule is normative rather than a matter of convenience.
*,?and[are ordinary characters in a filename on every platform this Standard targets:app/[slug]/page.tsxis a file — and a routing convention whole frameworks are built on — while reading as a character class to any matcher. An implementation that classifies at match time therefore expands that entry, matches nothing, and drops it from the unit’s input set. The drop is identical on the check side and on the record side, so the two agree, the unit reports a hit, and the file it was keyed on has changed. Neither the key, nor the record, nor a report derived from them shows the hole, and a sibling entry without metacharacters keeps the unit looking alive.A conforming implementation MUST classify as a path every entry it obtained by resolving something against the working tree itself: the expansion of a
gatherglob (§ 8.2), and any other register-phase resolution. Whatever characters such a name contains, the implementation has already seen the file it names. An entry that arrives as an unresolved declaration — acook.add_unitinputsentry (§ 22.1), or a declared output of another unit consumed by this one — MAY be classified by its spelling, on the same terms and by the same test its producer applies when capturing that same declaration (§ 17.6 rule 1); one declaration MUST NOT mean a pattern to its producer and a path to its consumer.Late resolution. A pattern MUST be expanded when the unit becomes ready (§ 18.1), not at registration and not never: a consumed output declared
dist/**names no file until its producer has run. The expansion MUST apply the same normalisation § 17.6 rule 1 applies when the producing unit captures that entry, in particular CS-0085’s trailing-**normalisation. A pattern expanding to nothing contributes nothing, and what remains is then subject to § 17.4 rule 1’s nothing-to-key-on rule.The side that checks a record and the side that writes one MUST resolve a declaration by the same rule, so that a unit records the input set it was judged against. An implementation that resolves differently on the two sides reports an input-set change on every run.
Narrowing what a pattern admits:
consumes. A unit MAY declare aconsumesglob allowlist (§ 22.1). When it is non-empty, a conforming implementation MUST admit to the resolved input set only those pattern-derived paths that match it. Three rules bound the narrowing, because narrowing a key errs toward the under-keyed direction, where a stale hit replays against inputs that have moved:
- A declared path MUST NOT be removed by
consumes. The author named that file as one the unit reads; a filter able to delete it would silently drop a determinant the declaration states outright.consumesnarrows an expansion — of what this pattern covers, I read only these — and an entry that is not an expansion has nothing to narrow.- A filter matching none of the pattern-derived candidates MUST NOT take effect: where that candidate set is non-empty and no pattern matches any of it, the unnarrowed set MUST be admitted.
- A filter MUST NOT be consulted to decide whether a key exists at all — the nothing-to-key-on rule is settled before any narrowing.
Rule 1 replaces the safeguard CS-0175 stated as the full consumed set stays the produced-upstream filter, so an excluded artifact cannot re-enter through the unit’s own
gather. That was written when a unit’s own inputs and the outputs it consumed were two sets folded by two rules, and the filter was how one rule kept the other honest. With one declared input set they are one list, and what survives is the distinction that was doing the work: an artifact excluded from one pattern’s expansion stays excluded however many of the unit’s patterns cover it, and a file the unit names outright is an input because the unit says it reads it. (CS-0175, amended by CS-0186.)
consumespatterns are matched against each candidate path as the unit declares it — relative to the unit’s working directory (§ 17.5 rule 3) — not against a workspace-root-relative rendering of it. A pattern anchored to the workspace root matches nothing from a unit registered below it, which rule 2 then makes inert rather than under-keying.
Note (informative). The reference implementation settles the classification inside
cook.add_unit: an entry naming an existing regular file in the unit’s working directory is a path, and any other entry is classified by the test § 17.6 applies to an output declaration. Every entry the register phase resolved from the tree satisfies the first arm by construction, which is what makes the rule above hold forgatherexpansions and for a module’s ownfs.globresults alike, without either having to say so.
17.1.1.3. Module source
Normative (CS-0204). A module a unit’s body loads is source the unit runs. Its content is therefore a determinant of that unit, exactly as the unit’s own command text is (observable 1 of §17.1), and a conforming implementation MUST NOT serve a cached result for a unit whose loaded module source has changed.
The rule binds every unit body that can reach module code: an execute-phase
cook/test/choreLua body (§ 24.2). A probe’sproducebody loads modules through the same doors, but this rule has nothing to bind there (CS-0244): a probe is never served from a store that outlives the invocation (§ 22.5.8), so there is no stored value for a module edit to invalidate.Keyed on resolved paths. The determinant is the content of each module file the unit actually loaded, identified by the path § 12.5 resolution selected. A conforming implementation MUST NOT substitute a coarser subject — the module search directory, the set of installed rocks, a lockfile — for the loaded set. Two reasons, and each alone is sufficient. A directory-wide subject makes every unit in a project depend on every module in it, so one edit to one module invalidates a build that never loaded it, which is a cache that stops being worth having on any project large enough to have modules. And the directory is not the contract: § 12.5 names a lookup, implementations MAY relocate what it searches, and a rule written over a location has to be rewritten every time the location moves, while a rule written over resolved paths does not. CS-0207 relocated it one entry later, from
cook_modules/to.cook/modules/, and this rule needed no amendment — which is the argument, demonstrated.Observed, not predicted. Which modules a body loads is a fact about the RUN, not about the declaration: the name may be computed, the load may sit behind a branch, and a multi-file module pulls in submodules its caller never names. A conforming implementation MUST therefore obtain the set by observing the load, and MUST observe every door into module code that it offers —
cook.load_module(§ 24.2) and, where the implementation exposes Lua’s ownrequire(§ 12.2), that too. Observing only the door the Standard names would leave a multi-file module’s submodules and every native module outside the key, sincerequireis the only way to reach either.Memoisation (§ 12.3.2) suppresses re-evaluation, not the dependency. A second unit served by a VM that already holds the module MUST record the module all the same; otherwise the first unit to load it pays for a determinant the second one is silently missing. What is recorded for a memoised load MUST be the file the implementation ACTUALLY loaded it from, not the file a fresh resolution would select now: a VM reused across working directories (§ 12.2) can hold a module loaded from one Cookfile’s tree while the other Cookfile’s tree holds a same-named file, and recording the latter keys the unit on source it never ran.
Only the project’s own source. A conforming implementation MUST NOT record a loaded path that lies outside the project — an interpreter-bundled module, a system Lua tree, anything reached through a search path the project did not compose. Those are the toolchain the unit ran on, and § 17.1.1 forbids the implementation from inferring toolchain or machine identity of its own: an author who wants the toolchain keyed declares it as a probe and seals on it. Recording them would also defeat the rule it appears to strengthen, since a host-absolute path folded into a portable key cannot be reconstructed on any other machine.
How the set is judged. The observed set is recorded with the unit’s cache record, and on a later check it MUST be judged against itself — each recorded path re-read at its current content — and NOT against any predicted set. This is the same absorb-and-forget rule § 17.3 rule 3 applies to depfile discoveries, for the same reason, and it is sound for the same reason: every way the loaded set can change traces back to a determinant that is already keyed. A recorded module that no longer exists MUST count as changed.
A conforming implementation MUST NOT judge a recorded module path as though it were a declared input. A declared input is judged by comparing the record against the declaration, and a module appears in no declaration; judged that way, every module-loading unit would report an input-set change against its own record on every run.
Sharing. Because the local and shared stores are addressed by the one key (§ 17.1.1), the module contents MUST be folded into that key and not merely checked locally. A unit keyed without them is one whose stale result escapes the machine that produced it: two machines whose modules differ compose the same key, and each is served the other’s answer.
An implementation MAY recover the loaded set for a cold consumer by the candidate-key mechanism of § 17.3.1, whose rules apply verbatim with the module set in place of the discovered set. It is a SHOULD there and a SHOULD here: without it a module-loading unit simply never hits the shared store until it has run locally once, which is a loss of reuse and never a wrong answer.
Where an implementation offers that recovery, it MUST offer it to producing and observing units alike (§ 17.1.1.1). An observing unit’s shared service is a verdict replay rather than an artifact restore, so it is a different lookup — and one that, on a cold machine, is the ONLY lookup, because the local check has nothing to hit. An implementation that reconstructs for one kind and not the other does not merely lose reuse for the other; it loses it permanently, since nothing on that path ever composes the key its own publisher used. The candidate set MUST also be recorded on the entry a recovered hit writes, so the entry a recovery leaves behind is the one a fresh execution would have written.
17.1.1.4. The seal-only key
Normative (CS-0223). A non-empty effective seal set (§ 8.4.3) is sufficient on its own to mint the key of § 17.1.1. A cacheable unit that declares no input, no output, and no materialised data member, but whose recipe seal set is non-empty, therefore HAS a key: the sealed probes’ canonical values (§ 22.5.5) are declared determinants, and their movement is what invalidates the unit. Having a key is keyability, not an obligation to store — recording remains the MAY of § 17.1.
This is one of the four ways a unit can have something to key on. A cacheable unit is keyable when it declares at least one of: a declared output, a declared input, a materialised data member, or a non-empty effective seal set. Only a unit with none of the four has nothing to key on, and it is that unit which runs uncached on every invocation (the engine’s one-shot disposition). A conforming implementation MUST NOT infer the uncached case from the absence of any single one of the four — in particular, not from the absence of a source, and not from the absence of a seal. A unit declaring an output alone is keyed on it, as is a fan-out member unit that names no file.
This clause is declaration-scoped, and the rule is asked twice — over the declared list at registration, and again over the resolved list when the unit is ready, because a declaration can be non-empty and resolve to nothing. § 17.4 rule 1 states which answer binds: the second. An implementation applying only this clause would serve a unit whose declared
dist/**matched no file, no member and no seal — the false green § 17.4 exists to refuse.This clause governs only whether a cacheable unit is keyable. It says nothing about units whose register-phase record disables caching outright — a bare shell step, an
@step, or any chore (§ 7.4) — which run every time for a different reason and are outside the rule.The rule is independent of effect kind (§ 17.1.1.1). An observing unit publishes no artifacts, having none, but its verdict is cacheable under this clause exactly as a producing unit’s outputs would be; a source-less
teststep under a non-empty recipe seal is the case that motivated it (§ 8.6, § 17.4).
17.1.2. Tracking Lua reads of var
Normative. A
cookstep whose>{ … }body is a Lua block (§ 23.1) MAY read declared variables through thevar.<NAME>accessor (§ 24.3). When it does, those reads MUST contribute to the unit’sconsulted_env_keysset (§ 10.2) on the same footing as a shell-body$<KEY>sigil contributes. The recording is required for the cache-invalidation contract of §17.1: a value change in any consulted key MUST invalidate the unit’s cache fingerprint.A conforming implementation MUST recover the consulted key set by static analysis of the Lua source text. It MUST recognise:
- Dot access —
var.IDENTfor any Lua identifier. No case constraint applies: declared variables are routinely lower-case, and under-recording is the unsafe direction.- String-indexed access —
var["NAME"]andvar['NAME']where the name is a literal string.The analysis MUST ignore occurrences appearing inside Lua short-string literals (
"…",'…'), long-string literals ([[…]],[==[…]==]), line comments (-- …), and block comments (--[[…]],--[==[…]==]).The analysis MUST NOT record a name for dynamic-key reads —
var[k],var[string.upper(x)], and the like — because the key is not statically resolvable from the source text. Authors who need cache invalidation on a dynamic-key read MUST surface the key statically (e.g., assign to a local first:local v = var["NAME"]).The analysis MUST NOT record a name for writes —
var.X = …andvar["X"] = …(which § 24.3 refuses at runtime in any case). The pattern is distinguished from a read by the assignment operator following the access (an=that is not part of==,~=,<=, or>=).An implementation MAY record additional keys (over-record) — over-recording only widens the set of cache-invalidating values and never silently serves stale output. An implementation MUST NOT under-record by ignoring a syntactically recognisable read of one of the forms above.
The recording mechanism is shared with the shell-side
$<NAME>sigil capture: the names flow into the unit’sconsulted_env_keyslist and are looked up against the declared-variable namespace at unit-registration time, exactly as shell-body sigils are.
Note 17.1.2.1 — environment seal vs. auto-fold (informative). For an environment variable a unit consumes, this section’s auto-fold already keys the unit on the variable’s value. An author SHOULD NOT additionally seal an ordinary shell probe that observes the same consumed variable: the variable would then fold twice — once as a consulted-env value here, and once inside the sealed probe’s value (§ 17.1.1). The two paths are for two different cases: rely on auto-fold for a consumed variable, or seal a named shell probe over an unconsumed one (§ 8.4.3). Double-folding is not a correctness bug, but it is redundant and splits attribution across two determinants in
cook why(§ 17.1.6).
17.1.3. Sharing and disposition effect
Caching over the local index is always on. Sharing — publishing a unit’s output artifacts to, and fetching them from, the shared content-addressed store — is also on by default for every cacheable unit, keyed by the same single key (§ 17.1.1). The local index and the shared store are addressed by that one key; a consumer reuses a shared artifact for a key K if and only if it recomputes K. An observing unit (§ 17.1.1.1) publishes no artifacts to the shared store, because it has none; its verdict is nonetheless cacheable whenever it has something to key on — a declared input, a materialised data member (§ 17.4 rule 1), or a non-empty seal set (§ 17.1.1.4). Non-publication follows from the empty output list, not from the step kind: an implementation MUST NOT decide either question by asking whether the unit came from a test step (CS-0186). A chore, being non-cacheable and side-effecting (§ 7.4), is never cached or shared.
The unit’s disposition (§ 8.4.3) selects its sharing policy. The disposition does not change the key (§ 17.1.1); it changes only whether the shared store participates and how a miss is resolved. A conforming implementation MUST observe:
-
(unannotated) — the unit MUST publish each declared output to the shared store after a successful run, and MUST attempt to fetch its outputs from the shared store by key before executing — both when a local cache entry exists but its outputs have drifted or are missing (the restore of § 17.2), and on a cold miss where no local cache entry exists. On a final miss (neither store holds the key) the unit is rebuilt. A recipe-level
sealchanges the unit’s key, not this default sharing policy. -
local— the unit is cached in the local index only. It MUST NOT publish any output to, and MUST NOT fetch any output from, the shared store — not on a cold miss and not on a drifted-output restore. On any miss the unit is rebuilt. -
pinned— the unit is fetch-only / designated-producer: it MUST be served from cache — the local index or a fetch-by-key from the shared store — and MUST NEVER be reproduced by the consumer. When neither store holds an artifact for the unit’s key (a cold miss), a conforming implementation MUST fail the build with a diagnostic that names the pinned unit; it MUST NOT execute the unit, and it MUST NOT serve any dependent that required the unit’s outputs.This rule is about artifacts, and it binds a producing unit. An observing unit has none to fetch and none to designate a producer of, so there is nothing for the disposition to serve and nothing for it to refuse: an implementation MUST treat
pinnedon an observing unit as inert rather than failing a build over a structural absence (CS-0186). § 8.4.3.3 keepspinnedoff ateststep at the surface, so the case is reachable only throughcook.add_unit(§ 22.1). It stops being inert when the payload question of § 17.1.1.1 is answered and there is a shared observation to fetch.
local and pinned are mutually exclusive (§ 8.4.3). Every fetch from the shared store — drift restore or cold fetch-by-key — is subject to the integrity verification of § 17.2: a fetched artifact whose bytes fail verification is treated as a miss.
Note (informative). Because the local index and the shared store share the one key (§ 17.1.1), a machine-independent unit (one that sealed no host probe) shares fleet-wide: any machine that recomputes the key serves the same artifact. A
localunit is the deliberate exception — its output stays on the machine that produced it. Apinnedunit shifts the trust boundary from “reproduce on demand” to “a designated producer populated the shared store”; consuming a pinned unit that was never produced is a hard error rather than a silent local rebuild, which is the point of the disposition. (CS-0108.)
A conforming implementation MAY offer a per-client publish-off (read-only)
mode: an invocation-level switch (e.g. --no-publish / COOK_NO_PUBLISH) or a
project config ([cloud] publish = false in .cook/cloud.toml) under which the
client MUST NOT publish any artifact to the shared store for ANY unit, while it
MUST still fetch by key, drift-restore, and serve pinned units from the shared
store exactly as in the default mode. Publish-off is orthogonal to a unit’s
disposition: it suppresses every upload globally (as local does per-unit) and
changes no key (§ 17.1.1). The engine MUST NOT assume a
locally-produced artifact was published; a fresh build under publish-off
completes by running the unit and recording it locally. Publish-off is a
client-side convenience for read-only consumers; write-authorization to a
shared store is the backend’s concern (e.g. credentialed access), not a property
the client asserts. (CS-0111.)
17.1.4. The nondet disposition
A cook step MAY carry the nondet disposition (§ 8.4.3) — the renamed v3 record disposition (CS-0115). Its artifact is cached and shared and keyed by the single cache key (§ 17.1.1) EXACTLY as an unannotated step: nondet adds NO determinant and changes NO component of the key.
A nondet step states a fact about its output: that it is intrinsically NON-reproducible — for example an LLM completion or a generative image — so that re-running it in an identical context is NOT guaranteed to produce a byte-equivalent artifact.
Byte-equivalence is therefore WAIVED for a nondet step. When the cache holds an artifact for the step’s key — the recording the nondet unit reuses — an implementation MUST treat that stored artifact as authoritative and MUST NOT re-execute the producer to reconcile a content difference; a present (on-disk or restorable) output is served as-is. A cold miss — no stored artifact and none restorable — MUST execute the producer once and MAY record its output.
nondet composes with seal: a change to any sealed determinant changes the key (§ 17.1.1) and therefore re-generates the recording, exactly as for any unit. nondet occupies the single trailing share_mod slot (§ 8.4.3) and is therefore mutually exclusive with local/pinned at the surface — a unit is local, pinned, or nondet, not a combination. (The byte-equivalence waiver is conceptually independent of any sharing/fetch policy; the v3 surface that allowed combining them was collapsed to one slot, with no change to the key model.)
The divergence oracle (cook cache verify) MUST NOT classify a content difference on a nondet step as an under-keyed determinant: the author has declared the output non-reproducible and owns determinant completeness through the step’s effective seal set.
Note (informative). The reference implementation realises the waiver as a boolean carried on the unit’s cache metadata (the internal field name is unchanged from v3); at the cache decision a
nondetunit does not schedule a present-but-content-drifted output for restore or rebuild. The cache schema version is unchanged. (CS-0109, CS-0115.)
17.1.5. Producer determinant manifest
An implementation that publishes a unit’s output artifacts to the shared content-addressed store (§ 17.1.3) SHOULD persist, retrievable by the same single cache key K (§ 17.1.1), a producer determinant manifest: the resolved determinant values that formed K — the declared input contents (as path → content-hash), the command/Lua-chunk hash, the declared output paths, the consulted environment values, and the values of every probe in the unit’s effective seal set. The manifest records the resolved values behind K’s component digests, not the artifact bytes.
The manifest exists for diagnosis and verification: it lets a cook why query attribute a cache miss to the specific differing determinant, and it lets the divergence oracle (cook cache verify) confirm that a unit’s recorded determinants recompose to K. A conforming manifest MUST serialise deterministically — the same K MUST yield byte-identical manifest content — which requires ordered (sorted-key) serialisation of every map.
The manifest is NOT an attestation of which producer made the artifact; cryptographic producer-identity attestation is out of scope at this revision. A local unit (§ 8.4.3) publishes no shared artifact and therefore no manifest. A missing or malformed manifest MUST NOT be treated as a cache error — it is best-effort diagnostic data, and an implementation that cannot read it falls back to its key-only behaviour.
Note (informative). The reference implementation persists the manifest as a JSON sidecar adjacent to the artifact bytes, keyed by the unit’s cloud key (one manifest per unit, shared across the unit’s N output artifacts since they share K). Maps serialise as sorted-key JSON objects and
u64digests as zero-padded lowercase hex strings, so the same key yields byte-identical content. It is exposed through the cache backend asput_manifest/get_manifestand is the data source for thecook whykey explanation (§ 17.1.6) and the divergence verifier. (CS-0110.) Since CS-0156 the manifest additionally records empty-directory implicit outputs (a defaulted field — older manifests parse as recording none), which candidate-key recovery (§ 17.3.1) reads to restore the full recorded output set.
17.1.5.1. The observation
Normative (CS-0189). An observation is what running a unit was seen to do, beyond the bytes it wrote: what it printed, how long it took, and when. A conforming implementation that records a unit’s completion at all MUST record an observation alongside it, for every unit kind. A producing unit’s observation sits beside its artifacts; an observing unit’s is the only thing it has.
An observation is recorded only for a run that succeeded, because those are the only runs a conforming implementation records (§ 17.4 rule 2 for an observing unit; a failed producing unit publishes nothing). It therefore cannot answer “why did this fail last time”, and an implementation MUST NOT present it as though it could. That question belongs to whatever build-log retention the implementation offers, which is outside this Standard.
What it holds.
- The output, as an ordered sequence of chunks, each tagged with the stream it came from. The ordering guarantee is § 24.1‘s and no stronger: preserved across the calls a unit made, not within a single spawn, whose two streams are separately buffered. An implementation MUST NOT record output in a shape that implies an interleaving it did not observe.
- The duration, the wall time the unit took on the run that was recorded.
- When it was recorded. A timestamp, not a position in a local history: an observation may be served to a machine that has no history of its own (rule 4 below), where “three builds ago” names nothing.
What it does not hold. No outcome field. The record exists only for a success, so an outcome recorded on it would have one legal value, and a field with one legal value is a constant that can drift from the thing it duplicates. Ask § 17.1.1.1: the existence of the record is the verdict.
Where it lives. The streams MUST NOT be stored in whatever index an implementation reads wholesale to decide a unit’s freshness: a chatty unit would then be re-read on every invocation of every build, to answer a question no freshness check asks. Scalars MAY sit in that index. The reference implementation puts the duration, the timestamp and the recorded size in the step index and the streams in the content-addressed store, retrievable by the unit’s key.
Size. A unit may print without bound. A conforming implementation MUST bound what it retains, MUST record that it truncated when it does, and MUST NOT present a truncated observation as complete. Where retention is bounded by eviction, an observation SHOULD be evicted before the artifacts of the same unit: losing an observation loses a log, while losing an artifact loses a build, and for an observing unit a lost observation degrades to a re-run.
Replay is opt-in. An implementation offering replay MUST NOT make it the default for a cache hit. A warm build’s value is that it is quiet and immediate; a hit that re-emits ten thousand lines of compiler output has spent the time it saved. § 17.1.1.1‘s two constraints bind whenever replay does happen: a replayed duration MUST NOT be reported as measured, and replayed output MUST NOT be presented as produced in this run.
17.1.6. Key explanation (cook why)
To make author-declared determinant fidelity (§ 17.1) auditable, the reference implementation surfaces a read-only key-explanation query, cook why <recipe>. The query answers one question over the named recipe’s reachable closure: what would a run of this recipe actually do, and why. It reports the closure’s work as a graph (§ 17.1.6.3), each cacheable unit’s single cache key K (§ 17.1.1) together with the resolved determinants behind K (§ 17.1.6.1), and how a miss propagates along the graph’s edges (§ 17.1.6.4).
The query MUST NOT execute any work unit in the closure, MUST NOT publish any artifact, and MUST NOT write any declared output to the working tree; it is strictly an observation of the current context. This no-execution requirement does not prohibit work that the mandatory registration pass itself performs. The query MUST resolve the closure and register the workspace by the same path a run of the same target would, so that the key it reports for a unit is the key a run would compute; a query that observes the graph under different registration conditions than the run path is not an explanation of that run.
Note (informative). Structure and determinants are one query rather than two because they are one question asked at two altitudes. A determinant report without edges cannot attribute a miss whose cause is upstream, and it cannot be read at all on a closure of any size: the aggregation of § 17.1.6.3 is what makes per-unit fidelity legible on a tree of thousands of units. An earlier revision of the reference implementation split the two across
cook whyand a separatecook daggraph-printing command; the split gave the structural half a second, weaker cache classification of its own and left neither half able to answer why a unit waits and why it rebuilds.cook dagis removed at CS-0171 and its aggregation model is the structural spine of this query. (CS-0171.)
17.1.6.1. Determinant reporting
For every cacheable unit in the closure, the query reports the unit’s cache key K and the resolved value of every determinant behind K: the declared input contents (as path → content-hash), the command/Lua-chunk hash, the declared output paths, the consulted environment values, and the value of every probe in the unit’s effective seal set (§ 17.1.1). For each unit it also reports whether the unit would be served from cache, locally or from the shared store, or rebuilt. The local and shared tiers MUST be reported as independent answers: a unit that is locally warm but absent from the shared store MUST NOT be rendered in a way that reads as “this will rebuild”.
A sealed probe whose value the query can resolve without dispatching a produce body of its own — for example a top-level files or tools declaration (§ 22.5.2.1), or a cook.probe() registration carrying the synthesised-manifest sentinel (§ 22.5.2.1) — MUST be resolved freshly at query time, so that the key the query reports for a unit sealing it is the key a run would compute (§ 17.1.6). The query dispatches no produce body of its own for the purpose of this report. A probe that the query’s own registration pass MUST resolve in order to register the closure — one feeding a gather <probe> source, or one read by register-phase Lua (§ 22.5.10) — is produced by that registration, which the query performs by the same path a run would (§ 17.1.6), and the value that registration just produced is what the query reports. A probe whose value requires a produce body, and that neither of the two preceding cases covers, is reported from its last materialised value; that value MUST be identified as reported from a prior invocation rather than resolved now. A probe with no materialised value at all is reported as unmaterialised rather than produced; a unit sealing it has no computable key, and the query MUST report it as rebuilding rather than as a cache miss (CS-0247). For a tools probe (§ 22.5.2.1), whose value is an object keyed by tool name recording each resolved binary’s content hash, the explanation surfaces each tool’s resolved path from the read-view metadata channel (§ 22.5.2), resolved freshly at query time: the canonical value carries identity only (CS-0157).
When a freshly resolved files declaration includes a path produced by a unit in the queried closure, the query MUST resolve that manifest entry from the producer classification, not from the working tree. A producer served from either cache tier contributes the content identity of the artifact that would be restored, whether or not that artifact is currently materialised. A producer that would rebuild leaves the manifest value, and therefore the sealing consumer’s key, not yet computable; the query reports the consumer as rebuilding on account of that producer and names the pending path. Current glob membership remains authoritative for paths outside the closure: a member present only in a prior manifest MUST NOT be retained merely because it was previously matched. (CS-0252.)
Fresh resolution can fail exactly as a run’s own resolution can: a top-level tools name that no longer resolves on PATH MUST fail the declaration (§ 22.5.2.1, CS-0214), and a files match that exists but cannot be read MUST fail the probe (§ 22.5.2.1, CS-0241). Where fresh resolution fails for either reason, the query MUST report the failure against that probe and MUST fall back to reporting its last materialised value — identified as such per the rule above — rather than failing the query outright: a diagnostic query MUST remain able to answer for the rest of the closure under the very condition it exists to diagnose.
Note (informative). Resolving a
filesortoolsdeclaration’s value at query time reads the current tree and PATH and hashes what it finds. It is one of the two cases that resolve without dispatching a VM at all (§ 22.5.8) — not a probe observation in general, since aproducebody’s observation is the execution of a unit — so it runs no unit and writes no output. And §17.1.6’s read-only MUSTs are about the closure’s work units, not the register pass that mints them: a query that registers the workspace by the same path a run would (§ 17.1.6) necessarily performs whatever probe resolution registration itself requires (§ 22.5.10). Neither the freshness requirement nor that registration publishes an artifact or writes to the working tree.
On a shared miss, where the unit is absent from the local index and the consumer cannot fetch its outputs from the shared store by K, and a producer determinant manifest (§ 17.1.5) exists for K, the query MUST diff the consumer’s resolved determinants against the manifest and name the differing determinant(s), so that the cause of the miss is answerable from the query’s output alone. A missing or malformed manifest MUST NOT be treated as an error (§ 17.1.5); the query reports that no producer manifest is published for K and falls back to reporting the consumer-side determinants only.
On a local miss, where the unit has a previous fingerprint record in the local index but that record does not match the unit’s current determinants, the query MUST name which determinant diverged from the record. This is the local-tier counterpart of the shared-miss diff above, and it answers the same question against the other tier: the shared diff says why the store could not serve K, this says why the local index could not. A unit with no previous record has nothing to have diverged from, and the query reports no attribution rather than inventing one.
The determinant a local miss names MUST be the one the implementation’s own rebuild decision turned on. A query that reported a different determinant than the one that will actually cause the re-execution would be worse than reporting none.
When the differing determinant named above is the unit’s effective seal set (§ 17.1.1), the query MUST name the probe key(s) whose value moved, and for an object-valued probe — a files manifest or a tools identity (§ 22.5.2.1) — MUST name the individual entries added, removed and changed, each with its old and new value where each applies (an added entry has no old value; a removed one has no new value), rather than reprinting the whole value; a scalar-valued probe reports its old and new value directly. This attribution obligation constrains the miss attribution alone: it does not narrow the full determinant report above, which continues to report the value of every probe in the unit’s effective seal set regardless of which determinant a miss names. This is one attribution obligation read at both tiers, not two rules that happen to agree: the shared-miss manifest diff and the local-miss record diff above each name a moved probe the same way.
A probe named by the local-miss diff with no prior recorded value of its own MUST be reported as a first observation, never as a diff against a fabricated old value. The prior value the local-tier attribution diffs against is the implementation’s own local per-probe record (§ 22.5.8); it is local forensics, not the index’s own record of the values behind the unit’s previous key, and the query MUST NOT perform a store lookup to obtain it. The two are written on different events — the local per-probe record moves whenever the probe is reached, while the index’s seal digest moves only when the sealing unit last executed — so the local-tier diff between them MAY be empty, or partial, even for a genuine seal-changed miss. Where the record cannot account for the divergence the local miss names, the query MUST report the seal set as the differing determinant without naming a probe key or an entry — the obligation above to name them is relieved exactly here, and is not satisfied vacuously by naming none — and MUST NOT present an empty or partial entry diff as the full account of the miss. The shared tier continues to answer from the producer determinant manifest’s recorded probe values (§ 17.1.5), unchanged.
Full determinant reporting is per-unit data and therefore belongs to the unit granularity of § 17.1.6.3. An implementation MUST make it reachable: at the unit level, and through a selector that names one unit or a subset of units directly without requiring the whole closure to be rendered at unit granularity.
17.1.6.2. Resolving a determinant the run has not produced yet
A declared input may be the declared output of another unit in the same closure. Its content at the moment the query runs is therefore not necessarily its content when the consuming unit executes, and the query MUST resolve such an input from the unit that produces it rather than from the working tree. The working tree is authoritative only for an input no unit in the closure produces.
For an input produced within the closure, resolution follows the producer’s own classification:
- Producer served from cache. The output bytes are already determined, because a cache hit reproduces a recorded artifact, so the input’s content hash is knowable whether or not the artifact has been restored to the working tree. The consuming unit’s key is computed over that hash.
- Producer will rebuild. The output bytes do not exist in their final form and cannot be obtained without executing the producer. The consuming unit’s key is not computable, and the query MUST NOT report one. It MUST instead report the unit as rebuilding on account of that producer, naming the producer and the input, and MUST report the input as undetermined rather than carrying either the working tree’s current bytes or a zero.
This resolution is not a prediction of a future state. The producer’s classification is an observation of the cache as it stands, and “these bytes are already determined” is a present fact about a recorded artifact. What the query MUST NOT do is invent a key: a unit whose determinants are not fully resolved has no key, and reporting one over stale or absent bytes would name a value the run will never compute.
Because a unit downstream of a rebuild is reported as rebuilding, the tallies of § 17.1.6.3 count it as such. A conforming implementation MUST keep the two distinguishable in any machine-readable form (§ 17.1.6.6): a unit rebuilding because its own determinants changed and a unit rebuilding because it waits on one that is are different findings, and only the first has a key behind it.
Note (informative). Resolving from the working tree alone is wrong in both directions, which is why this section is normative rather than a quality-of-implementation matter. On a cold tree with a warm cache the consumed file is absent although its bytes are fixed, so the consumer reports a rebuild that will not happen; after an edit the file is present but stale, so the consumer reports a hit against bytes that are about to be overwritten. The reference implementation classified units independently until CS-0173 and exhibited both errors on the same four-unit workspace. (CS-0173.)
17.1.6.3. Graph structure and aggregation
The closure is a graph whose vertices are work units and whose edges carry a kind recording why the edge exists. A conforming implementation SHOULD distinguish at least: a declared file input feeding a unit; an input reached through a discovered_inputs depfile (§ 17.3); a consumed probe value (§ 22.5.2); entry into a step group; a § 15.1 sequential barrier; a per-unit cross-recipe ordering edge (cook.dep_order / cook.dep_output, § 22.11); and a whole-recipe barrier (a dep-list entry or cook.require_recipe, § 22.9). These kinds are ordered by how much each constrains scheduling, from the data edges that merely carry values through to the whole-recipe barrier, which is the most constraining.
The unit-to-unit edges reported MUST be the dependency edges the implementation’s execution scheduling imposes on the same closure, and an implementation SHOULD obtain them from the single implementation of the wiring rules that execution itself consumes rather than from a parallel derivation. Two consequences are normative. A declared whole-recipe barrier MUST be reported whether or not a fine-grained reference also covers the same producer: the shipped dependency design is strictly additive (CS-0161), so the barrier executes and hiding it would report parallelism that does not exist; conversely a producer reached only through fine-grained references imposes no whole-recipe barrier and none may be reported. And a dependency routed through a recipe that contributes no units of its own MUST be reported against the units it forwards to, not dropped: such a recipe is transparent to ordering, and a graph that severs the chain there hides the ordering the build actually obeys. (CS-0202.)
Because a single high-level target may register thousands of units, the query MUST offer at least one aggregation level coarser than the unit, and the default level MUST be a coarse one. At a coarse level a node stands for many units, and the node’s cache reporting MUST be a tally over the units it collapses rather than a single boolean: a node that mixes hits and rebuilds MUST be distinguishable from one that is uniformly either.
When aggregation collapses a bundle of edges between two nodes into a single edge, the collapsed edge MUST report the most constraining kind present in the bundle. A whole-recipe barrier concealed inside a bundle of data edges is precisely the finding the graph exists to surface, so it is the finding that MUST survive collapsing.
An implementation MUST NOT emit an unaggregated unit-level rendering above an implementation-defined node ceiling without an explicit opt-in from the invocation. Refusing with a diagnostic that names the ceiling and the available coarser levels is conforming; emitting a rendering no reader can use is not.
Note (informative). The reference implementation offers
--level recipe|group|unit, defaulting torecipe, with a 200-node ceiling on--level unitraisable through--max-nodes. On DuckDB the recipe level renders 3 nodes for 1711 units. Thegrouplevel collapses each step group to one node with its member count, which is where a recipe’s actual parallelism becomes visible. (CS-0171.)
17.1.6.4. Cascade attribution
A unit that will rebuild invalidates every unit downstream of it that consumes its declared outputs, transitively. Reporting each such unit as an independent miss is accurate but not an explanation: it restates the consequence at every vertex and names the cause at none.
Where the graph shows that a unit’s miss is reachable from an upstream unit that will itself rebuild, the query SHOULD attribute the downstream unit’s miss to that upstream unit by name, in preference to reporting only the downstream unit’s own differing determinants. For a unit that will rebuild, the query SHOULD report how many further units its rebuild invalidates, so that the cost of a single changed input is visible at the point where the change enters the graph.
The invalidation count is over every unit downstream of the rebuilding one. Because a rebuild that reproduces byte-identical output leaves its consumers’ keys intact, the count is an upper bound, and a conforming implementation MUST NOT present it as a count of units that will certainly re-execute.
Attribution and invalidation counting are diagnostics over the graph. They MUST NOT alter any determinant value the query resolved under § 17.1.6.2, and they MUST NOT be used to promote a unit’s tier classification: a unit reported as served from cache must have been answered by an actual cache lookup, never inferred from its neighbours.
17.1.6.5. Observed history
An implementation MAY report, per unit or per aggregated node, the wall time that unit was last observed to take, recovered from the implementation’s own retained build records rather than measured by the query.
It MAY likewise report, from the same records, why the unit ran on that past build: the attribution the implementation gave at the time (§ 17.1.6.1). This answers a question no live classification can, namely why a unit that is a cache hit now re-executed when it last ran, which is what someone asks who finds work was redone while they changed nothing.
Where both a past attribution and a live one are reported for the same unit, they MUST be distinguishable, and any machine-readable form MUST carry them as separate values. One is a verdict on the run being explained and the other is a record of a different run; they routinely differ, and a reader that mistook one for the other would draw a conclusion about the wrong build.
Both forms of reporting are subject to three constraints. First, they MUST be presented as observations of a past run and MUST NOT be presented as a prediction or an estimate of the run being explained. Second, the absence of an observation MUST be distinguishable from an observation of zero: a unit that has never run under the implementation’s retention window, or that ran only as a cache hit and so was never timed, MUST be reported as having no observation rather than as fast. Third, the record MUST be joined to the unit by an identifier that is unambiguous for the unit, which the unit’s cache key is and a rendered display name is not. An implementation that records an attribution on a different event than the timing MUST carry that identifier on both, and MUST NOT pair an attribution drawn from one run with a timing drawn from another.
Note (informative). The reference implementation reads each unit’s recorded observation from the retained step index, joined on the unit’s cache key. It read
elapsed_msfrom thenode-completedrecords of the.cook/logs/<build-id>/events.jsonlstreams until CS-0189 moved observations into the index; the note said so for longer than it was true. A failed unit is not a timing source: its elapsed time measures how long it took to fail, which answers a different question. A node’s figure is the sum over the units collapsed into it, rendered as “N observed” alongside how many of those units the sum actually covers, so a sum over three of forty units is not read as the node’s build time. It reports no staleness bound: it did until CS-0216, as a field that was always zero because CS-0189 had already deleted the model that would have filled it, which is the second constraint above being broken by the very reporting it governs. Cache-hit records carry no elapsed time and the index is local to the machine, so a fleet-warm unit that never ran here has no observation at all. This is why the surface reports an observation rather than a projected build time: the data supports the former and not the latter. (CS-0171.)
17.1.6.6. Output formats
The query MUST offer at least one human-readable rendering and at least one machine-readable rendering. The machine-readable rendering MUST be deterministic: object keys sorted, and u64 digests as zero-padded lowercase hex strings, matching the manifest’s serialisation convention (§ 17.1.5). It MUST carry a wire-format schema version, and a structurally incompatible change to the payload MUST bump that version.
This requirement is per document, not per implementation. Where the query emits more than one document in its machine-readable rendering — because the selector of § 17.1.6.1 answers over a subset of the closure, or because a level of § 17.1.6.3 is rendered on its own — EVERY such document MUST carry the version. A document scoped to a subset of the units is the same wire format at reduced scope, not a format of its own, and it MUST report the same version as the document that covers the whole closure: the payload a consumer parses is the same payload, so a change that breaks one consumer breaks the other, and two versions could only differ by being wrong. An implementation MUST NOT emit an unversioned document on the grounds that a versioned one is available from another invocation. The diagram renderings of the paragraph below are outside this requirement, as they are outside the determinism requirement above, for the same reason: they are not the machine-readable rendering. (CS-0217.)
An implementation MAY additionally offer renderings targeting external diagram tooling. Such renderings are implementation-optional in the sense of CS-0047: no conformance requirement attaches to their syntax or to the availability of any tool that consumes them.
Note (informative). The reference implementation offers
--format text|mermaid|dot|json, defaulting totext. The graph payload and the determinant payload share one versioned JSON document rather than two, so a consumer reads structure and cache state from one query. That sentence describes a merge, not a count: since CS-0217 the JSON rendering emits a second document, the--unitselector’s, which is the merged one narrowed to the units the selector names and carries the same version. The query remains read-only throughout: on a shared cache hit it confirms the backend holds every output by key without restoring the bytes to the working tree. (CS-0112, CS-0171, CS-0217.)The per-document reading above is not a new idea; it is the sentence before it, applied to the surface the reference implementation actually shipped.
--unit <pattern> --format jsonemitted{recipe, units}with no version at all, and went on doing so through every payload change CS-0173, CS-0174 and CS-0189 made to the per-unit object. A rule stated over “the machine-readable rendering”, singular, reads as satisfied the moment any document carries a version; it took a crate-charter audit reading the renderer line by line to notice that the second one never had. (CS-0217.)
17.2. Cache integrity
An implementation that records output content in a content-addressable store — whether local, shared between machines, or remote — MUST treat that store as untrusted at restore time. When the implementation reads bytes from the store with the intent to install them as the on-disk content of a declared output, it MUST recompute the same content fingerprint that it recorded for that output and MUST compare the recomputed fingerprint to the recorded one. If the two fingerprints differ, the implementation MUST NOT install the bytes; it MUST treat the read as a cache miss and MUST fall through to the rebuild path defined in § 17.1 above.
This requirement holds for every backend, including a backend that resides on the same machine and the same filesystem as the workspace. The Standard does not distinguish “local” from “remote” backends in the cache contract; the integrity check is the same in both cases. An implementation MAY use any deterministic fingerprint that meets the observability constraint of § 17.1 (the fingerprint MUST be a function of the bytes being installed, with no dependence on wall-clock time, process identifiers, or other extrinsic state); it MUST use the same function it recorded the original artifact under. An implementation that exposes the artifact store to writers other than the local build (e.g., a multi-tenant shared backend) SHOULD use a cryptographically secure fingerprint (collision-resistant under adversarial input). The fingerprint verification required by this section MAY be performed as bytes flow through a streaming reader, with the verification surfacing as an end-of-stream error; an implementation is not required to materialise the full artifact in memory before verifying.
The intent is that an implementation’s cache restore path MUST NOT be a vector for installing attacker-controlled or otherwise unauthenticated bytes into the workspace. A backend whose contents have drifted, been corrupted, or been replaced — for any reason — MUST be observationally indistinguishable from a backend that simply does not have the artifact.
An implementation’s artifact-store write path MUST treat a put of bytes that conflict with bytes already stored at the same key as an error. “Conflict” means the bytes differ; re-writing identical bytes is idempotent and MUST succeed. The error MUST surface to the caller; the implementation MUST NOT silently overwrite the prior bytes. This requirement is the write-side analogue of the read-side fingerprint check: it ensures that a key in the artifact store maps to one and only one byte sequence over its lifetime, which is the invariant the read-side verification relies upon.
Example 17.2.1
recipe build
gather "src/main.c"
cook "build/main.o" { gcc -c $<in> -o $<out> }
On the first run, build/main.o’s unit executes and its completion MAY be recorded. On a subsequent run with an unchanged src/main.c and unchanged command text, an implementation MAY skip the unit. If the command text changes or src/main.c changes, the unit MUST re-run.
Note 17.2.1
The cache is optional. A conforming implementation that always re-executes every work unit — a clean-build implementation — is conforming so long as the dependency rules of § 13.1–§ 16.2 are respected.
17.3. Discovered inputs
A unit whose declaration carries discovered_inputs (§ 22.1.1) participates in post-execution input discovery. When such a unit is checked against an existing cache entry, a conforming implementation:
- MUST attempt to read the file at
discovered_inputs.fromfrom the unit’s working directory before composing the input-content set passed to the cache check. If the file is present and well-formed underdiscovered_inputs.format, the implementation MUST union the discovered paths with the unit’s declaredinputs[]for the purposes of the check. - MUST, when no restore context is available, treat a missing or malformed discovery file as no augmentation — the check proceeds with the thin (declared-only) input set, which causes an
InputSetChangedrebuild that regenerates the discovery file. MAY, when a restore context is available, substitute the stored entry’s recorded input set so the outputs walk and restore path can proceed; this avoids an avoidable rebuild when only the discovery file has been wiped from disk while the rest of the local entry is intact. - MUST, after a successful execution, parse the discovery file at
discovered_inputs.fromand amend the recordedStepEntry’s input set to include the discovered paths before persisting the entry to the local cache index. - MUST treat the discovery file as an implicit cache artifact: uploaded under its own artifact key during commit, restored from the backend on a hit-with-drifted-outputs check (§ 17.2).
The "make" discovery format is the Make rule format produced by GCC’s -MMD flag and equivalent compiler options. The conforming parser MUST: strip the leading target text up to and including the first :; join continuation lines (\\\n and \\\r\n); ignore entries beginning with /; ignore the unit’s first declared input; ignore entries whose paths do not exist on disk.
17.3.1. Candidate-key recovery on input change
When a discovered-inputs unit’s cache check reports changed input content, the working tree may nonetheless be in a state the store has already served — the edit-then-revert motion (checkout, stash pop, rebase, toggling a change to compare). An implementation that supports shared-store fetch-by-key (§ 17.1.3) SHOULD, before re-executing such a unit, attempt candidate-key recovery, subject to the following rules.
-
Candidate sets. The implementation SHOULD retain, retrievable by the unit’s declared-inputs-only key, the discovered-input path sets observed across prior successful executions — not merely the most recent one. A different input content can imply a different discovered set (that is why discovered inputs exist), so the most recent set alone cannot recompose the key of an older context. The retained collection MAY be bounded; evicting a set degrades to a re-execution, never a wrong result.
-
Key recomposition is the validation. For each candidate set, the implementation MUST recompose the candidate full key from the declared inputs and the candidate’s paths hashed at their current working-tree content — recorded hashes MUST NOT substitute for current content. A candidate naming a path absent from the working tree is skipped. A candidate whose files hash differently than the context that produced a stored entry composes a different key and misses naturally; no stored entry can be served against content it was not keyed by.
-
Output recovery by key. A candidate hit MUST restore the output set recorded for that key — for example from the producer determinant manifest (§ 17.1.5) — and MUST NOT assume the output paths of the unit’s most recent local entry: with pattern outputs (§17.6) the concrete output names can themselves depend on input content. Restored bytes remain subject to § 17.2.
-
Recovery is equivalent to execution. After a successful recovery the implementation SHOULD persist a local entry equivalent to the one a fresh execution in this context would have recorded — declared and discovered inputs, and the restored output set — and SHOULD remove previously-recorded outputs of the unit that the recovery did not restore, so that a recovery leaves the working tree byte-identical to a fresh build.
Note (informative). The reference implementation retains candidate sets in a discovered-input sets manifest stored under a reserved artifact index of the declared-only key: a JSON list of path lists, newest first, capped at 64 sets. The pre-existing single-set manifest (COOK-177) is still written so binaries predating this revision keep cold-fetching from shared stores. Concrete outputs are recovered from the determinant manifest (§ 17.1.5), which additionally records empty-directory implicit outputs so a manifest-driven restore preserves output-fidelity (§17.7). Candidate recovery applies unchanged to units without
discovered_inputswhose pattern outputs have content-dependent names: the sole candidate is the empty set and the same manifest-driven output recovery serves the hit. (CS-0156.)
17.4. Test-unit caching
A test_step unit (§ 8.6) is cached by § 17.1.1 like any other unit, as an observing unit in the sense of § 17.1.1.1: it declares no outputs, so a hit replays its verdict rather than restoring bytes. This section states only what is specific to the test kind, and CS-0186 withdrew from it everything that was not. In particular the fold of command text, consulted variable values and sealed probe values, the identity rules for an output-less unit, and what a hit replays are not restated here: they are § 17.1.1 and § 17.1.1.1, and a test obeys them on the same footing as a cook unit.
One thing is specific, and everything else in this section follows from it: a test’s input set is implicit. It comes from the recipe’s iteration source (§ 8.6.1) rather than from a declaration the author writes on the step, as a cook step’s inputs are.
-
The implicit source is resolved into declared inputs at register phase (CS-0186). A conforming implementation MUST resolve § 8.6.1‘s iteration source into the unit’s declared input set, at the point the unit is recorded, and MUST then cache the unit on that set by § 17.1.1 — by the same rule, through the same comparison, as any other unit. It MUST NOT reconstruct the set afterwards by inspecting the unit’s position in the graph.
Where the source is resolved is not a presentational choice. An implementation that recovers a test’s inputs from its DAG predecessors can only do so for units it knows to be tests, which makes the step kind a fact the cache has to consult; and having consulted it, it holds a set the declaration does not contain, so nothing downstream —
cook why, a verifier, a reader of the record — can see what the unit was keyed on by reading what it declared. Resolving at registration keeps the declaration complete, and leaves the cache with one rule for every unit.The set comprises:
- the iteration source of § 8.6.1, narrowed by mode per the rule immediately below;
- every recipe output the test references via
cook.dep_output(NAME)or$<NAME>cross-recipe substitution (§ 10.5);
Inputs are folded by content, and the timing that requires is stated in the note at the end of this section.
Per-item folding in one-to-one mode (CS-0182, extended by CS-0186). Where a
test_stepis in one-to-one mode (§ 8.6.1), each emitted unit MUST declare only its own iteration item, whatever the iteration source is. A conforming implementation MUST NOT let a change to one item move the key of a unit emitted for a different item.This is the rule § 17.1 observable 5 already states for
gather <probe>data-member fan-out — “editing one member re-runs only that unit” — and it is what acook_stepin the same mode has always done:cook "out/$<in.stem>.o"overgather "src/*.c"keys each object on its own source. Two step kinds fanning out over one source MUST NOT disagree about what a unit depends on. Without the narrowing a fan-out buys parallelism and no reuse: editing one file re-runs every unit in the fan-out.CS-0182 stated this for a
gathersource only, and excluded the case where the source is a precedingcookstep’s outputs — the shape of § 8.6‘s own Example 8.6.1 — on the ground that the item there is an upstream artifact rather than a declared input, so narrowing it would mean reclassifying an upstream output as the unit’s own input. CS-0186 removes that exclusion, and with it the prohibition, for the one-to-one case. The reasoning it rested on does not survive the mode being decided by the body: in one-to-one mode the item is what the unit reads —test { ./$<in> }runs exactly one binary — so declaring it is not a reclassification, it is the declaration finally being accurate. The exclusion cost the Standard’s own canonical example any reuse at all, which is the defect CS-0182 exists to name.The prohibition survives where the fold is genuinely wholesale. In single-unit mode the unit reads the entire source, so the entire source is its declared input set, and no per-item claim may be made about it.
How those entries are read, and how
consumesnarrows them, are not test-unit rules. A source entry naming a consumed output declareddist/**is a pattern, expanded when the unit becomes ready; an entry the register phase resolved from the tree is a path; and aconsumesallowlist narrows the former and never the latter. All three are stated once, for every unit, in § 17.1.1.2, and a test unit obeys them there. CS-0175 stated them here because a test unit was the only unit whose inputs an implementation resolved late; CS-0186 makes that untrue by making the declaration complete, and the rules move with the generality they always had.Nothing to key on means no key (CS-0138, generalised by CS-0186 and CS-0223). A unit with no declared output, no declared input, no materialised data member, and an empty effective seal set has nothing whose movement could invalidate it, and MUST therefore run on every invocation.
Stated over all four determinants — output, input, materialised data member, and non-empty effective seal set — this is one rule for every unit rather than a rule about tests, and it fires only where it must. A
cookunit always declares an output, so it never fires. An unsealedtest { cargo test }declares no output, is given no source, and iterates nothing: it has no key, and MUST NOT be given one, because its true inputs — the whole source tree — are opaque to the implementation and a key over the command text alone would be a false green.The rule is asked twice, and the second answer is the binding one. An implementation MUST apply it to the unit’s declared input list when the unit is registered, and again to the resolved list when the unit becomes ready. The two can disagree in one direction: a declaration is non-empty and resolves to nothing, which is what a pattern covering a directory its producer left empty does (§ 17.1.1.2). Registration sees an input and admits the unit; ready time sees none. Serving a hit on the strength of the declared list alone would key such a unit on nothing at all and replay its first verdict for the life of the store — the same false green the rule exists to refuse, reached through a declaration rather than through the absence of one. A conforming implementation MUST NOT serve a cached result for an observing unit whose resolved input set is empty unless it carries a materialised data member or a non-empty effective seal set.
A materialised member and a non-empty effective seal set are why the ready-time test is not stated over resolved inputs alone: either can key a unit that declares no file.
A materialised data member counts (CS-0186). A unit emitted by a
gather <probe>fan-out reads its member, the member is an observable input by § 17.1 observable 5, and it is folded into the unit’s key by that observable. Such a unit therefore has a key even when it declares no file at all, and editing one member MUST re-run that unit alone — the same guarantee observable 5 already gives itscooksibling. Before CS-0186 the reference implementation admitted only files here, so a data-membertestfan-out was never cached: it delivered parallelism and no reuse, which is the defect CS-0182 names.A
sealcreates a key when no other term does. An implementation MUST treat a non-empty effective seal set as satisfying this rule, because the probe values are the declared state whose movement invalidates the recorded outcome (CS-0223).The cache key and the unit’s identity MUST NOT include the recipe name or the step’s source position (§ 17.1.1.1). Moving a test within a recipe, or moving a recipe between Cookfiles, MUST NOT bust its cache. (Before CS-0185 this clause also named a
suitefield; that field no longer exists.)The prohibition binds synthesised determinant keys (CS-0236). Where an implementation mints a key of its own for a determinant the author declared anonymously — the anonymous file determinant a quoted
sealoperand desugars to (§ 8.4.3.1) — that key MUST be a function of the declaration’s operands alone. It MUST NOT include the name of the recipe or probe carrying the declaration, its source position, or a mint-order counter. The key is not private to the determinant it names: it is a member of the effective seal set of every cacheable unit in the recipe, and the declared-probe-values term of § 17.1.1 is computed over that set’s keys as well as its values. A positional synthesised key therefore does not merely re-identify one anonymous determinant on every edit; it re-keys every unit in the recipe, which is this clause read backwards.Because the operands are the whole declaration, equal keys hold exactly when the declarations are equal. Two anonymous declarations with equal operands name one determinant, and a conforming implementation MUST treat the second as that same declaration rather than as a duplicate key. Operand order is not part of the declaration: the operands are a set (§ 8.4.3.1).
-
Recordable outcomes. A conforming implementation MAY record a test result for replay only when the unit’s outcome is passed — that is, the body completed with a success exit status (a shell body exiting zero, or a Lua body raising no error). A test that is failed or blocked MUST NOT be cached. A unit is blocked when any dependency of it failed — a build dependency, or (CS-0177, § 8.6) another test. The implementation MAY discard a previously-cached entry whose corresponding test is observed to fail in a subsequent run.
This rule is what makes an observing unit’s replay meaningful at all: because only a pass is recordable, the existence of a record is the verdict, and § 17.1.1.1 needs nothing stored to replay it.
Withdrawn (CS-0186), reinstated elsewhere (CS-0189). This section carried two further rules. Both are gone from here permanently, and both now hold for every unit kind rather than for tests.
Rule 3, “replayed payload”, required a test’s hit to reproduce the originally-recorded
(stdout, stderr, duration_secs, success)quadruple.successwas implied by rule 2 and needed no statement. The rest is § 17.1.5.1, which records what a run printed and how long it took for every unit, and bounds how a hit may reproduce it. CS-0186 withdrew the rule rather than keeping it because a rule about what a hit replays is not a rule about tests: acookunit’s hit replayed no build log either, and preserving the payload for one step kind would have re-created the divergence this milestone exists to remove.Rule 4, “cross-machine portability”, required a result recorded on machine A to be a hit on machine B for identical inputs. It was never delivered: the store it described was project-local, and nothing published to or fetched from a shared one. A conforming implementation now MUST admit an observing unit to § 17.1.3 on the same terms as any other unit — its observation is publishable and fetchable by the unit’s single key (§ 17.1.1), and its disposition governs that publication exactly as a producing unit’s governs its artifacts.
Reinstating it has a consequence worth stating plainly, because it is the point rather than a side effect: a test can pass because someone else’s run of it passed. That is what a content-addressed cache means — identical declared inputs, identical command, identical sealed probe values, therefore identical outcome — and it is the same trade a producing unit already makes when it restores a teammate’s artifact instead of rebuilding. Where that inference is unsound because the unit depends on something its declaration does not name, the remedy is the one that already exists: name it, with a
seal(§ 8.4.3.1), or withhold the unit from sharing withlocal.
Note (informative). Rule 1’s fold of a consumed output is an output-content fold, not a dependency-execution fold — the distinction is what gives a test the same early cutoff a consuming
cookstep gets (§ 8.4). A dependency that re-executes but produces byte-identical output leaves the consuming unit’s key unchanged, so its cached result stands; a fold over the dependency’s command hash or its own source inputs would instead re-run the test on any upstream re-execution, over-invalidating relative to a cook-step sibling consuming the same dependency.
That is also why a one-to-one test declares its item and not the input behind it. Given
gather "src/*.c",cook "build/$<in.stem>",test { ./$<in> }, the unit forbuild/adeclaresbuild/a— notsrc/a.c. Editingsrc/a.crebuildsbuild/a, and only if those bytes move does the test re-run. Declaring the input as well would re-run the test on every recompile that changed nothing, which is exactly the over-invalidation this note warns against, and it would additionally re-run the units for every OTHER item, since one input set is shared by the whole fan-out.
Because a consumed output does not exist until its producer runs, a declared input naming it cannot be resolved to concrete files at registration. The reference implementation resolves any glob-shaped declared input when the unit becomes ready — the scheduling point where every predecessor has completed and its outputs are materialised (§ 18.1). This is one resolution serving every unit kind; it is not a test-unit mechanism, and no part of the cache asks what kind of step a unit came from. Only the immediate predecessor’s output is ever declared, never the transitive closure, which keeps early cutoff chaining: a transitive dependency whose output stays byte-identical is already captured by its direct consumer’s unchanged output. (CS-0138, CS-0186.)
17.5. Cache portability invariants
A conforming implementation that maintains a cache of recipe execution results MUST hold the following invariants for the cache to be portable across teammates and across workspace-relocation moves on disk.
-
cookfile_pathis workspace-root-relative. For every cached recipe, thecookfile_pathrecorded in the cache metadata MUST be the importee’s Cookfile path expressed as a forward-slash relative path from the workspace root, and MUST contain no absolute-path components. This invariant follows from the workspace shape established by § 11.2 and § 20.1. -
cache_keyuniqueness is per-Cookfile-cache-file scope. A cache entry’s identity within its index file is determined by itscache_key(typically derived from the unit’s primary output path with optional variant suffix). Thecache_keydoes not encode the recipe namespace; cross-Cookfile uniqueness is provided by per-Cookfile cache locality (each Cookfile maintains its own cache index file under its own directory). -
cache_meta.input_pathsis importer-relative. For a unit registered by recipe R in Cookfile C with working directory W, every entry incache_meta.input_pathsMUST be a path relative to W. This holds for both same-Cookfile inputs and cross-Cookfile dep inputs; substitution-time path rewriting (§ 11.3) ensures cross-Cookfile dep paths arrive in importer-relative form. For sigil-anchored imports the path can contain..segments; the cache check usesW.join(input_path)to locate the file on disk, and..segments resolve correctly against W.
A conforming implementation MAY layer additional content-addressed artifact storage atop the index files; that layer is outside the scope of this section.
Probe units (§ 22.5) are outside these invariants, and deliberately so (CS-0243). A probe value is never stored, never published, and never transported: it is observed afresh on every invocation in which the probe is reached (§ 22.5.8), so there is nothing about it for a portability invariant to hold. Portability of a unit that seals a probe is unaffected — the sealed values enter that unit’s key by § 17.1.1, and two machines that observe the same world compute the same key and share the same artifact, which is the sharing that was ever worth having here.
Note 17.5.1 — Chore parameters and caching (informative) [#exec.cache.chore-params]
A reader landing here looking for how chore parameters (§ 7.1.1) interact with the cache should consult § 7.4 (§7.4): a chore body MUST NOT register a cached work unit, so chore parameters never enter the cache fingerprint. Recipes do not take parameters (§ 6.1, § 7.1.1), so the parametric-cache-key question does not arise in the current Standard. This note exists so that searches for “parameter” within the cache chapter point readers at the right section.
17.6. Output globs
A unit whose declared outputs[] (§ 22.1.2) contains one or more glob pattern entries participates in post-execute output resolution. After the unit’s command completes successfully, a conforming implementation:
- MUST walk the unit’s declared
outputs[]once. For each literal path entry, the entry passes through unchanged. For each glob pattern entry, the implementation MUST resolve the pattern against the unit’s working directory and substitute the resolved file set for the entry, dropping any directory matches (CS-0064). The combined resolved set is unioned across all entries; if a single literal path matches more than one entry (literal or glob), it MUST appear exactly once in the resulting set. - MUST compose the unit’s
StepEntry.outputsfrom the resulting fully-resolved literal-path set, in the same way it would for a unit whoseoutputs[]were originally all literal (§ 17.1). - MUST treat an empty resolved set as the unit having produced no outputs. A
StepEntryMUST still be written so that the input fingerprint can short-circuit on subsequent runs; restoration on a hit MUST be a no-op for such an entry. - MUST upload each resolved output to the backend under the same artifact-key scheme used for literal outputs.
On a subsequent cache check for the same unit, the implementation MUST use the recorded StepEntry.outputs directly for the disk-integrity walk and for try_restore (§ 17.2). The implementation MUST NOT re-resolve the patterns against the current filesystem during the check. The recorded file set is the source of truth for “what the unit produced last time it ran”; cache invalidation flows through the unit’s input fingerprint, not through pattern re-resolution.
Note 17.6.1
The resolved file set persisted in StepEntry.outputs is indistinguishable from the set that would have been persisted by a unit whose outputs[] were literal from the start. Future cache reads, cross-machine restores, and artifact lookups operate on the literal paths only; the original glob pattern is not recorded.
Build-owned directory outputs (CS-0115). An outputs[] entry written with a trailing slash (dir/) is a directory output: it declares that the unit owns the entire subtree rooted at dir/. Like a glob entry it is a non-literal, terminal output (§22.1.2) and participates in post-execute resolution — after the command completes, a conforming implementation MUST resolve the entry by walking the subtree and substituting the set of regular files found beneath it (directory entries dropped, CS-0064). In addition, a directory output is build-owned: once the unit settles, the subtree MUST contain exactly the produced (or restored) file set and no other regular files — the hit-equals-miss guarantee. A conforming implementation MUST realise this in a way that distinguishes freshly-produced files from leftovers of a prior build, since the two are indistinguishable by post-command inspection alone. The reference technique is: on a (re)build, empty the subtree immediately before executing the producing command, so the post-command contents are exactly what the command produced; on a cache-hit restore, remove every regular file under the subtree that is not a member of the restored (recorded) set. In both cases the implementation SHOULD prune directories left empty as a result, and such pruning MUST NOT follow symbolic links (so it cannot escape the subtree by that path). Comprehensive handling of symbolic links within a directory output — both the file enumeration and the deletion it drives — is a tracked follow-up (not in v1.0): a directory output that contains symbolic links (for example a node_modules/ produced by a symlinking package manager) is not fully supported in this version and SHOULD NOT be relied upon. This build-ownership does NOT apply to glob entries (dir/**, dir/*.js) or literal entries, which remain additive.
17.7. Stale-output reconciliation
§17.1 establishes that the cache changes only how much work happens, never what it produces. That guarantee extends from cache values to the output tree itself: when a recipe’s declared output set shrinks between runs, the outputs it no longer declares MUST NOT be left behind on disk. A build whose output directory accumulates artifacts that nothing declares misrepresents its own graph — a downstream glob (§17.6), tar, or deploy step would pick up the stale file.
The canonical case is gather <probe> (§ 22.5.10, § 8.2), whose output set is computed from data: removing a data member means the next run correctly produces the remaining members but would otherwise orphan the dropped member’s artifact.
The rule
For each recipe R reached and re-registered in an invocation, after R’s work units complete, a conforming implementation MUST compute
orphans(R) = prior_declared_outputs(R) − current_declared_outputs(R)
where the prior declared outputs are the output paths R recorded on its last successful run (the cache snapshot, §17.5, read before it is overwritten) and the current declared outputs are the union of the output paths of every work unit R registered this run (after gather <probe> fan-out). For each path p in orphans(R):
- if
pis not present on disk, the implementation MUST take no action; - if
pis present and its current content hash equals the hash Cook recorded when it wrotep, the implementation MUST removepand report it (e.g.swept orphaned output: <p>); - if
pis present but its content hash differs from the recorded hash, the implementation MUST keeppand SHOULD warn (e.g.<p> changed since Cook wrote it — not removing).
R’s recorded declared-output set then advances to the current set.
Scope firewall
Reconciliation MUST run only for recipes actually reached in the current invocation. A targeted cook <recipe> MUST NOT sweep any other recipe’s outputs; recipes that did not run keep their recorded outputs untouched. An orphan of R that is a current declared output of some other recipe reached this run MUST NOT be removed — it is still live.
Safety properties
A conforming implementation:
- MUST only ever delete files it itself wrote and recorded (a tracked-set diff; it MUST NOT sweep a file it did not create);
- MUST NOT delete a file whose content changed since Cook wrote it (the hash guard above);
- on a first run, or against a cache with no recorded outputs, MUST compute an empty prior set and therefore delete nothing;
- reconciles files only in this edition — directories are left in place.
test steps declare no outputs and are no-ops for reconciliation.
Opt-out
Reconciliation is default-on. An implementation MUST provide an opt-out (the reference implementation: --no-prune, or the COOK_NO_PRUNE environment variable) that disables the sweep for an invocation; with it set, orphaned outputs are retained.
Note (informative). Reconciliation reuses the output paths and content hashes the cache already records (§17.1, §17.6) — there is no new on-disk ledger — and is guarded so a user-modified artifact is always kept. It is the output-file analogue of the integrity rule of §17.2: Cook never silently leaves stale bytes behind, whether in a restored artifact or an orphaned output.
17.8. Determinant-fidelity verification (cook cache verify)
The divergence oracle is surfaced by the reference implementation as the opt-in
command cook cache verify. It is a fidelity / diagnostic tool, not a trust
gate: trust rests on author-declared determinants (§17.1.1), and this command
observes whether those declarations are complete. An implementation is NOT
required to run it to be conformant; when run, it MUST behave as follows.
For each cacheable unit (§17.1.1) the cache holds an artifact for under its key K, the verifier re-executes the unit in the current context and compares the re-produced output bytes against the bytes the cache would serve for K:
- If the unit is not a
nondetunit (§17.1.4) and a re-produced output’s bytes differ from the cached bytes under the matching key K — or a recorded output is not re-produced at all — the verifier MUST report a divergence. A divergence is either an under-keyed determinant (a real determinant was not declared, so K failed to distinguish two contexts) or a non-reproducible producer. The command MUST exit non-zero when any divergence is reported. - If the unit is a
nondetunit, the verifier MUST NOT perform the byte comparison: byte-equivalence is waived fornondet(§17.1.4). It MAY apply a weaker check — that each recorded output is re-produced — and MUST NOT classify anondetcontent difference as a divergence. - A faithfully-keyed, reproducible unit re-produces byte-identical outputs and MUST be reported as passing.
Where a producer determinant manifest (§17.1.5) is retrievable for K, the implementation SHOULD attach its recorded determinants (consulted environment, sealed probe values, declared inputs) to a divergence report to aid attribution. Manifest absence MUST NOT change the byte verdict.
Note (informative). Running the verifier across machines (e.g. in CI on a host distinct from the producer) is the intended deployment: a unit that shares fleet-wide yet embeds an undeclared host determinant surfaces as a divergence there. Running it twice on one machine against the same workspace is the local degenerate case — K matches by construction, so any divergence is purely a producer/determinant property. (CS-0113.)