Skip to content

Appendix E — Changes

Appendix E. Changes (informative)

Informative. This appendix is the chronological changelog of amendments to the Cook Standard. Each entry has a stable CS-NNNN ID, a one-line summary, the list of sections affected, and the commit / PR reference. Entries are grouped under tagged versions; see the Versions index immediately below.

Versions

  • v0.18 (unreleased) — CS-0257, CS-0256, CS-0255, CS-0254, CS-0253, CS-0252, CS-0251, CS-0250, CS-0249, CS-0248, CS-0247, CS-0246, CS-0245, CS-0244, CS-0243, CS-0242, CS-0241, CS-0240, CS-0239, CS-0238, CS-0237, CS-0236, CS-0235, CS-0234, CS-0233, CS-0232, CS-0231, CS-0230, CS-0229, CS-0228, CS-0227, CS-0226, CS-0225, CS-0224, CS-0223, CS-0222, CS-0221, CS-0220, CS-0219, CS-0218, CS-0217, CS-0216, CS-0215, CS-0214, CS-0213, CS-0212, CS-0211, CS-0210, CS-0209, CS-0208, CS-0207, CS-0206, CS-0205, CS-0204, CS-0197, CS-0196, CS-0195, CS-0194, CS-0193, CS-0192, CS-0165, CS-0166, CS-0167, CS-0168, CS-0169, CS-0170, CS-0171, CS-0172, CS-0173, CS-0174, CS-0175, CS-0176, CS-0177, CS-0178, CS-0179, CS-0180, CS-0181, CS-0182, CS-0184, CS-0185, CS-0186, CS-0187, CS-0188, CS-0189, CS-0190, CS-0191. CS-0245 fixes a normative contradiction in §17.1.6.1: a sealed probe’s value now reaches cook why by whichever of two routes applies — resolved fresh at query time when it needs no produce body, or reused from the production the query’s own registration pass already had to perform — rather than from the last materialised record, which survives only for a probe neither route reaches and must then be labelled as a prior invocation’s value; and a seal-triggered miss names the probe key and the changed manifest entries instead of reprinting the whole value. CS-0207 moves the installed-module tree from a top-level cook_modules/ to .cook/modules/, under the build-output root that already holds the caches, and deletes the hand-vendored top-level search candidates along with the vendoring workflow built on them. The old directory was two things at once — the package manager’s install root and the only legal home for a hand-authored module — which is why no ignore rule for it could be correct, and why this repository shipped a release against a tree CI rejected. A hard cut rather than a deprecation window, because a window would have to keep the old path in the search order for its whole life, which is the conflation the cut exists to delete; what a window buys is bought instead by a not-found diagnostic that recognises a stale tree and names the migration. CS-0206 lets a Cookfile use a Lua file that lives where it belongs: use ./build/helpers.lua addresses a tracked project file by path, with the alias derived from the basename or stated explicitly, so a helper no longer has to be copied or symlinked into the package directory to be reachable. The path is confined to the declaring Cookfile’s own subtree — .., absolute paths and import’s // workspace-root sigil are all refused — because CS-0204 records a loaded module’s path relative to the unit’s working directory, and a path outside that window either cannot be keyed or would be folded into a shared key in a form another machine cannot reconstruct. CS-0205 makes a use alias reach every execute-phase body rather than the one body kind that happened to carry it: the binding existed in a chore body and in no cook step, test step or probe produce body, so the very shape the conformance corpus pins for this contract failed at run time for a year while a parse-only fixture and a codegen harness that reads no emitted Lua both stayed green. The visibility rule now names each body kind, the binding is required to go through cook.load_module so an alias-bound module stays a CS-0204 determinant, and skipping a body that does not name the alias is stated conformant — a local is observable only to code that names it, while binding unconditionally would run a register-oriented module on a worker VM and key every Lua-bodied unit on it. CS-0204 makes module source a determinant of the work that loads it: a module edit was invisible to the cache, so a cook body and a probe produce body alike were served their pre-edit answers, and because the one key addresses the shared store too, that answer escaped the machine that produced it. Keyed on the resolved path of every module actually loaded (observed on both doors, cook.load_module and require, memo hits included), never on the search directory; judged against the recorded set itself; folded into the one key with the candidate-key reconstruction that keeps cold sharing alive. Register-phase module use is verified sound and the reason is written down. CS-0197 lets a fan-out member declare what it reads: quoted globs trailing ingredients <probe> resolve at register time and fold into every member unit’s declared inputs, closing the position where a member-fanout body’s source file was in no key (stale local hits, cross-project false sharing) and no spelling could declare it; coarse by design, per-member precision reserved. CS-0196 states what a unit’s project identity is — the configured [cloud] project name or empty, never anything derived from the checkout’s location — and wires the register-phase CacheContext that had never been wired: two clones of one workspace under different directory names share entries (unconfigured keys are byte-identical to before, so nothing re-keys), two distinctly-configured projects stop sharing, and [cache] ignore_env reaches unit keys for the first time. CS-0195 moves the last tostring probe rendering — a fan-out output pattern — onto the CS-0192 law, register-time over the pre-pass store: scalars render as their canonical token and a composite or absent member is a register-phase diagnostic instead of a heap address inside a declared output path; with it every substitution position shares one renderer and lua_access is deleted. CS-0194 makes the drain thread a role rather than a fixed thread: a worker VM designated as the drain during a chore window IS the drain thread, and the normative content moves from mechanism (“not on a worker VM”) to the observable property — the whole chore body’s output, print and both streams of every cook.sh call alike, reaches the controlling terminal in call order, ending the split where print hit the tty while cook.sh streams detoured through the capture sink and progress channel; it also fixes the §7.1.2.1 defect where a cook.sh child of a chore Lua step saw no exported parameters. CS-0193 makes a chore shell_command step an ordinary uncached interactive unit: probe references follow §22.5.7 (literal in the captured command, key unioned into probes, substituted per CS-0192 — previously a chore probe ref raised outside module context and created no edge, an unusable surface), the one-invocation-per-line semantics and their consequences (failing step stops the body, no state persistence between steps) become normative, and interactive output is declared terminal-owned — a stated exemption from CS-0188/CS-0189, because capturing an interactive step’s streams would break the TTY property that defines it. CS-0192 defines probe-value substitution over the value’s JSON type rather than inheriting Lua’s tostring by reference: a string renders verbatim, a number as its canonical JSON token, a boolean as its literal, while null, arrays, objects and absent members in command position become execute-phase diagnostics — a table used to interpolate its heap address, different bytes on the same command line every run, and an absent member the four bytes nil. CS-0189 records an observation — what a unit printed, how long it took, when — for every unit rather than for none, settling the question CS-0186 deliberately left open for every unit kind at once, and reinstating §17.4’s withdrawn rules 3 and 4 generalised: an observing unit shares on the same terms as any other, so a test can pass because a teammate’s run of it passed. CS-0191 removes iteration_item from the cook.add_unit surface, leaving member to carry it: the table had already called it “the test-reporting spelling of member” and permitted one field for both, and permitting two spellings is what let the implementation emit both from one expression. CS-0190 states what a shell_block body has always meant and never said: one shell invocation, the block’s lines joined under set -e, so a failing line stops the block and a cd on one line survives to the next. It was implemented twice, identically, in the code generator; it is now one pure rule in cook-contracts, which is where a decision every phase must agree on belongs. CS-0188 gives a unit’s output one owner: both streams of every cook.sh call are captured and attributed to the calling unit, in call order, replacing a sentence that named a destination (the worker’s stderr, one descriptor shared by every parallel unit) rather than an owner. It withdraws with that the one place the Standard mandated a mechanism whose only observable effect was to break the rule it now states, § 22.5.7‘s requirement to rewrite a probe-referencing command into a Lua step: two cook steps differing only in whether the command mentions a probe printed, respectively, everything and nothing. CS-0187 removes $<file:PATH> outright: a determinant mechanism with its own accumulator, hoisting convention, register API, declaration field, resolution path and cache observable, all of it predating a probe substrate that now carries the same job generically — a files producer plus a seal. It is removed rather than resugared over that substrate, because the resugaring has open questions (whether a reference mints a graph node, how an anonymous probe key is synthesised, whether cook why can still name the file that moved) and settling them under the old shape would constrain the answer. CS-0186 gives every unit one cache, as CS-0185 gave every unit one registration function, and states for the first time what a declared input is: each entry is a path or a pattern, classified where it enters the declaration rather than re-read from its spelling later, because *, ? and [ are ordinary characters in a filename and a resolved app/[slug]/page.tsx re-scanned as a pattern matches nothing and leaves the unit keyed on a file it silently stopped watching. § 17.1.1 had defined a cacheable unit as “a step with at least one declared output”, which made declaring outputs the criterion for being cached — the same inference CS-0185 removed at the registration surface. The output list decides something else: whether a hit restores bytes or replays a verdict, an effect kind derived from the declaration and never stored, since a stored kind can contradict the declaration it came from. An observing unit, having no output path to be identified by, is identified by a digest of its declaration, and three exclusions from that digest are normative — not the input contents, because an identity that moves whenever the contents move can never be compared against a prior record and every invalidation then presents as a first-ever build with no reportable cause; not the recipe name or source position, which § 17.4 already forbade; and nothing further, so two units no determinant separates share one record, which is CS-0169’s rule applied to the case it did not reach. Rule 3’s replayed (stdout, stderr, duration_secs, success) quadruple is withdrawn: success was already implied by rule 2, and the streams and duration were a divergence from every other unit kind rather than a decision — a cook unit’s hit replays no log and reports no elapsed time. Whether a hit should reproduce what a run printed is a real question about every unit kind at once, and it is left open rather than settled for one kind by inheritance from a deleted store. Rule 4’s cross-machine replay is withdrawn as never delivered — the removed store was project-local — and its reinstatement is bound to the same question. § 8.6 stops distinguishing a test by restating the output rules in the negative: step_kind selects the REPORTER, and declaring no outputs is a consequence of what a test is for rather than the thing that makes it one. CS-0184 states the rule § 10.2‘s closed function always implied but never said: a placeholder means the same thing in every shell-text position, so the same token under the same scope resolves to the same step and reports the same diagnostic whether it sits in a cook output pattern, a cook body, a test body, or a chore body. Positions differ only in what they ADMIT — an output pattern takes no $<file:PATH>, a test body no $<out…> — never in what a token means, and a position may not decline to substitute a token it fails to recognise: an output pattern with no iteration driver is not thereby literal, and emitting it raw put an unresolved $<…> in a declared output path where the shell read it as a redirection and the value it named never entered the key. A position that cannot honour a resolution MUST refuse it rather than substitute a value the phase cannot have, which is what a probe-value reference in a test command hits: the command runs verbatim, so substitution would fall to register time, where a demand-scheduled probe has not been produced. § 22.5.7‘s fan-out test clause is recorded as a known implementation gap rather than weakened to match. CS-0183 was withdrawn: its subject, the COOK_CMD_FAILED payload, is inter-crate transport rather than Cookfile language, and the number is left unreused because a shipped commit references it. CS-0180 adds fs.remove (§25.6, new; §25.6–§25.10 renumbered to §25.7–§25.11) — the fs surface could create files and not delete them. An absent path returns false rather than raising, so cleanup code need not guard with fs.exists and race; a directory is refused and there is no recursive form; symlinks are removed without following. CS-0179 adds cook.cookfile, the structure-preserving Cookfile edit surface the CS-0176 verbs write through (§22.12, new): splice an entry into a named list inside a named recipe’s module call, append a declaration, or locate a call without writing. Preservation is normative and re-rendering is prohibited by name — evaluating a Cookfile’s Lua to a table and printing it back writes standard = cxx_std out as whatever that variable held, drops every comment, and reorders fields by pairs() order, all of it invisible in a file that still builds. Failure is explicit and total: an edit that cannot be located names what it sought and leaves the file byte-identical, which is exactly what a re-rendering implementation cannot do, since it cannot tell that anything was unusual. Locating splits along what the frame grammar knows — recipes and module calls are structure and are located as such; fields are opaque Lua and are found by a scan within the located span, under rules that keep a } inside a string or comment from closing a list and a field name from matching inside a longer token. CS-0178 gives a probe declaring no inputs no cache key, so it re-produces on every invocation in which it is reached. §22.5.4’s fingerprint has seven sections: 1-3 (marker, key, produce source) are always present and constant for a given probe, and 4-7 (ENV/TOOLS/FILES/UPSTREAM) are empty unless declared, so a probe declaring nothing had a constant fingerprint and §22.5.8 then required the implementation to load the cached bytes and skip execution — permanently, for the life of the store. A { cat STATE } probe answered once and never observed again, and rm -rf .cook did not help because the value lives in the shared store; the only way to make it re-observe was to declare an input it did not need, which is what the otherwise-inexplicable ingredients line inside a probe was really for. Test units have obeyed the mirror-image rule since CS-0138 (no declared source, no key, always runs); this brings the unit kind whose entire purpose is observation into line with it. Keylessness propagates along requires, because a probe folding section 7 over an upstream fingerprint that stays constant while the upstream’s value moves would be served across the very change it exists to notice. Three things are deliberately untouched: tools/envs/files producers lower to sections 5/4/6 and are self-keying, so upgrading a compiler still re-fingerprints a tools probe; a unit sealing a keyless probe keys on the probe’s value, not its fingerprint, so re-observing an unchanged world costs no consumer its cache hit; and demand-driven scheduling still refuses to run — or even fingerprint — an unreached probe. §22.5.8 amended. CS-0177 makes a failed test step block its dependents. Every other step kind already did: a failed cook unit cancels its dependent subtree and reports skipped (upstream-failed), and § 22.5.8 states the same rule for probes, citing general unit-failure semantics. The test kind alone was exempt, so a dependent ran its body over a tree its own gate had just rejected — chore ship: checks printed its output with checks red and the run reported the failure afterwards through the exit status, by which time a release chore has already pushed its tag. The exemption also made a dependency on a test the one edge in the language that ordered work without gating it, which is not what the dep-list spelling reads as. The rule reaches dependents and not siblings, so a test runner still executes and reports the whole suite rather than halting at the first red; blocking follows the dependency edge and never shared scheduling. §17.4 rule 2’s blocked outcome, which already forbade caching a unit whose build dependency failed, is widened to any failed dependency including another test. §19.2 is unchanged: it already required a non-zero exit for a test ‘blocked by an upstream failure’. §8.6 amended, §17.4 rule 2 amended. CS-0176 adds cook.chore, the public sibling of cook.recipe that lets a module register a chore of its own (§22.11, new). Registering a chore was previously reachable only from the code generator, so a module offering project-management verbs (cc.add, cc.link) had no way to make them invocable without the author transcribing a chore NAME block for each — the work the module exists to remove. Three constraints make it safe. The name MUST carry a dotted prefix identifying the registering module (cook_cc may use cc. or cook_cc.), which is what preserves §12.7.8’s name-ownership rule: that rule is scoped to recipes by this entry, on the ground that a recipe is a DAG node whose minting changes what a build does while a chore is uncached, outside the DAG, and inert until invoked — so ownership is kept by partitioning the namespace rather than refusing the registration. Dots are correspondingly banned where an author writes a name and required where a module does. Registration is confined to module evaluation, because the module identity the prefix is checked against is exact only while the module’s own chunk runs — afterwards an implementation knows only which module was loaded most recently, which a second use makes wrong. And chore semantics MUST be established for the body by the implementation: a cook.chore body is not codegen-wrapped, so an implementation reusing the surface-chore mechanism silently produces cacheable, non-interactive units in violation of §7.4. CS-0175 gives a test unit the counterpart of a cook unit’s input declaration: a consumes glob allowlist (§ 22.4) narrowing which immediate-predecessor outputs its key folds, so a churny artifact class a check never opens — an inlined-sourcesContent sourcemap rewritten by a comment-only edit upstream — stops costing every downstream check its cached pass. Because narrowing errs toward the under-keyed direction, where a stale green replays against inputs that have moved, two safeguards are normative rather than advisory: a filter matching none of a non-empty candidate set is inert, and the full consumed set stays the produced-upstream filter so an excluded artifact cannot re-enter through the test’s own ingredients (CS-0186 replaces the second with a provenance rule of the same effect — consumes narrows a pattern’s expansion and never removes a path the unit declares — and moves the surface to cook.add_unit). The same entry fixes a latent under-keying defect it exposed — a predecessor’s terminal dist/** resolved to the empty set on the consuming side, contributing nothing to any downstream test’s key — by requiring one output declaration to resolve identically whether its producer is capturing it or a consumer is folding it (§ 17.4 rule 1). CS-0174 gives cook why the causal answers it was already holding and throwing away. A local miss now names the determinant that diverged from the unit’s previous fingerprint record (§17.1.6.1), which the reference implementation computed inside its own rebuild check and reduced to a boolean; the effect was that a shared miss got a manifest diff while a local miss got a list of determinants and no verdict, leaving the explain tool less informative than the build log it exists to pre-empt. Separately, §17.1.6.5 (renamed from “Observed timing” to “Observed history”) admits reporting why a unit ran on a retained past build, recovered from the same records as its timing. That is the only causal answer available for a unit that is a cache hit now, and it is what someone asks who finds work was redone while they changed nothing. Live and historical attributions MUST be distinguishable and MUST be separate values in any machine-readable form, and an implementation splitting attribution and timing across two records MUST carry the join identifier on both rather than pairing one run’s cause with another run’s timing. CS-0173 makes cook why’s determinant resolution graph-aware: a declared input that another unit in the closure produces is resolved from that producer, not from the working tree (§17.1.6.2, new). Classifying each unit independently was wrong in both directions and on the same workspace — on a cold tree with a warm cache a consumer reported a rebuild that would not happen, because the file it reads was absent although a producer hit had already determined its bytes; after an edit it reported a hit against bytes about to be overwritten. A producer that is served from cache yields its outputs’ content hashes; a producer that rebuilds leaves its consumer with no computable key, and the query must report the consumer as rebuilding on account of that producer, naming it, rather than inventing a key over stale or absent bytes. §17.1.6.3’s prohibition is narrowed accordingly: attribution still may not promote a tier classification, but resolving an input from its producer is an observation of the current cache rather than a prediction. Subsections renumbered .2–.5 → .3–.6. CS-0172 separates the declared-variable namespace from the process environment, which were one table. The config sandbox’s var sink WAS cook.env, pre-seeded from the ambient environment and a .env file, so every ambient variable was an undeclared build variable — $<HOME> resolved in a Cookfile that declared nothing, and the sandbox’s host.env gate, whose whole purpose is that a config body’s inputs are declared, could be bypassed by not declaring them. Four consequences followed and are fixed together: (1) the namespace is now exactly what the config blocks export, with the invocation override (--set) restricted to a declared name and applied after the blocks run, closing App. D.15 by construction; (2) config blocks resolve BEFORE any register-phase Lua — codegen wraps everything after the config function in __cook_main, which the engine calls after dispatch — so a top-level module_call, which is where a toolchain is configured, can read a declared value; (3) values keep their Lua type (string | number | boolean) for var.NAME reads and render to a string form for $<NAME> interpolation and cache keys, so a boolean knob can drive a boolean module option (half the cc codegen vocabulary was unreachable from config before); (4) envs { } probes read os.getenv — the ambient environment they always meant — rather than the conflated table a config block could redefine. var is read-only outside a config body (§21.3, §24.3): the store lives where Lua cannot reach it, so a determinant cannot be mutated after a unit is keyed on it. An execute-phase Lua body receives variables in string form, and reading a non-string one there is refused at register phase rather than coerced into a truthy "false". Retired with no shim: cook.env, cook.require_env, the $<env.NAME> prefix (now $<var.NAME>, §10.7), and the .env layer. §5.3, §5.3.1, §9, §10.7, §17.1.2, §21.3, §24.3 amended; C.21.1 rewritten. CS-0171 merges cook dag into cook why, leaving one read-only transparency query: §17.1.6 is restructured into determinant reporting, graph structure and aggregation, cascade attribution, observed timing, and output formats. The two commands each answered half of one question and were each weakest where the other was strong — why had every determinant behind the key but no structure, no aggregation, and no ceiling, so it could neither be read at scale nor attribute a miss whose cause was upstream; dag had edge kinds, aggregation levels, and a node ceiling, but classified the cache with a second, local-index-only check surfaced as a bare [cached]/[stale] marker. why survives as the name (it is the specced surface and the question), the dag implementation survives as the structural spine (aggregation is what makes per-unit fidelity legible), and the duplicate cache check is deleted. The merged query gains cascade attribution (a rebuild reports how many downstream units it forces; a downstream miss names the upstream cause instead of restating the consequence) and best-effort observed timing from retained build logs, hedged in the spec because the data is: cache hits are untimed, retention is twenty builds, and records are machine-local. cook dag is removed with no shim, having never shipped in a tagged release. CS-0170 removes DAG waves and the ratatui terminal viewer from the reference implementation and flattens the DAG payload to {recipes, nodes, edges} (schema 2 → 3). The engine stopped scheduling in waves at SHI-222 Phase 4; the surviving grouping synthesised whole-recipe edges the engine does not impose — 1685 of them for a single real edge on DuckDB — which masked the genuine per-unit cook.dep_order edges. cook dag gains --level and --format and now works in a default build. CS-0169 makes a literal output path declarable by at most one work unit, run-scoped, refused at register phase with both producing units named (§22.1). Two units sharing one output produced a build that reported success and never settled — the root pathology behind CS-0167’s object-path defect, and silent at every layer until now. Output globs and build-owned directory outputs are explicitly out of scope. CS-0168 closes the three gaps that pushed large targets off the high-level cc surface entirely: sources gains glob and !-exclusion entries with order-independent set-difference semantics (§28.3.1.1, reusing the ingredients item grammar rather than inventing a second exclusion spelling), a deterministic resolved order, duplicate collapsing, and an error on an include pattern that matches nothing; fpic becomes an explicit target field; and optimize / debug_symbols / gc_sections join standard and warnings as portable code-generation options (§28.3.10.1), with gc_sections required to emit both its compile and its link half. Surface v0.9 → v0.10, additive. CS-0167 makes the cc module’s object paths collision-free: the basename-derived build/obj/<target>/<stem>.o was specified to alias distinct translation units onto one object wherever a target held repeated source filenames, and is replaced by the source-path-relative build/obj/<target>/<src-rel-to-workspace-root>.o, injective by construction; the depfile follows the object rather than the basename, and the compile database must share one derivation with the compiler rather than re-deriving it (§28.3.5, §28.3.5.1 new, §28.3.11; surface v0.8 → v0.9, breaking). Config-block redesign, cross-Cookfile composition: a config block’s scope is exactly its own Cookfile — var.* outputs and host.* reads do not cross an import boundary (§11.6, R2); @PRESET selection is a single per-run global validated against the union of the named config blocks of every loaded Cookfile, with a member that lacks the selected overlay running its base (§11.6, R3). Preset routing (config.<alias>) and the diamond agree-or-error rule are reserved as a future direction (§11.6, non-normative). CS-0166 moves the on-disk recipe index from human-readable TOML to a binary, path-interned .idx file and bumps CACHE_VERSION 6 → 7; readability moves to cook why and the new cook cache dump, and superseded indexes are deleted rather than migrated.
  • v0.17 (unreleased) — CS-0164. Config-block redesign, output surface: the config output namespace is var.* (replacing env.*), consumed only via $<NAME> and never injected into a step’s process environment (§5.3, §5.3.1, §9.2, §10.2/§10.7). Reading env in a config body is a load-time did-you-mean error.
  • v0.16 (unreleased) — CS-0159, CS-0160, CS-0161, CS-0162, CS-0163. Config-block redesign, sandbox + host surface: a config body runs in a sandboxed Lua environment reading external input only through host.os / host.arch / host.env / host.read (§5.3.1, §5.3.2). Plus test-unit seal (CS-0159), derived test-unit names (CS-0160), cook.dep_order (CS-0161), and the $<in.FIELD> canonical-JSON rendering catch-up (CS-0162).
  • v0.15 (unreleased) — CS-0103, CS-0104, CS-0105, CS-0106, CS-0107, CS-0108, CS-0109, CS-0110, CS-0111, CS-0112, CS-0113, CS-0114, CS-0115, CS-0116, CS-0117, CS-0118, CS-0119, CS-0120, CS-0121, CS-0122, CS-0123, CS-0124, CS-0125, CS-0126, CS-0127, CS-0128, CS-0129, CS-0130, CS-0131, CS-0132, CS-0133, CS-0134, CS-0135, CS-0136, CS-0137, CS-0138, CS-0139, CS-0140, CS-0141, CS-0142, CS-0143, CS-0144, CS-0145, CS-0146, CS-0147, CS-0148, CS-0149, CS-0150, CS-0151, CS-0152, CS-0153, CS-0154, CS-0155, CS-0156, CS-0157, CS-0158. Cache-trust v3: collapse the two-tier (loose-local / strict-shared) cache model to a single content-addressed key over author-declared determinants. Removes §17.1.1 tool-binary fingerprinting, the cache.tools / .cook/cloud.toml declared-toolchain mechanism, and the engine-inferred host/machine determinant; the cache key is now H(declared input contents, command/Lua chunk, declared output paths, consulted env values, declared probe values) addressing the local and shared store identically (§17.1.1). Introduces the author-control disposition surface (seal / local / pinned / record) at the §17 / App. A skeleton level; per-feature normative sections land under their own CS-IDs (§8.4.3 dispositions, §22.5.2 produce as tools|env, §6 record, §7 cook cache verify). CS-0108 gives the dispositions their sharing effect (local opt-out, pinned fetch-only); CS-0109 gives the record disposition its cache effect — keyed normally, byte-equivalence waived (§17.1.4); the verifier skips byte-checking record steps. CS-0110 persists a producer determinant manifest alongside each shared artifact, retrievable by the same key (§17.1.5). CS-0111 adds a client-side publish-off / read-only mode (--no-publish / COOK_NO_PUBLISH / [cloud] publish = false) that fetches by key but uploads nothing (§17.1.3). CS-0112 adds the read-only cook why query rendering each unit’s full attributed key and, on a shared miss, diffing the consumer’s determinants against the producer manifest (§17.1.6); CS-0113 adds the opt-in cook cache verify determinant-fidelity tool that re-runs cached steps and flags byte-divergence under a matching key, exempting record steps (§17.8). CS-0118 reconciles the nondet double-home left by the rename — one normative definition (§17.1.4) cross-referenced from §8.4.3, with “recording” named as the stored artifact a nondet unit reuses. CS-0119 adds build-owned directory outputs — a trailing-slash cook "dir/" entry that owns its whole subtree, reusing the glob terminality (§22.1.2) and post-execute resolution (§17.6) machinery plus build-owned reconciliation (strays/orphans deleted on hit and rebuild, hit==miss). CS-0120 adds §20.2 entry-point selection — upward Cookfile discovery bounded at the workspace boundary, cwd-scoped bare names, root-anchored cache identity, and the reserved // root-anchored CLI target syntax. CS-0121 extends §10.6’s name-reference edge rule to chores — a chore-body $<recipe> reference pulls the referent into the build closure (previously the path resolved per CS-0094 but the recipe never built). CS-0122 repairs probe-value consumption from native cook bodies — probe references reach register-time capture as literal $<key:...> text in the command string (never a function-valued command), and cook.add_unit rejects non-string command values (§22.5.7). CS-0123 catalogues cook.json_decode / cook.yaml_decode as both-phase API (§24.8), requires them on execute-phase worker VMs, and corrects Example 8.4.2.1’s fs.* names (fs.read/fs.write). CS-0124 graduates the CLI exit-code taxonomy to a normative §19.2 (closing App. D.11): exit 0 iff the invocation succeeded, a failing test step fails a recipe run, and cook cache verify exits non-zero on divergence. CS-0125 adds §22.5.10’s source-ref resolution rule for ingredients <probe> — a declared probe whose key equals the entire ref wins over the key:field selector split, so two-segment (ns:name) probe keys are consumable in ingredients position. CS-0126 adds the diagnostics contract: parse-time did-you-mean for config-block NAME “value”, checked-path hard errors for unresolvable placeholders, closest-match hints for undeclared $<NAME>, and traceback-free default error output. CS-0127 adds §22.1’s field-typing rule for cook.add_unit — every documented field has exactly one accepted Lua type, and a conforming implementation MUST reject a wrong-typed value with a register-phase error naming the field, rather than silently coercing it to its default. CS-0128 refines §7.1.2.2’s chore-body $<NAME> substitution to be quote-context-aware: a placeholder in an unquoted region is single-quoted for word-safety, one inside a double-quoted region expands to the value escaped for that context (so echo "hello $<who>" yields hello world, not hello 'world'), and one inside a single-quoted region is emitted verbatim — a surface-semantics change reserved-flagged for the v1.0 language cut. CS-0133 (v1.0 language cut) removes declaration-only cook steps — a body-less cook "out" (which registered no work unit and enabled the OneShot $<in> trap) is now a parse error; a cook body ({ … } / >{ … }) is mandatory (§8.4, App. A.4). CS-0134 (v1.0 language cut) makes the recipe body purely declarative and removes the >> / >>{ register sigils and the @ interactive prefix (with its interactive_command step kind) from the language entirely: a recipe body admits only ingredients / seal / cook / plate / test steps and bare module_call lines (auto-classified register-phase, reinstating the rule CS-0072 removed), rejecting loose shell, @, and standalone > / >{ / >> / >>{; the two-region ordering rule and recipe body-bundling collapse to vacuous; chores keep the imperative surface (loose shell / > / >{) and are implicitly interactive; absorbs the surface half of the recipe-body declarative-purity change (§3, §6–§8, §13, §15, §16, App. A/B/C). CS-0151 moves cc.compile_commands to a top-level finalizer-backed call (§28.3.11) — a complete-by-construction compile_commands.json rendered via cook.on_register_complete over the whole registered-target set, with the recipe-body form removed (cook_cc 0.14 hard break). CS-0154 redefines a brace-balanced block’s body as the character span between the braces — the opening-line remainder and closing-line prefix are body segments, the post-close text is the enclosing production’s trailer — and carries shell quote state across lines so multi-line quoted strings (inline JSON) parse (§3.9, App. A.5). CS-0155 makes probe-driven recipes follow the output-pattern mode rule — literal-output steps gather the preceding step’s outputs instead of member-iterating into colliding units, a literal-output first step is rejected, and $<out_N> resolves per member (§8.4.1, §22.5.10).
  • v0.14 (unreleased) — CS-0092, CS-0093, CS-0094, CS-0095, CS-0096, CS-0097, CS-0098, CS-0099, CS-0100, CS-0101, CS-0102. Native top-level probe declaration surface: probe NAME[: deps] header (implicitly register-phase, sugar over cook.probe()); optional ingredients body section (glob + !-exclude); exactly one produce block (>{ } Lua / { } shell typed by as — bare string / as lines array / as json parsed); module-prefix colon disambiguation; quoted-name escape hatch. Affects §22.5.2 (new), §4, App. A §A.3.2, App. C; §22.5 renumbered when §22.5.2 was inserted. CS-0093 adds §17.7 stale-output reconciliation — orphaned outputs are swept when a recipe’s declared output set shrinks between runs (hash-guarded so a user-modified file is kept, scoped to recipes reached this run, files only), default-on with a --no-prune / COOK_NO_PRUNE opt-out; cross-references §8.3 / §22.5.10 for the canonical for_each case. CS-0099 removes the using keyword from cook steps — the body opener ({ / >{) follows the output pattern(s) directly. CS-0100 is an implementation-conformance record for §17.4 test-unit fingerprints (no normative change). CS-0101 adds the $<file:PATH> file-reference placeholder — interpolates the resolved match list and folds each referenced file’s content into the consuming unit’s cache fingerprint (§9, §10.8, §17.1 observable 6). CS-0102 re-encodes probe values msgpack → canonical JSON (pretty, key-sorted, trailing LF), materialised at .cook/probes/<key>.json; fingerprint marker V1→V2; strings tightened to UTF-8, numbers to finite.
  • v0.13 (unreleased) — CS-0077, CS-0085, CS-0087, CS-0088, CS-0089, CS-0090, CS-0091. Unified register-phase + work-unit DAG: §13.3 (one DAG across every reachable recipe; “wave” grouping retired), §22.3 (recipe-name uniqueness within a Cookfile), §22.6 (recipe-discovery contract — surface and register-phase Lua names share one dispatch namespace). Chore parameters (§7.1.1–7.1.3, §9, §13.2, §17.5.1, App. A.3.1): required, defaulted-string, defaulted-Lua =(EXPR), and variadic +NAME/*NAME on chore headers; bind as Lua locals, $<name> placeholders, and env vars in shell children. Note 7.5.1.1 (CS-0088) clarifies that the §7.5.1 register-time parameter check applies only to chores reachable from the dispatch target. CS-0089 adds §8.4.2’s Lua-expression output form (cook (EXPR) using …); CS-0090 adds §17.1.2’s static tracking of cook.env.<KEY> reads inside using >{ … } Lua bodies to the unit’s consulted_env_keys set. CS-0091 adds §8.3’s for_each data-member iteration source (probe and $(cmd) sources, $<item> / $<item.FIELD> binding, one-to-one over data members across cook / plate / test, aggregate-over-members reserved), §22.5.10’s pre-pass evaluation + static-input rule for for_each-feeding probes, and §17.1 observable #5 (per-member materialised-element fold); it also repairs the dangling §22.5.7 lua.sigil slug.
  • v0.12 (cs-standard/v0.12, 2026-05-17) — CS-0076. cc.checks.* feature-test namespace and cc.config_header CMake-compatible substitution; cc:check:<kind>:<name>:<short-fp> joins the canonical probe-key namespace; surface §28 v0.5 → v0.6.
  • v0.11 (cs-standard/v0.11, 2026-05-15) — CS-0074. Probe units: new §22.5 cook.probe register-phase API; probes field on cook.add_unit for consumer→probe DAG edges; demand-driven scheduling (probes execute only when reached); msgpack probe-value caching with kind: "probe_value" artifact-meta tag; cook.cache.set deprecated (§24); cook.cache.get execute-phase tightened.
  • v0.10 (cs-standard/v0.10, 2026-05-13) — CS-0073. Structural redesign: Standard reorganised into four Parts (Cookfile language, Execution model, Cook Lua API, Standard module catalogue) with per-topic chapters. Retired slug prefixes grammar.*, modules.*, stdmods.*, plus several lua.* and lexical.* / recipes.* leaves; new prefixes conf.*, toplevel.*, decl.*, steps.*, phl.*, exec.*, comp.*, mods.*, cat.*, pre-v1.*, corpus.*. New normative prose for cook.step_group (§22.5), both-phase Cook Lua surfaces (§24.3–§24.7), and module lifecycle (§12.3). Annexes B/C and D/E swapped. Retired URLs resolve via page-level redirects. No normative behaviour changes for any conforming Cookfile.
  • v0.9 (cs-standard/v0.9, 2026-05-13) — CS-0072. Top-level register blocks + top-level module_call + recipe-body module_call dispatch removal. New conformance fixtures register-block-*, top-level-module-call-*, recipe-body-bare-module-call-is-shell. Breaking grammar change; pairs with cook_cc 0.4.0-1 (target-makers wrap with cook.recipe).
  • v0.8 (cs-standard/v0.8, 2026-05-07) — CS-0045, CS-0046, CS-0047, CS-0048, CS-0049, CS-0050, CS-0051, CS-0052, CS-0054, CS-0055, CS-0056, CS-0057, CS-0058, CS-0059, CS-0060, CS-0061, CS-0062, CS-0063, CS-0064, CS-0065, CS-0066
  • v0.7 (cs-standard/v0.7, 2026-05-03) — CS-0033, CS-0034, CS-0035, CS-0036, CS-0037, CS-0038, CS-0039, CS-0040, CS-0041, CS-0042, CS-0043, CS-0044
  • v0.6 (cs-standard/v0.6, 2026-05-03) — CS-0031, CS-0032
  • v0.5 (cs-standard/v0.5, 2026-05-01..2026-05-03) — CS-0022, CS-0023, CS-0024, CS-0025, CS-0026, CS-0027, CS-0028, CS-0029, CS-0030
  • v0.4 (cs-standard/v0.4, 2026-04-29) — CS-0018, CS-0019, CS-0020, CS-0021
  • v0.3 (cs-standard/v0.3, 2026-04-28) — CS-0015, CS-0016, CS-0017
  • v0.2 (cs-standard/v0.2, 2026-04-26..2026-04-28) — CS-0011, CS-0012, CS-0013, CS-0014
  • v0.1 (cs-standard/v0.1, 2026-04-22..2026-04-24) — CS-0001 through CS-0010

v0.1 [#changes.v0-1]

CS-0001 — Cook Standard v0.1 established

Date: 2026-04-22 Version: v0.1 Sections affected: entire Standard (establishment). Summary: Initial skeleton established. Chapters 0, 1 and Appendix A are fully written; chapters 2–7 ship as numbered outlines with NORMATIVE-TODO stubs referencing the Rust parser as the de-facto authority. Conformance corpus and cook-lang integration harness are wired. tree-sitter-cook is known stale and is NOT wired for conformance in this change; see CS-0002. Reference: Established in the commit that introduces docs/standard/.

CS-0002 — Planned: tree-sitter-cook conformance audit

Date: planned. Version: v0.1 Sections affected: none in the Standard itself. This entry is a forward reference to a follow-up plan that will audit tree-sitter-cook/grammar.js against the Standard and bring it into conformance, at which point a tree-sitter harness against the same corpus will be added alongside the existing cook-lang harness. Summary: Tree-sitter currently lags behind the Rust parser on several constructs: multi-output cook steps, shell-block using clauses, test steps, ingredient excludes (!"..."), Lua-code config blocks, and the module-call heuristic. The follow-up plan will: (a) enumerate divergences, (b) update grammar.js and its externals, (c) wire a tree-sitter corpus against docs/standard/conformance/, (d) verify both implementations agree. Reference: to be filled when the plan is written.

CS-0003 — § 3 Lexical structure filled

Date: 2026-04-22 Version: v0.1 Sections affected: § 3.1, § 3.2, § 3.3, § 3.4, § 3.5, § 3.6, § 3.8, § 3.9, § 3.10, App. B.2 (chapter intro plus B.2.2, B.2.3, B.2.4). Summary: Normative prose for chapter 2 filled, replacing the NORMATIVE-TODO stubs in § 3.1–2.6 and § 3.8–2.10. § 3.7 (line prefixes) was established by the PoC and is unchanged. The cascade in § 3.10 codifies the lexer’s first-match ordering with a defined “keyword separator” term, and each section grounds its non-obvious edge cases in a governing test under cli/crates/cook-lang/src/. Appendix B gains three rationale subsections covering one-token-per-line, ASCII-only identifiers, and contextual keyword reservation. Reference: this commit.

CS-0004 — § 4.1 Syntactic grammar filled

Date: 2026-04-22 Version: v0.1 Sections affected: § 4.1–3.8, App. B.3. Summary: Replaced the eight NORMATIVE-TODO stubs in § 4.1 (CS-stub-11 through CS-stub-18) with normative prose walking through the top-level grammar, top-level ordering, variable declarations, use and import declarations, config blocks, recipes (explicit and implicit headers, dependency lists, reserved-segment rule), and the step-dispatch cascade inside a recipe body. Filled App. B.3 with a chapter-level intro and three rationale subsections covering the top-level ordering rule, the column-0 requirement on implicit headers, and the recipe-body treatment of NAME "value". Reference: this commit.

CS-0005 — § 6 Recipes and step kinds filled

Date: 2026-04-22 Version: v0.1 Sections affected: § 6.1–4.11, App. B.4. Summary: Normative prose filled for the recipe body: explicit and implicit header forms with the column-0 restriction on the implicit form (§ 6.1), dependency-list semantics with parse-time/load-time split (§ 6.2), the single ingredients line with include and !"…" exclude items (§ 8.2), the seven-step-kind cascade and its dispatch priority (§ 8.3), the single-output cook step with its four forms including declaration-only (§ 8.4), the multi-output cook step with its block-only using rule (§ 8.5), the plate single-template step (§4.7, since removed), the test step with timeout and should_fail modifiers in fixed order (§ 8.6), lua_line and lua_block delimitation deferring semantics to § 10/§ 21 (§ 8.7), plain and interactive shell steps with @ as a dispatch-time marker (§ 8.8), and the module-call heuristic with single-line and multi-line brace handling (§ 4.4). App. B.4 records the rationale for rejecting cook "a" "b" using "cmd", the one-template plate shape, and the two-token module-call heuristic. Reference: this commit.

CS-0006 — § 10 Execution model filled

Date: 2026-04-22 Version: v0.1 Sections affected: § 10.1–5.8, App. B.5. Summary: Replaced the NORMATIVE-TODO stubs for § 10.1–§ 5.8 with normative prose defining the two-phase register/execute model, capture-mode purity, step groups and within-recipe parallelism, explicit and implicit cross-recipe dependencies, exact-string ingredient–serves matching, interactive-step draining, abstract cache semantics keyed on source-derived observable inputs, and syntactic-before-semantic diagnostic ordering. Added five B.5.K rationale subsections covering register-phase purity, exact string matching, @ draining, cache abstraction, and diagnostic-ordering scope. Reference: this commit.

CS-0007 — § 21 Cook Lua API filled

Date: 2026-04-22 Version: v0.1 Sections affected: § 21.1–6.7, App. B.6. Summary: Replaced the § 21 stubs with normative prose defining the Cook Lua API surface, classified by register-phase vs. execute-phase: the cook table (cook.add_unit, cook.sh, cook.exec, cook.interactive, cook.recipe), the using-block globals (input, output, inputs, outputs, input_N), the fs.* filesystem helpers, the path.* path helpers, and the shell-using-string placeholders ({in}, {out}, {all}, {stem}, {name}, {ext}, {dir}). Added App. B.6 rationale covering the phase-split design, why shell placeholders and using-block globals coexist, why fs.* and path.* are separate namespaces, why cook.add_unit is register-phase only, and why recipe-name tokens win over cook.env in the using-string fallback. Reference: this commit.

CS-0008 — § 11.1 Modules filled

Date: 2026-04-22 Version: v0.1 Sections affected: § 11.1–7.6, App. B.7. Summary: Normative prose filled for the module system: the use/import split (§ 11.1), use resolution with built-in-then-cook_modules/ ordering (§ 11.2), import path semantics (§ 11.3), the observable cook_modules/ lookup algorithm with flat-then-directory precedence (§ 11.5), the module authoring contract including the init hook (§ 7.5), and the parse-time/load-time split for duplicate-import and cycle detection (§ 7.6). App. B.7 records the rationale for the two-declaration design, built-in resolution order, duplicate-parse-time enforcement, cycle load-time enforcement, and module-name/reserved-segment non-collision. Reference: this commit.

CS-0009 — Cross-recipe references redesign

Date: 2026-04-24 Version: v0.1 Sections affected: § 6.2, § 10 (new), § 9.3, § 12.1 (new), § 11.1 (renamed), § 13 (moved from § 10). App. B.5.N/O/P (new). App. C.N (new). Summary: Introduced § 10 “Cross-recipe references” as the normative home for name-reference surface (dep-list, bare placeholder, accessor placeholder), resolution, path accessors, dep-driven iteration, string-substitution, and dependency implications. Split the former § 11.1 “Modules” into § 12.1 (use declaration, under Cook Lua API) and § 11.1 “Cross-Cookfile composition” (import and qualified references). Moved “Execution model” to § 13 so that later-chapter references do not forward-reference undefined surface. Removed the ingredient/serves byte-equality implicit-dep rule from the Execution model (old § 10.5); cross-recipe ordering is now established only by name references. Added rationale B.5.N (removal motivation), B.5.O (accessor-placeholder iteration surface vs. explicit from), and B.5.P (closed accessor set). Added a worked example under App. C. Runtime: cli/crates/cook-engine/src/analyzer.rs no longer constructs edges from ingredient/serves string equality. Reference: this commit.

D.10. CS-0010 — Slug-based cross-references.

Date: 2026-04-24 Version: v0.1 Sections affected: § 2.7 (new); all prose source files (bulk rewrite of heading markers and inline refs); scripts/slug-mapping.ts (new registry file). Summary: Cross-references in this Standard are now author-assigned stable slugs of the form chapter.leaf. Prose refs use §{chapter.leaf} in source and render as the current section number. Heading anchors use the slug directly (URLs such as /02-lexical/#lexical.identifiers). The prior positional sec-N-M-K anchor scheme is removed.

Motivation. Section renumbering previously drifted every ref pointing at the affected sections. The fix commits after the CS-0009 restructure (commits 35d46d9 and e83c490) repaired the visible drift; the underlying positional anchor scheme was the root cause. Slug-based refs decouple identity from ordering. See § 2.7 for the full chapter-prefix registry and invariants.

Impact. Prose sources changed in bulk; rendered output unchanged. External deep links using the old #sec-N-M-K anchors break — the Standard is pre-release, no external consumers were affected. A new build-time lint rejects any bare § N.M in prose to prevent regressions.

Conformance. No impact. The change is authoring-surface only.

v0.2 [#changes.v0-2]

D.11. CS-0011 — Remove top-level variable declarations.

Date: 2026-04-26 Version: v0.2 Sections affected: § 3.2, § 3.3, § 3.4, § 3.5, § 3.10 (test-cascade renumber); § 4.1, § 4.2, § 5.3, § 8.1; grammar.var-declarations (deleted); § 5.3.1 (new); § 8.3 (Note 4.4.2 deleted); §{lua.env-alias} (new); App. A.1, A.2, A.4, A.5; App. B.2.4 (rewritten), B.3.8 (deleted), B.3.9 (new).

Summary: Removes the top-level variable_declaration form (NAME "value") and its supporting machinery: the VarDecl lexer token (§ 3.2), the blocking-keyword half of the contextual reservation (§ 3.4), the line-classification cascade test for the form (§ 3.10 test 11; the former test 12 renumbers to 11), and the recipe-body reclassification rule (§ 8.1). The §3.3 prose-grammar section, the App. A.2 production, the App. A.2 “Identifiers blocked” rule, and the App. A.4 shell_command grammar comment’s parenthetical reference are all deleted; App. B.2.4 is rewritten to motivate the contextual-keyword rule purely on the reserved-recipe-segment set, and App. B.3.8 is deleted as moot.

In place of the removed surface, the Standard adds § 5.3.1 (new §3.6.1) specifying that a Cookfile MAY contain at most one unnamed config_block (the base config) and zero or more named config_blocks (the overlay configs); a conforming implementation MUST select at most one overlay at load time by an implementation-defined mechanism, MUST reject a selection that does not match any declared overlay, and MUST execute base before overlay against shared Cook Lua API state with last-write-wins semantics on overlay writes. Both bodies execute during the load phase, after use resolution and before recipe registration. §{lua.env-alias} (new §6.1.2) binds the bare global env to cook.env inside config_block bodies only, bringing the env.X = "..." usage already shown in examples into normative conformance. App. B.3.9 (new) records the design rationale for consolidating variables into config blocks, the single-overlay choice over multi-select, and the implementation-defined selection mechanism.

Implementation status. This change is spec-only. The Rust parser (cli/crates/cook-lang) and tree-sitter-cook continue to accept the old form; bringing them into conformance with the Standard at this version is the subject of a follow-up CS that will also add the §3.6.1 conformance fixtures (base-only, base + overlay last-write-wins, overlay-only, missing-overlay-is-error, base-runs-before-overlay).

Reference: this commit.

D.12. CS-0012 — Cook Standard versioning, App. D reshape, conformance-claim convention.

Date: 2026-04-26 Version: v0.2 Sections affected: § 0.5 (rewritten); § 1.1 (amended); App. B.0.1 (new); App. D (new Versions index, per-CS **Version:** lines, this entry); standard/VERSION (new); standard/src/content/docs/index.mdx (Status header now reads from VERSION); standard/scripts/slug-mapping.ts (new slug rationale.versioning-pre-1-0); standard/README.md (cross-link to the cut procedure); CONTRIBUTING.md (two new subsections: cut procedure and implementation conformance claims).

Summary: Adopts MAJOR.MINOR versioning for the pre-1.0 era. A cut (= MINOR bump + App. D Versions index entry + cs-standard/vX.Y git tag, all in one commit on main) MAY batch one or more CS entries; CSes per cut and version cadence are independent. No PATCH track pre-1.0. The transition to strict SemVer at 1.0 is deferred.

standard/VERSION becomes the canonical machine-readable version source; the docs index index.mdx imports it via Vite’s ?raw and renders v{VERSION} in the page header. § 1.1 is amended to define what “conforms to Cook Standard v0.X” means (the implementation satisfies the existing three points against the prose and corpus of the cs-standard/v0.X tag); the mechanism by which an implementation claims a version is implementation-defined and not normatively required.

App. D is restructured: a top-of-page Versions index lists each cut and the CSes it covers; every CS body now carries a **Version:** line between **Date** and **Sections affected**. CS-0001 through CS-0010 are grouped under v0.1; CS-0011 and CS-0012 are grouped under v0.2.

App. B.0.1 (new) records the rationale for MAJOR.MINOR over strict SemVer pre-1.0, batched cuts, convention-only conformance claims, and git-tagged corpus. CONTRIBUTING.md gains the procedural recipe for cutting a version and the project convention by which an implementation claims a Standard version.

Implementation status. This change is authoring-surface only. No conformance-corpus changes. Two cs-standard/vX.Y tags are cut as a manual post-merge step (see the design’s §4 for the exact git merge-base and git tag invocations).

Reference: this commit.

D.13. CS-0013 — App. E pre-1.0 checklist (informative).

Date: 2026-04-28 Version: v0.2 Sections affected: App. E (new informative appendix); App. D (this entry).

Summary: Adds App. E to track language-level frictions and design tensions known to need resolution before 1.0. Initial entries: (E.1) the conformance corpus’s parse.txt files are coupled to one implementation’s AST dump format, so historical-tag corpora break when the dump format changes; (E.2) cook’s templater inside using and plate strings interprets ${...} as its own env-lookup form, which collides with bash’s ${VAR:-default} parameter-expansion vocabulary; (E.3) cook string literals do not recognize \" as an escape, so embedded double quotes silently truncate the string; (E.4) cross-references CS-0002 for the standing tree-sitter staleness item; (E.5) explicit end keyword is ceremony for what the next top-level construct could imply; (E.6) the implicit name: deps recipe header form has no in-repo usage and creates a silent typo vector; (E.7) chore as a sibling of recipe for the command-runner shape, with default-interactive shell and a parse-time ban on ingredients/cook/plate/test.

App. E is informative — no normative obligations on implementations. Each entry pairs the current behaviour with the workaround (for active frictions) or the possible resolution (for design tensions). None of the listed items is blocking pre-1.0 work; the appendix exists as a single canonical location so they aren’t relitigated each time they’re encountered.

Implementation status. No conformance-corpus changes. No parser changes. The cs-standard/v0.2 tag is not moved (E.1’s coupling does not apply to this change — it’s appendix prose only).

Reference: this commit.

D.14. CS-0014 — tree-sitter-cook v0.2 conformance audit.

Date: 2026-04-28 Version: v0.2 Sections affected: none in the Standard itself. App. D (this entry); CS-0002 is now closed by this CS.

Summary: Brings tree-sitter-cook/grammar.js and its external scanner into syntactic conformance with Cook Standard v0.2 (App. A), addressing the divergences catalogued in CS-0002.

Grammar changes: removes the variable_declaration rule (per CS-0011); adds the module_call step kind per App. A.4 (line whose first token is BARE_IDENTIFIER ”.” [A-Za-z_]…); adds the ingredient_exclude form to ingredients_step (!"…"); restructures cook_step so the multi-output form takes a block_using_clause only (rejecting cook "a" "b" using STRING per the App. A.4 normative rule). The external scanner’s scan_shell_content now defers any IDENT.IDENT_START… line at the start of a recipe body to the grammar’s module_call rule.

A new conformance harness at tree-sitter-cook/scripts/conformance.mjs walks standard/conformance/{positive,negative} and asserts every positive parses cleanly while every syntactic negative produces an ERROR/MISSING node. Three negatives encode rules that the Standard places on the parser but that tree-sitter cannot enforce from the grammar alone (top-level ordering, duplicate ingredients, accessor placeholders inside using strings); the harness records them as accepted-with-note rather than failing. Run with node scripts/conformance.mjs from tree-sitter-cook/, or npm run conformance.

tree-sitter-cook claims Cook Standard v0.2: a header comment in grammar.js, the version and description fields in package.json and tree-sitter.json carry the claim. CS-0002’s “tree-sitter-cook is known stale” note is superseded by this entry.

Implementation status. Implementation-only change. The Rust parser’s behaviour is unchanged. No prose changes to the Standard, no corpus additions or removals.

Reference: this commit.

v0.3 [#changes.v0-3]

D.15. CS-0015 — Recipe-body phase split (> execute / >> register / module-call register), region rule, body-unit bundling.

Date: 2026-04-28 Version: v0.3 Sections affected: § 3.7 (rewritten); § 3.10 (cascade renumbered, >>{ and >> tests added, triple-arrow reservation); § 8.1 (token classes + region cross-link); § 8.3 (new step kinds, Note 4.4.2 region rule); § 8.7 (rewritten — > />{/>>/>>{ family, body bundling § 15.2); § 4.4 (desugaring target updated); § 24.1 (promoted to both phases); § 22.2 (execute-phase ban added); § 13.1 (Example 8.2.1 rewritten to >>); § 13.2 (new normative table); § 15.1 (surface-construct table rewritten); App. A.3 (recipe_body split into declarative_region and imperative_region, “Region ordering rule” added); App. A.4 (inline_lua_line and inline_lua_block productions added; inline_lua_block of using-clause renamed to using_lua_block; using >>{ rejected); App. A.5 line prefixes; App. B.4.8–B.4.12 (new rationale subsections); App. B.4.13 (renumbered from B.4.11).

Summary: Splits the Cookfile’s Lua surface into two phase-explicit families:

  • Single-> family (> body, >{ … }) is execute-phase. Lines and blocks compile into a body unit (§ 15.2) whose lua_code payload runs on a worker VM at execute, after the recipe’s declarative work units complete. This is the “runs when the recipe runs” form.
  • Doubled-> family (>> body, >>{ … }) is register-phase. Lines and blocks are inlined into the recipe-body Lua function and evaluated during recipe registration. This is the surface form a Cookfile uses to call register-only Cook Lua API helpers (cook.add_unit, cook.export, cook.step_group) and to declare register-phase locals consumed by subsequent declarative steps.
  • module_call (e.g., m.fn(…)) desugars to inline_lua_line (single-line) or inline_lua_block (multi-line) per §4.11 — register-phase, matching the doubled-prefix family.

A recipe-body region rule (Note 4.4.2 / App. A.3) partitions every recipe_body into a declarative region followed by an imperative region. Declarative-region steps (ingredients, cook, plate, test, inline_lua_line, inline_lua_block, module_call) MUST appear before imperative-region steps (lua_line, lua_block, shell_command, interactive_command). A conforming implementation MUST reject a recipe in which a declarative-region step appears after the first imperative-region step. The diagnostic identifies the offending step’s line and the line on which the imperative region began.

A body-bundling rule (§ 15.2) coalesces consecutive imperative-region steps into one execute-phase work unit per @interactive-bounded run. Inside the body unit, adjacent shell_command lines coalesce into a single cook.sh call (newline-joined, set -e prefixed) so that a cd followed by a ./app runs in one shell process. The Lua VM persists across the whole bundle, so a local declared in one > line is visible to the next. @interactive steps remain their own draining units (§ 16.2) and break the bundle.

cook.sh is promoted to both phases (§ 24.1). cook.exec, cook.interactive, cook.add_unit, cook.step_group, and cook.recipe remain register-only and MUST raise a Lua runtime error when called from execute-phase Lua. A new normative phase-classification table at § 13.2 records every place a Lua expression can appear and the phase that evaluates it.

App. A.4 introduces inline_lua_line ::= ">>" LUA_LINE_CONTENT? NEWLINE and inline_lua_block ::= ">>{" LUA_BLOCK_CONTENT "}" NEWLINE. The grammar production formerly named inline_lua_block (the >{ … } form inside a using clause) is renamed to using_lua_block to disambiguate from the new step kind. App. A.4 also adds the rule “using >>{ is rejected”: a register-phase Lua block in a using-clause position is incoherent and a conforming implementation MUST reject it. § 3.7 adds two new prefixes (>>{, >>) and the § 3.10 cascade gains corresponding tests in longest-prefix order, plus a parse-time rejection for runs of three or more > characters at line start.

App. B.4.8–B.4.12 record the rationale: why > is execute and >> is register (B.4.8), why module_call defaults to register-phase (B.4.9), why the recipe body splits into declarative and imperative regions (B.4.10), why bundling is per-recipe rather than per-line (B.4.11), and why register-only Cook Lua API helpers raise from execute-phase Lua (B.4.12). The previous B.4.11 (module-call heuristic) is renumbered to B.4.13.

Implementation status. Lockstep — the Rust parser, codegen, and tree-sitter grammar update in this same change. New conformance fixtures land under § 1.1: positive 016 (>> and >>{), updated 008 and 010; negative 008 (imperative-then-declarative), 009 (using-register-block), 010 (triple-arrow reservation).

Reference: this commit.

D.16. CS-0016 — Standard cut: v0.3.

Date: 2026-04-28 Version: v0.3 Sections affected: standard/VERSION (0.2 → 0.3); App. D Versions index (new v0.3 entry); this entry.

Summary: Cuts the Cook Standard at v0.3. Per the cut procedure in CONTRIBUTING.md (and § 0.5), this entry plus the v0.3 row in the Versions index plus a cs-standard/v0.3 git tag at the merge commit constitute the published cut. v0.3 batches CS-0015, the recipe-body phase split that adopts the > />> /module-call surface, the region rule, the body-unit bundling rule, the § 13.2 table, and the lockstep Rust + tree-sitter implementation that follows it.

The conformance corpus’s parse.txt files change format in this cut: Step::Lua and Step::LuaBlock continue to be emitted by the existing dump, but module-call lines now dump as InlineLua / InlineLuaBlock rather than Lua / LuaBlock, and two new dump lines (InlineLua code=…, InlineLuaBlock code=…) appear for >>-prefixed steps. Per CONTRIBUTING.md’s rule on parse-format-altering cuts, the cs-standard/v0.3 tag is force-moved to the merge commit where the parser and the corpus agree. cs-standard/v0.2 and cs-standard/v0.1 are left in place as historical records; the backwards-conformance recipe (cook --set VERSION=0.2 standard.against-tag) does not pass against v0.2 post-CS-0015.

Implementation status. Authoring + tagging step. No prose changes beyond the Versions index and this entry.

Reference: this commit.

D.17. CS-0017 — use aliases visible to execute-phase Lua; cook.load_module two-phase.

Date: 2026-04-28 Version: v0.3 Sections affected: § 24.2 (new §6.3.4); § 12.2 (new §7.4 with the existing §7.4 renumbered to §7.5); § 12.1 (clarification on dual-phase binding); App. B (rationale subsection on lexical scoping); positive conformance fixture 017-use-from-execute (new).

Summary: Extends the use declaration so the alias it introduces is in scope for both phases of every recipe in the declaring Cookfile, not register-only. The codegen (cli/crates/cook-luagen/src/recipe.rs) prepends a local <alias> = cook.load_module("<name>") line per use to every execute-phase body unit’s lua_code payload, so > alias.fn(...) calls the same module function as alias.fn(...) in declarative position — the only difference is the phase it runs in.

cook.load_module is promoted from register-phase to both phases (§6.3.4 new). The execute-phase variant lives on the worker VM (cli/crates/cook-luaotp/src/pool.rs) and uses the same cook_modules/<name>.lua / cook_modules/<name>/init.lua lookup as its register counterpart, resolved against the calling work unit’s current_working_dir. Loaded modules are cached per-VM by (working_dir, name) so a worker thread that handles units from two different Cookfiles does not serve one Cookfile’s foo to the other. The init() hook (§7.5) runs at first load on each (worker VM, working_dir, name) tuple.

The worker VM also refreshes package.path per work unit to <working_dir>/cook_modules/?.lua;<working_dir>/cook_modules/?/init.lua;<defaults>, so require("foo") is a working synonym for cook.load_module("foo") on the worker side.

§7.4 (new) records the lexical-per-Cookfile scoping rule: a use declaration’s alias is scoped to its declaring Cookfile and is not visible across import boundaries; cross-Cookfile composition stays at the recipe level via qualified name references (§ 11.3), not at the module level. Two Cookfiles that both use a module with the same name resolve to their own cook_modules/ independently; the worker-VM cache key is (working_dir, name) so the two loads do not collide.

A module function loaded into the execute-phase VM that calls register-only API (cook.add_unit, cook.exec, cook.interactive, cook.step_group, cook.recipe) raises the § C.6.12 error. Module authors document per-function which phase a function targets; both-phase functions restrict themselves to cook.sh, cook.env, cook.cache, cook.platform, fs.*, and path.*.

Why: Before CS-0017, the only way to invoke a use-d module function from imperative-region Lua was to (a) re-resolve it manually via cook.load_module(...) on the worker VM (which wasn’t even installed there) or (b) wrap it in a register-phase call that records a unit. Both were verbose and asymmetric. CS-0017 collapses the asymmetry: use means “bring this alias into scope,” period. Whether a given function call lands in the declarative or imperative region is decided by the recipe-body region rule (§ 8.3, Note 4.4.2), and the alias is bound either way.

Implementation status. Lockstep — codegen, worker VM, and spec land together. New positive conformance fixture (017-use-from-execute) pins the AST shape; the codegen-side and runtime-side checks are in cook-luagen/tests/conformance.rs and the runnable demo in examples/v03-phase-split/Cookfile (recipes module-call-execute and module-via-require).

Reference: this commit.

v0.4 [#changes.v0-4]

D.18. CS-0018 — Drop the name: recipe header form.

Date: 2026-04-29 Version: v0.4 Sections affected: § 3.1 (§2.1); § 3.10 (test 12 deleted, test 13 renumbered to 12); § 6.1 (two-alternative recipe_header collapsed to one); § 6.1 (§4.1 rewritten: table and two-form prose removed, Note 4.1.2 removed); § 6.2 (Note 4.2.1 deleted); App. A.3 (EBNF: explicit | bare-colon alternatives collapsed to single production; “Implicit header constraint” deleted); App. B.3.7 (deleted — rationale for the column-0 anchor no longer applies); App. E.6 (resolved).

Summary: Removes the BARE_IDENTIFIER ":" dependency_list? recipe header form (previously called the “implicit” form). The recipe_header production is now a single alternative: "recipe" name (":" dependency_list)?. The form had zero in-repo usage, created a silent typo vector (an accidental name: value line at column 0 silently parsed as a recipe named name with dep value), and the only published rationale (App. B.3.7) was defensive — it justified only the column-0 anchor that made the form less dangerous, not the form’s existence. Removing the form eliminates the anchor rule, the “Implicit header constraint” in App. A.3, and the B.3.7 rationale subsection. The lexer’s line-classification cascade loses the column-0 BARE_IDENTIFIER ":" test and renumbers.

The parser-side change (delete the implicit-form lex branch and six pinned parser tests, add a negative conformance fixture under standard/conformance/negative/) lands as Task 2 of the same v0.4 plan.

Implementation status. Spec-side only in this commit. Parser-side in Task 2 of the same plan.

Reference: this commit.

D.19. CS-0019 — Drop end; implicit termination by next top-level keyword or EOF.

Date: 2026-04-29 Version: v0.4 Sections affected: § 3.2 (Token table — RecipeEnd removed); § 3.4 (column-0 enforcement on top-level keywords; end removed from the keyword list); § 3.10 (cascade test for bare end removed); § 5.3 (closing end requirement removed; config_block form definition updated); § 6.1 (closing end requirement removed; recipe form definition updated); § 4.3 (new §4.1.1; slug new); App. A.2 config_block production (drop "end" NEWLINE); App. A.3 recipe production (drop "end" NEWLINE?); App. A.5 LUA_SOURCE definition (rephrased); App. B.3.10 (new — implicit-termination rationale, slug new).

Summary: A recipe_body and config_body extend from the line after the introducing header up to (but not including) the next column-0 line whose first whitespace-separated token is recipe, config, use, or import, or end-of-file (whichever comes first). The closing end keyword is removed entirely from the language. A bare end line is now a Token::Content: at top level it is rejected (§ 4.1); inside a recipe body it dispatches as a shell_command running the literal command end. The lexer is tightened to require column 0 for the four top-level keywords; an indented recipe foo line inside a recipe body is no longer misclassified as a header.

Why: end was ceremony — every author wrote it without thinking, and the parser had no need for it that the next top-level construct or EOF could not serve. App. B.3.10 records the full rationale.

Implementation status. Spec-side only in this commit. The conformance-corpus migration (strip trailing end from every Cookfile fixture), the Rust parser change (drop Token::RecipeEnd, column-0 enforcement, body-by-implicit-termination), and the tree-sitter grammar update (drop "end" from recipe and config_block rules) are Tasks 5–8 of the v0.4 plan and land in subsequent commits.

Reference: this commit.

D.20. CS-0020 — Add chore as a sibling of recipe.

Date: 2026-04-29 Version: v0.4 Sections affected: § 7 (new chapter §4a, slug new); § 7.1 / § 7.2 / § 7.3 / § 7.4 / § 7.5 (new); § 3.4 (chore added to top-level keyword set, column-0 enforced); § 3.10 (new cascade test for chore keyword, test count 11 → 12); § 4.1 and § 4.2 (acknowledge chore as peer of recipe); App. A.1 toplevel_item alternation (gain chore); App. A.3.1 (new chore production with smaller body alternation, three normative paragraphs: step-kind ban, default-interactive, no-caching); App. B.4.14/15/16 (new — sibling-not-flag, why-the-four-bans, why-default-interactive).

Summary: A chore is a top-level form parallel to recipe for command-runner-shaped bodies. A chore body MAY contain inline_lua_line, inline_lua_block, lua_line, lua_block, module_call, interactive_command, and shell_command, but MUST NOT contain ingredients_step, cook_step, plate_step, or test_step. Every shell_command in a chore body is treated as interactive (default-interactive); the @ marker is preserved as a no-op for symmetry with recipe bodies. A unit registered from inside a chore body MUST set cache = false; the runtime raises if a register-phase call would record cache = true from a chore context.

The chore-vs-recipe distinction is purely an authoring/static-checking layer over the existing engine. A chore compiles to register-phase Lua identical in shape to a recipe with no cook step and interactive = true on every shell unit. App. B.4.14–4.16 record the rationale: keyword-as-intent-carrier, naturally-grammatical step-kind ban, peer-keyword reflects peer-semantics.

Implementation status. Spec-side only in this commit. The Rust parser (Token::ChoreHeader, Chore AST struct, parse_chore), codegen + runtime (chore-as-recipe-with-default-interactive + cache=false ban), tree-sitter grammar (chore rule with _chore_item alternation), and 6 conformance fixtures (3 positive, 3 negative) land in Tasks 10–13 of the v0.4 plan.

Reference: this commit.

D.21. CS-0021 — Standard cut: v0.4.

Date: 2026-04-29 Version: v0.4 Sections affected: standard/VERSION (0.3 → 0.4); App. D Versions index (new v0.4 entry); this entry. App. E entries E.5, E.6, E.7 marked resolved with cross-links to CS-0019, CS-0018, CS-0020 respectively.

Summary: Cuts the Cook Standard at v0.4. Per the cut procedure in CONTRIBUTING.md (and § 0.5), this entry plus the v0.4 row in the Versions index plus a cs-standard/v0.4 git tag at the merge commit constitute the published cut. v0.4 batches CS-0018, CS-0019, and CS-0020 — the three pre-1.0 checklist items from App. E that compose into “recipe-form simplification + chore”. E.5 (drop end), E.6 (drop implicit header), E.7 (chore as sibling) all close.

The conformance corpus’s parse.txt files change format in this cut: format_cookfile adds a chores: section per Cookfile, so every existing positive fixture’s parse.txt gained a chores: line in CS-0020. Per CONTRIBUTING.md’s rule on parse-format-altering cuts, the cs-standard/v0.4 tag is force-moved to the merge commit where the parser and the corpus agree. cs-standard/v0.3 and earlier tags are left in place as historical records.

Implementation status. Authoring + tagging step. No prose changes beyond the Versions index, this entry, and the App. E resolution markers.

Reference: this commit.

v0.5 [#changes.v0-5]

D.22. CS-0022 — Cook-step iteration unification: output-pattern as sole driver, using "cmd" removed, {in.ACCESSOR} family.

Date: 2026-04-30 Version: v0.5 Sections affected: § 8.4 (table reduced to three forms); new § 8.4.1; § 8.5 (multi-output rule simplified); § 10.5 (surface list extended to shell_block); § 9.3 (rewritten — placeholder vocabulary, no iteration logic); § A.4 (“using dispatch order”, “Multi-output cook-step rule”, “Iteration coherence”); App. B.6.1 (wording), B.6.5–B.6.8 (new rationale); App. E.2 and E.3 marked substantially / partially resolved with cross-links to this entry.

Summary: The cook step’s iteration mode (one-to-one over own inputs, one-to-one over dep outputs, or many-to-one) is decided exclusively by the output pattern list. The using-clause’s body ceases to be an iteration trigger. The using "cmd" string form is removed; using { cmd } (a one-line shell_block) replaces it. The bare path-accessors {stem}, {name}, {ext}, {dir} are removed from the placeholder vocabulary in favour of the dotted family {in.ACCESSOR}, {out.ACCESSOR}, {out_N.ACCESSOR}. Multi-output indexing uses {out_N} (1-indexed). Cross-recipe references (§ 10.5) extend to shell_block content; using_lua_block continues to use cook.dep_output(NAME) per B.6.1. The § 10.4 firewall rejecting {lib.ACCESSOR} in non-driving using-clause bodies is preserved.

Why: The pre-CS-0022 surface had two non-adjacent normative sources for iteration mode (one in §6.7, one in §5.4) that overlapped, with a silent footgun when an author put {in} in a using-string while the output pattern was literal — successive iterations clobbered the same output. The string form using "cmd" and the block form using { cmd } expressed the same idea through two surfaces, and the placeholder layer was specified for the former only. Bare path-accessors and dotted accessors meant the same thing in different positions. CS-0022 collapses the surface: one rule for iteration (the output pattern), one shell surface (the block), one accessor spelling (dotted).

Conformance impact. parse.txt files in standard/conformance/ change format only at the AST level: Step::Cook units with a using-clause now dump the inner shape as ShellBlock([...]) or LuaBlock(...) exclusively (the former Shell("...") arm is gone). Per CONTRIBUTING.md’s rule on parse-format-altering changes, the implementation lands lockstep with the corpus migration in this CS — the corpus and the parser agree at the merge commit. The cs-standard/v0.5 tag is the merge commit where parser and corpus agree.

Implementation status. Lockstep — Standard, parser (cli/crates/cook-lang), codegen (cli/crates/cook-luagen), tree-sitter grammar (tree-sitter-cook/), conformance corpus, and the project’s own Cookfiles all moved together. Spans 25 commits across 8 phases (spec, parser AST, codegen, tree-sitter, conformance, repo migration, cleanup, plus an cli/e2e-fixtures/iteration_benchmarks integration artifact and four codegen regressions surfaced + fixed during example development). Final test matrix green: cargo test --workspace, pnpm test in standard/, npm test + node scripts/conformance.mjs in tree-sitter-cook/.

Reference: this commit (and the merge of branch cs-0022-iteration-unification).

D.23. CS-0023 — Standard cut: v0.5.

Date: 2026-04-30 (originally cut); amended 2026-05-01 to fold CS-0024 into the v0.5 cut. Version: v0.5 Sections affected: standard/VERSION (0.4 → 0.5); App. D Versions index (new v0.5 entry); this entry. App. E entries E.2 and E.3 carry CS-0022 and CS-0024 cross-references (CS-0022 landed in the CS-0022 commit series; CS-0024 landed in the CS-0024 commit series and was folded into the v0.5 cut by this amendment).

Summary: Cuts the Cook Standard at v0.5. Per the cut procedure in CONTRIBUTING.md (and § 0.5), this entry plus the v0.5 row in the Versions index plus a cs-standard/v0.5 git tag at the merge commit constitute the published cut. v0.5 ships CS-0022 (cook-step iteration unification) and CS-0024 (plate/test step unification — block bodies, three iteration modes, {out} / {lib.ACCESSOR} firewall). The two changes are paired: CS-0022 introduced the block-body surface and the placeholder vocabulary for cook; CS-0024 extended the same surface to the remaining declarative-region step kinds (plate, test). v0.5 was originally cut on 2026-04-30 with CS-0022 alone; the amendment on 2026-05-01 folded CS-0024 in and moved the cs-standard/v0.5 tag forward to the merge commit of the CS-0024 series. Together these changes warrant a minor bump: they remove the using "cmd" form, remove the plate "STRING" / test "STRING" forms, remove bare path-accessors, make the cook step’s output pattern the sole iteration driver for cook, deduce iteration mode from body content for plate/test, and change load-time semantics for several surfaces that were silently buggy in v0.4 and earlier.

The conformance corpus’s parse.txt files change format in this cut: Step::Cook units with a using-clause dump the inner shape as ShellBlock([...]) or LuaBlock(...) exclusively (the former Shell("...") arm is removed in CS-0022); Step::Plate and Step::Test carry a body=ShellBlock(...) or body=LuaBlock(...) field exclusively (the former command=String field is removed in CS-0024). Per CONTRIBUTING.md’s rule on parse-format-altering cuts, the cs-standard/v0.5 tag points at the merge commit where the parser and the corpus agree post-CS-0024. cs-standard/v0.4 and earlier tags are left in place as historical records; the backwards-conformance recipe (cook --set VERSION=0.4 standard.against-tag) is not expected to pass against v0.4 post-CS-0022 or post-CS-0024.

Implementation status. Authoring + tagging step. No prose changes beyond standard/VERSION, the Versions index, this entry, and the metadata bump on D.24’s **Version:** line. The amended cut moves the cs-standard/v0.5 tag from the original CS-0023 commit to the amendment commit.

Reference: this commit (amended cut).

D.24. CS-0024 — Plate/test step unification: block bodies, three iteration modes, {out}/{lib.ACCESSOR} firewall.

Date: 2026-05-01 Version: v0.5 (folded into the v0.5 cut by the CS-0023 amendment of the same date — see D.23) Sections affected: the plate step (§4.7 rewritten; §4.7.1 new — iteration-mode deduction); § 8.6 (§4.8 rewritten); § 8.6.1 (§4.7.1); § 23.2 (§6.4.1 new — plate/test binding table); § 9.4 (§6.7.1 new — plate/test placeholder table); § 13.2 (§8.1.2 new rows); § 10.4 (§5.4 firewall extended to plate/test bodies); § 10.5 (§5.5 extended to plate/test shell blocks); App. A.4 (productions for plate_step, test_step, body, test_modifiers; normative paragraphs “Plate/test mode coherence” and “Plate/test source presence”); App. B.4.7 (deleted); App. B.4.17–B.4.21 (new rationale subsections).

Summary: Plate and test steps adopt the full block-body surface introduced for cook steps in CS-0022. The old plate STRING and test STRING forms (accepting only a literal command template) are replaced with plate body and test body test_modifiers, where body ::= shell_block | using_lua_block. Iteration mode for plate and test is deduced from the body’s placeholder/identifier content at register time: {in} / {in.ACCESSOR} → one-to-one; {all} (shell) / inputs (Lua) → many-to-one (batched); neither → one-shot. The {out} placeholder is rejected in plate and test bodies (no declared output), as is {lib.ACCESSOR} (no dep-driven iteration for steps without output patterns). The static Lua scan distinguishes input (one-to-one) from inputs (many-to-one), ignoring the identifier in string/comment/long-string positions.

Why: Before CS-0024, plate was restricted to a single command template and test to a single template plus modifiers. Any plate or test that needed more than one shell line required a workaround (a Lua block in a separate cook step, or a wrapper script). CS-0022 gave cook steps full block bodies; CS-0024 extends the same surface uniformly to plate and test. The motivating example — plate >{ for _, f in ipairs(inputs) do … end } — requires the using-lua-block surface and many-to-one mode, both of which were unavailable. The surface unification also closes a naming inconsistency: pre-CS-0024 plate used {out} to mean “the iteration item,” while {in} means exactly that everywhere else.

Implementation status. Spec + reference implementation + tree-sitter + conformance corpus + in-repo Cookfile migration + iteration_benchmarks coverage all landed in the CS-0024 commit series (0f525f0 through 1127ba3).

Reference: the CS-0024 commit series, folded into the v0.5 cut by the CS-0023 amendment commit.

D.25. CS-0025 — Cache observables: machine identity, tool-binary content, env denylist.

Date: 2026-05-01

Version: v0.5

Sections affected: § 17 ¶3 (additive); App. B (rationale paragraph after §B-225, “Cross-machine cache correctness”).

Summary: Extends the § 17 list of permissible cache observables with (i) host machine identity (target triple, libc version, locale-affecting environment), (ii) the resolved content of the binaries invoked by the unit’s command text, and (iii) an implementation-defined denylist of environment variables excluded from the resolved-environment observable. All three are permissive (MAY); a single-machine implementation that elides them remains conforming.

Motivation. The shared/team cache (Cook Cloud) requires that an artifact produced on one machine be correct when served to another. Today’s command_hash = hash_str(command_text) plus input-content hashing is insufficient: Alice’s gcc-13 parser.o would be served to Bob’s gcc-11 build under the same command text. The amendment authorizes implementations to capture the cross-machine variation without changing language semantics. The denylist clarifies that “the resolved environment” was never intended to include ambient session state or unrelated credentials.

D.26. CS-0026 — Parse-time enforcement of §7.2 import path shapes.

Date: 2026-05-02

Version: v0.5 (implementation catch-up for the §7.2 prose already present since the cross-Cookfile composition chapter)

Sections affected: § 11.2 (§7.2 — normative rules on tree-relative and sigil-anchored path shapes were already present; this entry records the reference implementation catching up to those rules).

Summary: The cook-lang parser now enforces the three path-shape constraints from §7.2 at parse time, in addition to the lexer accepting any non-whitespace token as an import path. Specifically: (1) a tree-relative path containing a .. segment MUST be rejected with the normative diagnostic import path '<path>': '..' segments are not permitted; use the workspace-root sigil '//path' for cross-cutting imports; (2) an absolute path that does not begin with // MUST be rejected with import path '<path>': absolute paths are not permitted; tree-relative or '//' sigil; (3) a sigil-anchored path (//-prefixed) with a .. segment after the sigil MUST be rejected with import path '<path>': '..' segments are not permitted after '//'. Internally, the parser now classifies valid paths into ImportPath::Tree(String) or ImportPath::Sigil(String) (the stored string for Sigil is the path after the // prefix). The lexer continues to accept any non-whitespace token for the path field; rejection is a parser-level concern.

Reference: the Phase 1 commit of the cross-Cookfile caching implementation plan (Tasks 1.1–1.3).

D.27. CS-0027 — Inferred-deps unification across CLI commands; cmd_dag workspace mode.

Date: 2026-05-02

Version: v0.5 (implementation-only; closes App. E.10 — the § 10.6 contract previously held only under cmd_run).

Sections affected: none in the Standard surface. Closes App. E.10.

Summary. Pre-CS-0027, the reference implementation realized the § 10.6 contract — “a name reference to recipe B in recipe A’s body or dep-list establishes a dep edge from A to B” — only when the build was driven by cmd_run (cook NAME). cmd_test and cmd_dag either passed &BTreeMap::new() to the wave grouper (cmd_test, both single-Cookfile and workspace branches) or had no workspace branch at all (cmd_dag). The asymmetry made an otherwise-conformant Cookfile fail under one CLI invocation and succeed under another, with the diagnostic surfacing as a register-time cook.dep_output("NAME") error (“recipe ‘NAME’ has no terminal output (not registered or has no cook steps)”) rather than a clean cross-recipe-ordering message.

CS-0027 unifies the path. The single-Cookfile {NAME} body-ref-extraction loop previously inlined in cmd_run is hoisted to a compute_single_inferred_deps helper; the workspace counterpart (compute_workspace_inferred_deps) was already in place from the cross-Cookfile-caching work. Both helpers are now invoked at every CLI entry that hands recipes off to cook_engine::run::run: cmd_run, cmd_test, and cmd_dag. The cmd_dag workspace branch — previously absent — mirrors cmd_run’s workspace setup (workspace recipe-info, prefix-keyed registry map, qualified-consumer/qualified-dep edge map) and routes each ready recipe through its prefix’s registry by reusing the (prefix, local_name) split that cook_engine::run::run already performs internally. Conflict-warning emission (recipe X has both explicit ': dep' and inferred '\{dep\}' dependency) is also factored into warn_single_dep_conflicts and warn_workspace_dep_conflicts and called from each CLI path.

Why: The asymmetry was discoverable only by writing a Cookfile whose test body referenced a cook-step recipe via {NAME} and invoking cook --test rather than cook NAME. Authors who had been driving builds through cmd_run would conform to § 10.6 by accident; the moment they switched to cook --test for CI, the same Cookfile would emit a register-time error from a CLI command that has no language-level reason to behave differently. Pre-1.0 stability requires that the contract hold uniformly across the CLI surface, since the language spec speaks only of recipes and dependencies, not of which cook subcommand is in use.

Implementation notes. Each of the six CLI call sites — cmd_run, cmd_test, and cmd_dag in both single-Cookfile and workspace branches — now reads from the same two helpers in cli/crates/cook-cli/src/pipeline.rs. cmd_dag’s register-only loop is factored into collect_dag_units, mirroring the per-wave dispatch in cook_engine::run::run (run.rs:170+) but stopping short of work-unit DAG construction; the dag visualizer only needs the registered units, the explicit edge map, and the inferred-deps map. Two unit tests in pipeline.rs (single_inferred_deps_body_ref_produces_edge, single_inferred_deps_empty_when_no_body_refs) join the existing workspace_inferred_deps_* cases to pin both helpers under regression. The examples/cmd_test_inferred_deps_e10/ fixture is the end-to-end pin: a two-recipe Cookfile (prepare cook + verify_prepare test referencing {prepare}) plus a verify.sh that asserts cook --test --jobs 1 does not surface the “no terminal output” error and that verify_prepare reaches done.

Residual scope (out of E.10). The add_test API does not yet propagate step_group_dep_refs into unit-DAG dep_edges, so a test recipe whose body refs a sibling recipe will race that sibling under --jobs > 1. The wave grouper’s build_inferred_groups performs bidirectional BFS over inferred edges, which can pull a reverse-inferred-edge recipe (e.g., a verify_X whose test body refs {X}) into a cook X build. Both are pre-existing engine concerns surfaced — but not introduced — by CS-0027; neither falls inside § 10.6‘s “A → B” wording, and both are tracked separately rather than rolled into this CS.

Conformance impact. None at the parser/luagen layer (no AST or dump-format changes). The examples/cmd_test_inferred_deps_e10/verify.sh harness pins the runtime contract for cmd_test; an analogous runtime fixture for cmd_dag is not yet in tree because cmd_dag blocks on an HTTP server (a non-blocking --dag-emit-json mode would let it slot into the same harness; tracked as future work).

Reference: the CS-0027 commit on the feature/cross-cookfile-caching branch.

D.28. CS-0028 — cook.dep_output translates local→global names via Registry qualified prefix.

Date: 2026-05-02

Version: v0.5 (implementation-only catch-up; the §7.3 contract on cross-Cookfile body refs was already in the Standard).

Sections affected: none in the Standard surface. Closes a gap in the §7.3 reference implementation surfaced by transitive sigil-import scenarios.

Summary. Pre-CS-0028, cook.dep_output(name) and cook.dep_output_list(name) looked up name directly in the workspace-scope terminal_outputs map. After CS-0027 / eed8287, recipes were stored under their globally-qualified keys (e.g., "queue.proto.proto_lib" when the entry-point Cookfile is apps/server and the chain is server → //libs/queue → //libs/proto), but a transitively-imported Cookfile’s regenerated Lua still references its cousins by their local alias (e.g., cook.dep_output("proto.proto_lib") inside libs/queue’s Lua). The lookup therefore failed at register time with recipe 'proto.proto_lib' has no terminal output whenever the entry-point Cookfile was a deep importer of an importer.

CS-0028 threads Registry.qualified_prefix into register_dep_output_api and qualifies the local name at lookup: global_key = if prefix.is_empty() { name } else { format!("{}.{}", prefix, name) }. The dep ref recorded into step_group_dep_refs is the global key, so DAG edge wiring against recipe_leaves (which uses globally-qualified names) lines up. Path rewriting via alias_dirs continues to use the local name unchanged — the alias is the local name’s first dot-component.

Why: Surfaced by cli/e2e-fixtures/monorepo_codegen/: cook server_bin from apps/server/ failed because the chain server → //libs/queue → //libs/proto has libs/queue running under qualified_prefix = "queue", and queue’s Lua body cat {proto.proto_lib} queue.tmpl lowers to cook.dep_output("proto.proto_lib"). The recipe is stored under queue.proto.proto_lib. The Standard’s §7.3 contract — “a name reference to recipe B in an imported Cookfile resolves to that recipe’s terminal output” — implicitly requires this translation; it was simply never exercised by the existing cache_benchmarks_sigil fixture, which only goes one import-hop deep.

Implementation notes. Single fix site: cli/crates/cook-register/src/dep_output_api.rs gains a qualified_prefix: String parameter on register_dep_output_api and applies it to both cook.dep_output and cook.dep_output_list lookup paths. The two production callers (engine.rs and the unit_api.rs test) and the eight in-file unit tests are updated. test_dep_output_translates_local_to_global_via_qualified_prefix and test_dep_output_empty_qualified_prefix_no_translation are the in-crate regression pins; the runtime fixture lives in cli/e2e-fixtures/monorepo_codegen/ (transitive sigil + deep-subdir entry).

Conformance impact. None at the parser/luagen layer (no AST or dump-format changes; no language surface change). The Standard’s §7.3 wording was already correct; this is a reference-implementation fix.

Reference: the CS-0028 commit on the feature/cross-cookfile-caching branch.

D.29. CS-0029 — Diamond imports: cook.dep_output resolves cross-Cookfile refs via per-alias canonical importee prefix.

Date: 2026-05-02

Version: v0.5 (implementation-only catch-up; the §7.5 diamond-dedup contract was already in the Standard).

Sections affected: none in the Standard surface. Closes a residual gap in CS-0028’s §7.3 reference-implementation catch-up that was exposed once the cli/e2e-fixtures/monorepo_codegen/ workspace was built from the root.

Summary. Pre-CS-0029, cook.dep_output("alias.recipe") resolved cross-Cookfile body refs by prepending the calling registry’s qualified_prefix to the local name (CS-0028’s qualify-by-prefix scheme). This works for linear-transitive cases — apps/server → //libs/queue → //libs/proto where each Cookfile sits on a single import path — but breaks for diamond imports, where the same Cookfile is reachable via two chains. In monorepo_codegen/, both apps/cli and apps/server → libs/queue reach libs/proto. find_full_prefix deterministically picks a single canonical workspace prefix for proto (e.g., "server.queue.proto" if that path won the BTreeMap insertion race), and proto’s recipes are stored in terminal_outputs under that one prefix. CLI’s local alias "proto" then has to resolve to "server.queue.proto" — not "cli.proto", which CS-0028’s prefix-prepend would yield.

CS-0029 threads a per-Registry alias_qualified_prefixes: BTreeMap<String, String> map into register_dep_output_api. The map associates each direct-import alias with its target Cookfile’s canonical workspace qualified prefix (the same value that find_full_prefix(target_canon) returns). At lookup, cook.dep_output(name) splits name on the first dot. If the first component is a key in alias_qualified_prefixes, the lookup uses that alias’s canonical importee prefix to compose the global key (<importee_prefix>.<recipe>, or just <recipe> when the importee prefix is empty). Otherwise, the call is treated as a same-Cookfile reference and falls back to the calling registry’s qualified_prefix (CS-0028’s behavior). Path rewriting via alias_dirs continues to use the local alias unchanged.

The map is computed once per Cookfile by Workspace::alias_qualified_prefixes_for(importer_dir) (parallel to the existing alias_dirs_for), and supplied through the new Registry::with_alias_qualified_prefixes builder. pipeline.rs calls both methods at registry construction time for the root and every imported Cookfile.

Why: Diamond imports are the textbook case for cross-Cookfile caching to earn its keep — a shared lib referenced by multiple cousins, with §7.5 dedup folding the two paths. CS-0028’s prefix-prepend scheme silently relied on each Cookfile having a single ancestry chain; the diamond breaks that assumption. The right resolution rule is “go to the alias’s canonical target prefix, regardless of who’s asking” — exactly the semantics Workspace::alias_qualified_prefixes_for makes explicit.

Implementation notes. Touches cli/crates/cook-register/src/{dep_output_api.rs, engine.rs, unit_api.rs} plus cli/crates/cook-cli/src/{workspace.rs, pipeline.rs}. Two new in-crate regression tests pin the diamond and same-Cookfile fallback paths: test_dep_output_diamond_resolves_to_canonical_importee_prefix and test_dep_output_same_cookfile_uses_self_prefix. The CS-0028 transitive-sigil test is renamed to test_dep_output_resolves_via_alias_qualified_prefix and updated to use the new alias-map signature. The runtime fixture lives in cli/e2e-fixtures/monorepo_codegen/: cook top from the workspace root exercises the diamond on libs/proto (cli direct + server transitive via queue), and cook server_bin from apps/server/ continues to exercise the linear-transitive case.

Conformance impact. None at the parser/luagen layer (no AST or dump-format changes; no language surface change). The Standard’s §7.3 (cross-Cookfile body refs) and §7.5 (diamond dedup) wording was already correct; this is a reference-implementation fix that closes the gap CS-0028 missed.

Reference: the CS-0029 commit on the feature/cross-cookfile-caching branch.

D.30. CS-0030 — cook.add_test propagates step-group dep refs into unit-DAG dep edges.

Date: 2026-05-03

Version: v0.5 (implementation-only catch-up; closes the residual scope CS-0027 explicitly tracked).

Sections affected: none in the Standard surface. Closes the residual-scope item recorded in D.27 (CS-0027): “The add_test API does not yet propagate step_group_dep_refs into unit-DAG dep_edges, so a test recipe whose body refs a sibling recipe will race that sibling under --jobs > 1.”

Summary. Pre-CS-0030, cook.add_test (cli/crates/cook-register/src/test_api.rs) created a CapturedUnit with DepKind::TestSibling/Sequential but did not read state.step_group_dep_refs. As a result, a {X} body ref inside a test step lowered to cook.dep_output("X") (which correctly recorded "X" in step_group_dep_refs) was then dropped by add_test’s capture logic. The wave grouper saw no edge from the test recipe to X, scheduled them in the same wave, and the test raced its upstream under --jobs > 1. CS-0030 mirrors cook.add_unit’s pattern in add_test: after pushing the test unit, copy step_group_dep_refs into dep_edges so each ref becomes a (test_unit_idx, dep_name) pair. The wave grouper then schedules the upstream recipe before the test.

Why: Surfaced by examples/cross_cookfile_test/, which mirrors cli/e2e-fixtures/cross_cookfile_plate/ exactly except the body-ref-bearing step is a test instead of a plate. Pre-CS-0030, plate (which lowers to cook.add_unit) succeeded 10/10 trials at --jobs 4 while test (which lowers to cook.add_test) failed 10/10 with grep: lib/build/gen.bin: No such file or directory — a clean A/B isolation of the bug to the add_test code path. Post-CS-0030, both succeed 10/10. The § 10.6 contract — “a name reference to recipe B in recipe A’s body establishes a dep edge from A to B” — now holds uniformly across cook, plate, and test step kinds, instead of plate/cook only.

Implementation notes. Single fix site, ~5 lines added at test_api.rs:46-50 mirroring unit_api.rs:233-236. New in-crate regression test test_add_test_propagates_step_group_dep_refs_to_dep_edges seeds a synthetic step_group_dep_refs entry, runs cook.add_test, and asserts the resulting dep_edges contains the synthetic ref. The runtime fixture is examples/cross_cookfile_test/walkthrough.sh, which 3/3-passes post-fix; its sibling cli/e2e-fixtures/cross_cookfile_plate/ provides the reference plate-side behavior for diff-based regression checking.

Conformance impact. None at the parser/luagen layer (no AST or dump-format changes; no language surface change). The Standard’s § 10.6 and §7.3 wording was already correct; this catches the add_test code path up to the contract.

Reference: the CS-0030 commit on the feature/cross-cookfile-caching branch.

v0.6 [#changes.v0-6]

D.31. CS-0031 — Standard cut: v0.6.

Date: 2026-05-03 (originally cut); amended 2026-05-03 to fold CS-0032 into the v0.6 cut and recognize the sigil import semantics as the load-bearing change.

Version: v0.6

Sections affected: standard/VERSION (0.5 → 0.6); App. D Versions index (new v0.6 entry; v0.5 entry widened from 2026-05-01 to 2026-05-01..2026-05-03 and its CS list extended with CS-0026..CS-0030); this entry. Reference-implementation version mirrors bumped: cli/crates/cook-lang/src/lib.rs (COOK_STANDARD_VERSION), cli/crates/cook-lang/README.md, cli/crates/cook-lang/CONFORMANCE.md, project root README.md.

Summary. Cuts the Cook Standard at v0.6. Per the cut procedure in CONTRIBUTING.md (and § 0.5), this entry plus the v0.6 row in the Versions index plus a cs-standard/v0.6 git tag at the merge commit constitute the published cut. v0.6 ships CS-0032, the load-bearing entry that establishes //-anchored sigil imports as a first-class language construct (§7.2 grammar, §7.5 diamond-dedup scoping, §7.6 workspace-root determination, §7.7 cache portability). It also batches the reference-implementation catch-up CSes that landed during the v0.5-in-progress era: CS-0026 (parse-time enforcement of §7.2 import path shapes), CS-0027 (inferred-deps unification across CLI commands; cmd_dag workspace mode; closes App. E.10), CS-0028 (cook.dep_output qualifies lookups by the calling registry’s prefix for transitive sigil imports), CS-0029 (cook.dep_output resolves cross-Cookfile refs via per-alias canonical importee prefix for diamond imports), and CS-0030 (cook.add_test propagates step-group dep refs into unit-DAG dep edges, closing CS-0027’s residual scope).

Why a minor bump. v0.6 adds net-new language surface — the // sigil import path form, the workspace-root determination contract, and the cache portability invariant (CS-0032). The CS-0026..CS-0030 catch-up CSes are technically v0.5 era (per the operating rule that **Version:** records authoring time, they retain **Version:** v0.5), but their landed implementation is what makes the v0.6 surface usable in practice. Without CS-0032 carved out as a distinct entry, the changelog would misrepresent v0.6 as an implementation-only cut; CS-0032 makes the language-surface scope of v0.6 explicit.

Cut commit content. The original cut commit performed five actions: bump standard/VERSION from 0.5 to 0.6; bump COOK_STANDARD_VERSION in cli/crates/cook-lang/src/lib.rs to "0.6"; mirror the claim in cli/crates/cook-lang/README.md, cli/crates/cook-lang/CONFORMANCE.md, and the project root README.md; add the v0.6 entry to the App. D Versions index; widen the v0.5 entry’s date and extend its CS list; and add the CS-0031 body. The 2026-05-03 amendment adds the CS-0032 entry, extends the v0.6 row to CS-0031, CS-0032, and amends this body. The cs-standard/v0.6 git tag is moved forward to the amendment commit (precedent: CS-0023’s amendment that folded CS-0024 in moved the cs-standard/v0.5 tag forward in the same way). The conformance harness passes against the amendment commit.

Reference: the amendment commit on main, where cs-standard/v0.6 now points.

D.32. CS-0032 — //-anchored sigil imports: §7.2 grammar, §7.5 diamond-dedup scoping, §7.6 workspace-root determination, §7.7 cache portability.

Date: 2026-05-03

Version: v0.6

Sections affected: § 11.2 (§7.2 — adds the //path sigil grammar production and the normative rejections for ..-bearing tree-relative paths, absolute paths, and ..-bearing sigil paths); § 11.3 (§7.3 — qualified transitive-imports definition); § 11.5 (§7.5 — scopes diamond dedup to sigil-anchored imports); § 20.1 (§7.6 — workspace-root determination via --root / .cookroot walk-up / tree-import inference / self-root / sigil-anchor rejection, Rules 1–5); § 17.5 (§7.7 — content-addressed cache invariants under workspace path moves); App. A.7 (import_path grammar production for tree vs sigil shapes; path_segment normalization).

Summary. Establishes the sigil import as a first-class language construct distinct from tree-relative imports. An import alias //path declaration whose path begins with // resolves the importee against the workspace root, regardless of where the importing Cookfile sits in the directory tree. Tree-relative imports (./path, ../path — the latter rejected per the §7.2 normative diagnostics) continue to resolve relative to the importer’s directory. The two forms compose: a workspace can mix tree-relative and sigil edges in the same Cookfile.

The workspace root is determined by §7.6 Rules 1–5, in priority order: (1) --root <path> flag wins; (2) .cookroot marker found by walk-up from the entry-point Cookfile; (3) tree-import inference walk picking the highest ancestor under which all sigil paths resolve; (4) self-root for sigil-free Cookfiles; (5) rejection with a normative diagnostic when sigils require an anchor that cannot be inferred. §7.5 is scoped so that diamond dedup applies only to nodes reachable through sigil-anchored paths, preserving tree-relative locality (a Cookfile imported twice via ./foo from two different parents resolves to two distinct namespaces; the same Cookfile imported via //libs/foo resolves to one canonical instance with a single qualified prefix). §7.7 codifies the cache portability invariant: content-addressed cache keys MUST NOT include absolute paths, so a built workspace can be cp -r’d to a new path or shared across machines and continue to hit cache.

App. A.7 adds the import_path = tree_path | sigil_path production with the normative path_segment rule rejecting .. segments. App. A is the authoritative grammar reference; the parser’s enforcement (CS-0026) cites these productions verbatim.

Why a v0.6 bump. The sigil import form is net-new language surface. Before v0.6, every import declaration resolved relative to the importer’s directory; this is the first time Cook supports a workspace-relative path form. The §7.6 workspace-root contract is also new — there was no notion of a “workspace” at the spec level prior to this. The §7.7 cache portability invariant promotes prior implementation behavior to a normative contract, making it a portable promise rather than a coincidence.

Conformance impact. Four new conformance cases land alongside CS-0032’s normative prose: standard/conformance/positive/033-import-sigil-accepted/ and three negatives (034-import-dotdot-rejected/, 035-import-absolute-rejected/, 036-import-sigil-dotdot-rejected/). Each negative pins one of the §7.2 normative diagnostics. The Rust parser (CS-0026) and the Lua codegen / register layer (CS-0027..CS-0030) realize the contract end-to-end; the conformance harness is green at the v0.6 cut.

Implementation status. Lockstep — the spec, parser AST (ImportPath::Tree | ::Sigil), parser path-shape rejection, codegen for cross-Cookfile body refs, register-side qualified_prefix machinery, workspace-root walk-up, and the runtime fixtures (cli/e2e-fixtures/cache_benchmarks_sigil/, cli/e2e-fixtures/monorepo_codegen/, the four bug-hunt fixtures) all landed across the feature/cross-cookfile-caching branch and merged together. CS-0026..CS-0030 are the implementation series that catches up to this entry’s normative prose.

Reference. The feature/cross-cookfile-caching branch merge commit on main (the same commit cs-standard/v0.6 is moved forward to by the CS-0031 amendment).

v0.7 [#changes.v0-7]

D.33. CS-0033 — $<IDENT> sigil-disambiguated placeholder syntax with closed-set resolution.

Date: 2026-05-03

Version: v0.7

Sections affected: § 9.1 (new §2.11), § 10.2 (rewrite), §{xref.env-namespace} (new §5.7), § 9.3 (§6.7 + §6.7.1 rewrite), App. A placeholder / sigil_placeholder productions, App. B.8 (out_0 rejection rationale), App. B.9 (cook.require_env runtime mechanism rationale), App. E.8 (fully resolved). Reference-implementation: cli/crates/cook-{lang,luagen,register,contracts} (lexer reservation, scanner, resolver, substitution, runtime helper, shared accessor constant), 5 new positive conformance fixtures (034-shell-brace-expansion through 038-shell-json-literal), 3 new negative conformance fixtures (037-reserved-env-recipe-rejected, 038-out-zero-rejected-sigil, 039-in-in-many-to-one-sigil). 24 in-tree Cookfiles (examples + tree-sitter-cook) migrated from {IDENT} to $<IDENT>.

Summary. Replaces Cook’s placeholder syntax {IDENT} with the strict sigil shape $<IDENT> and tightens the substitution layer’s resolution rule from open (silent fallthrough to cook.env[TOKEN] for any unrecognized token) to closed (every placeholder MUST resolve to a builtin, an in-scope recipe, or a declared env var; otherwise a load-time diagnostic). The motivation is the structural collision between the legacy {...} shape and shell idioms that use { and } — Bash brace expansion, parameter expansion, awk inline scripts, find -exec rm {} \;, JSON literals; under the new shape these idioms pass through to the shell verbatim because the strict-bail rule (§ 9.1) treats any byte sequence not matching $<IDENT> exactly as literal.

The substitution layer is wired uniformly across all four shell-text contexts: cook step output patterns, cook ... using { ... } shell-block bodies, plate and test shell-block bodies, and bare shell_command steps in both recipe and chore bodies. The chore-body wiring closes App. E.8 — pre-CS-0033, the chore codegen path skipped substitution entirely, surfacing as a shell error for any chore using a placeholder. App. E.2 (the original ${VAR:-default} collision symptom) is structurally eliminated: ${VAR:-default} does not begin with $< so it is unambiguously literal under the new lexer rule.

The closed-set resolver lives in cook-luagen/src/resolver.rs and produces one of three outcomes per IDENT: builtin (inlined), recipe in scope (lowered to cook.dep_output("name") or accessor variant), or env-runtime (lowered to cook.require_env("name")). The runtime side, cook.require_env, captures the cook.env keyset at the end of config-block evaluation and rejects any subsequent lookup whose key is not in the captured set. The “declared” check is enforced at register time, after config-block evaluation completes; subsequent recipe-time writes to cook.env do not retroactively satisfy the contract.

Why a flag-day cut. Pre-1.0, the alternative — dual-grammar transition admitting both {IDENT} and $<IDENT> for one version, then dropping {IDENT} — was considered and rejected. Two valid spellings invite reading-friction, the lexer disambiguation surface area grows when it should be shrinking, and every doc and example would have to choose a spelling then update again at deprecation. The migration tooling that would have justified dual-grammar (a CLI subcommand for downstream Cookfile rewriting) was not built — there is no installed base to migrate, and a one-off re.sub(r'\{([a-zA-Z_][a-zA-Z0-9_.]*)\}', r'$<\1>', src) pass handles the in-tree corpus.

Implementation notes. The scanner (cook-luagen/src/sigil.rs) is byte-level and bails on the first non-IDENT byte after $< rather than searching forward for a > — this is the property that makes shell idioms like ${HOME} or $<HOME bar> (with embedded space) literal rather than producing a misleading “unclosed placeholder” diagnostic. The resolver distinguishes “not a builtin shape” (BuiltinMatch::No → fall through to recipe/env) from “malformed builtin shape” (BuiltinMatch::Malformed → hard error, e.g., $<out_0> per App. B.8). The reserved-recipe-segment rule was tightened in this change to reject env as either the first or last dotted segment, ensuring $<env.X> is unambiguously an env-var lookup and cannot collide with a recipe whose segment is named env.

Conformance impact. The corpus migrated wholesale to $<IDENT> syntax. Five new positive fixtures (034-shell-brace-expansion through 038-shell-json-literal) pin the strict-bail rule against shell {}-idioms: Bash brace expansion, parameter expansion, awk script, find -exec, and JSON literal. Three new negative fixtures (037-reserved-env-recipe-rejected, 038-out-zero-rejected-sigil, 039-in-in-many-to-one-sigil) pin the closed-set rejection cases. The conformance harness was disabled during the parser-rewrite phase and re-enabled at corpus-migration time; both cook-lang and cook-luagen conformance test bundles pass against the cut commit.

Reference: the CS-0033 commit series on feature/sigil-placeholder-syntax.

D.34. CS-0034 — Standard cut: v0.7.

Date: 2026-05-03

Version: v0.7

Sections affected: standard/VERSION (0.6 → 0.7); App. D Versions index (new v0.7 entry); this entry. Reference-implementation version mirrors: cli/crates/cook-lang/src/lib.rs (COOK_STANDARD_VERSION), cli/crates/cook-lang/README.md, cli/crates/cook-lang/CONFORMANCE.md, project root README.md.

Summary. Cuts the Cook Standard at v0.7. Per the cut procedure in CONTRIBUTING.md (and § 0.5), this entry plus the v0.7 row in the Versions index plus a cs-standard/v0.7 git tag at the merge commit constitute the published cut. v0.7 ships CS-0033, the sigil-disambiguated placeholder syntax with closed-set resolution. The tree-sitter-cook/grammar.js banner is bumped to claim staleness against cs-standard/v0.7 (the grammar itself remains pinned at v0.4 + CS-0022, tracked by CS-0002). The cs-standard/v0.7 git tag is placed at the cut commit on feature/sigil-placeholder-syntax after merge to main.

Why a minor bump. v0.7 changes the placeholder syntax itself — every Cookfile that uses placeholders is breaking-source-incompatible with v0.6 (the shape changes from {IDENT} to $<IDENT>). Pre-1.0, this is documented and tooling-supported (one-shot regex migration); post-1.0 it would warrant a major bump. The closed-set resolution change also tightens authored contracts — a typo in an env var name now produces a hard error rather than silent empty-string substitution — which is observable to any Cookfile that relied on the silent fallthrough.

Cut commit content. Bump standard/VERSION from 0.6 to 0.7; bump COOK_STANDARD_VERSION to "0.7" in the cook-lang lib.rs; mirror the claim in the README files; add the v0.7 entry to the App. D Versions index; add this body. The conformance harness passes against the cut commit.

Reference: the v0.7 cut commit on the feature/sigil-placeholder-syntax branch (post-merge, on main).

D.35. CS-0035 — events.jsonl wire-format invariants: lexicographic key order and node-field source of truth.

Date: 2026-05-03

Version: v0.7

Sections affected: none normative (this entry is informative; no chapter prose changes). Reference-implementation: cli/crates/cook-progress/src/render/json.rs (corrected misleading insertion-order comment; standardised node-field on BuildState lookup across all event variants), cli/crates/cook-progress/src/log_store.rs (matching comment correction; same code path), cli/crates/cook-progress/src/model/build.rs (InteractiveStart/InteractiveEnd now register the interactive node into BuildState so the JSON renderer’s node_name(state, recipe, node) lookup returns the inline name rather than the synthesized node#N fallback).

Summary. Pins two invariants of the events.jsonl wire format that were previously undocumented and mis-implemented in two ways. (1) Key order is lexicographic, not insertion order. The JSON writer’s prior comment claimed it inserted ts and v “at the front” by rebuilding the JSON object, but serde_json::Map is BTreeMap-backed in the cook-progress build (the preserve_order feature is not enabled), so keys are sorted alphabetically on serialization. A build-started line surfaces as {"recipes":…,"total_nodes":…,"ts":…,"type":…,"v":…}, not as the comment advertised. CS-0035 commits to lex order as the v1.0 contract — it is deterministic, requires no new dependency (indexmap), and is friendly to diff-based consumers. The comment is corrected to describe what the code actually does. A unit test (keys_are_emitted_in_lexicographic_order) pins the invariant. (2) The node field in every event resolves through BuildState, not through the inline name carried by some event variants. Before CS-0035, node-started, node-skipped, node-cache-hit, interactive-start, and interactive-end wrote the inline name field carried by the ProgressEvent variant directly, while node-completed, node-failed, and node-output looked the name up via node_name(state, recipe, node). The two paths produce the same string in the happy path but differ if the inline name and the BuildState-registered name ever drift. CS-0035 standardises on the BuildState lookup everywhere with a stable node#N fallback for out-of-order arrivals. To preserve the happy-path contract, BuildState::apply now registers interactive nodes (the only variants whose nodes were previously not entered into r.nodes).

Why informative-only. The events.jsonl schema is a v1.0 stability surface but is not yet covered by chapter-level prose in the Standard (it is implementation-side, in cli/crates/cook-progress/). CS-0035 is the audit entry that pins the invariants in the changelog ahead of the v1.0 cut; a future entry will lift the schema into a normative appendix once the event surface stabilises across the v0.x series.

Path chosen. Of the two paths considered for the key-order issue — Path A (commit to lex order, fix the lying comment) and Path B (enable serde_json/preserve_order, paying an indexmap dependency for insertion-order keys) — Path A was selected. Lex order is deterministic without coordinating across writer call sites, well-formed JSON consumers are agnostic to key order, and indexmap is a non-trivial transitive dep for a write-only audit format.

Reference: the CS-0035 commit on the worktree-agent-ac9dd34b7ab47a405 branch.

D.36. CS-0036 — Tool-binary fingerprinting recognises every newline-separated statement, not just the first token.

Date: 2026-05-03

Version: v0.7 (post-cut amendment; reference-implementation fix that aligns the § 17 observable-inputs prose with what the cache actually fingerprints).

Sections affected: § 17 (Note 8.6.2 added — informative coverage statement for tool-binary fingerprinting); App. D (this entry).

Summary. Pre-CS-0036, cook-cache::context::step_context_hash fingerprinted only the first whitespace-delimited token of the unit’s command text. For any multi-line shell script — the dominant Cookfile pattern — only the first line’s first token contributed to the per-step context hash; every tool invoked on subsequent lines (gcc, clang, the linker, python, awk, …) was silently ignored. A second symptom: a leading POSIX env-prefix (LC_ALL=C make foo) was passed to which("LC_ALL=C"), resolved to None, and collapsed every such command to a single empty-tool fingerprint. The result was that toolchain swaps did not invalidate the cache for any but the most trivial single-line recipes — exactly the case the cache_benchmarks fixture’s link recipe relies on.

CS-0036 reworks step_context_hash to (a) split the command text on newlines, (b) per line, skip leading VAR=value env-prefix tokens (a token whose name part is a valid shell-style identifier), (c) take the next remaining token as the executable, (d) resolve via which + canonicalize, (e) deduplicate via BTreeSet<PathBuf> so the fold order is deterministic, and (f) fold each unique resolved binary’s content hash into the per-step fingerprint. The recognised-subset policy is documented in the new informative Note 8.6.2 in § 17: only the first executable on each newline-separated statement is recognised; tools chained via ;, &&, ||, pipes, command substitution, subshells, or invoked indirectly through scripts are NOT recognised by this pass.

Why § 17 gains an informative note rather than tightened normative prose. § 17‘s normative wording is correct as written: it permits (“MAY consider”) the resolved content of invoked binaries without prescribing a recognition algorithm. Recognition is undecidable in general (Rice’s theorem on shell text), so the Standard cannot reasonably normatively require any particular subset. The new Note 8.6.2 is informative: it tells authors what the reference implementation fingerprints today, and it advertises the expected escape hatches (one-statement-per-line, planned cache.tools = { ... } declaration). Other implementations are free to recognise more or less.

Implementation notes. Touches cli/crates/cook-cache/src/context.rs only. Five new in-crate tests pin the behavior: step_context_hash_fingerprints_tools_on_later_lines (the headline regression), step_context_hash_skips_env_prefix (the VAR=value collapse fix), step_context_hash_deterministic_across_line_order (BTreeSet fold ordering), step_context_hash_ignores_blank_and_comment_lines, and is_env_prefix_token_recognises_assignments (env-prefix predicate unit-tests). The cli/e2e-fixtures/cache_audit_first_token/ fixture pins the contract end-to-end through the cook binary: a multi-line recipe whose tool changes between runs MUST invalidate the cache.

Conformance impact. None at the parser or codegen layer. No language surface change. The conformance corpus is unchanged. The change is observable only via cache hit/miss behavior, which is implementation-defined per § 0.3 and § 17.

Reference: the CS-0036 commit on worktree-agent-a012ebda236e76f35 (cache-fingerprint multi-line fix).

D.37. CS-0037 — §8.6.1 cache integrity: restore MUST verify backend bytes against the recorded fingerprint.

Date: 2026-05-03

Version: v0.7

Sections affected: standard/src/content/docs/08-execution-model.mdx (new §8.6.1 normative subsection); App. D Versions index (v0.7 row extended with CS-0037); this entry. Reference-implementation: cli/crates/cook-cache/src/check.rs (try_restore now hashes backend bytes with xxh3_64 and rejects on mismatch); cli/crates/cook-cache/tests/integration_restore_on_hit.rs (new restore_rejects_tampered_backend_bytes regression test).

Summary. Adds a normative MUST/MUST NOT pair to § 17.2: an implementation that reads bytes from a content-addressable artifact store with the intent to install them as a declared output MUST recompute the recorded content fingerprint and MUST treat any mismatch as a cache miss, falling through to the rebuild path of § 17. The rule is backend-agnostic: it applies to local-filesystem stores as well as networked / shared backends. No language-surface change.

Why. Cache integrity is part of the v1.0 stability promise. The reference implementation’s restore-on-hit path (added with the 2026-05-02 addendum to support drift recovery from a shared backend) wrote backend bytes to disk without checking their hash against the cached FileRecord.hash. For the in-tree LocalBackend this is observationally equivalent to a verify (the same process wrote the bytes), but a remote / shared backend (Cook Cloud’s R2/D1 backend, CI artifact stores, anything writable by another principal) would, under the unfixed code, install whatever bytes the backend returned — including bytes substituted by a compromised or buggy backend — under a cache hit. The examples/cache_audit_unverified_restore/walkthrough.sh audit fixture demonstrates the symptom by tampering with the on-disk LocalBackend artifact: pre-fix, cook installed the tampered bytes under a hit; post-fix, the hash check rejects them and the recipe rebuilds. Codifying the rule normatively prevents the same hole from reappearing in any future backend, including third-party implementations that target the Standard.

Conformance impact. None at the parser / luagen layer; the rule lives entirely in § 13 and is observable only through the cache. The reference test pinning the rule is a Rust integration test (restore_rejects_tampered_backend_bytes) rather than a corpus fixture, since the conformance harness has no notion of a populated backend. A future expansion of the harness to cover cache semantics (cf. App. E pre-1.0 checklist) would add a corpus-level test; for v0.7 the in-crate regression plus the audit walkthrough are the conformance evidence.

Implementation status. Lockstep — the §8.6.1 normative subsection, the try_restore fix, and the regression test land in the same commit on the worktree-agent-aad1d5af628d364f9 branch. Implementation uses the same xxhash_rust::xxh3::xxh3_64 function the cache already uses for FileRecord.hash (hash_file in cli/crates/cook-cache/src/check.rs); both sides of the comparison are derived from the same function so the check is exact, not heuristic.

Reference: the CS-0037 commit on the worktree-agent-aad1d5af628d364f9 branch.

D.38. CS-0038 — use_name constrained to LUA_IDENT at parse time.

Date: 2026-05-03

Version: v0.7

Sections affected: § 5.1 (rewritten — adds the use_name LUA_IDENT constraint, new Example 3.4.2, augmented Note 3.4.1); App. A.2 (use_declaration production rewritten with use_name nonterminal; new normative paragraph “use_name constraint”); App. A.5 (new LUA_IDENT primitive production; new “LUA_IDENT vs BARE_IDENTIFIER” paragraph). Reference-implementation: cli/crates/cook-lang/src/lexer.rs (new LexError::InvalidUseName variant, new check_use_name helper, six new unit tests). Conformance: three new negative fixtures (040-use-name-with-spaces-rejected, 041-use-name-with-dash-rejected, 042-use-name-with-dot-rejected).

Summary. Tightens the use NAME declaration to require NAME to match /^[A-Za-z_][A-Za-z0-9_]*$/ — the Lua identifier shape — independent of whether it is written bare or inside double quotes. Pre-CS-0038, the lexer accepted any BARE_IDENTIFIER or STRING after use, including pathological forms such as use "has spaces", use cpp.bad, and use foo-bar. The codegen layer (cook-luagen/src/recipe.rs) then dropped the verbatim text into a local NAME = cook.load_module(...) binding, producing malformed Lua that surfaced as a runtime parse error far from the source line. Post-CS-0038 the lexer rejects these at parse time with InvalidUseName { name, line }, citing the offending name and source line directly.

Why hyphens are rejected outright. A subtle pre-CS-0038 misfeature: cook-luagen/src/recipe.rs rewrote - to _ inside the generated Lua local binding (module_name.replace('-', "_")) so that hyphenated module names would tokenise as a Lua identifier. The rewrite was a workaround for the lax parser admitting hyphens, but it silently collapsed foo-bar and foo_bar to the same Lua local — an unobservable shadowing whose only symptom was the wrong module being bound under one of the two names. Rejecting hyphens at parse time eliminates the rewrite’s reason to exist; the codegen layer needs no change because the parser now guarantees the name is already a Lua identifier.

Why LUA_IDENT is its own production. App. A’s existing BARE_IDENTIFIER production admits internal dots and hyphens (it is the same production used for recipe names, dependency names, and import aliases, all of which legitimately want dotted forms). The use_name constraint is narrower: the matched text is bound at load time as a Lua local under the same spelling and therefore must round-trip through Lua’s identifier grammar. The new LUA_IDENT primitive captures the narrower shape, making the constraint expressible as a grammar production rather than as out-of-band prose. The use_name is currently the sole site of LUA_IDENT in App. A; future productions that bind text as Lua locals (none planned at v0.7) would reuse it.

Implementation notes. The lexer change is a four-line addition in cli/crates/cook-lang/src/lexer.rs: a new LexError::InvalidUseName { name, line } variant, a check_use_name helper that performs the regex validation by hand (no regex dependency), and a single call site in the Token::UseDecl branch of tokenize. Six new unit tests pin the rejection cases (spaces, hyphen, dot, leading digit) and the accepted cases (underscore-internal, leading underscore). The codegen layer (cook-luagen/src/recipe.rs:488-495) is left untouched; the obsolete module_name.replace('-', "_") rewrite is now provably dead code (the parser rejects any name containing - before it ever reaches codegen), but removing it is a separate cleanup tracked outside this CS.

Conformance impact. Three new negative fixtures land alongside this entry: 040-use-name-with-spaces-rejected/ pins the quoted-with-spaces case, 041-use-name-with-dash-rejected/ pins the bare-with-dash case, and 042-use-name-with-dot-rejected/ pins the bare-with-dot case. Each fixture’s error.txt asserts a substring of the new diagnostic (“'use' name 'X' is not a valid Lua identifier”). All existing positive fixtures that exercise use (010-use-and-module-call/, 017-use-from-execute/) already satisfy the LUA_IDENT rule and continue to parse cleanly. The conformance harness passes against the CS-0038 commit.

Reference: the CS-0038 commit on worktree-agent-a2d8a370be4c0889a (the lang-audit branch).

D.39. CS-0039 — Stateful brace-balance: multi-line Lua long strings, block comments, and shell heredocs are opaque to the brace counter.

Date: 2026-05-03

Version: v0.7

Sections affected: § 3.9 (rewritten to require stateful opaque-span tracking; new sub-sections § 3.9.1 and § 3.9.2; new Examples 2.9.2 and 2.9.3); App. A BRACE_BALANCED production comment; App. A “Brace-balance algorithm” prose. Reference implementation: new cli/crates/cook-lang/src/brace_scan.rs module exposing LuaScanner and ShellScanner; lua_block.rs, shell_block.rs, and recipe.rs::collect_module_call rewritten to drive the stateful scanners. Conformance corpus: positive fixtures 039-lua-multiline-long-string, 040-lua-multiline-block-comment, 041-lua-long-string-leveled, 042-shell-heredoc-brace-in-body, 043-shell-heredoc-quoted-delim.

Summary. The pre-CS-0039 count_brace_delta helper in cli/crates/cook-lang/src/lua_block.rs was line-local: it correctly ignored braces inside single-line Lua line comments, single- and double-quoted strings, and single-line long strings ([[…]]), but it did not carry opaque-span state across source lines. Three classes of multi-line Lua / shell construct were therefore broken: (i) multi-line Lua long strings [[ … ]] and [==[ … ]==], (ii) multi-line Lua block comments --[[ … ]] and --[==[ … ]==], and (iii) POSIX shell heredocs (<<TAG, <<-TAG, <<'TAG', <<"TAG"). A } byte appearing on any line interior to one of these spans would decrement the running brace depth and prematurely close the surrounding >{ … } Lua block, using { … } shell block, or multi-line module-call body. CS-0039 makes the brace counter stateful: it now carries long-string level, block-comment level, and pending heredoc delimiters across lines, treating every brace byte interior to such a span as opaque data.

Lua side. A new LuaScanner (brace_scan.rs) tracks an Option<u32> long-bracket level. A [==[ opener at level n is opaque until a ]==] closer at the same level is seen — possibly many lines later. The same level-aware open/close logic governs --[==[ … ]==] block comments. Single-line constructs (line comments, quoted strings) remain single-line. LuaScanner drives collect_lua_block (for >{ … }) and collect_module_call (for multi-line mod.fn(…) invocations).

Shell side. A new ShellScanner (brace_scan.rs) maintains a FIFO queue of pending heredoc closers, each tagged with its delimiter text and <<- (tab-strip) flag. While the queue is non-empty, body lines are scanned only for the front-of-queue delimiter; brace bytes are ignored. Multiple heredocs queued on a single opener line (cmd <<A <<B) are consumed in opener order. The closer is matched against the trimmed body line, consistent with SHELL_BLOCK_CONTENT line normalisation: this lets authors write a heredoc inside an indented { … } body without dedenting the closing delimiter to column 0. ShellScanner drives collect_shell_block.

Shell-side decision: full support. The instructions for this CS offered a tractable alternative — REJECT heredocs inside using { … } with a clear diagnostic — but full support was chosen because (i) heredocs are a load-bearing POSIX construct that authors expect to “just work” in any shell-like context, (ii) the scanner state is small (a Vec<(String, bool)>) and the implementation is comparable in size to a rejection diagnostic, and (iii) trim-first matching collapses the runtime indentation question that would otherwise force authors to dedent or use <<- with strict tab discipline.

Why a non-cut entry on v0.7. CS-0039 lands as a v0.7 erratum: it is a normative correction to the § 3.9 algorithm that was already part of v0.7 but was under-specified for multi-line opaque spans. No version bump is taken — implementations that already happened to track multi-line state are conformant; implementations that were line-local (the reference parser pre-CS-0039) need to update.

Conformance impact. Five new positive fixtures land alongside this entry and pin the multi-line carry behaviour:

  • 039-lua-multiline-long-string — } byte inside multi-line [[ … ]] is data.
  • 040-lua-multiline-block-comment — } byte inside multi-line --[[ … ]] is data.
  • 041-lua-long-string-leveled — [==[ … ]==] level matching; ]] does not close [==[.
  • 042-shell-heredoc-brace-in-body — } byte inside <<EOF body is data.
  • 043-shell-heredoc-quoted-delim — <<'END' quoted delimiter form is recognised.

Companion unit tests: the cs_0035_* family in tests.rs, shell_block.rs, and the per-scanner tests in brace_scan.rs. The conformance harness is green at the CS-0039 commit.

Implementation status. Lockstep — the spec rewrite, the new brace_scan module, the three call-site rewrites (lua_block.rs, shell_block.rs, recipe.rs), the unit tests, and the five conformance fixtures land in the same commit. Walkthrough fixtures live under cli/e2e-fixtures/lang_audit_lua_multiline_string/, cli/e2e-fixtures/lang_audit_lua_block_comment/, and cli/e2e-fixtures/lang_audit_shell_heredoc_brace/.

Reference: the CS-0039 commit on main (this commit).

D.40. CS-0040 — cook.load_module MUST memoize per VM and MUST raise on cycles.

Date: 2026-05-03

Version: v0.7

D.41. CS-0041 — cook --init template fix; cook --test exit-code propagation; CLI exit-code taxonomy.

Date: 2026-05-03

Version: v0.7

Sections affected: App. E.11 (new — informative CLI exit-code taxonomy). Reference-implementation: cli/crates/cook-cli/src/pipeline.rs (cmd_init template, both cmd_test branches), examples/cli_audit_test_exit_code/ and cli/e2e-fixtures/cli_audit_exit_codes/ (new walkthroughs).

Summary. Closes two CLI-surface bugs and documents the exit-code taxonomy that cook already implements but had never written down.

The --init template emitted a v0.3-era recipe shape with an end line. CS-0019 dropped end from the recipe grammar in v0.4, so the literal end parsed as a shell command in the generated Cookfile and the first cook invocation after cook --init failed with exit 127 (end: command not found). The new template uses the post-CS-0019 indented body shape (recipe build + indented commands, no terminator) and an unquoted recipe name consistent with the rest of the in-tree corpus.

cook --test discarded the engine’s Result (let _result = run_with_progress(...)) and unconditionally returned Ok(()), so any test failure silently exited 0. The fix is a one-character change in both cmd_test branches (workspace and single-Cookfile): replace let _result = ... with ? so the engine result propagates. Test failures already surface as EngineError::TaskFailures carrying command failure details, which engine_error_to_cook_error already maps to CookError::CommandFailed (exit code 1). The misleading “test execution complete (detailed results pending cook-engine integration)” message was removed; the TODO for structured test output remains as a code comment, where it belongs.

The CLI exit-code taxonomy that the reference implementation already followed (CookError::exit_code in cli/crates/cook-cli/src/error.rs) is now documented in App. E.11 as an informative table. The status is informative pre-1.0 because (a) the Standard’s normative chapters are about the Cookfile language, not the CLI, and (b) other implementations are free to map the same error variants to different numeric codes. App. E.11 calls out one open question for 1.0 — whether TestFailure should split off from exit 1 once structured test output lands.

Why now. Both bugs predate the v0.7 placeholder migration; the --init regression dates to CS-0019 (v0.4) and the --test regression to whenever cmd_test was first wired. They are visible-on-first-use UX bugs (“first command a new user runs is broken”) and CI hazards (“pipeline silently exits 0 on a red test”), and the fix surface is small enough to bundle with the documentation of the existing exit-code mapping. The Standard impact is purely informative — App. E.11 — so this entry does not warrant a version bump beyond the v0.7 line it lands on.

Reference: the CS-0041 commit on the worktree branch.

D.42. CS-0042 — Output-path uniqueness across recipes: plan-time rejection of shared cook outputs without a dep edge.

Date: 2026-05-03

Version: v0.7

Sections affected: § 16.1 (added §8.4.1 — Output-path uniqueness across recipes); reference-implementation: cli/crates/cook-engine/src/dag_builder.rs (collision check), cli/crates/cook-engine/src/lib.rs (new EngineError::OutputCollision variant), cli/crates/cook-engine/src/run.rs (caller updated to propagate the error), cli/crates/cook-cli/src/pipeline.rs (CLI error mapping).

Summary. Closes a HIGH-severity correctness gap: pre-fix, two non-dep-related recipes that declared the same cook "<path>" output raced silently under --jobs > 1. The engine emitted no diagnostic; the artifact’s contents and any dependent cache keys became non-deterministic across runs. The new normative §8.4.1 requires that two distinct recipes MUST NOT both declare the same canonical output path on cook steps unless the cross-recipe ordering rules of § 16.1 establish an edge between them in either direction, and that conforming implementations MUST detect this at plan time before any work runs.

The reference implementation realises the rule in dag_builder::build_dag: per wave, accumulate (working_dir.join(output_path), recipe_name) from every CacheMeta, build an undirected dep graph from RecipeUnits.deps, and for each path with two or more declarants, reject with EngineError::OutputCollision { path, recipes } if any pair is disconnected. The diagnostic names both colliding recipes and the canonical path, and the CLI surfaces it as a CookError with remediation guidance (“add an explicit : <recipe> dep or merge into one recipe”). The audit fixture cli/e2e-fixtures/engine_audit_shared_output_race/walkthrough.sh asserts the post-fix behaviour end-to-end: non-zero exit, both recipe names in the diagnostic, the colliding path in the diagnostic, and no artifact written.

Why no negative conformance fixture. The §8.4.1 rule is a register/execute-phase rejection, not a syntactic one — the conformance harness in cli/crates/cook-lang/tests/conformance.rs walks parser-level fixtures only. The audit walkthrough at cli/e2e-fixtures/engine_audit_shared_output_race/ is the regression gate.

Reference: this commit.

D.43. CS-0043 — events.jsonl stream field carries true fd-of-origin.

Date: 2026-05-03

Version: v0.7

Sections affected: none in the normative chapters (the events.jsonl wire format is not yet covered by a normative section — see App. E for the pre-1.0 follow-up). Reference-implementation: cli/crates/cook-contracts/src/lib.rs (new OutputStream enum), cli/crates/cook-luaotp/src/pool.rs (WorkResult::output_lines: Vec<String> → Vec<(OutputStream, String)> at the worker boundary; execute_shell and execute_test tag captured lines by fd at the point of capture), cli/crates/cook-engine/src/{lib,executor}.rs (EngineEvent::OutputLine.is_stderr: bool → EngineEvent::OutputLine.stream: OutputStream; both call sites in executor.rs plumb the captured stream through instead of hardcoding false), cli/crates/cook-cli/src/pipeline.rs (engine→progress bridge maps OutputStream to cook_progress::Stream instead of synthesizing it from a hardcoded boolean).

Summary. Fixes a wire-format honesty bug in the events.jsonl JSON renderer. Pre-CS-0043, every node-output event was emitted with "stream": "stdout" regardless of whether the captured byte came from the child’s stdout or stderr. The Stream::Stderr variant was reachable in the cook-progress enum and JSON-rendered correctly as the literal string "stderr", but the bridge in cli/crates/cook-cli/src/pipeline.rs derived stream from a boolean is_stderr field that the engine’s two EngineEvent::OutputLine emission sites in executor.rs (success branch and failure branch) hardcoded to false. The root cause was upstream: WorkResult::output_lines in cook-luaotp was Vec<String>, discarding fd-of-origin at the worker boundary.

The fix plumbs stream provenance through the entire pipeline. A new OutputStream { Stdout, Stderr } enum lives in cook-contracts (the shared kernel — cook-luaotp already depends on it). WorkResult::output_lines becomes Vec<(OutputStream, String)>; execute_shell tags stderr lines Stderr and stdout lines Stdout at the point they’re decoded from output.stderr / output.stdout; execute_test does the same. EngineEvent::OutputLine carries stream: OutputStream instead of is_stderr: bool and re-exports the contracts enum as cook_engine::Stream for callers that don’t already depend on cook-contracts. The cook-cli bridge maps the contracts enum to the wire-format cook_progress::Stream enum (the two are isomorphic; the second exists because cook-progress is serde::Serialize for the JSON renderer and cook-contracts is intentionally serde-free).

Why a patch-level fix. This is a correctness fix for the implementation, not a language change. The events.jsonl wire-format stream field was already documented in the JSON renderer’s behavior (a "stdout" / "stderr" lowercase string) and in the node-output schema test in cli/crates/cook-progress/src/render/json.rs; the field’s valid values are unchanged, but "stderr" was unreachable end-to-end pre-fix. No Cookfile, conformance fixture, or downstream consumer is broken by this change — consumers that branched on "stream" were already prepared for both values.

Why CS-0043 in v0.7. The events.jsonl schema is a v1.0 stability surface (App. E.11 — to be filed). Cutting v0.7 with a known wire-format lie would compromise that stability promise: a future v1.0 audit might (correctly) flag the all-"stdout" behavior as the documented contract, freezing the bug. Recording this fix as a changelog-only entry against v0.7 (no version bump, no Standard prose change) closes the gap before any downstream tooling pins on the broken behavior.

Pre-1.0 follow-up. App. E should track the absence of a normative events.jsonl schema section. Per the Cook Standard versioning policy (CS-0012), public wire formats produced by the reference implementation and consumed by external tooling deserve normative section status before v1.0. The current events.jsonl shape is documented only in cli/crates/cook-progress/src/render/json.rs; promoting it to a normative section (covering type discriminator, field schemas per event kind, the ts / v envelope, and the stream field’s "stdout" / "stderr" enumeration) is a prerequisite for v1.0.

Reference: the CS-0043 commit on the active branch.

D.44. CS-0044 — Shared fs.*/path.*/cook.platform.* implementation across register and execute VMs.

Date: 2026-05-03

Version: v0.7

Sections affected: § 25.1 (new Note 6.5.2 pinning the cross-phase identity contract).

Summary. Pre-CS-0044, the reference implementation shipped two parallel implementations of fs.*, path.*, and cook.platform.* — one in cli/crates/cook-register/src/{fs,path,platform}_api.rs for the register-phase VM, the other in cli/crates/cook-luaotp/src/{fs,path,platform}_api.rs for the execute-phase worker VMs. path_api.rs was byte-identical between the two crates; platform_api.rs was structurally identical with only the cook-table-fetch shape differing; fs_api.rs had diverged because the execute-phase VMs needed per-call working-directory resolution (CS-0017 multi-Cookfile imports) while the register-phase VM captured the cwd once at registration. The drift was a v1.0 risk: bug fixes to one copy would silently miss the other, and the § 25.1 “Phase: Both” promise was implementation-fragile.

CS-0044 extracts the three tables into a new cli/crates/cook-lua-stdlib crate and parameterizes the fs.* cwd source over a WorkingDirSource enum with two variants — Static(PathBuf) for register-phase use and Live(Arc<Mutex<PathBuf>>) for execute-phase use. Both cook-register and cook-luaotp now depend on cook-lua-stdlib and call its register_fs_api / register_path_api / register_platform_api factories instead of carrying local copies. Note 6.5.2 makes the identity contract normative: every observable property of these tables MUST be identical between the two phases, modulo the working-directory difference inherent to the multi-Cookfile-import case.

Implementation note. The fs_api unification adopts the execute-phase (cook-luaotp) cwd-resolution pattern as canonical: WorkingDirSource::resolve() is called inside every fs.* closure rather than the cwd being captured at registration. For the Static variant this is operationally indistinguishable from the previous register-phase behavior (the path is cloned from a PathBuf instead of read through a Mutex); for the Live variant it preserves the CS-0017 contract that lets one worker VM serve items from multiple Cookfiles. As a side effect, any latent CS-0017 cwd-resolution bug in the pre-CS-0044 register-phase fs_api is fixed for free — the unified implementation has only one cwd-resolution code path and it is correct under both call patterns.

Hard non-changes. CS-0044 does not introduce a filesystem sandbox; it replicates today’s behavior exactly. The user-visible Lua API surface is unchanged — every existing recipe continues to work without modification. Sandboxing is a follow-up that this consolidation enables but does not deliver.

Reference: the CS-0044 commit series on the worktree-agent-a6e35722b1cd28f43 branch.

v0.8 [#changes.v0-8]

D.45. CS-0045 — Project-root sandbox for cook / test / chore step Lua bodies.

Date: 2026-05-03

Version: v0.8

Sections affected: § 25.1 (new §6.5.8 normative sandbox section, new §6.5.9 normative shell-escape-hatch section), the plate step (new Note 4.7.2 pinning the plate-step exemption).

Summary. Pre-CS-0045, the Cook Lua API surface confined fs.* paths to the recipe’s working directory (a path-resolution anchor) but applied no upper bound: a cook step body could read /etc/passwd, write to /tmp/leaked, ..-traverse out of the project, or shell out via os.execute("rm -rf /"), and the implementation would happily comply. The cache fingerprint records the captured lua_code string but cannot capture out-of-project side effects, so a hostile or accidentally-broken cook step could silently corrupt the user’s environment while still appearing cache-clean.

CS-0045 closes that gap by adding a normative project-root sandbox to the Cook Lua API. Every path-taking entry in fs.* (§6.5.1–6.5.7) MUST further confine its argument to the project root (the workspace root, § 20.1) when invoked from a sandboxed context. The sandboxed contexts are cook / test / chore step bodies and all register-phase Lua execution (config blocks, inline-Lua, cook-step register-time orchestration); the explicit non-sandboxed context is the plate step body, because shipping outside the project root is what plate exists for (§4.7, since removed). The sandbox check is lexical: paths are normalized via component-by-component .. / . resolution without consulting the filesystem, so fs.write and fs.mkdir_p continue to succeed against not-yet-existent paths. An out-of-project path raises a Lua runtime error tagged with CS-0045; no silent truncation or fallback is permitted.

The companion §6.5.9 deals with the Lua-side shell escape hatches os.execute and io.popen: in a sandboxed context, both MUST raise rather than spawn a child process, because arbitrary shell text bypasses the path-confinement check and the unit’s command_hash. cook.sh (§6.3.1) is unaffected — it already runs with the recipe’s working-directory rooting and is recorded in the unit’s command hash. Plate-step bodies retain unrestricted access to all three.

Implementation note. The reference implementation lives in cli/crates/cook-lua-stdlib/src/sandbox.rs and cli/crates/cook-lua-stdlib/src/shell_guard.rs. SandboxPolicy is an enum with two variants (Off and Confined { project_root }) and SandboxSource mirrors the WorkingDirSource split from CS-0044 so the execute-phase worker pool can flip the policy per work item. WorkPayload::LuaChunk gains a step_kind: StepKind field that the worker reads on each item: Cook / Test / Chore install Confined, Plate installs Off. The register-phase VM is always installed with Confined, since every register-phase Lua execution participates in the captured cache fingerprint.

Why path.* is not sandboxed. path.* (§6.6) is pure string manipulation. It performs no I/O. Constraining path.join to in-project results would force authors to reach for string.format to build out-of-project path strings for plate steps, which is a worse outcome than leaving path.* as a transparent string utility. The sandbox is on the I/O-performing surface only; that is sufficient to enforce hermeticity.

Why tests are sandboxed identically to cook steps. test step bodies are non-cacheable but hermetic-by-intent: the user runs them under cook --test precisely to verify the project’s correctness without depending on the host environment. A test body that reads /etc/passwd to fabricate an outcome is a worse outcome than the test failing to compile under the sandbox. Authors who want to test a plate-step’s behavior should invoke that plate from the test’s cook.sh (which runs in the recipe’s working directory and is recorded in the test’s command_hash) rather than re-creating the plate’s escape-hatch surface inside the test body.

Hard non-changes. CS-0045 is purely additive on the rejection side: a Cookfile that previously did not depend on out-of-project I/O continues to work without modification. The user-visible Lua API signatures of fs.* and path.* are unchanged; only their behavior on out-of-project arguments tightens. os.execute and io.popen semantics in plate steps and outside Cook entirely (e.g. running a Cookfile-generated Lua script directly) are unchanged.

Audit fixtures. cli/e2e-fixtures/register_audit_sandbox_escape/walkthrough.sh exercises the cook-step / config-block / os.execute rejection paths and the plate-step exemption. cli/e2e-fixtures/luaotp_audit_path_traversal/walkthrough.sh exercises the relative .. traversal, absolute fs.write, fs.glob outside-pattern, and io.popen rejection paths and pins the no-regression case (in-project paths MUST still succeed).

Reference: the CS-0045 commit on the cs-0045-sandbox-cook-steps branch.

D.46. CS-0046 — Removal of cook --test runner.

Date: 2026-05-03

Version: v0.8 (unreleased)

Sections affected: none in the normative chapters — the Cookfile language, test_step syntax (§ 8.6), and cook.add_test (§ 21.1) are unchanged. App. E.10 and App. E.11 have informative cross-references to the runner that are updated to reflect its removal. Reference-implementation: cli/crates/cook-cli/src/cli.rs (drops the --test, --filter, --verbose, --timeout-multiplier, --wrapper, --list flags), cli/crates/cook-cli/src/main.rs (drops the dispatch arm), cli/crates/cook-cli/src/pipeline.rs (drops cmd_test), cli/crates/cook-cli/src/test_output.rs (deleted — JUnit XML / JSON / terminal-summary scaffolding that only cmd_test consumed), examples/cli_audit_test_exit_code/ and examples/cmd_test_inferred_deps_e10/ and examples/cross_cookfile_test/ (deleted — fixtures that exercised the removed runner end-to-end).

Summary. The pre-1.0 reference implementation shipped a cook --test subcommand whose contract was always provisional: it discovered every recipe containing a test_step and shelled the bodies through the same DAG executor as cook NAME. The bundled sub-flags (--filter, --verbose, --timeout-multiplier, --wrapper, --list) were stubs that printed not yet implemented warnings and then ran the full set anyway; the structured-output scaffolding in test_output.rs (JUnit XML, JSON, terminal summary) was never wired to engine results. Cutting v1.0 with this surface in place would freeze a half-shape — author-visible flags that don’t do what their names imply, plus an exit-code split (CS-0041, App. E.11) that depends on structured test results that don’t exist — into the stability promise. CS-0046 removes the runner outright; v1.0 will define a test execution surface whose semantics are designed up front rather than retrofitted onto today’s stub.

What stays. The recipe-level test step (§ 8.6, App. A.4) is unchanged: parsing, register-phase recording via cook.add_test, and execute-phase invocation through the same worker pool that runs cook and plate steps all behave exactly as before. A test step continues to participate in the DAG of its enclosing recipe, and cook RECIPE_NAME still runs every step in that recipe — including its test_steps — exactly as it did before CS-0046. The removal is scoped to the CLI dispatch flag and its sub-flags; no language-surface, register-phase, or execute-phase behavior changes.

What goes. The --test, --filter, --verbose, --timeout-multiplier, --wrapper, and --list CLI flags; the cmd_test dispatcher in pipeline.rs and its workspace/single-Cookfile branches; the unused test_output.rs result-rendering module (TestStatus, TestCaseResult, TestSuiteResult, TestResults, JUnit XML emitter, JSON emitter, terminal summary). The audit fixtures under examples/cli_audit_test_exit_code/, examples/cmd_test_inferred_deps_e10/, and examples/cross_cookfile_test/ are deleted; each was scoped to behavior of the removed runner.

Why now. v0.8 is the last opportunity before the v1.0 stability cut to drop a half-finished surface without a deprecation cycle. The --test runner’s flag set was actively misleading (--filter ignored, no per-test timing, no machine-readable output) and its CI failure mode had needed a CS-0041 patch to surface non-zero exits at all. Shipping a v1.0 cook --test would then commit to backwards compatibility for behavior nobody wants long-term. Removal first, redesign later, is the conservative path.

Forward-looking note. Test execution is implementation-defined in v0.8. The Cook Standard’s normative chapters describe what a test_step is (§ 8.6) and how a conforming implementation registers and runs one (§ 21.1); whether and how a CLI surface aggregates multiple recipes’ test steps into a single batch run is not specified. The v0.7-track cook --test runner was the reference implementation’s first attempt at such a surface; it has been withdrawn pending redesign.

Reference: this commit, on the cs-0046-remove-cook-test branch.

D.47. CS-0047 — DAG viewer split out + feature-gated.

Date: 2026-05-03

Version: v0.8

Sections affected: none in the normative chapters (the cook --dag viewer is a tooling affordance of the reference implementation, not a Cookfile-language surface). Reference-implementation: new cli/crates/cook-dag-viewer/ crate (carries dag_data.rs, dag_server.rs, the embedded dag_viewer.html, and the cmd_dag data-shaping + serve entry point); cli/crates/cook-cli/Cargo.toml (drops tiny_http and open direct deps, adds cook-dag-viewer as an optional dep behind a new viewer cargo feature); cli/crates/cook-cli/src/{main.rs,pipeline.rs} (the mod dag_data / mod dag_server modules are gone, and cmd_dag becomes feature-gated — viewer-less builds short-circuit with a clear error pointing at --features viewer); cli/Cargo.toml (registers the new workspace member). App. D Versions index gains a v0.8 row alongside CS-0045 and CS-0046.

Summary. Splits the browser-based DAG viewer (cook --dag) out of cook-cli into its own cook-dag-viewer crate, and gates the wiring on cook-cli behind a new viewer cargo feature. Pre-CS-0047, the viewer’s tiny_http HTTP server, open-based browser-launch, and ~400 lines of embedded HTML/JS were unconditional dependencies of every cook build. Post-CS-0047, vanilla cargo install cook-cli (and cargo build in the workspace) produces a slim binary with no HTTP-server dependency; users who want the viewer rebuild with cargo build --features viewer (or cargo install --features viewer). Invoking cook --dag against a viewer-less binary produces a clear diagnostic naming the rebuild flag, rather than a command not found surprise.

cook --dag is implementation-optional. The Standard does not require conforming implementations to ship a graphical DAG viewer. The reference implementation provides one as a tooling affordance, and its presence is observable through the cook --dag flag; whether that flag is wired in any given build of the reference binary is now a build-time decision. Other implementations MAY omit --dag entirely, MAY ship their own viewer under a different flag, or MAY emit a JSON snapshot for an external tool. The pre-1.0 references to cmd_dag in App. D and App. E are descriptive of the reference implementation’s behavior, not normative requirements.

Why off by default. Two reasons. First, tiny_http is a network-listening dependency: even bound to 127.0.0.1, an HTTP server in the default build expands the supply-chain surface area of a tool whose primary job is parsing Cookfiles and orchestrating subprocesses. Second, the viewer is a developer-affordance flag — the median cook invocation in CI, in containers, and in scripted callers never reaches --dag. Pulling tiny_http, open, and the embedded HTML asset into every binary to support a flag most users never run is the wrong default. Distros and packagers who want the viewer enable it explicitly; everybody else gets a smaller binary and a smaller dependency footprint. The opposite default (“on, but opt-out via --no-default-features”) would have been defensible for symmetry with cargo idioms, but the supply-chain argument and the “most users never run --dag” observation tip the choice toward default = []. This is the conservative pre-1.0 default; if v1.0 ships with the viewer reaching feature parity with mainstream visualization tools and gaining broad enough adoption that turning it off becomes the surprising case, the default can be revisited then.

Reference-implementation contract. When the viewer feature is enabled, cook --dag <RECIPE> MUST behave exactly as it did pre-CS-0047: prepare the wave-grouped DAG, start a local HTTP server bound to a random 127.0.0.1 port, open the user’s default browser at that port, and serve the embedded viewer page until interrupted. When the feature is disabled, cook --dag MUST exit with a non-zero status and a diagnostic that names the rebuild flag (cargo build --features viewer). The split is a refactor — no behavior visible to a viewer-feature-enabled build is changed. The data-shaping helper (build_wave_dag_data from §App. E.10’s CS-0027 work) and the serve loop (serve_dag) move bytes-for-bytes into the new crate; the only Rust-level change is replacing the local CookError use in dag_server.rs with a crate-local ViewerError variant that the cook-cli wrapper maps back to CookError::Other at the boundary.

Conformance impact. None. The Standard does not pin cmd_dag behavior, the conformance harness does not exercise it, and no Cookfile authoring surface is touched.

Reference: the CS-0047 commit on the cs-0047-dag-viewer-extract branch.

D.48. CS-0048 — Wire-format schema versioning for events.jsonl, recipe cache, DAG viewer JSON.

Date: 2026-05-03

Version: v0.8 (unreleased)

Sections affected: none in the normative chapters (Cookfile language and execution semantics are unchanged). App. E.11 (events.jsonl wire format) and App. E.12 (events.jsonl normative-section gap) gain informative cross-references to this entry. Reference-implementation: cli/crates/cook-progress/src/event.rs (new PROGRESS_SCHEMA_VERSION constant), cli/crates/cook-progress/src/render/json.rs (writer reads the constant; new check_schema_version reader helper with SchemaCheckError), cli/crates/cook-progress/src/log_store.rs (the events.jsonl line writer reads the same constant), cli/crates/cook-progress/src/lib.rs (re-exports), cli/crates/cook-cache/src/store.rs (RecipeCache.version renamed to schema_version with serde alias = "version" for bincode-positional-back-compat; load-time policy doc-comment), cli/crates/cook-cache/src/manager.rs (test field rename), cli/crates/cook-dag-viewer/src/lib.rs (new VIEWER_SCHEMA_VERSION constant), cli/crates/cook-dag-viewer/src/dag_data.rs (schema_version: u32 added to WaveDagData), cli/crates/cook-dag-viewer/src/dag_viewer.html (entry-point JS guard refuses payloads with schema_version > 1).

Summary. Cook ships three serialized wire formats that v1.0 commits to as long-lived integration surfaces: the events.jsonl event stream (App. E.11), the on-disk recipe cache (.cook/cache/<recipe>.bin, referenced by App. D.42), and the DAG-viewer JSON payload served by cook --dag to the embedded viewer. Pre-CS-0048, only the recipe cache carried an explicit version number on the wire (an integer field tagged version and equality-checked against CACHE_VERSION on load); the events.jsonl format already emitted a top-level v integer per line but it was a hardcoded literal with no exported constant or reader-side policy, and the DAG-viewer JSON had no version field at all. CS-0048 pins all three to schema_version = 1 at the v1.0 cut and codifies the read/write/evolution policy that future v1.x revisions will follow.

The three constants. A single named constant per crate sources the writer-side value, so a future bump is a single-line edit:

  • cook_progress::PROGRESS_SCHEMA_VERSION: u32 = 1 — the value emitted as the top-level v field on every events.jsonl line and on every --output=json line. Both the JsonWriter renderer and the LogStore event-recorder read from this constant.
  • cook_cache::store::CACHE_VERSION: u32 = 3 (re-exported from cook_fingerprint::record) — the value of the schema_version field on every persisted RecipeCache and a fingerprint input. The dual role is intentional: a fingerprint-side bump (any change to StepEntry / FileRecord / per-step keys) is by definition an incompatible on-disk-format change, so the two move together.
  • cook_dag_viewer::VIEWER_SCHEMA_VERSION: u32 = 1 — the value of the schema_version field on the WaveDagData JSON payload that cook --dag splices into the embedded viewer. The matching JS-side constant in dag_viewer.html enforces the read policy in the viewer.

Read policy. A reader MUST refuse a payload whose schema_version exceeds the highest version it recognises (“the file was written by a future cook”). A reader MUST accept any schema_version at or below its highest recognised version (additive-only evolution within a major version means older writers’ missing fields are filled by #[serde(default)]). Concretely:

  • events.jsonl: cook_progress::check_schema_version returns Ok(v) for v <= PROGRESS_SCHEMA_VERSION and Err(SchemaCheckError::Unsupported { found, max_known }) otherwise. Lines whose v field is missing or non-integer are also refused (MissingVersion, InvalidJson).
  • Recipe cache: RecipeCache::load returns None (cache-miss; the file is regeneratable) if the persisted schema_version does not match CACHE_VERSION. The check is exact equality today, not the forward-compatible <= CACHE_VERSION form, because pre-v1.0 caches used a positional bincode layout where field evolution was non-additive — older files cannot be safely deserialized into the current struct shape regardless of intent. The <= form takes effect at v1.0 once the additive-only contract starts.
  • DAG-viewer JSON: the embedded JS at the entry point of dag_viewer.html refuses payloads whose schema_version exceeds VIEWER_SCHEMA_VERSION, replacing the canvas with a diagnostic banner and a rebuild instruction. A missing field is treated as legacy v1 (defensive default — no v0.x payload omitted the field, but the guard prefers a render to a hard error if the field is somehow stripped in transit).

Evolution policy. Future revisions MUST be additive-only within a major version. New fields are introduced via #[serde(default = "...")] (or, on the viewer side, a JS typeof === 'undefined' guard with a sensible default). Existing fields are NEVER renamed or removed; enum variants are NEVER renamed or removed. A schema_version bump signals an incompatible structural change — at v1.0 all three formats lock in schema_version = 1 (events.jsonl, DAG viewer) / schema_version = CACHE_VERSION (recipe cache); v1.x revisions that need an incompatible change bump to 2 and document the migration in App. D.

Why the events.jsonl envelope stays flat. The plan considered three shapes for the events.jsonl envelope: (a) wrap each line in a LineEnvelope { schema_version, event } outer struct; (b) flatten a shared schema_version field onto every variant via #[serde(flatten)]; (c) build the envelope manually next to the existing internally-tagged enum. The reference implementation already emits a flat envelope with ts, v, and the event payload’s keys interleaved in lex order (CS-0035) — i.e. shape (c). CS-0048 does NOT switch to (a): the lex-order invariant is a documented v1.0 stability contract, and a nested event object would shift every key one level deeper and reorder the payload around it. Instead, the existing flat shape is recognised as the envelope, the existing v field is recognised as the schema-version field (this matches the App. E.12 description of the events.jsonl envelope already), and the only behavioural change is that v now sources its value from PROGRESS_SCHEMA_VERSION and reader-side validation is provided by check_schema_version. The wire bytes are unchanged from the v0.7 cut.

Why RecipeCache.version was renamed to schema_version. The v0.7 field name version was a vestige of the cook-cache crate’s pre-cook-fingerprint era when the same constant tagged both a schema-version and a “cook ABI” generation. Post-CS-0048, the field is exclusively a schema-version, and the rename aligns its name with the same semantics as the events.jsonl v field and the DAG-viewer payload’s schema_version field. bincode is positional, so the rename is invisible to existing on-disk caches — the field’s serialized position is unchanged. A #[serde(alias = "version")] is added defensively for any future JSON / non-positional encoding of the same struct.

Why the DAG-viewer JSON gains a field. The --dag viewer is the only one of the three formats with a JS-side reader living outside the Rust workspace. Pre-CS-0048, an experimental viewer on top of an older payload (or a future cook on top of an older viewer) had no way to detect the mismatch — the JS would just render whatever shape it found, with silent omissions on missing fields and silent ignores on unrecognised ones. The new schema_version field plus the JS guard at the entry point of dag_viewer.html makes the mismatch loud and named.

Conformance impact. The conformance harness exercises Cookfile parsing, register-phase semantics, and execute-phase semantics; none of these surfaces touch the three wire formats covered by CS-0048. The new behaviour is pinned by unit tests in cook-progress (writer_emits_schema_version_constant, check_schema_version_accepts_current_version, check_schema_version_accepts_lower_versions, check_schema_version_rejects_higher_versions, check_schema_version_rejects_missing_v_field, check_schema_version_rejects_invalid_json) and cook-cache (load_v2_returns_none_via_version_check retained, new load_future_schema_version_returns_none and schema_version_field_round_trips_at_cache_version).

Cross-references. App. E.11 (informative) describes the events.jsonl envelope keys (ts, v, type) and the per-event field schemas; CS-0048 is the entry that pins v as PROGRESS_SCHEMA_VERSION. App. E.12 (informative) flagged the lack of a normative section for events.jsonl; CS-0048 is the v0.x reference-implementation half of that work — pinning the version constant and the read policy ahead of the v1.0 normative-section pass that App. E.12 calls for.

Reference: the CS-0048 commit on the cs-0048-schema-versioning branch.

D.49. CS-0049 — Rust public-API hygiene: #[non_exhaustive] on language-surface enums + cook-luagen dead-code removal.

Date: 2026-05-03

Version: v0.8

Sections affected: none in the Standard prose. Reference-implementation only: cli/crates/cook-contracts/src/lib.rs (OutputStream, StepKind, WorkPayload, DepKind); cli/crates/cook-lang/src/lib.rs (ParseError); cli/crates/cook-lang/src/ast.rs (Step); cli/crates/cook-luagen/src/resolver.rs (IterMode, OutputShape, BuiltinKind, Resolved, ResolveError); cli/crates/cook-luagen/src/cook_step.rs (dead cook_step_mode removal); cli/crates/cook-luagen/src/template.rs (dead output_pattern_kind and unused accessor field on OutputPatternKind::DepDriven). Downstream wildcard-arm additions in cli/crates/cook-luaotp/src/pool.rs, cli/crates/cook-engine/src/dag_builder.rs, cli/crates/cook-luagen/src/{recipe,dep_ref}.rs, and cli/crates/cook-lang/tests/conformance.rs.

Summary. Marks every Rust enum on a language-surface public API in the reference implementation as #[non_exhaustive] so that the implementation can grow new variants post-1.0 without forcing a major bump on the Rust crate API. The annotation is purely a Rust source-level guard: downstream Rust consumers MUST add a wildcard match arm against any of these enums; in-crate matches are unaffected. Wire-format compatibility for serialized enums (cook_progress::ProgressEvent and friends) is governed independently by CS-0048’s schema-versioning policy — #[non_exhaustive] does not touch serialization.

The same commit removes a small set of stale #[allow(dead_code)] items in cook-luagen whose live counterparts (the recipe-name-aware variants) have replaced them in production code paths. The removed surface is internal (pub(crate)), so this is not an API change.

Enums marked. Each is on a public API surface that the reference implementation expects to extend post-1.0:

  • cook_contracts::OutputStream — fd-of-origin tag for captured child output (CS-0043). New variants (e.g., Combined, Pty) are conceivable.
  • cook_contracts::StepKind — step-kind tag plumbed through WorkPayload::LuaChunk for sandbox policy selection (CS-0045). New step kinds map directly to new variants.
  • cook_contracts::WorkPayload — kinds of captured work; new payload kinds (e.g., a future host-tool invocation primitive) are anticipated.
  • cook_contracts::DepKind — within-recipe dependency edges; the parallelism story is not closed (e.g., a future “fan-out” kind).
  • cook_lang::ParseError — top-level parser error categories; new error kinds are routinely needed as language surface grows.
  • cook_lang::ast::Step — the seven-kind step cascade; this enum has historically grown (per CS-0015, CS-0020) and will keep growing.
  • cook_luagen::resolver::{IterMode, OutputShape, BuiltinKind, Resolved, ResolveError} — the closed-set placeholder resolver’s surface (CS-0033). Future builtins, modes, or resolution outcomes attach here.

Forward-compatibility policy. Adding a new variant to any #[non_exhaustive] enum in the reference implementation is a SemVer-minor change for the affected Rust crate: external consumers will need to refresh their match arms, but their code continues to compile because they were already required to write a wildcard arm. This policy is consistent with how the standard library applies #[non_exhaustive] (std::io::ErrorKind, etc.). It is independent of and complementary to CS-0048, which governs the wire-format SemVer of serialized enums in cook-progress and any other on-disk or IPC surfaces.

Dead-code removal. Three items were flagged #[allow(dead_code)] in cook-luagen and have been removed because their recipe-name-aware counterparts subsume them in production:

  • cook_step::cook_step_mode() — superseded by cook_step_mode_with_names(). The five cs_0022_mode_tests test cases were migrated to call cook_step_mode_with_names (with empty or populated recipe-name sets, as appropriate) so the coverage stays.
  • template::output_pattern_kind() — superseded by output_pattern_kind_with_recipes(). Was only reachable through the removed cook_step_mode() and is dropped together with it.
  • template::OutputPatternKind::DepDriven { accessor: String, .. } — the accessor field on this variant was constructed but never read. The variant itself is not removed (it is matched in cook_step.rs and recipe.rs); only the dead field is dropped, keeping dep_name and lua_expr.

Conformance impact. None. No surface syntax, execution semantics, Cook Lua API, or module-system behavior is changed. The conformance harness (cargo test -p cook-lang --test conformance) passes against this commit unchanged.

Why a v0.8 entry on a Rust-only change. The reference implementation lives in lockstep with the Standard pre-1.0 (CS-0012). Even when a change is purely a Rust API hygiene matter, it lands in App. D so that the changelog remains the single chronological record of every amendment to anything the Standard or its reference implementation publish to consumers — internal and external. The v0.8 row in the Versions index opens here; subsequent v0.8 entries (including the CS-0048 wire-format work and other hygiene items en route to a cs-standard/v0.8 cut) will land alongside.

Reference: the CS-0049 commit on cs-0049-non-exhaustive-and-deadcode.

D.50. CS-0050 — Engine MUST create parent directories of declared cook-step outputs before the step runs.

Date: 2026-05-03

Version: v0.8

Sections affected: § 18.1 (new §8.7 normative subsection in the Execution model chapter; §8.7 “Diagnostic ordering” renumbered to §8.8). App. D Versions index v0.8 row extended to include CS-0050. standard/scripts/slug-mapping.ts registers the new clause and renumbers the diagnostic-ordering slug. Reference-implementation: cli/crates/cook-engine/src/executor.rs (new ensure_output_parent_dirs helper, threaded through process_ready and the interactive main-thread path; new failures parameter on process_ready; three new unit tests pinning the create / parent-is-file diagnostic / idempotent paths plus a unit-helper test for the no-parent edge case). New audit fixture cli/e2e-fixtures/engine_audit_auto_mkdir_outputs/ exercises the end-to-end behaviour against the built cook binary.

Summary. Pre-CS-0050, the engine dispatched a cook step’s shell text into a worker subprocess without first ensuring that the directory of the step’s declared output existed. A recipe such as cook "build/out/foo.txt" using { ... } would fail with bash: build/out/foo.txt: No such file or directory unless the author manually wrote mkdir -p build/out (or mkdir -p $<out.dir>) at the head of every cook-step shell body. An audit pass found this boilerplate in nearly every example fixture; it is unconditional, mechanical, and exactly the kind of deterministic prelude a build engine can synthesize on the author’s behalf.

CS-0050 closes that gap by adding a normative § 18.1 subsection that requires a conforming implementation to ensure the parent directory of every output path declared by a cook step exists before invoking the step’s shell text. The creation MUST be idempotent — mkdir -p-style — and MUST NOT proceed if the parent path resolves to a non-directory (e.g., a regular file already exists at that path); the implementation MUST in that case surface a clear diagnostic naming the output and the offending parent, and MUST NOT execute the step’s shell text. Output paths are resolved against the unit’s working directory using the same resolution that the cache fingerprint applies (per § 17), so the rule composes cleanly with cross-Cookfile imports (CS-0017, CS-0044) and the //-anchored sigil import path form (CS-0032).

Scope. The rule applies to cook steps only. plate (§4.7, since removed) and test (§ 8.6) steps declare no cacheable output paths and are therefore not covered; authors of plate and test step bodies that need to create directories MUST do so explicitly, exactly as before. Inputs are not affected — a missing input remains a hard error (the user’s bug). An output path with no parent component (a path that resolves to the working directory itself) is a no-op.

Why this is the engine’s job. Every cook step declares its output paths up-front so the cache can fingerprint them, the DAG can route dependencies, and the § 16.1 collision detection (CS-0042) can reject duplicate writers at plan time. The parent directory of a declared output is a mechanical consequence of that declaration; making the author repeat it in shell text adds boilerplate without improving determinism, cache fidelity, or sandbox guarantees. The precedent in adjacent build systems is consistent (Make’s $(@D) idiom, Ninja’s autogenerated mkdir rules, Bazel’s sandbox-setup parent-dir creation); Cook now matches.

Idempotence and concurrency. create_dir_all-style creation is concurrency-safe under POSIX: multiple processes (or step-group siblings) targeting the same parent directory all succeed without contention. CS-0042 already prevents two recipes from declaring the same output path without a dep edge, but two recipes legitimately sharing a parent directory (e.g., both writing into dist/) is the common case and continues to work without synchronization. A symlinked parent that points to an existing directory is idempotently accepted; a symlinked parent that points at a regular file is rejected with the same diagnostic shape as a direct regular-file collision.

Sandbox interaction. The pre-step directory creation is the engine acting on a path that the register phase has already declared as an output of the unit. It is not a Cook Lua API call and is therefore independent of the fs.* sandbox in § 25.1 (CS-0045). Out-of-project output paths are governed by the cache contract in § 17 and the path-resolution rules in § 20.1; CS-0050 does not relax them.

Existing recipes. The rule is purely additive on the success side: a recipe that already contains mkdir -p $<out.dir> (or any equivalent) in its cook-step shell text continues to work. The author’s explicit mkdir -p becomes idempotently harmless after the engine’s pre-step mkdir -p runs. Sweeping the redundant boilerplate out of existing fixtures is left to a follow-up commit; CS-0050 does not require it.

Implementation note. The reference implementation lives in cli/crates/cook-engine/src/executor.rs. A new private helper ensure_output_parent_dirs(work_node) -> Result<(), String> walks work_node.cache_meta.output_paths (the gate is non-empty cache_meta.output_paths, which is set only for cook steps — plate and test register-time helpers leave cache_meta = None), resolves each output against work_node.working_dir, takes the parent component, checks for a non-directory at that parent, and falls through to std::fs::create_dir_all. The helper is invoked from process_ready immediately after NodeStarted and before pool.submit, and from the interactive main-thread loop immediately before run_interactive_on_main. On Err, the call site surfaces a NodeFailed event with a CS-0050:-tagged diagnostic, records the failure, and cancels the transitive dependent subtree — the same control flow the executor uses for a worker-pool failure.

Audit fixture. cli/e2e-fixtures/engine_audit_auto_mkdir_outputs/walkthrough.sh cleans any pre-existing build/ directory, runs cook build against the just-built debug binary, and asserts that the declared output build/out/foo.txt exists with the expected content even though the recipe does not contain mkdir -p. The fixture is a single positive case; the negative parent-is-file path is pinned by the test_executor_cs_0050_parent_is_file_diagnostic unit test in cook-engine.

Conformance impact. None at the harness level — the conformance corpus exercises Cookfile parsing, register-phase semantics, and cross-recipe scheduling, and a recipe whose declared output sits in a missing subdirectory was already accepted by parser and register paths. The runtime behavioural change (pre-step parent-dir creation) is pinned by the new cli/crates/cook-engine/src/executor.rs unit tests and the cli/e2e-fixtures/engine_audit_auto_mkdir_outputs/ audit fixture. cargo test --workspace passes (773 → 773 + 4 new tests).

Reference: the CS-0050 commit on cs-0050-auto-mkdir-output-parents.

CS-0051 — Chore body executes as a single drain

Date: 2026-05-04

Version: v0.8

Sections affected: § 7.3 (4a.3 prose tightened; the per-step draining sentence is replaced with a normative single-drain rule, and Example 4a.3.1 commentary is aligned). App. D Versions index v0.8 row extended to include CS-0051. Reference-implementation across cli/crates/cook-contracts, cli/crates/cook-register, cli/crates/cook-luagen, cli/crates/cook-engine, and cli/crates/cook-progress; the layered Rust changes land on cook-progress-cargo-style over the CS-0051 commit series.

Summary. Pre-CS-0051, a chore body’s shell steps each compiled to a separate interactive work unit, and the engine treated each as its own drain (§ 16.2). §4a.3 carried the explicit normative claim “every shell step is its own draining unit.” The reference progress renderer therefore emitted one Running <chore> line per shell step and leaked the parser’s internal @<line> fallback labels into the verb stream as Cooked <chore>/@N between body steps. The chore-vs-recipe semantic — that a chore takes the controlling terminal as one continuous handoff session — was implicit; the normative wording contradicted user expectation by permitting other recipes’ work to interleave between body steps.

CS-0051 makes the single-drain semantic normative for chore bodies. A conforming implementation MUST execute every imperative-region step of a chore body within a single drain: from the moment the first imperative-region step begins, no work unit of any other recipe MAY run until the body completes. The body’s shell steps and execute-phase Lua steps run in source order on the controlling terminal under one shared drain window. The interactive-drain rule of § 16.2 continues to govern individual interactive units in non-chore contexts (recipe-internal @ steps); only the chore-body case is collapsed.

Scope. The single-drain rule covers chore bodies only. Recipe-internal @-prefixed shell steps and the interactive = true flag on cook.add_unit (§ 22.1) continue to drain per-unit per the existing § 16.2 rule. A chore body’s failure semantics are unchanged from the per-unit case: the first failing step ends the drain and propagates through the cancellation cascade as before. The prior “every shell step is its own draining unit” wording’s implication that a chore body could partially complete with mixed-recipe interleaving is removed.

Implementation note. The reference implementation flags chore-body units at register time. cook-contracts::WorkPayload::Interactive gains an is_chore: bool field; the runtime’s cook.add_unit registrar (cook-register/src/unit_api.rs) reads CaptureState.current_chore_active (set by cook._enter_chore / cook._exit_chore) and stamps the flag accordingly. cook-luagen::compile_chore ensures every chore-body unit (shell or Lua-bundle) is emitted with interactive = true so they all reach the executor’s interactive-drain queue. The executor (cli/crates/cook-engine/src/executor.rs) groups consecutive same-recipe interactive entries marked is_chore = true into a single drain window, emits one InteractiveStart / InteractiveEnd event pair for the whole window, and on mid-drain failure attributes the failure to the body via a step k/n: … diagnostic. The progress renderer (cli/crates/cook-progress/src/render/event_writer.rs) renders the window as a single Running <chore> line and suppresses per-step verb lines for the body’s anonymous shell units.

Conformance impact. None at the parser level; CS-0051 is an execute-time rule. The conformance corpus does not currently exercise chore-body scheduling shape — the reference-implementation behaviour change is pinned by new unit tests in cook-engine (chore-window grouping; mid-drain failure attribution; legacy non-chore interactive regression) and end-to-end rendering snapshots in cook-progress.

Test note. The cook-register runtime test suite pins the chore-mode flag plumbing via add_unit_inside_chore_marks_payload_is_chore_true and add_unit_outside_chore_marks_payload_is_chore_false — the two canonical cases of _enter_chore / _exit_chore round-tripping into WorkPayload::Interactive::is_chore.

Lua-bundle integration. The reference implementation enforces the single-drain rule for both shell-step and Lua-bundle chore bodies. WorkPayload::LuaChunk gains an is_chore: bool field set by the runtime registrar from CaptureState.current_chore_active, mirroring the Interactive plumbing. The executor’s chore-window pre-walk admits either payload variant when the flag is set; the per-step execution branches on payload kind — Interactive runs via run_interactive_on_main on the drain thread, LuaChunk submits to the worker pool and synchronously awaits the single result (chore windows enter only when pending == 0, so the next pool_rx.recv() returns the in-flight Lua item). The user-observable property of CS-0051 — that a chore body executes as one drain on the controlling terminal — holds for any mix of shell and Lua imperative-region steps in the body.

Reference: the CS-0051 commit series on cook-progress-cargo-style.

CS-0052 — Declared toolchain pinning for cross-machine cache correctness [#changes.cs-0052]

Date: 2026-05-04

Version: v0.8

Sections affected: § 17 Note 8.6.2 amended (final paragraph added — declared-toolchain mechanism). Reference-implementation: cli/crates/cook-cache/src/cloud_config.rs (CacheSection.tools: Vec<String> field plus cache_tools() accessor), cli/crates/cook-fingerprint/src/context.rs (DeclaredToolError, ExecutionContext::probe_with_declared_tools, ExecutionContext::declared_tools_hash, ExecutionContext::declared_tools, compute_declared_tools_hash; step_context_hash folds the declared-tools hash before the per-step tool fold), cli/crates/cook-engine/src/run.rs (build-time wiring at the cache bootstrap site; misdeclaration becomes EngineError::CacheError before any unit registers).

Summary. Cook’s per-step context_hash already folds the SHA-256 content of every binary the engine can identify by walking the rendered command body — but that walk has a documented coverage limit (first executable token of each newline-separated statement; tools invoked downstream of &&/||/pipes/bash -c etc. are not captured). On a single-developer machine the gap is harmless because the elided tools are constant; on a shared cache it is a soundness gap. CS-0052 introduces an explicit, opt-in declared toolchain — a list of executable names the project promises will be relevant to every unit’s identity regardless of whether the auto-detection sees them in the literal command text. Each declared name is resolved via which at build start, the resulting binary is SHA-256’d, and a single combined hash folds into every step’s context_hash. An empty or absent declaration produces a deterministic sentinel-zero contribution (so projects without the configuration get a stable, distinguishable identity contribution); a misdeclared name causes the build to fail before any unit executes (loud failure surfaces typos, which is the entire point of explicit declaration). The reference implementation exposes the mechanism as cache.tools = ["gcc", "ld", …] in .cook/cloud.toml. The intended audience is environments where toolchain identity is governed at the environment layer — dev containers, nix-shell, hermetic Docker images — where atomic environment swaps match global-fold semantics and the auto-detection cannot see binaries hidden behind shell indirections.

Why now. The cross-machine cache is the load-bearing piece of Cook Cloud. The 2026-05-01 cache-cloud-readiness work locked the trait surface and the per-step context hash; cross-machine correctness — Bob serving Alice’s cache-hit safely — requires that toolchain identity participates in the key. Auto-detection covers the typical case (gcc -c …); the declared-toolchain mechanism is the explicit escape hatch for the cases auto-detection cannot reach. Without it, a consumer who upgrades only their strip (while leaving gcc pinned) silently serves stripped objects produced by the old strip to other machines.

Conformance impact. No grammar change. No Lua API change. No on-disk schema change. StepEntry.context_hash’s storage and shape are unchanged; its value differs once the eight-byte declared-tools-hash field folds into the hash input — this is a one-shot rebuild for every project on the upgrade boundary regardless of whether the project opts into cache.tools. Eviction reaps the orphaned pre-CS-0052 entries on its own schedule. The [cache] tools field has #[serde(default)], so pre-amendment .cook/cloud.toml files parse identically.

Test plan. Seven unit tests in cook-fingerprint::context::tests (CS-0052 markers) pin: empty-input sentinel zero, determinism, content sensitivity, membership sensitivity, order independence, missing-tool errors, and step-context-hash fold contribution. Four integration tests in cli/crates/cook-cache/tests/integration_declared_tools.rs exercise the full cloud_key-construction path: empty-tools sentinel, declared-tool changes the cloud_key, misdeclared tool errors at probe with a diagnostic that names devcontainer/nix-shell, and content-drift on the same declared name yields different cloud_keys. End-to-end coverage in cli/e2e-fixtures/cache_benchmarks/verify.sh scenario 18: adding tools = ["gcc"] to .cook/cloud.toml causes a one-shot full rebuild, the next run fully hits, and a misdeclared tool prints the configured error.

Reference: the CS-0052 commit series on main.

CS-0054 — SHA-256 content integrity for cloud-grade cache backends [#changes.cs-0054]

Date: 2026-05-04

Version: v0.8

Sections affected: § 17.2 amended (one normative SHOULD-sentence appended to the “MAY use any deterministic fingerprint” paragraph — implementations exposing the artifact store to writers other than the local build SHOULD use a cryptographically secure fingerprint). Reference-implementation: cli/crates/cook-fingerprint/src/backend.rs (ArtifactMeta.content_hash: [u8; 32] field with #[serde(default)] plus ArtifactMeta::zero_content_hash() placeholder helper; CacheBackend::get / CacheBackend::put trait-doc contract tightened — implementations MUST self-verify content integrity before returning bytes, and MUST stamp content_hash with SHA-256 of the bytes before persisting the sidecar), cli/crates/cook-cache/src/backend.rs (LocalBackend::put computes SHA-256 over the bytes and stamps the sidecar; LocalBackend::get recomputes and fails closed on mismatch, missing sidecar, or malformed sidecar; sidecar write becomes atomic via meta.json.tmp + rename), cli/crates/cook-engine/src/executor.rs (four call-site struct constructors initialise content_hash with the zero sentinel — put overwrites authoritatively).

Summary. Cook’s pre-CS-0054 cache integrity check used xxh3_64 (cli/crates/cook-fingerprint/src/check.rs:362-365) — fast, deterministic, sound against accidental drift, but non-cryptographic. A 64-bit collision is ~2^32 work under birthday bounds, well within reach for an adversary with write access to a shared artifact bucket. CS-0054 closes the soundness gap by adding a backend-self-verifying SHA-256 layer at the CacheBackend::get boundary: put computes SHA-256 of the bytes and stamps it into ArtifactMeta.content_hash; get recomputes the SHA-256, compares to the recorded value, and returns Ok(None) on mismatch (treat as miss, fail closed). The bytes returned by a conforming get are byte-identical to the bytes most recently put under that key — the contract holds equally for LocalBackend and any future cloud backend, with no change to the trait signature. The existing xxh3_64 check in check.rs is retained: it guards the engine-to-backend value contract (bytes match the recorded output.hash), while CS-0054 guards the backend’s internal storage contract (what came out is what was put in).

Why now. The single-machine cache survives non-cryptographic integrity because the only writer is the local build — nobody else has byte-level access to the CAS. A multi-tenant SaaS backend does not have that property: bytes can be tampered with by parties other than the local build, by network corruption, or by a misconfigured replication tier. The existing trait surface was locked in 2026-05-01 specifically so the cloud backend could ship without re-litigating the engine-side seam; the integrity primitive is the last piece of that seam that needed cloud-grade hardening before the cloud backend lands.

Threat model. Defended: bit-flip / disk corruption (local), network corruption (shared backend), and bytes-only tampering on a shared backend (an adversary with write access to the byte object but not the meta sidecar). The collision-resistance bound is ~2^128 for SHA-256, vs. the prior ~2^32 for xxh3_64. Out of scope (deferred to signed-artifact / SLSA work): an adversary that consistently rewrites both bytes and meta.content_hash to a self-consistent pair. CS-0054 is the bytes-only-tampering layer; the bytes-and-meta-consistent-tampering layer is a separate design.

Conformance impact. No grammar change. No Lua API change. No CACHE_VERSION bump. The new content_hash field on ArtifactMeta carries #[serde(default = "ArtifactMeta::zero_content_hash")], so pre-CS-0054 sidecars deserialize cleanly; their content_hash defaults to [0; 32], which fails the integrity check on first read and surfaces as a miss — a one-shot rebuild for any project that upgrades across the boundary, identical to the orphan-on-upgrade behaviour CS-0052 §4.4 documented. Eviction reaps pre-amendment entries on its own schedule. The CacheBackend::get and CacheBackend::put signatures are unchanged; the contract tightens (implementations MUST self-verify, MUST stamp content_hash). This is a documented breaking change for any out-of-tree implementer; the in-tree LocalBackend is the only implementer today.

Test plan. Five new unit tests in cook-cache::backend::tests pin: put_computes_sha256_in_meta (round-trip, sidecar inspection, known-answer SHA-256 match, sentinel-overwrite assertion), get_succeeds_when_bytes_match_meta (happy round-trip), get_fails_closed_on_byte_tamper (mutate the on-disk bytes file post-put, sidecar untouched), get_fails_closed_on_meta_tamper (rewrite the sidecar’s content_hash to a different value), get_fails_closed_on_missing_meta (rm the sidecar — no integrity proof, must fail closed). Existing call sites and integration tests across cli/crates/cook-engine/src/executor.rs and cli/crates/cook-cache/tests/ were updated to pass the zero sentinel; their continued green status is the regression bar. End-to-end coverage in cli/e2e-fixtures/cache_benchmarks/verify.sh (31 scenarios) and cli/e2e-fixtures/cache_dep_drift/verify.sh (3 scenarios) exercises the full put/get round-trip through real builds.

Reference: the CS-0054 commit on saas-prep-phase-1-2.

CS-0055 — Backend trait idempotency contract for the artifact store [#changes.cs-0055]

Date: 2026-05-04

Version: v0.8

Sections affected: § 17.2 amended (one normative MUST-paragraph appended — the write-side analogue of the read-side fingerprint check; conflicting puts MUST error, identical re-puts MUST succeed). Reference-implementation: cli/crates/cook-fingerprint/src/backend.rs (CacheBackend::put trait-doc tightened with an explicit “Idempotency contract (CS-0055)” paragraph: a put to a key that already holds an artifact MUST distinguish identical bytes — Ok(()) no-op — from conflicting bytes — BackendError::Other with a diagnostic naming the key and the differing hashes; a missing or unparseable existing sidecar is treated as no prior artifact and writes through), cli/crates/cook-cache/src/backend.rs (LocalBackend::put pre-flights the existing sidecar’s content_hash against SHA-256(new_bytes); idempotent on match, errors on conflict, falls through on missing/malformed/zero-sentinel sidecar).

Summary. Pre-CS-0055 LocalBackend::put overwrote silently: put(k, bytes_a) followed by put(k, bytes_b) with bytes_a != bytes_b succeeded and the next get(k) returned bytes_b. On a single-developer machine this is harmless — the only writer is the local build, and a determinism bug that produces different bytes for the same cloud_key is a rebuild problem the user fixes locally. On a multi-tenant SaaS backend it is a soundness gap: two clients can race on the same cloud_key, and one client running with a poisoned toolchain (a binary not declared in cache.tools whose bytes drifted, an LD_PRELOAD shim, a compromised build environment) silently replaces the other’s legitimate artifact. The CS-0054 read-side check does not help — the conflicting put’s sidecar is self-consistent against its own bytes. CS-0055 closes the gap by requiring the write path to detect the conflict using the SHA-256 primitive CS-0054 already established: put reads the existing sidecar’s content_hash, compares against SHA-256(new_bytes), and on mismatch refuses with BackendError::Other and a diagnostic naming the key in hex. The write-side invariant the CS-0054 read-side check assumes — “one key, one byte sequence over the artifact’s lifetime” — becomes a normative property of the trait rather than an emergent property of the local-only deployment.

Why now. CS-0054 is the read-side soundness primitive for the cloud backend. CS-0055 is the write-side analogue. Both must land before the cloud backend ships, because the cloud backend’s HTTP-level CAS (e.g., R2’s If-Match: <etag>) needs a trait contract to honour — and the contract must be locked in before out-of-tree implementers exist. Pairing CS-0055 with CS-0054 keeps the integrity primitives in lockstep with the trait surface that 2026-05-01 locked.

Threat model. Defended: cross-tenant key collision with bytes drift (tenant A’s legitimate put(k, bytes_a) cannot be silently replaced by tenant B’s put(k, bytes_b)); local determinism-bug detection (a build that produces different bytes than a previous build for the same cloud_key surfaces as a put error at the moment the bug occurs, not as silent stale-cache symptoms downstream). Out of scope (deferred to signed-artifact / SLSA work): adversary with delete access (delete-then-put defeats the conflict check); adversary with sidecar-only write access (rewriting the sidecar’s content_hash to match the new bytes’ hash defeats the conflict check, though the CS-0054 read-side check still catches the inconsistent sidecar-vs-bytes pair on the next get).

Conformance impact. No grammar change. No Lua API change. No CACHE_VERSION bump. CacheBackend::put’s signature is unchanged; the contract tightens (implementations MUST reject conflicting bytes, MUST treat identical re-puts as idempotent). This is a documented breaking change for any out-of-tree implementer that previously overwrote silently. The in-tree LocalBackend is the only implementer today. Pre-CS-0054 sidecars (whose content_hash deserializes to the zero sentinel) are treated as “no prior artifact” by the write-through path, so the CS-0054 read-side fail-closed plus the CS-0055 sentinel-write-through give a clean orphan-on-upgrade behaviour.

Test plan. Four new unit tests in cook-cache::backend::tests pin the contract: put_idempotent_on_same_bytes (re-put with identical bytes succeeds and get round-trips), put_rejects_conflict (re-put with different bytes returns BackendError::Other whose message contains “conflict” and the key in hex; prior bytes preserved on disk), put_recovers_from_missing_meta (sidecar deleted between puts — second put writes through and restores a healthy entry), put_recovers_from_corrupt_meta (sidecar overwritten with non-JSON garbage — second put writes through). Existing tests (including local_backend_put_idempotent, which already passed identical bytes twice) continue to pass; the engine’s call sites never put conflicting bytes at the same key in steady state because cloud_key collapses identical inputs to identical keys. End-to-end coverage in cli/e2e-fixtures/cache_benchmarks/verify.sh (31 scenarios) and cli/e2e-fixtures/cache_dep_drift/verify.sh (3 scenarios) exercises the full put/get round-trip through real builds; every steady-state put is either to a fresh key or an idempotent re-put with identical bytes — both succeed under the new contract.

Reference: the CS-0055 commit on saas-prep-phase-1-2.

CS-0056 — Streaming CacheBackend trait via Box<dyn Read> [#changes.cs-0056]

Date: 2026-05-04

Version: v0.8

Sections affected: § 17.2 amended (one permissive sentence appended to the “MAY use any deterministic fingerprint” paragraph — fingerprint verification MAY be performed as bytes flow through a streaming reader, surfacing as an end-of-stream error; an implementation is not required to materialise the full artifact in memory before verifying). Reference-implementation: cli/crates/cook-fingerprint/src/backend.rs (CacheBackend::get returns Option<Box<dyn Read + Send>> instead of Option<Vec<u8>>; CacheBackend::put takes &mut dyn Read and &mut ArtifactMeta instead of &[u8] and &ArtifactMeta; the &mut on meta lets put stamp content_hash in-place so the caller observes the canonical hash without re-reading the sidecar; trait docs add a new contract — put MUST stream into a temporary, hash on the fly, finalize at EOF, and reject with BackendError::Other("caller-claimed content_hash differs from streamed bytes") if the caller passed a non-zero hash that disagrees with the streamed bytes), cli/crates/cook-cache/src/backend.rs (VerifyingReader<R> newtype that tees bytes through a SHA-256 hasher and surfaces mismatch as io::Error(InvalidData) at EOF; get_bytes / put_bytes free-function convenience helpers wrapping the streaming surface for callers that already have bytes in hand; LocalBackend::get returns a VerifyingReader-wrapped File; LocalBackend::put streams to a 64 KiB-buffered tmp file, computes SHA-256 on the fly, performs the CS-0055 conflict check after EOF, then commits via tmp-rename), cli/crates/cook-fingerprint/src/check.rs (try_restore drains the streaming reader via read_to_end and treats any read failure as a miss), cli/crates/cook-engine/src/executor.rs (four put call sites switched to the put_bytes helper; ArtifactMeta bound as mut so put_bytes can stamp content_hash).

Summary. Pre-CS-0056, the CacheBackend trait pinned its I/O shape at Vec<u8> / &[u8], forcing every artifact into memory in full on both get and put. For the typical 10–100 KB object file this is fine; for multi-GB artifacts (linked binaries, debug-info-rich shared objects, container layers in CI workflows) it OOMs the build. The shape also silently encoded “all bytes resident before verification” into the contract — there was no way for an implementer to hash-as-they-stream from disk on the local backend, or hash-as-they-stream from an HTTP body on the future CloudBackend, without violating the trait. CS-0056 reshapes the trait to Box<dyn Read + Send> / &mut dyn Read, preserving the CS-0054 integrity primitive by surfacing verification as bytes flow (a VerifyingReader<R> wrapper tees bytes through a SHA-256 hasher and raises io::Error(InvalidData) at EOF on mismatch) and preserving the CS-0055 conflict-detection contract by streaming the new bytes into a temporary file, computing the hash on the fly, comparing against any existing sidecar’s content_hash, and only renaming-into-place if the conflict check passes. Two free-function helpers (get_bytes, put_bytes) wrap the streaming surface with the old Vec<u8> / &[u8] ergonomics for engine call sites that already have bytes in hand and don’t benefit from streaming.

Why now. CS-0054 and CS-0055 hardened the trait’s integrity and write-side soundness; CS-0056 hardens its liveness. The future CloudBackend consumes streaming bodies natively (every modern content-addressable backend — R2, S3, GCS, KV — hands you a streaming reader), and a Vec<u8> shape would force the cloud backend to buffer multi-GB HTTP responses in memory before the engine could see a byte. The trait surface was locked in 2026-05-01 specifically so the cloud backend could ship without re-litigating the engine-side seam; the I/O shape is the last piece of that seam that needed cloud-grade hardening before the cloud backend lands.

Threat model. No new threat-model category. CS-0056 preserves CS-0054’s bytes-only-tampering defence and CS-0055’s cross-tenant-collision defence under a streaming I/O shape. The verification still surfaces at the backend boundary; the only delta is the timing — “as bytes flow + finalize at EOF” instead of “buffer then compare”. A caller that reads partial bytes from the verifier and acts on them before EOF would see attacker-controlled bytes; the engine’s restore path (and the get_bytes helper) drains the full reader before installing anything, so this is a discipline a caller MUST honour but is not a new vulnerability — pre-CS-0056, the same caller would have received the bytes in a Vec<u8> and acted on them before any verification. The streaming shape adds the option to verify-as-you-go; it does not remove the existing buffer-then-verify path (get_bytes is exactly that path, layered on top).

Conformance impact. No grammar change. No Lua API change. No CACHE_VERSION bump. The .bin and .meta.json on-disk formats are byte-identical to CS-0055; only the in-memory I/O shape changes. The CacheBackend::get and CacheBackend::put signatures CHANGED — out-of-tree implementers must rewrite, and the in-memory ArtifactMeta binding shifts from let meta = … to let mut meta = … at every call site. The in-tree LocalBackend is the only implementer today; the future CloudBackend consumes the new shape natively. The § 17.2 amendment is permissive (no new MUST) — the trait shape is implementation-internal and not language-surface; the new sentence makes explicit that incremental / streaming verification is conformant.

Test plan. Ten new unit tests in cook-cache::backend::tests pin the streaming contract: verifying_reader_passes_through_on_match, verifying_reader_errors_on_mismatch, verifying_reader_passes_through_on_empty_match, local_backend_get_returns_streaming_reader, local_backend_get_streaming_errors_on_byte_tamper, local_backend_put_streams_with_zero_sentinel, local_backend_put_rejects_caller_hash_mismatch, local_backend_put_accepts_caller_hash_match, put_bytes_helper_round_trip, get_bytes_helper_round_trip. Existing CS-0054 / CS-0055 tests preserved with semantic coverage intact, just rewritten against the streaming surface (most via the put_bytes / get_bytes helpers): put_computes_sha256_in_meta, get_succeeds_when_bytes_match_meta, get_fails_closed_on_byte_tamper, get_fails_closed_on_meta_tamper, get_fails_closed_on_missing_meta, put_idempotent_on_same_bytes, put_rejects_conflict, put_recovers_from_missing_meta, put_recovers_from_corrupt_meta. Five integration tests rewritten in lockstep: integration_first_build_depfile.rs, integration_config_toggle.rs, integration_multi_output_restore.rs, integration_discovered_inputs_restore.rs, integration_restore_on_hit.rs. End-to-end coverage in cli/e2e-fixtures/cache_benchmarks/verify.sh (31 scenarios) and cli/e2e-fixtures/cache_dep_drift/verify.sh (3 scenarios) exercises the full put/get round-trip through real builds.

Reference: the CS-0056 commit on saas-prep-phase-1-2.

CS-0057 — BackendConfig — timeout, retry, max-artifact knobs [#changes.cs-0057]

Date: 2026-05-04

Version: v0.8

Sections affected: None normative. The Standard already permits implementation-defined cache tunables under § 17 (“the hash function, on-disk layout, eviction policy, and storage medium are implementation-defined”); CS-0057 is a reference-implementation amendment that exposes user-overrideable backend knobs without amending normative text. Reference-implementation: cli/crates/cook-fingerprint/src/backend.rs (new BackendConfig struct alongside the trait — timeout: Duration, max_retries: u32, backoff_initial: Duration, backoff_max: Duration, max_artifact_bytes: u64; Default impl pins the spec defaults at 30s / 3 / 100ms / 5s / 1 GiB), cli/crates/cook-cache/src/backend.rs (LocalBackend gains a config: BackendConfig field and a with_config(root, config) constructor; the existing new(root) delegates to with_config(root, BackendConfig::default()) for back-compat; LocalBackend::put enforces max_artifact_bytes mid-stream — the existing 64 KiB-buffered streaming loop accumulates total bytes via saturating_add, and on overflow discards the temp file and returns BackendError::Other("artifact exceeds max_artifact_bytes ({total}); cap {limit}"); the timeout / max_retries / backoff_initial / backoff_max fields are no-ops on disk I/O but ride along for the future CloudBackend constructor; new public config() accessor for diagnostic / test use), cli/crates/cook-cache/src/cloud_config.rs (CloudSection gains five optional Option<u64> / Option<u32> fields — timeout_secs, max_retries, backoff_initial_ms, backoff_max_ms, max_artifact_mib; new CloudConfig::backend_config() method overlays the present values onto BackendConfig::default()), cli/crates/cook-engine/src/run.rs (cache-bootstrap site switched from LocalBackend::new(cache_dir) to LocalBackend::with_config(cache_dir, cloud_config.backend_config()) so the per-build BackendConfig is active for the build’s lifetime).

Summary. CS-0054 / CS-0055 / CS-0056 hardened the CacheBackend trait’s integrity, write-side soundness, and liveness; the surface that remained soft was operational — the four tunables every production cloud-cache client needs (per-call timeout, retry-with-exponential-backoff, backoff cap, max-artifact-size cap). CS-0057 lays the wiring without touching the trait shape: a new BackendConfig struct with sensible defaults lives alongside the trait in cook-fingerprint::backend; LocalBackend::with_config(root, BackendConfig) is the new constructor, with LocalBackend::new(root) preserved as a thin wrapper that delegates to with_config(_, default()); the engine’s cache bootstrap reads [cloud] overrides from .cook/cloud.toml (five new optional keys: timeout_secs, max_retries, backoff_initial_ms, backoff_max_ms, max_artifact_mib) and threads the resulting BackendConfig into the backend constructor. LocalBackend honours max_artifact_bytes today (a streamed-byte counter inside the 64 KiB-buffered put loop aborts oversize puts with BackendError::Other naming both the streamed total and the configured cap); the HTTP-shaped knobs (timeout, max_retries, backoff_initial, backoff_max) are no-ops on disk I/O but documented and ready for the upcoming CloudBackend whose constructor will accept the same BackendConfig shape with no plumbing churn.

Why now. CloudBackend does not exist yet — it lands in the next ticket. CS-0057’s job is to land the constructor-side seam before the cloud backend ships, so the cloud backend can be reviewed in isolation against a stable BackendConfig contract rather than introducing both the network code path and the configuration surface in a single PR. Pinning max_artifact_bytes enforcement at the streaming-put boundary today (on LocalBackend) also gets the test coverage in place against a deterministic, easily-pinned implementation; the future CloudBackend re-uses the same enforcement logic on its tmp-buffer-or-stream path.

Threat model. No new threat-model category. The max_artifact_bytes cap defends against resource exhaustion — a runaway producer (a build that mistakenly emits a multi-GB log file as an output, or a poisoned step that produces an enormous payload) can no longer fill local disk or burn cloud-storage budget unbounded. Defended: oversize puts (refused at streaming, with diagnostic naming the limit and total). Out of scope: server-side enforcement (the cloud backend will independently enforce server-side quotas; CS-0057 is the client-side fail-fast that saves the round-trip on oversized payloads), per-recipe overrides (a future [cache.recipes."foo"] table could lift the cap for specific recipes), and adaptive timeouts (a separate “auto-tune timeout based on observed latency” refinement).

Conformance impact. No grammar change. No Lua API change. No CACHE_VERSION bump. The on-disk byte and sidecar formats are byte-identical to CS-0056. The CacheBackend trait surface is unchanged — BackendConfig is constructor-side; nothing in the trait observes it. LocalBackend::new(root) is preserved as a thin wrapper over with_config(root, default()), so existing call sites (engine bootstrap, all unit tests, all integration fixtures) continue to compile and behave identically. Pre-CS-0057 .cook/cloud.toml files parse identically — the five new keys are #[serde(default)] and produce a BackendConfig::default() when absent.

Test plan. Four new unit tests in cook-cache::backend::tests pin the constructor and oversize-rejection contract: backend_config_default_values (sanity-check that BackendConfig::default() produces 30s / 3 / 100ms / 5s / 1 GiB), local_backend_with_config_honored (construct with a custom config, observe each field via the config() accessor), local_backend_put_rejects_oversize_artifact (set max_artifact_bytes = 100, put 200 bytes; assert Err, message contains “exceeds” and “100”, BackendError::Other variant, no artifact persisted at the key), local_backend_put_accepts_artifact_at_limit (put exactly max_artifact_bytes bytes; assert success and round-trip equality). Two new unit tests in cook-cache::cloud_config::tests pin the TOML overlay: backend_config_uses_defaults_when_unset (empty [cloud] produces BackendConfig::default() field-for-field), backend_config_overrides_from_toml (TOML with all five fields set produces the right Duration / u64 values, including the _secs / _ms / _mib unit conversions). End-to-end coverage in cli/e2e-fixtures/cache_benchmarks/verify.sh (31 scenarios) and cli/e2e-fixtures/cache_dep_drift/verify.sh (3 scenarios) exercises the LocalBackend::new → LocalBackend::with_config(_, default) rewrite path; their continued green status is the regression bar.

Reference: the CS-0057 commit on saas-prep-phase-1-2.

CS-0058 — CloudBackend skeleton — sync HTTP client over a v1 wire protocol [#changes.cs-0058]

Date: 2026-05-04

Version: v0.8

Sections affected: None normative. The wire protocol is implementation-defined per § 17 (“the on-disk layout, eviction policy, and storage medium are implementation-defined” — the network protocol is the cloud backend’s “storage medium”). Reference-implementation: new cli/crates/cook-cache/src/cloud_backend.rs (pub struct CloudBackend implementing CacheBackend over HTTP/1.1 against /v1/artifacts/... — batch_query POSTs JSON, get returns a VerifyingReader-wrapped response body keyed on X-Cook-Content-Hash, put streams the caller’s reader through a CappedReader that enforces max_artifact_bytes mid-stream, delete folds 404 into idempotent success, health GETs /v1/health; internal helpers for retry-with-jittered-exponential-backoff and HTTP-status-to-BackendError mapping); cli/crates/cook-cache/src/cloud_config.rs (new optional api_key: Option<String> field on CloudSection; new CloudConfigError::MissingApiKey and MissingEndpoint error variants; new resolved_api_key() accessor that prefers COOK_CLOUD_API_KEY env var over the TOML field, with empty env var treated as unset; load_or_default validation extended to require both endpoint and a resolved API key when cloud.enabled = true); cli/crates/cook-cache/src/lib.rs (one new pub mod cloud_backend; plus a pub use re-export of CloudBackend); cli/crates/cook-cache/Cargo.toml (one new runtime dependency — ureq = { version = "2", features = ["tls", "json"] } for sync HTTP + rustls TLS + send_json / into_json ergonomics; one new dev-dependency — mockito = "1" for HTTP mocking in unit tests); cli/crates/cook-engine/src/run.rs (cache-bootstrap site branches on cloud_config.cloud.enabled — when enabled, constructs CloudBackend::new(endpoint, resolved_api_key, backend_config) from the validated config; otherwise falls through to the existing LocalBackend::with_config path; the existing health-check pattern at the bottom of the bootstrap applies in either branch).

Summary. CS-0054 / CS-0055 / CS-0056 / CS-0057 hardened the CacheBackend trait — its integrity, write-side soundness, liveness, and operational tunables. The trait was ready to be implemented over a network. CS-0058 is that implementation: the client side of the wire protocol that the future Cook Cloud SaaS artifact server will speak. The protocol is versioned under /v1/: POST /v1/artifacts/batch_query (JSON body {keys: [hex...]}, response {present: [hex...]}), GET /v1/artifacts/{key_hex} (bytes streamed in body, meta carried in X-Cook-* headers — X-Cook-Content-Hash is load-bearing for the VerifyingReader wrap, the rest are meta), PUT /v1/artifacts/{key_hex} (bytes streamed from caller’s reader, same X-Cook-* headers, X-Cook-Content-Hash MAY be the zero sentinel and the server SHOULD stamp the computed hash on receipt), DELETE /v1/artifacts/{key_hex} (204 success, 404 idempotent success), GET /v1/health (200 healthy, anything else BackendError::Transient so the existing engine bootstrap pattern logs “backend unavailable” and disables for the build). Auth is bearer-token via Authorization: Bearer <api_key> on every request; the API key is resolved from COOK_CLOUD_API_KEY env var (priority 1) or [cloud] api_key in .cook/cloud.toml (priority 2); a missing key when cloud.enabled = true is a build-start error (MissingApiKey), identical in shape to the existing MissingProject. The status-code map is: 200/201/204 → success, 400 → BackendError::Other("bad request: ..."), 401/403 → BackendError::Unauthorized, 404 → caller-handled (get → Ok(None), delete → Ok(())), 409 → BackendError::Other("conflict at <key>: ...") (CS-0055 over the wire), 413 → BackendError::Other("server rejected oversize artifact: ...") (CS-0057 server-side), 429 → BackendError::QuotaExceeded, 5xx → BackendError::Transient (retried). The retry shell honours CS-0057’s BackendConfig: only Transient retries, up to max_retries, jittered exponential backoff (backoff_initial doubling each step, capped at backoff_max, ±25% uniform jitter applied to each sleep). The PUT path enforces max_artifact_bytes mid-stream via a CappedReader wrapper around the caller’s reader; on overflow the in-flight HTTP body write is aborted and the client returns BackendError::Other("artifact exceeds max_artifact_bytes ({total}); cap {limit}") — bytes never reach the wire on overflow, saving the round-trip on oversize payloads. The GET path wraps the response body in the same VerifyingReader LocalBackend::get uses on disk — the bytes either stream through to the caller’s read_to_end byte-identical to what the server sent, or the EOF read raises io::Error(InvalidData), identical fail-closed semantics on either backend.

Why now. CS-0054–CS-0057 locked the trait shape. CS-0058 is the first non-LocalBackend implementer; the wire protocol it pins is what the Cook Cloud server (a separate ticket) will be built against. Landing the client first lets the protocol shape be reviewed in isolation against a deterministic mock (mockito) before the server-side design absorbs both the protocol and the storage layer. The shape is also load-bearing for any future out-of-tree backend (an enterprise self-hosted artifact server, a vendor-specific S3-compatible facade); having the protocol pinned in a referenced spec makes that surface stable.

Threat model. No new threat-model category. The same CS-0054 byte-only-tampering defence applies (X-Cook-Content-Hash carries the SHA-256 from sender to receiver; the client’s VerifyingReader finalizes at EOF and refuses bytes that don’t match the header), the same CS-0055 cross-tenant-collision defence applies (server returns 409 Conflict on differing-bytes-at-same-key, mapped to BackendError::Other("conflict at <key>: ...")), the same CS-0057 resource-exhaustion defence applies (client-side max_artifact_bytes mid-stream cap; server-side 413 enforcement in addition). The Authorization: Bearer <api_key> header is NEVER logged and NEVER surfaced in BackendError diagnostics — the helper that maps HTTP errors to BackendError includes the response body’s diagnostic (server-supplied) but never the request’s auth header value. The tracing::debug! lines at the request boundary likewise redact the API key. A [cloud] api_key = "..." form checked into a shared / CI repository is documented as not recommended (env-var form is preferred) — a build-time tracing warning when the TOML form is detected is a follow-up enhancement. Out of scope: server-side authentication beyond bearer-token, OAuth, mTLS, signed-URL auth, multipart / chunked-resumable uploads, compression of bodies, HTTP/2, server-stamped-hash response header (the cloud client cannot stamp meta.content_hash in-place without buffering or hashing client-side; this is a documented asymmetry with LocalBackend — a future server-side enhancement could return X-Cook-Server-Stamped-Content-Hash on 200 OK).

Conformance impact. No grammar change. No Lua API change. No CACHE_VERSION bump. The CacheBackend trait surface is unchanged — CloudBackend is a new in-tree implementer of an existing trait. The .cook/cloud.toml schema gains one new optional field — api_key under [cloud] — with #[serde(default)]. Pre-CS-0058 .cook/cloud.toml files parse identically; validation tightens only when cloud.enabled = true (the disabled path — the default — sees no behavioural change). When enabled, the new validation requires endpoint and a resolved API key in addition to the existing project requirement; this is a tightening, but the disabled path is unaffected and the enabled path was non-functional pre-CS-0058 (the cloud backend it pre-emptively hinted at didn’t exist). The LocalBackend::with_config call path in run.rs is byte-for-byte preserved on the cloud.enabled = false branch.

Server side is future work. This change ships only the client of the v1 wire protocol. The server side — the Cook Cloud artifact server, its R2 / D1 / KV storage layer, its multi-tenant auth, its server-side max_artifact_bytes and rate-limit policy — is tracked in a separate ticket and will land independently. The wire protocol is pinned in CS-0058’s design spec as the contract both sides honour.

Test plan. Fourteen new unit tests in cook-cache::cloud_backend::tests against mockito-based mock servers pin the protocol-and-error-mapping contract: cloud_backend_get_round_trips (200 with X-Cook-Content-Hash; bytes round-trip through the verifier), cloud_backend_get_returns_none_on_404, cloud_backend_get_fails_closed_on_byte_tamper (200 with bytes whose SHA-256 disagrees with the header; read_to_end raises InvalidData), cloud_backend_put_round_trips (200; mockito body matcher confirms the bytes), cloud_backend_put_rejects_oversize (max_artifact_bytes = 100, 500 bytes through PUT; client surfaces BackendError::Other with “exceeds” and “100” in the diagnostic; mockito expect_at_most(1) because the abort timing is implementation detail), cloud_backend_put_handles_409_conflict, cloud_backend_batch_query_round_trips, cloud_backend_retries_on_5xx (first call 503, second call 404; both mocks expect(1); backend issues both calls), cloud_backend_does_not_retry_on_401 (mock expect(1); auth failure surfaces immediately), cloud_backend_does_not_retry_on_429 (same shape, BackendError::QuotaExceeded), cloud_backend_health_returns_ok_on_200, cloud_backend_health_returns_transient_on_5xx (4 calls — 1 initial + 3 retries — all 503; then Transient), cloud_backend_delete_204_succeeds, cloud_backend_delete_404_idempotent, cloud_backend_get_errors_on_missing_content_hash_header (200 OK without X-Cook-Content-Hash is server misbehaviour, surfaces as BackendError::Other mentioning the missing header), jitter_factor_in_range (50 samples all in [0.75, 1.25]). Six new unit tests in cook-cache::cloud_config::tests pin the api_key validation: cloud_enabled_requires_api_key (no env, no TOML field → MissingApiKey), cloud_enabled_uses_env_var_api_key (env wins, TOML absent), cloud_enabled_uses_toml_api_key (TOML used, no env), cloud_env_var_wins_over_toml (both set; env value resolved), cloud_enabled_requires_endpoint (endpoint missing → MissingEndpoint), cloud_empty_env_var_falls_through_to_toml (COOK_CLOUD_API_KEY="" is treated as unset). Tests that mutate COOK_CLOUD_API_KEY hold a Mutex<()> (ENV_LOCK) to serialise mutation across the parallel test runner. End-to-end coverage of the cloud path requires a real server and lands when Cook Cloud ships; the existing cache_benchmarks/verify.sh (31 scenarios) and cache_dep_drift/verify.sh (3 scenarios) regression-bar the unchanged LocalBackend path under the new bootstrap branch.

Reference: the CS-0058 commit on saas-prep-phase-1-2.

CS-0059 — Cloud auth simplification + Retry-After honour [#changes.cs-0059]

Date: 2026-05-05

Version: v0.8

Sections affected: None normative. Reference-implementation: cli/crates/cook-cache/src/cloud_config.rs (the [cloud] api_key: Option<String> field added by CS-0058 is removed from CloudSection; resolved_api_key() is rewritten as env-var-only — COOK_CLOUD_API_KEY non-empty → Some(key), else None; MissingApiKey’s diagnostic is rewritten to name only the env var and reference the future cook cloud login flow); cli/crates/cook-fingerprint/src/backend.rs (BackendError::QuotaExceeded is changed from a unit variant to QuotaExceeded(Option<Duration>) — the Option is the parsed Retry-After hint, with None preserving CS-0058’s terminal behaviour); cli/crates/cook-cache/src/cloud_backend.rs (new parse_retry_after(&ureq::Response) -> Option<Duration> helper that recognises RFC 9110 §10.2.3 delta-seconds form and falls through HTTP-date form to None; map_status_error gains a retry_after: Option<Duration> parameter threaded through the 429 branch; map_ureq_error is reordered to read the header before consuming the response body; retry_loop gains a quota-retry arm that sleeps hint.clamp(backoff_initial, backoff_max) without jitter, bounded by max_retries, without advancing the exponential-backoff cursor used by the Transient arm).

Summary. Two adjustments to CS-0058’s wire-protocol client motivated by the BetterAuth + Cloudflare Workers stack the Cook Cloud server will run on. (1) The [cloud] api_key TOML field is removed. It was the CS-0058 §9 Q6 foot-gun: a long-lived bearer token sitting in a file that is frequently checked into shared repositories. With BetterAuth long-lived prefix-tagged keys this becomes the kind of secret you really do not want git-committed. CS-0059 narrows the resolution path to COOK_CLOUD_API_KEY env var only — the standard CI / shared-repo pattern. Local developers will get an interactive cook cloud login flow in a future spec (out of scope for CS-0059); for now they export COOK_CLOUD_API_KEY=<token> in their shell. Stray api_key = "..." lines from pre-CS-0059 cloud.toml files deserialise cleanly because serde ignores unknown fields by default; the field is silently ignored on upgrade. (2) Retry-After is honoured on 429. CS-0058 §9 Q1 punted this; Cloudflare’s Rate Limiter binding emits Retry-After: <delta-seconds> natively and BetterAuth’s rate-limit middleware can too, so honouring it now means smooth backoff on day one of the server build. The retry shell sleeps the server-supplied hint clamped to [backoff_initial, backoff_max] — no jitter (the server told us when to come back) and the exponential-backoff cursor used by Transient retries is not advanced (the two backoff schedules are independent). Retry-After: <HTTP-date> form is recognised by the parser but treated as None-no-hint (terminal); Retry-After absent likewise. Both deferrals preserve CS-0058’s behaviour where the server emits no actionable hint.

Conformance impact. No grammar change. No Lua API change. No CACHE_VERSION bump. The CacheBackend trait surface is unchanged; the BackendError::QuotaExceeded variant payload changes shape, which is a Rust-API breaking change for any out-of-tree code that pattern-matches on the enum (zero in-tree consumers outside cook-cache itself; all in-tree call sites are updated atomically). The .cook/cloud.toml schema loses one optional field — the disabled-cloud path (the default) is byte-for-byte unchanged; the enabled-cloud path now requires COOK_CLOUD_API_KEY to be set in the environment, surfaced via the existing MissingApiKey error with a tighter diagnostic. Pre-CS-0059 cloud.toml files with api_key = "..." lines load without error (serde unknown-field tolerance); the field is silently ignored.

Test plan. Three new unit tests in cook-cache::cloud_backend::tests pin the Retry-After semantics: cloud_backend_honors_retry_after_on_429 (server emits 429 + Retry-After: 1 then 200; assert exactly two HTTP calls and elapsed ≥ ~900ms), cloud_backend_clamps_retry_after_to_backoff_max (server emits 429 + Retry-After: 600 then 200, with backoff_max = 50ms; assert elapsed < 2s — the 10-minute hint clamps to 50ms), cloud_backend_retry_after_http_date_falls_through_to_none (server emits 429 + Retry-After: <HTTP-date>; assert exactly one HTTP call, terminal). The pre-CS-0059 cloud_backend_does_not_retry_on_429 is renamed to cloud_backend_does_not_retry_on_429_without_retry_after and asserts QuotaExceeded(None) — it now pins specifically the no-hint branch. In cook-cache::cloud_config::tests, cloud_enabled_with_project_ok, cloud_enabled_requires_endpoint, and cloud_empty_env_var_treated_as_unset (renamed from cloud_empty_env_var_falls_through_to_toml) are refactored to remove TOML api_key references; new legacy_toml_api_key_field_silently_ignored pins the migration story; deleted: cloud_enabled_uses_toml_api_key, cloud_env_var_wins_over_toml (no TOML form to assert against). Tests touching COOK_CLOUD_API_KEY continue to hold the existing ENV_LOCK mutex.

Server side. The Cook Cloud server (separate ticket) MUST emit Retry-After: <delta-seconds> on 429 responses to enable client-side honouring; the Cloudflare Rate Limiter binding does this natively when configured. Server-side authentication continues to validate the Authorization: Bearer <api_key> header; how the API key is provisioned is now exclusively the cook cloud login flow’s concern (future spec) for interactive use, and the env-var / CI-secret pattern for non-interactive use.

Reference: the CS-0059 commit on saas-prep-phase-1-2.

CS-0060 — cook --dag switches from browser viewer to TUI [#changes.cs-0060]

Date: 2026-05-06

Version: v0.8

Sections affected: None normative. The cook --dag viewer is a tooling affordance of the reference implementation, not a Cookfile-language surface (CS-0047 already pinned this — cmd_dag is implementation-optional and the Standard does not require a graphical viewer at all). Reference-implementation: cli/crates/cook-dag-viewer/ (the tiny_http HTTP server, the open-based browser-launch, the embedded dag_viewer.html template, and the serve_dag entry point that CS-0047 split out are removed; in their place a ratatui-based three-pane TUI ships behind the same viewer cargo feature, driven by a new internal ViewFrame trait whose v1 SnapshotFrame impl wraps the existing register-only walk; build_wave_dag_data and the WaveDagData schema CS-0048 pinned at schema_version = 1 are preserved bytes-for-bytes — the TUI consumes the same snapshot the HTML viewer consumed, only the rendering surface changes); cli/crates/cook-cli/Cargo.toml (the viewer feature’s transitive deps swap from tiny_http + open to ratatui + crossterm); cli/crates/cook-cli/src/{main.rs,pipeline.rs} (the feature-gated cmd_dag branch wires into the TUI entry point instead of serve_dag; the --theme option and the non-TTY fallback are added at the same dispatch site); cli/crates/cook-dag-viewer/src/{tui/*.rs,view_frame.rs,snapshot_frame.rs} (new TUI scaffold — index pane, graph pane, detail pane, fuzzy-search overlay, help overlay, theme module).

Replaces. The browser-based DAG viewer originally introduced in CS-0047 and versioned by CS-0048. The historical record stands; CS-0060 is the change that swaps the rendering surface. The CS-0048 wire-format pin (WaveDagData.schema_version = 1) is preserved — the TUI is a new consumer of the same schema, not a new schema.

Summary. cook --dag now opens a terminal UI in the user’s current shell instead of spawning a local HTTP server and launching a browser tab. The data shape (WaveDagData) and the viewer cargo feature are unchanged; only the presentation layer is replaced. The TUI uses a three-pane layout — the index pane (left) lists every node in the wave-grouped DAG, the graph pane (center) renders the wave layout with the current node highlighted, and the detail pane (right) shows the selected node’s metadata (recipe, unit, declared inputs/outputs, declared dependencies). A fuzzy-search overlay (/) jumps to a node by name, and a help overlay (?) lists the keymap. A non-TTY caller (cook --dag | less, CI redirected output) gets a printed wave/recipe/unit tree fallback rather than the interactive UI.

Rationale. The browser viewer required port allocation, a window switch into the user’s default browser, and a running HTTP server tied to the lifetime of the cook process. A TUI delivers the same DAG inspection experience inside the terminal that the build was launched from, eliminates the port-and-browser dependency, and composes naturally with terminal multiplexers (tmux, zellij) and remote shells (SSH). The supply-chain argument from CS-0047 — that an HTTP server in the default-on path is a poor fit for a build-orchestration tool — applies equally to a viewer-feature-enabled binary; the TUI keeps the viewer feature opt-in (CS-0047’s gate) while removing the network-listening dependency from the feature-enabled path entirely. The ground laid by CS-0048 (additive WaveDagData schema with a load-bearing schema_version field) is fully preserved — the TUI consumes the same WaveDagData snapshot the HTML viewer consumed, so the data-shaping helper (build_wave_dag_data from CS-0027) and the schema pin (CS-0048) remain authoritative.

User-visible changes. cook --dag opens a TUI (three-pane layout: index / graph / detail). cook --theme {auto,mono} picks the TUI color theme — auto honours the terminal’s color capability and respects NO_COLOR, mono forces a monochrome palette suitable for terminals where ANSI color escapes interfere with multiplexer rendering or the user prefers a flat presentation. The browser-based viewer and its embedded HTML template are removed; the tiny_http and open runtime deps the viewer feature pulled in are gone. The --dag keymap: j / k navigate the index, [ / ] jump along incoming / outgoing edges from the selected node, H / J / K / L pan the graph-pane camera, / opens fuzzy search, ? opens the help overlay, q quits. The full keymap is rendered by ? and is also documented in the new design spec.

Architecture note. The cook-dag-viewer crate now exposes an internal ViewFrame trait that abstracts “what the TUI is looking at right now”. v1 ships a SnapshotFrame impl backed by the existing register-only walk — it is computed once at cook --dag entry and is immutable for the lifetime of the TUI session. A future release MAY add a LiveFrame impl that mutates in response to executor events from the cook-progress events.jsonl stream (App. E.11), enabling a “live build mode” where node states light up during a concurrent cook build. The trait is internal to the viewer crate and not part of any published Rust-API surface; no external consumers depend on it yet. The ViewFrame shape is therefore free to evolve in a follow-up change without an App. D entry until it is ever exposed across a crate boundary.

Conformance impact. None. The Standard does not pin cmd_dag behaviour (CS-0047 explicitly carves the viewer out as implementation-optional), the conformance harness does not exercise it, and no Cookfile authoring surface is touched. The CS-0048 WaveDagData.schema_version = 1 pin is preserved — the TUI is a new in-tree consumer of the same wire format, not a schema change. The viewer cargo feature gate from CS-0047 is preserved — vanilla cargo install cook-cli continues to produce a slim binary with no viewer dependencies, and the diagnostic naming the rebuild flag (cargo build --features viewer) is preserved word-for-word for viewer-less builds invoked with cook --dag.

Migration. This is a breaking user-visible change for anyone scripting around the old browser behaviour — for example, a wrapper that read the 127.0.0.1 port number from cook --dag’s stderr and curl’d the viewer JSON, or a CI step that asserted on the browser-launch side effect. Such callers SHOULD migrate to consuming the WaveDagData JSON directly via the existing in-tree codegen path, or to the non-TTY fallback (cook --dag | less, which prints the wave/recipe/unit tree and exits). There is no deprecation period for the HTML viewer — it is removed in this release; the rationale for skipping a deprecation cycle is that the viewer feature was opt-in (CS-0047), the affected user population is therefore narrow, and the new TUI is feature-equivalent for the inspection use cases the HTML viewer served.

Reference: the CS-0060 commit on the feature/dag-tui-viewer branch.

CS-0061 — as STRING display-name modifier on test_step [#changes.cs-0061]

Date: 2026-05-07

Version: v0.8

Sections affected: §4.8 (test step), App. A (grammar — test_modifiers), App. A (normative constraints — as rejected on non-test steps).

Summary. Adds an optional as STRING leading modifier to test_step. The modifier sets the test’s display name in runner reports. Without it, the implementation assigns an auto-generated identity. The STRING admits $<IDENT> placeholder substitution (CS-0033) at register-time codegen; the display name is metadata and is not part of the test’s cache fingerprint. Modifier order is fixed: as → timeout → should_fail; out-of-order sequences are rejected with a diagnostic naming the offending modifier. The as modifier is test-step-only; cook_step and plate_step reject it.

Codegen normative rule (§4.8, added in Task 2.5). When as STRING is present, the code generator MUST emit name = <substituted-expr> in the cook.add_test call. When absent, no name field is emitted.

Grammar change. test_modifiers ::= ("as" STRING)? ("timeout" NUMBER)? "should_fail"? (was: ("timeout" NUMBER)? "should_fail"?).

cook.add_test field defaults and validation (§6.3.5, added in Task 2.6). CS-0061 also nails down the register-phase field contract for cook.add_test:

  • suite defaults to the enclosing recipe’s fully-qualified name (e.g. "lib.tests") when absent or empty. Pre-Task 2.6 the default was the empty string.
  • command is required and MUST be non-empty; a missing or empty value MUST raise a register-phase Lua error whose message identifies the field.
  • timeout MUST be a positive integer; 0 or less MUST raise a register-phase Lua error identifying the field.
  • name and should_fail are unchanged: name defaults to "", should_fail defaults to false.

The enclosing recipe’s qualified name is tracked on the register-phase capture state and set by the register engine immediately before invoking the recipe body function; it is cleared after the body returns.

tree-sitter implementation (Task 2.11). tree-sitter-cook/grammar.js gains the as_name optional field in the test_step rule (before timeout); queries/highlights.scm adds "as" to the keyword list and promotes as_name: (string) to @string.special. Three corpus fixtures pin the double-quoted shape (the tree-sitter grammar does not yet support single-quoted strings — tracked under CS-0002; conformance/positive/044-045/048 remain as accepted-with-note failures for that reason). All CS-0061 negative ordering constraints (negative/043-048) parse-reject correctly.

Reference: the CS-0061 commits on feature/test-runner-v1.

CS-0062 — Search-path order within cook_modules/ [#changes.cs-0062]

Summary. §7 grows a new normative clause defining the search-path order an implementation MUST configure for package.path and package.cpath per execute-phase work unit. The order is: hand-vendored top-level files first (cook_modules/?.lua, cook_modules/?/init.lua, cook_modules/?.<so-ext>), then the LuaRocks-installed tree (cook_modules/share/lua/<lua-version>/?.lua, cook_modules/share/lua/<lua-version>/?/init.lua, cook_modules/lib/lua/<lua-version>/?.<so-ext>), then the original interpreter defaults.

Rationale. This phase lands the cook modules CLI on top of the previous phase’s bundled LuaRocks. The on-disk shape adopted is LuaRocks-native (share/lua/<ver>/, lib/lua/<ver>/) so cook can install rocks from luarocks.org without forking layout policy. Hand-vendored top-level files retain highest priority so authors can override a rock-installed version by dropping a file at cook_modules/<name>.lua; the LuaRocks tree is searched second. App. C-rationale §C.7 records the layout choice in detail.

Conformance impact. A conforming implementation MUST configure both package.path and package.cpath per the order above for every execute-phase work unit. The order is normative; reordering it is a conformance bug. The <lua-version> and <so-ext> variables are platform-derived: 5.4 is the current Lua MAJOR.MINOR pinned by mlua; <so-ext> is .so on Linux/macOS, .dll on Windows.

Reference: the CS-0062 commits on feature/luarocks-modules (Tasks M3.5 + M3.6).

CS-0063 — cook.add_unit rejects directory inputs and outputs at register time [#changes.cs-0063]

Summary. §6.2 grows a new normative Path-shape restriction clause: every entry in inputs, output, and outputs is a file path, and a conforming implementation MUST raise a Lua runtime error at register time when any path in inputs resolves (relative to the recipe’s working directory) to an existing directory, and when any path in output / outputs resolves to an existing directory. Non-existent output paths MUST still be accepted (declared outputs are typically not yet built at register time). The diagnostic MUST contain the substring is a directory and the offending path.

Rationale. Cook’s cache hashes file content, not directory trees. Silently accepting a directory input produced an empty cache record (only _source_hash) and the unit re-ran on every invocation — a silent correctness bug surfaced by the copy.tree example in the since-removed book chapter 9. Failing fast at register time gives the author an actionable diagnostic (“use a glob like ‘dir/*’ or list specific files”) instead of mysterious cache misses on the second run. Listing the input files individually keeps the cache key tied to file content, so a successful run is genuinely reusable.

Conformance impact. A conforming implementation MUST stat each path the Cookfile (or a module called from it) passes in inputs / output / outputs to cook.add_unit, and MUST reject the call with a runtime error containing is a directory when the stat resolves to a directory. Symlinks to directories MUST also be rejected. New negative fixture 052-directory-input-rejected/ (with register_error.txt) pins the rule; a companion register-phase conformance harness in cook-register/tests/conformance.rs consumes fixtures with register_error.txt (mirroring the codegen-phase harness’s treatment of codegen_error.txt).

Reference: this commit.

CS-0064 — fs.glob drops directory matches [#changes.cs-0064]

Summary. §6.5.6 grows a normative clause: fs.glob MUST drop matches whose final (symlink-resolved) type is a directory. Pre-CS-0064 the result included every path the underlying glob library matched, files and directories alike. Post-CS-0064 the result is files only; sub-directories that happen to match the pattern are silently filtered. A pattern that matches only directories MUST return an empty sequence (it is not an error).

Rationale. CS-0063 made directory paths a register-time error in cook.add_unit inputs/outputs. The natural composition cook.add_unit({ inputs = fs.glob("dir/*"), ... }) would then fail whenever dir/ contained any sub-directory — for a path the author never wrote by hand. Filtering at the glob layer makes the common “all files matching a pattern” idiom well-defined without weakening CS-0063: directory entries are silently dropped at enumeration time, and any directory path written by hand still trips CS-0063’s diagnostic. Symlink-to-directory follows std::fs::metadata semantics (the link is followed) so a symlink whose terminal type is a directory is also dropped, matching CS-0063’s symlink rule. The same filter is applied to the register-side helpers backing recipe.ingredients and cook.resolve_ingredients so the rule holds uniformly across every glob expansion the language exposes.

Conformance impact. A conforming fs.glob implementation MUST stat each candidate match and exclude entries whose terminal type is a directory. A broken symlink (or any other stat error) is treated as “not a directory” — the entry is kept, and any downstream consumer that requires the path to actually exist will diagnose the missing file with its own, more specific message. Implementations MAY perform the filtering at any point during enumeration; the observable behavior is what conformance pins. No new fixture is required: directory filtering is an execute-phase observation and the conformance harness does not yet execute Lua bodies, but unit tests in cli/crates/cook-lua-stdlib/src/fs_api.rs (static_glob_filters_out_directories, static_glob_filters_symlink_to_directory, static_glob_keeps_symlink_to_file) pin the three cases, and a register-side test in cli/crates/cook-register/src/context.rs (resolve_glob_filters_directories) pins the parallel filter on recipe.ingredients / cook.resolve_ingredients.

Reference: this commit.

CS-0065 — Register-phase cook.load_module MUST honour the cook_modules/ resolution order specified in §7 [#changes.cs-0065]

Summary. §7.4 grows a normative clause extending the four-path cook_modules/ search order (originally CS-0062, execute-phase only) to register-phase cook.load_module(name) calls. A conforming implementation MUST search: (1) cook_modules/<name>.lua; (2) cook_modules/<name>/init.lua; (3) cook_modules/share/lua/5.4/<name>.lua; (4) cook_modules/share/lua/5.4/<name>/init.lua. The first candidate that exists on disk wins; the error message when none exist MUST name all four tried paths.

Rationale. CS-0062 established the canonical four-path order for execute-phase workers but left register-phase cook.load_module on a two-path search (<name>.lua, <name>/init.lua only). A module installed via LuaRocks (e.g. cook_cc) lands under cook_modules/share/lua/5.4/; without this extension, use cook_cc would fail at register time even though the module is correctly installed. Unifying the search order across phases removes a class of “works at execute but not at register” surprises and makes the register-phase resolution invariant to how the module was installed.

Conformance impact. A conforming implementation MUST extend the four-path search to register-phase cook.load_module. Conformance fixture 053-rocks-share-lua-resolution/ is upgraded from parse-only to runtime-verified: the chore verify body reads rockmod.value from a module installed at the share/lua/5.4 path and asserts 42. Three new unit tests in cli/crates/cook-register/src/module_loader.rs (test_load_module_resolves_share_lua_flat, test_load_module_resolves_share_lua_init, test_load_module_top_level_wins_over_share_lua) pin the positive and priority cases.

Reference: this commit.

CS-0066 — Register-phase module loading MUST configure package.path/package.cpath and MUST permit C extensions [#changes.cs-0066]

Summary. §7.4 gains a normative paragraph requiring a conforming implementation to configure package.path and package.cpath on the register-phase Lua VM before executing any module body loaded via cook.load_module. The search order mirrors the execute-phase order established by CS-0062. Additionally, a conforming implementation MUST NOT restrict native C extension loading in the register-phase VM; C extensions (.so/.dll) installed under cook_modules/ MUST be loadable at register time.

Rationale. Multi-file rocks (such as cook_cc) use Lua’s native require() internally to load their sub-modules (e.g., require("cook_cc.toolchain") inside cook_cc/init.lua). Without package.path and package.cpath configured for the register-phase VM, these internal requires fail because they search only the system Lua paths, not the project’s cook_modules/. This class of failure was latent before real multi-file rocks existed; the migration of examples/modules/lua-build from a vendored single-file cpp.lua to the multi-file cook_cc rock triggered it. The companion C-extension rule closes the parallel gap: cook_cc/finder.lua depends on lpeg (a C extension), so a register-phase VM that blocks native modules cannot load cook_cc at all. The register phase already runs fully user-trusted Lua (Cookfile bodies, module code); permitting C extensions introduces no new trust boundary.

Conformance impact. A conforming implementation MUST configure package.path and package.cpath (same order as CS-0062) before executing each module body in the register phase. The reference implementation stashes the original package.path/package.cpath on first load per VM using the _cook_original_path/_cook_original_cpath sentinel approach, mirroring the execute-phase idiom. A conforming implementation MUST create the register-phase Lua VM with all standard libraries enabled (equivalent to mlua::Lua::unsafe_new()) so that native module loading (package.loadlib) is available. The project-root fs.* sandbox (CS-0045) MUST still be installed on the register-phase VM immediately after creation; the expanded VM capabilities do not relax the filesystem confinement rules.

CS-0067 — cc finder multi-strategy chain (M1) [#cs-0067]

What changed. Cook Standard §9.2 graduates from v0.1 to v0.2:

  • §9.2.3.7 cc.link: LinkOpts grows a frameworks field. Each entry emits -framework <name> at link time on macOS; no-op on other hosts.
  • §9.2.3.8 cc.find: rewritten from pkg-config-only to a four-stage chain (project-registered → curated → pkg-config → bare-probe). FindResult grows a tried field listing every strategy consulted. FindOpts.version is now honoured.
  • §9.2.3.12 cc.register_finder: new — project-scoped extension seam for the chain.
  • §9.2.3.13 cc.find_or_error: new — raising convenience wrapper.
  • §9.2.4 transitive propagation: frameworks joins the propagated field set.
  • §9.2.5 error catalogue: two new entries for the new raising surfaces.

Reference implementation cook_cc 0.2.0-1.

Migration. Existing call sites of cc.find(name) continue to work unchanged; new fields on the result are additive. Existing target makers (cc.bin/cc.lib/cc.shared) gain an optional frameworks option; pre-v0.2 Cookfiles that do not set it observe identical behaviour.

Reference: this commit.

CS-0068 — cc cmake-compat strategy (M2) [#cs-0068]

Summary. §9.2.3.8 grows from v0.2’s four-stage cc.find chain (project → curated → pkg-config → bare-probe) to v0.3’s five-stage chain inserting cmake-compat between pkg-config and bare-probe. FindOpts gains a cmake boolean that lifts cmake-compat to position 3 (replacing pkg-config’s slot for that call). §9.2.3.8.1 normatively specifies the cmake-compat strategy steps (driver detection, EXIST/COMPILE/LINK probes, imported-target rejection). §9.2.3.8.2 and §9.2.3.8.3 specify the COMPILE-mode and LINK-mode output grammars. §9.2.5 grows three catalogue rows naming the cmake-compat-specific failure modes. Attempt.strategy enumeration grows "cmake-compat". App. B gains three rationale sections (legacy-mode trust boundary, chain-order trade-off, v0.2 preservation).

Rationale. A long tail of modern C/C++ libraries ship XxxConfig.cmake but no .pc (SDL3, glfw3, Vulkan SDK, fmt, nlohmann_json, most vendor SDKs). M1’s curated short list and pkg-config-only chain leave Cook unable to discover them. Hand-writing curated finders per library does not scale. cmake-compat reuses cmake’s own Config-resolution implementation (via cmake --find-package legacy mode) as a discovery shim — cmake is never invoked to build anything; Cook’s own compiler invocations drive the build. The trust boundary is explicit: cmake-compat’s accuracy is delegated to cmake; the only Cook-implemented part is output parsing.

Conformance impact. A conforming v0.3 implementation MUST treat opts.cmake = false (default) calls identically to v0.2 calls for stages 1–3 (project → curated → pkg-config); the chain extension is additive. New behaviour: stages 4 (cmake-compat) and 5 (bare-probe) are consulted after pkg-config misses. opts.cmake = true skips pkg-config entirely.

Reference: the CS-0068 commits on shi-135-m2-cmake-compat.

CS-0069 — Execute-phase cook.load_module MUST honour the §7 resolution order [#cs-0069]

Summary. §7 grows a clarifying paragraph: a conforming implementation MAY expose cook.load_module(name) on the execute-phase Lua VM as an extension over the register-phase requirement, and when exposed it MUST honour the same four-path resolution order specified by CS-0065 (cook_modules/<name>.lua, cook_modules/<name>/init.lua, cook_modules/share/lua/5.4/<name>.lua, cook_modules/share/lua/5.4/<name>/init.lua).

Rationale. The reference implementation has exposed an execute-phase cook.load_module(name) since M0 to support recipe-body Lua scripts that load modules ad-hoc. Pre-CS-0069 the execute-phase implementation only searched two paths (cook_modules/<name>.lua, cook_modules/<name>/init.lua), causing recipe-body load_module calls to fail for any LuaRocks-installed module — most notably breaking the M1 raylib-game integration example at runtime (the Cookfile’s use cook_cc translates to an execute-phase cook.load_module("cook_cc") whose only resolvable copy lives under cook_modules/share/lua/5.4/cook_cc/init.lua). CS-0069 closes this register-vs-execute parity gap.

Conformance impact. Implementations MUST search the four paths in §7’s order and the diagnostic on miss MUST list all four attempted paths. Implementations that previously searched only the two top-level paths MUST extend their search; the change is additive and cannot break any Cookfile that worked under the pre-CS-0069 behavior.

Reference: the CS-0069 commits on main.

CS-0070 — Execute-phase Lua VM MUST expose cook.cache [#cs-0070]

Summary. §6.3.4 grows a normative clause requiring cook.cache.get, cook.cache.set, and cook.cache.scope to be available on BOTH the register-phase and execute-phase Lua VMs. The execute-phase implementation MAY use in-memory-only storage scoped to the worker’s lifetime; the register-phase implementation MUST persist values across invocations per the cache contract.

Rationale. Standard §6.3.4 lists cook.cache among “both-phase APIs” but the reference implementation only registered it on the register-phase VM. The omission was silent until CS-0069 fixed execute-phase cook.load_module resolution — raylib-game’s use cook_cc now correctly resolves and runs cook_cc.init(), which calls cook.cache.get("compiler") to rehydrate the toolchain probe from the register-phase cache. The execute-phase VM had no cook.cache table at all, raising attempt to index a nil value (field 'cache'). CS-0070 closes the both-phase parity gap explicitly: implementations MUST expose the cache API on both VMs.

Conformance impact. Implementations MUST expose cook.cache.{get,set,scope} on both VMs. The execute-phase storage MAY be ephemeral (in-memory, per worker); persistence semantics for the execute-phase API are explicitly NOT specified here and remain available for refinement (see e.g., a future disk-persistence design proposal for cross-run persistence). The minimum bar is that calls do not error.

Reference: the CS-0070 commits on main after CS-0069.

CS-0071 — Execute-phase Lua VM MUST expose cook.export/cook.import [#cs-0071]

Summary. §6.3.4 grows a normative clause requiring cook.export(name, info) and cook.import(name) -> table? to be available on BOTH the register-phase and the execute-phase Lua VMs. The execute-phase implementation MAY share storage with the register-phase or use a per-worker scratch store when the recipe body is both producer and consumer; cross-worker visibility is the implementer’s choice and remains available for refinement.

Rationale. §6.3.4 already lists cook.export and cook.import among the both-phase APIs (Note 6.3.1 enumerates them alongside cook.env, cook.platform, cook.cache, and the dep-output helpers), but the reference implementation only registered them on the register-phase VM. Reference-implementation smoke tests revealed that any recipe-body Lua that drives a cook_cc target maker — every example under examples/ — tripped on the missing execute-phase cook.export (the cc.bin body calls cook.export(name, {...}) so transitive consumers can cook.import it). CS-0071 closes the both-phase parity gap exactly as CS-0070 did for cook.cache.

Conformance impact. Implementations MUST expose cook.export/cook.import on both VMs. The execute-phase storage MAY be ephemeral (per-worker, in-memory) when the producer and consumer share a worker; cross-worker visibility is the implementer’s choice and remains available for refinement. The minimum bar is that calls do not error and round-trip values within a single worker VM.

Reference: the CS-0071 commits on main after CS-0070.

v0.9 [#changes.v0-9]

CS-0072 — Top-level register blocks (v0.9)

Issue. Target-maker calls (cook_cc.bin, etc.) belong at file scope in v0.3, but the only register-phase Lua context the Cookfile grammar admits is the recipe-body >> prefix. Authors writing recipe foo / > cook_cc.bin(…) (selecting the execute-phase prefix by mistake) hit a runtime-only §6.3.2 register-only API diagnostic with no source location. This change introduces a top-level register block whose body splices verbatim into the compiled register-phase Lua program at the source position, plus a top-level bare module_call dispatch, making top-level the canonical home for target-makers. CS-0072 also removes the implicit register-phase module_call dispatch from the recipe-body step cascade — authors who want register-phase Lua inside a recipe body MUST use >> (inline_lua_line) or >>{ (inline_lua_block).

Resolution. Cookfile grammar v0.9 admits two new top-level constructs (register_block per § 5.4 and top-level module_call per § 4.4) and removes the in-recipe-body module_call dispatch from § 8.1. The terminator-set of § 4.3 is extended to include the register keyword and the top-level module_call shape. The reference parser at cli/crates/cook-lang ships the implementation; conformance fixtures register-block-* and top-level-module-call-* pin the surface; the deleted 010-use-and-module-call fixture is replaced by recipe-body-bare-module-call-is-shell. The cook_cc standard module v0.4.0 migrates its target-makers (bin / lib / shared / headers) to call cook.recipe(…) internally, allowing cook_cc.bin("game", {…}) to be called directly from a register block or as a top-level module_call.

Touched sections. §2.4 (keywords), §2.10 (line classification), §3.1 (top-level items), §3.2 (ordering), §3.6 (config_body termination reference), new §3.7 (register_block grammar), new §3.7.5 (top-level module_call), §3.8 (recipe_body termination reference; renumbered Recipes), §3.9 (step-dispatch cascade amendment; renumbered Step dispatch), §4.1.1 (terminator-set), §4.4 (step-kinds table; Note 4.4.2 region rule), §4.9.2 (module_call historical note), §4.11 (section rewritten as historical), §8.1.2 (phase-classification table), §8.2 (execution-model splicing), §8.3 (step-groups table), App. A (A.1 EBNF, A.2/A.3 body-termination, A.3 declarative_item, A.3.1 chore_declarative_item, step-dispatch priority cascade).

Conformance fixtures. standard/conformance/positive/register-block-{empty,single,multiple,interleaved,target-maker}, standard/conformance/positive/top-level-module-call*, standard/conformance/positive/recipe-body-bare-module-call-is-shell, standard/conformance/negative/register-{inside-recipe-body,with-name}, standard/conformance/negative/top-level-colon-call. The deleted fixture is standard/conformance/positive/010-use-and-module-call.

v0.10 [#changes.v0-10]

CS-0073 — Structural redesign: Parts and per-topic chapters [#cs-0073]

Date: 2026-05-13 Version: v0.10 Sections affected: entire Standard (reorganisation). Specifically:

  • Renumbered Chapters 0–9 → Chapters 0–28 across four Parts.
  • Retired slug prefixes: grammar.*, modules.*, stdmods.*, lexical.placeholders, lua.shell-placeholders, lua.use-env, lua.builtin-modules, lua.local-modules, recipes.body-bundling.
  • New slug prefixes: conf.*, toplevel.*, decl.*, steps.*, phl.*, exec.*, comp.*, mods.*, cat.*, pre-v1.*, corpus.*.
  • New normative prose:
    • §22.5 cook.step_group (formal definition);
    • §24.3 cook.env, §24.4 cook.cache, §24.5 cook.export/cook.import, §24.6 cook.platform, §24.7 cook.dep_output/cook.dep_output_list (formalises surfaces previously pinned in CS-0066/0070/0071 amendments);
    • §12.3 Module lifecycle (consolidates lifecycle prose previously scattered across §6.3.4, §6.8, and §7.4).
  • Annexes B and C swapped (Examples now precedes Rationale).
  • Annexes D and E swapped (Pre-1.0 checklist now precedes Changes).
  • Annex C (Rationale) heading numbers renumbered to match new chapter map; slugs preserved.
  • Annex E (Changes) gets per-version subheaders.
  • Annex F (Conformance corpus) added as a stub.

Summary. The Standard is reorganised into four Parts — The Cookfile language (Part I, author-facing), Execution model (Part II), The Cook Lua API (Part III), and Standard module catalogue (Part IV) — with each chapter focused on one topic. Phase classification, cache semantics, and placeholder specifications each have a single normative home, eliminating the prior three-way splits. The use declaration moves from the Lua API chapter to Modules. Chores becomes a real chapter. Workspace root and cache portability move into the Execution model. Body bundling moves from the recipes chapter into step groups. Step dispatch has one normative source. The new normative prose in §22.5, §24.3–§24.7, and §12.3 formalises Cook Lua surfaces previously specified only in amendments. No normative behaviour changes for any conforming Cookfile; this entry is structural and editorial only. The conformance corpus is unchanged. Retired anchored URLs continue to resolve through build-emitted page-level redirects (anchor-level precision for retired anchors is implemented via the client-side fragment rewriter in a follow-on editorial pass).

Conformance impact. None. The reference parser, the conformance corpus, and the runtime semantics are byte-for-byte unchanged from v0.9. Implementations that already conform to v0.9 conform to v0.10 without modification. The cook standard.against-tag chore against cs-standard/v0.9 is therefore expected to pass the corpus contract; any failure is parser-impl drift unrelated to this CS (see App. D §D.1 for the brittleness note).

Reference: this commit.

v0.11 [#changes.v0-11]

CS-0074 — Probe units: cook.probe, probes field on cook.add_unit, demand-driven scheduling [#cs-0074]

Date: 2026-05-15 Version: v0.11

Summary. CS-0074 introduces probe units. New register-phase API cook.probe(key, opts) registers a DAG unit whose output is a Lua value rather than a file (§22.5). Consumer units declare probe-edge dependencies via a probes field on cook.add_unit; the $<key:field> sigil in command templates auto-populates this field (§22.5.6, §22.5.7). Probe values are msgpack-encoded and stored via the existing cache backend; the ArtifactMeta.kind field disambiguates probe-value artifacts from file artifacts (§22.5.5, §22.5.8). Probes execute on demand: a probe MUST NOT run unless at least one scheduled non-probe consumer transitively references its key (§22.5.8). cook.cache.set is deprecated and raises a runtime error on the execute-phase VM (§24). cook.cache.get on the execute-phase VM reads from a per-run probe-value store populated by the scheduler (§24).

Sections affected: §22.5 (new chapter — Probe units), §17 (cache cross-reference), §21 (Lua-API §21.1 cook.probe pointer + cook.cache.get cross-phase note), §24 (both-phase cook.cache.get tightening), App. E (this entry).

Motivation. Modules such as cook_cc have used cook.cache.set/cook.cache.get as a hand-rolled memoisation layer for configure-time probes (compiler detection, package finder results, toolchain version strings). This pattern has three problems: (1) probe values are not fingerprinted, so a changed compiler does not invalidate cached build units; (2) the kv store is local-machine only and cannot be shared via Cook Cloud; (3) the execute-phase cook.cache.set was in-memory-only (per CS-0070), making cross-invocation rehydration depend on register-phase-only writes that callers found confusing. cook.probe replaces the hand-rolled pattern with a first-class register-phase concept that plugs into the existing cache and cloud machinery end-to-end.

Normative scope.

  • New §22.5: formal definition of cook.probe(key, opts) API (§22.5.3), probe inputs and fingerprint algorithm (§22.5.4), value types (§22.5.5), consumer probes field on cook.add_unit (§22.5.6), $<key:field> command-template sigil (§22.5.7), demand-driven execution semantics and per-run probe-value store (§22.5.8), and cycle detection (§22.5.9).
  • Amendment to §17 (Cache): probe units join the cache machinery; probe fingerprint addresses a msgpack-serialised value in CacheBackend; probe artifacts ride the events.jsonl event stream and Cook Cloud wire protocol; ArtifactMeta.kind distinguishes "probe_value" from file artifacts.
  • Amendment to §21 (Lua API overview): cook.probe pointer; cook.cache.get cross-phase note.
  • Amendment to §24 (Both-phase cook.cache): cook.cache.get on the execute-phase VM MUST read from the per-run probe-value store (not the persistent kv store); cook.cache.set is deprecated and raises a runtime error on the execute-phase VM.

Reference implementation:

  • standard/src/content/docs/22-probe-units.mdx (chapter)
  • standard/src/content/docs/appendix/E-changes.mdx (this entry)
  • standard/src/content/docs/21-lua-api.mdx (§21.1 cook.probe pointer + cook.cache.get cross-phase note)
  • standard/src/content/docs/24-both-phase.mdx (cook.cache.get tightening)
  • cli/crates/cook-contracts/src/lib.rs (ProbeUnit, ProbeInputs, WorkPayload::Probe, RecipeUnits.probes, CapturedUnit.probes)
  • cli/crates/cook-register/src/probe_api.rs, src/unit_api.rs, src/engine.rs, src/probe_value.rs
  • cli/crates/cook-fingerprint/src/probe.rs
  • cli/crates/cook-engine/src/dag_builder.rs (probe→consumer edges + demand-driven prune)
  • cli/crates/cook-engine/src/executor.rs (probe cache hit/miss + upstream fingerprint propagation)
  • cli/crates/cook-luaotp/src/probe_value.rs (worker-VM probe dispatch + probe-value store)
  • cli/crates/cook-luagen/src/sigil.rs, src/resolver.rs, src/template.rs ($<key:field> pipeline)

Test plan.

  • Conformance fixtures: standard/conformance/positive/probe-*, standard/conformance/negative/probe-*
  • Integration tests: cli/crates/cook-cli/tests/probe_integration.rs

Reference: this commit (Tasks A2-A6).

CS-0075 — cc module probe-based migration

Affects: cook_cc rock (§ 28), surface v0.4 → v0.5.

The cc module’s register-phase ad-hoc caches migrate to first-class cook.probe units (§ 22.5). The following probe keys are now canonical:

  • cc:compiler:<override> — toolchain detection
  • cc:linker-search-dirs — bare-probe linker search paths
  • cc:cmake-driver — cmake-compat driver discovery
  • cc:find:<name> — per-library find result

Target makers (cc.bin, cc.lib, cc.shared, cc.headers) accept a needs = {…} field for declarative system-library discovery. The imperative cc.find and cc.find_or_error functions return a sigil record — a table of $<cc:find:<name>.<field>> placeholders resolved at execute time by the existing template-expansion pipeline (§ 22.5.7).

cc.find_or_error failure becomes demand-driven: missing libraries fail the build at execute time rather than raising during register. This is a deliberate tradeoff — recipes that do not consume the missing library no longer fail with register-time noise.

The cc:* probe namespace is non-normative pattern documentation; cook_cc itself is not part of the conformance surface, but the design informs other blessed modules.

v0.12 [#changes.v0-12]

CS-0076 — cc.checks and cc.config_header

Affects: cook_cc rock (§ 28), surface v0.5 → v0.6.

The cc module gains two new public surfaces layered on the cook.probe substrate from CS-0074:

  • cc.checks.* — seven feature-test functions (has_header, has_function, has_define, sizeof, endian, has_compile_flag, has_link_flag) each registering a cc:check:<kind>:<name>:<short-fp> probe and returning a probe-sigil string. The probe’s produce body composes a small probe.c, invokes the compiler resolved by cc:compiler:<o>, and returns the scalar result.

  • cc.config_header(template, output, vars) — registers a cook.add_unit whose command invokes a vendored renderer performing CMake-style @VAR@, ${VAR}, #cmakedefine, and #cmakedefine01 substitution. The function walks vars to auto-detect probe sigils and folds them into the unit’s probes edge set; sigil-shaped values expand at execute time inside the command’s vars-literal argument via the existing template pipeline (cook-luagen/src/template.rs).

cc:check:<kind>:<name>:<short-fp> joins the canonical probe-key namespace alongside cc:compiler:, cc:linker-search-dirs, cc:cmake-driver, and cc:find: per § 28.3.16.

The renderer script (cook_cc.config_header_renderer) ships vendored within the rock and is located at register time via package.searchpath; no COOK_MODULE_DIR engine sigil was required.

Reference implementation: cook_cc 0.6.0-1 at rocks.usecook.com (repo: cook-modules, tag: cook_cc-0.6.0-1).

Reference: this commit.

v0.13 (unreleased) [#changes.v0-13]

CS-0077 — Unified register-phase and work-unit DAG

Date: 2026-05-18

Version: v0.13

Sections affected: §13.1 (clarification of “single Lua VM per Cookfile registration pass”), §13.3 (new — unified work-unit DAG), §22.3 (recipe-name uniqueness within a Cookfile), §22.6 (new — recipe-discovery contract), App. C.13.5 (new rationale — DAG unification), App. C.21.9 (new rationale — recipe-name uniqueness).

Summary: Retires the dual recipe-level + per-wave work-unit DAG model in favour of a single unified work-unit DAG covering every recipe reachable from any dispatched target within a Cookfile registration pass. Cross-recipe dependencies become ordinary edges of the unified DAG; “wave” grouping is no longer a normative concept. Recipe-discovery is normatively defined as the union of surface declarations (recipe/chore blocks) and cook.recipe calls from register blocks, top-level module calls, or wrapper Lua functions (e.g. cook_cc.bin); cook NAME dispatch resolves against this unified set. Listing surfaces (e.g. cook list) MUST surface both sources without invoking recipe bodies. Within a single Cookfile registration pass, the set of names registered by cook.recipe MUST be unique; a duplicate produces a hard error naming both registration sites and identifying the kind of each (surface declaration or register-phase Lua call).

Implementation closes the discovery gap that prevented cook NAME from dispatching recipes registered through cook_cc.bin (examples/modules/raylib-game/, examples/modules/sdl3-game/). Wire-compatible: the public cook.recipe API surface is unchanged; the reference implementation uses a codegen-private helper (cook.__register_surface) to tag surface registrations for diagnostic purposes, but this is implementation-defined and not part of the Standard.

Reference implementation: landed in the cook repository.

Reference: this commit chain (front-loaded as Phase 0; subsequent commits land the engine and CLI changes against this contract).

Implementation milestones (informative):

  • 2026-05-18: Phase 0 — Standard amendments landed (§13.3, §22.3, §22.6, App. C rationale, this CS entry).
  • 2026-05-18: Phase 1 Task 1.1 — Registry renamed to RegisterSessionBuilder in cook-register and all callers.
  • 2026-05-18: Phase 1 Task 1.2 — CaptureState split into SessionCaptureState (probes; per register-pass) and BodyCaptureState (units, step-groups, dep-edges, layer state; per recipe-body invocation) in cook-register. Body state lives behind a SharedBodySlot = Rc<RefCell<Option<BodyCaptureState>>> so closures detect “called outside a recipe body” cleanly once the Phase 2 entry point lands.
  • 2026-05-18: Phase 1 Task 1.3 — register_cook_api_capture renamed to install_cook_api in cook-register (function installs the entire register-phase cook.* surface, not just cook.recipe).
  • 2026-05-18: Phase 1 Task 1.4 — RegistrationSource { Static { line }, Dynamic { line } } enum added in cook-register; RegisteredRecipe gains a source field; cook.recipe records Dynamic registrations with line numbers via a Lua call-stack walk against the Cookfile source label.
  • 2026-05-18: Phase 1 Task 1.4 (review polish) — clarified ends_with rationale and named MAX_LUA_STACK_DEPTH in caller_line_in_cookfile helper.
  • 2026-05-18: Phase 2 Task 2.1 — RegisteredCookfile, RegisteredRecipePub, RecipeKind public types defined in cook-register; register_cookfile and list_names stubs added.
  • 2026-05-18: Phase 2 Task 2.1 (review polish) — RegisteredCookfile gains #[derive(Debug, Clone)]; final_env switched to BTreeMap for parity with RecipeUnits.env_vars and project determinism rule.
  • 2026-05-18: Phase 2 Task 2.2 — register_cookfile implemented in cook-register (one-shot eleven-step pipeline: load source, dispatch config blocks, freeze env, probe cycle detection, local topo-sort of requires, body invocations in topo order); RegisterError::DependencyCycle variant added.
  • 2026-05-18: Phase 2 Task 2.2 (review polish) — strengthen topo-order test to use a 3-recipe graph where alphabetical ≠ topo and assert command payload per recipe; format DependencyCycle errors as a -> b -> a instead of ["a", "b", "a"].
  • 2026-05-18: Phase 2 Task 2.3 — collision detection landed: RegisterError::RecipeCollision { name, sites } with RegistrationSite { line, kind: SurfaceRecipe | SurfaceChore | Dynamic } exposed publicly; detect_collisions runs after config-block dispatch in register_cookfile.
  • 2026-05-18: Phase 2 Task 2.3 (review polish) — document detect_collisions fail-fast intent; add 3-site collision test that locks down the or_default().push(...) invariant for N >= 3.
  • 2026-05-18: Phase 2 Task 2.4 — list_names implemented in cook-register (no-body-invocation register pass — load, config blocks, collision detection, probe-cycle detection, return name+source+requires); install_remaining_apis and dispatch_config_blocks extracted as shared helpers from register_cookfile.
  • 2026-05-18: Phase 2 Task 2.4 (review polish) — patch cache_meta.recipe_name from qualified_name in register_cookfile’s drain loop (closes deferred follow-up (d) before Phase 3 wires the entry point into the engine); strengthen list_names test with source/kind assertions.
  • 2026-05-18: Phase 2 (review polish) — cook.probe(...) at top level now succeeds in register_cookfile; body-push is conditional on body-slot occupancy. Top-level probes are session-scoped per spec §6 step 4 and §7; body-scoped probes still schedule as consumer work-units within the owning recipe. New positive test pins the contract.
  • 2026-05-18: Phase 2 Task 2.5 — positive test: wrapper-registered recipe dispatch via register_cookfile. A top-level call to mod.bin("game") (where mod.bin internally calls cook.recipe) surfaces "game" as a Dynamic registration with a non-zero line pointing at the Cookfile call site. register_cookfile now seeds __cook_cookfile_path in the no-CacheContext fallback path so the caller_line_in_cookfile call-stack walk resolves correctly when callers (tests, legacy entry points) don’t supply a CacheContext. Closes Phase 2.
  • 2026-05-18: Phase 3 Task 3.1 — codegen now emits cook.__register_surface[_chore] (with __line = N) for surface recipe/chore blocks; RegisteredRecipe gains a kind: RecipeKind field carried through to RegisteredRecipePub; surface-vs-dynamic distinction (and surface recipe-vs-chore distinction) is now visible to detect_collisions. The public cook.recipe API still tags Dynamic registrations; parse_meta_lists is now shared across all three install paths in cook-register::capture.
  • 2026-05-18: Phase 3 Task 3.1 (review polish) — hoist __register_surface[_chore] API names to cook-contracts constants so codegen and capture share the source of truth; refresh stale comment near detect_collisions; add direct capture-layer tests for both surface closures (line + kind + units assertions).
  • 2026-05-18: Phase 3 Task 3.2 — codegen tests pin chore + requires-bearing recipe emission shapes (__register_surface_chore + __line); register test pins the surface-vs-dynamic collision diagnostic naming both sites with SurfaceRecipe and Dynamic kinds. Closes Phase 3.
  • 2026-05-18: Phase 4 Task 4.1 — RegisteredWorkspace aggregation type added in cook-engine (workspace-level container that merges per-import RegisteredCookfile into a single view: qualified names, units_by_recipe, probes, per-Cookfile final_env, working_dir, and alias_dirs). Type-only; per-import merge logic lands in Phase 5 Task 5.1. cook_register::{RegisteredCookfile, RegisteredRecipePub} re-exported from cook-engine for consumers; cook_register::RecipeKind deliberately not re-exported to avoid collision with the engine’s own progress-event RecipeKind.
  • 2026-05-18: Phase 4 Task 4.2 — audited dag_builder::build_dag for wave-coupling assumptions (findings: NONE — coarse deps and fine-grained dep_edges already resolve intra-call against the running recipe_leaves accumulator; the wave loop hid the property externally because each call saw only one wave); pinned the cross-recipe edge contract with a new cli/crates/cook-engine/tests/unified_dag_build.rs integration test exercising two-recipe coarse-dep, three-recipe chain, and fine-grained dep_edges in single calls. Docstring on build_dag now spells out the unified-call contract.
  • 2026-05-18: Phase 4 Task 4.4 — cook-engine::run_inner rewritten to walk a single unified work-unit DAG built across every reachable recipe in one dag_builder::build_dag call and dispatched through one executor::execute_dag walk; wave_grouper.rs deleted from cook-engine. Cross-recipe edges live directly on the unified work-unit DAG (no wave loop, no per-wave registration). Synthetic RecipeStarted/RecipeCompleted lifecycle events still fire for zero-unit recipes (meta-targets); Task 4.5 will move the per-recipe lifecycle to unit-driven transitions. The public run entry point keeps its legacy &BTreeMap<String, RegistryEntry> signature for the duration of Phase 4 — it aggregates the registries into a RegisteredWorkspace via a transitional shim — so cook-cli continues to compile against the engine; Phase 5 Task 5.1 retires the shim. wave_grouper survives only as a viewer-local module in cook-dag-viewer for display-grouping (no runtime semantics).
  • 2026-05-18: Phase 4 Task 4.4 (review polish) — fix probe-cache regression in register_workspace_from_registries shim by aggregating per-recipe probes into the workspace-level RegisteredWorkspace.probes map (first-wins on key collision; Phase 5’s register_cookfile path will surface conflicts properly); tighten cycle diagnostics in toposort_reachable to name only the unresolved cycle nodes (in-degree > 0 at termination) instead of dumping the entire reachable set; add probe_produce_does_not_re_execute_on_cache_hit regression test in cli/crates/cook-cli/tests/probe_integration.rs exercising CS-0074 probe-value cache fast path end-to-end.
  • 2026-05-18: Phase 4 Task 4.4 (revision) — removed the transitional register_workspace_from_registries shim from cook-engine. run_inner takes &RegisteredWorkspace as its sole registration input; cook-cli is intentionally broken until Phase 5 Task 5.1 lands the proper register_workspace helper. Also tightens cycle-error formatting to name only the cycle nodes.
  • 2026-05-18: Phase 4 Task 4.5 — RecipeStarted / RecipeCompleted events in cook-engine are now unit-driven: a recipe’s RecipeStarted fires when its first unit transitions out of Waiting; RecipeCompleted (or RecipeFailed) follows its last unit’s completion. The per-recipe elapsed time runs from that first transition (not from DAG-construction), and the wave-aligned firing pattern is retired. Zero-unit (meta-target) recipes continue to be emitted synthetically from run_inner so reporters always see one started/completed pair per reachable recipe. Confirmed Task 4.4 had already landed the unit-driven RecipeTracker helpers (ensure_recipe_started, finish_recipe_node); Task 4.5 documents the contract on the tracker struct, refreshes the call-site comment in run_inner, and ratifies the semantics in the Standard.
  • 2026-05-18: Phase 4 Task 4.6 — From<cook_register::RegisterError> for cook_engine::EngineError impl: DependencyCycle → CycleDetected("recipe cycle: a -> b -> a"); RecipeCollision → RegistrationFailed { recipe, message } (Phase 5 lifts to a structured CLI error).
  • 2026-05-18: Phase 5 Task 5.1 — register_workspace and register_single_cookfile pipeline helpers landed in cook-engine::pipeline::registers. Call cook-register::register_cookfile once per Cookfile (root + each import); merge per-import results into a RegisteredWorkspace with qualified names/units/probes/env. A single SharedTerminalOutputs is threaded through every per-Cookfile builder so cross-Cookfile cook.dep_output("alias.recipe") lookups resolve through one backing store. CLI command migrations (cmd_run, cmd_test, cmd_dag) follow in Tasks 5.3+.
  • 2026-05-18: Phase 5 Task 5.2 — build_recipe_infos_from_registered lands in cook-engine::pipeline::recipe_info; recipe_infos are now synthesised from RegisteredWorkspace.names + units_by_recipe.terminal_outputs instead of AST. Lua-registered recipes (cook_cc.bin, dynamic chores) become first-class members of the dependency graph the engine resolves. Legacy AST-driven build_single_recipe_infos, build_workspace_recipe_info, and the private workspace_to_layout helper are deleted from the pipeline layer; find_full_prefix is retained as the only Workspace-keyed helper. Inferred-dep helpers (compute_inferred_deps_from_registered, dep_conflicts_from_registered) are NOT added: in the unified-DAG world, cook.dep_output / cook.add_unit record cross-recipe edges directly into RecipeUnits.dep_edges at register time, collapsing the legacy “inferred-deps” computation into the captured DAG.
  • 2026-05-18: Phase 5 Task 5.3 — cmd_run migrated to register_workspace + the unified DAG via cook_engine::run. Lua-registered recipes (e.g. via cook_cc.bin) are now dispatchable from cook NAME — the primary acceptance criterion for this migration is reached. cmd_serve and collect_workspace_recipe_names (used by cmd_test scope resolution) migrated to the same register helpers in lockstep so cook-cli compiles for the cmd_run / cmd_serve / cmd_menu / cmd_list / cmd_init paths; cmd_test remains blocked on the Task 5.5 run_for_test reintroduction (1 remaining cook-cli compile error, down from 7). cache_ctx is still constructed inside cook_engine::run (passed as None to register_workspace); lifting it out so the register pass observes the same context as the executor is tracked as a follow-up.
  • 2026-05-18: Phase 5 Task 5.4 — cmd_list and cmd_menu migrated to cook_register::list_names via the new list_workspace_names / list_single_cookfile_names pipeline helpers. Lua-registered recipes now appear in the listing surface without body invocation. The cook_register crate is also re-exported as a module from cook-engine so consumers can name cook_engine::cook_register::RecipeKind without taking a direct cook-register dependency — the engine-root collision with its own progress-event RecipeKind is sidestepped by routing through the crate-module path.
  • 2026-05-18: Phase 5 Task 5.5 — cmd_test and cmd_dag migrated to register_workspace. cmd_test resolves scope via the cheap list_workspace_names path and runs through the same unified-DAG executor (cook_engine::run::run) as cmd_run, filtered to the test scope: candidate-recipe selection (None / Recipe-closure / Namespace-prefix), recipe-level pre-filter against --filter globs, post-execution per-TestId filtering, and TaskFailures.partial_test_results extraction so cook-step failures surface as Blocked test rows instead of short-circuiting the run. cmd_dag emits per-unit nodes from the reachable slice of RegisteredWorkspace.units_by_recipe; inferred_deps is the empty map in the unified-DAG world (cross-recipe edges live directly on RecipeUnits.dep_edges). The legacy run_for_test entry point on cook_engine is gone; cook-cli compiles cleanly. cook_engine now also re-exports the cook_contracts and cook_cache crates as modules so cook-cli can name cook_engine::cook_contracts::RecipeUnits and cook_engine::cook_cache::ThreadSafeCacheManager through the engine boundary. merge_into in pipeline::registers now qualifies intra-Cookfile requires entries with the import’s prefix so the analyzer’s dep-graph walk sees a consistent fully-qualified namespace across every Cookfile in the workspace.
  • 2026-05-18: Phase 5 Task 5.6 — RecipeCollision from register_cookfile is now plumbed as a typed PipelineError::RecipeCollision { name, sites } → CLI CookError::RecipeCollision (exit code 3). Multi-line diagnostic names both registration sites with line and kind labels per spec §8 (“as a `recipe` block” / “as a `chore` block” / “by cook.recipe at register-phase”). All four register helpers in cook-engine::pipeline::registers (register_workspace, register_single_cookfile, list_workspace_names, list_single_cookfile_names) share one map_register_error helper that preserves the collision variant and folds every other RegisterError into PipelineError::Other (pre-Task-5.6 behavior for non-collision errors).
  • 2026-05-18: Phase 6 Tasks 6.1+6.2 — delete RegisterSessionBuilder::register_recipe and migrate cook-register/src/tests.rs to register_cookfile with reads from units_by_recipe. Final clean break — no shim, no #[deprecated]. The legacy per-recipe entry point is gone; register_cookfile and list_names are the only register-phase entry points. Integration tests in cook-register/tests/{config_blocks,conformance}.rs migrate to the same API. The orphan cook-engine::pipeline::dag_units viewer-bridge (only consumer of register_recipe outside tests; no external callers) is deleted in the same commit so the workspace continues to compile. Two CS-0074 conformance fixtures (probe-template-desugaring, probe-unresolved-require) place cook.add_unit at top-level register-block scope — a pattern that the new design rejects (body-slot is None during top-level Lua load); the conformance harness skips them with a Phase 7 follow-up note. Body-scope probe tests refocus on RecipeUnits.units (where body-scope probes lower to WorkPayload::Probe capture units) rather than RecipeUnits.probes (session-scope view); top-level probe cycle test moves to the canonical top-level form.
  • 2026-05-18: Phase 6 Task 6.3 — delete cook-engine::registry_entry module and cook-engine::pipeline::registries (transitional types unused after the RegisteredWorkspace migration in Phase 5).
  • 2026-05-18: Phase 7 Task 7.4 — positive conformance fixture recipe-dispatch-from-register-block/ pins §22.6: a recipe registered solely by cook.recipe("hello", ...) inside a top-level register block parses, codegens, and registers cleanly under the new register_cookfile pipeline (no surface recipe block declares the name).
  • 2026-05-18: Phase 7 Task 7.5 — negative conformance fixture recipe-name-collision-surface-vs-dynamic/ pins §22.3: a surface recipe build block colliding with a cook.recipe("build", ...) call from a top-level register block is rejected end-to-end through codegen + register_cookfile with a RecipeCollision diagnostic naming both sites.
  • 2026-05-18: Phase 7 carry-forward — Phase 6 had marked two CS-0074 probe fixtures (probe-template-desugaring/, probe-unresolved-require/) and two cook-cli probe integration tests (probe_consumer_end_to_end_first_run_then_cache_hit, probe_produce_does_not_re_execute_on_cache_hit) as skipped/ignored because they called cook.add_unit from a top-level register block, which register_cookfile rejects (body slot is None). Phase 7 reshapes each fixture and test so the cook.add_unit call lives inside the consuming recipe’s body (>>{ ... }), matching the CS-0077 contract. The is_legacy_top_level_add_unit_fixture skip helper is deleted; the two #[ignore] markers are removed. As part of the cache-hit fix for body-scope probes, register_cookfile now drains the workspace probes map from probe_registry (rather than session_state.probes) so body-scope probes also participate in the executor’s probe-cache fast path; the per-recipe RecipeUnits.probes view continues to surface only session-scope probes per §7.
  • 2026-05-18: Phase 8 (smoke-test fix) — dag_builder now materialises WorkPayload::Probe DAG nodes for top-level probes referenced by recipe consumers. Previously the index was built only from RecipeUnits.units-resident Probe payloads, so probes registered at register-block scope (via cook.probe(...) from helpers like cook_cc.checks.has_header) were silently dropped from the DAG and consumer cook.cache.get reads returned nil at execute time.
  • 2026-05-18: Phase 8 (regression fix) — cook-luagen codegen now augments each surface recipe NAME’s emitted requires field with cross-recipe $<NAME> body references discovered via dep_ref::extract_dep_refs. In the unified register-phase model, recipe bodies run in topo order at register time; a body that calls cook.dep_output(NAME) (the lowering for $<NAME>) must have its target already registered when it runs. Pre-fix, only explicit requires declarations drove the topo-sort, so recipe app_with_lib with body $<libfoo> and no explicit requires would register before libfoo and fail with “recipe ‘libfoo’ has no terminal output”. Surfaced by cli/e2e-fixtures/iteration_benchmarks/ smoke.

CS-0078 — Multi-line cook outputs and ingredients patterns [#changes.cs-0078]

Summary. App. A.4 grows a normative paragraph specifying that whitespace between successive STRING outputs in cook_step and successive ingredients in ingredients_step MAY include line breaks. Pattern collection across continuation lines is required when each continuation line begins with " (or !" for ingredients_step). A non-quote token on the same physical line as a quoted pattern is a diagnostic when it is not the legal terminator (using for cook). A continuation line whose first token is not a quote terminates the declaration without a diagnostic — the standalone-line case is ambiguous between misspelled continuation and a new step, and the Standard preserves the declaration-only form by choosing silent termination.

Rationale. Multi-output cook steps with three or more outputs (and ingredients declarations with several patterns) become unreadable on a single line. The Doom 3 demo Cookfile uses three-output AAS cook steps as the forcing example. The change is backward-compatible: single-line forms continue to parse unchanged; only the previously-rejected multi-line form now parses. The decision to NOT diagnose a standalone non-quote line preserves the declaration-only cook_step shape (the user might have intended to terminate the declaration and start a new step).

Conformance impact. A conforming parser MUST accept multi-line forms. Positive fixtures 057-cook-multi-line-outputs and 058-multi-line-ingredients pin the new shape. Negative fixtures multi-line-cook-non-quote-continuation and multi-line-ingredients-non-quote-continuation pin the diagnostic behavior when a non-quote token appears trailing a quoted pattern on the same physical line. The silent-termination case for standalone non-quote continuation lines is intentionally not pinned by a negative fixture (the behavior is “no error, declaration treated as complete,” which is observed via the absence of a diagnostic, not via a specific diagnostic match).

CS-0079 — fs.glob accepts an array of patterns [#changes.cs-0079]

Summary. §25.7 widens the signature of fs.glob from (pattern: string) to (pattern: string | array of string). When given an array, a conforming implementation MUST glob each pattern in call order and concatenate the per-pattern results in that order, preserving per-pattern internal order. The CS-0064 directory-filter rule applies independently to each pattern. Invalid argument types raise a Lua runtime error tagged fs.glob.

Rationale. The Doom 3 demo Cookfile selects platform sources by globbing several patterns and concatenating the results. The single-string form forced a for + table.insert loop per joined pattern, which dominated the Cookfile’s platform-source block visually. The array form expresses the intent in one call.

Backward compatibility. Single-string callers continue to work bit-for-bit identically; the array form is purely additive.

Conformance impact. A conforming implementation MUST accept both forms. Positive fixtures fs-glob-array-of-patterns and fs-glob-array-preserves-order pin the Cookfile surface; the runtime contract (call-order concatenation, per-pattern CS-0064 filter, sandbox check applied per pattern, error on bad argument types) is verified by cook-lua-stdlib::fs_api unit tests.

CS-0080 — cook_cc PRIVATE/PUBLIC propagation, surface v0.7 [#changes.cs-0080]

Summary. §28.4 introduces a PRIVATE/PUBLIC distinction for cook_cc target options. The bare fields defines, system_libs, frameworks, extra_ldflags are PRIVATE-by-default: a conforming implementation MUST use them only for the target’s own compile/link and MUST NOT export them through cook.export(name, info). The new fields export_defines, export_system_libs, export_frameworks, export_extra_ldflags are the explicit-public counterparts and propagate to consumers via the transitive links walk. export_includes retains its existing fall-back to bare includes (the sole backcompat carve-out). Toolchain defaults (cc.toolchain({ ... })) are merged into the target’s bare fields under this rule and therefore stay target-local. The conformance surface bumps from v0.6 to v0.7; the reference implementation is cook_cc-0.8.0-1.

Rationale. dhewm3 has 8+ static libs that consume each other via links = { ... }. Under v0.6 implicit-public propagation, every define a lib carried leaked to consumers. idLib’s ID_LIB_INTERNAL would pollute every consumer’s compile; idLib’s USE_OPENAL (which DOES want to be public) was indistinguishable from ID_LIB_INTERNAL in the surface. M4-narrow draws the line. The doom 3 demo Cookfile is the forcing example.

Backward compatibility. Single-target Cookfiles that use only cc.bin (no downstream consumer) observe no behavior change. Multi-target Cookfiles that relied on bare-field propagation through links = { ... } must rename the leaking field to export_* to preserve the v0.6 behavior. The export_includes fall-back means includes-only projects (the majority of single-lib examples) continue to work unchanged. The Standard’s example corpus required one migration (examples/modules/lua-build/Cookfile, liblua’s system_libs → export_system_libs).

Conformance impact. Surface v0.7 implementations MUST:

  • Set info.defines = opts.export_defines or {} (NO fall-back to bare defines).
  • Set info.system_libs = opts.export_system_libs or {}.
  • Set info.frameworks = opts.export_frameworks or {}.
  • Set info.extra_ldflags = opts.export_extra_ldflags or "".
  • Set info.includes = opts.export_includes or opts.includes (fall-back kept).

Three positive conformance fixtures pin the new surface: cc-private-define-does-not-propagate (negative-shape positive fixture: parses a Cookfile that relies on the PRIVATE rule), cc-export-define-propagates (the PUBLIC path), and cc-export-includes-backcompat-fallback (locks the fall-back carve-out). Runtime semantics are verified by the cook_cc busted suite (cook_cc/spec/targets_spec.lua “cc PRIVATE/PUBLIC propagation” describe-block).

CS-0081 — Per-platform source selection canonical pattern in §28.7 [#changes.cs-0081]

Summary. Adds §28.7 “Per-platform source selection” to the cook_cc chapter, naming the cook.platform.os + fs.glob Lua idiom as the canonical pattern for selecting per-platform sources for cc targets. The new section is structured in three subsections: §28.7.1 specifies the canonical pattern with two interchangeable Lua forms; §28.7.2 calls out and excludes the structured sources_per_platform = { linux = ..., darwin = ... } non-pattern, with the design rationale enumerated; §28.7.3 documents the interaction with the CS-0080 PRIVATE/PUBLIC propagation rules. No surface change. The Standard surface version stays at v0.7; the cook_cc reference implementation stays at 0.8.0-1.

Rationale. The doom 3 demo Cookfile is the forcing example: it gathers per-platform engine sources via the cook.platform.os + fs.glob join shown verbatim in §28.7.1. The pattern works today with zero implementation changes (cook.platform is §24.6, fs.glob array form is §25.7/CS-0079, plain Lua if and table.insert are the host language), but it is not the only shape an author might invent and the Standard had no anchor a demo viewer could cite. Without a normative naming, future Cookfiles would either reinvent the shape (a register_blocks per-OS, a hand-rolled select_sources(...) helper, an attempt at a structured option) or copy whichever cargo-cult shape the most-visible example happens to use. §28.7 fixes the canonical idiom and explicitly disclaims the structured-table alternative so neither implementations nor authors revisit the question.

Backward compatibility. Purely additive. No existing Cookfile becomes non-conforming. No cook_cc rock surface changes. The conformance harness gains one positive fixture but loses no behaviour. Implementations that already accept arbitrary unknown option keys on cc.bin (rather than erroring) are out of compliance with §28.5 independently of CS-0081; this entry clarifies but does not introduce that requirement.

Conformance impact. A conforming implementation gains no new normative obligation it did not already have under v0.7. §28.7 is documentary in posture: it names a pattern that already conforms (canonical) and a pattern that already does not (structured per-platform table — fails the “unknown option key” rule of §28.5). One positive fixture is added: cc-platform-source-selection (a Cookfile that exercises the canonical pattern with cook.platform.os == "linux" gating an fs.glob extension). The fixture is parse-only at this stage; runtime behaviour is verified by the implementation’s existing cook.platform + fs.glob + cc.bin test coverage and does not require a new harness entry point.

CS-0084 — cc.shared accepts an explicit output override [#changes.cs-0084]

Summary. Adds an optional output field to SharedOpts (§ 28.3.3). When present, the implementation MUST use it verbatim as the link unit’s output path and MUST record it as the recipe’s lib_path export. When absent, the existing default build/lib/lib<name>.<ext> is unchanged. The cook_cc reference implementation surface ticks v0.7 → v0.8 (additive only; no existing call shape changes); rock version bumps to 0.10.0-1.

Rationale. The dhewm3 bring-up is the forcing example. dhewm3’s engine dlopens game plugins by basename: sys_local.cpp:DLL_GetFileName builds <basename><BUILD_LIBRARY_SUFFIX> (i.e. base.so, d3xp.so — no lib prefix) and framework/Common.cpp:LoadGameDLLbyName searches PATH_EXE first (the binary’s directory, build/bin/ in the cook layout). The hard-coded build/lib/lib<name>.so shape of v0.7 cc.shared produces neither the right filename nor the right directory for any plugin-style runtime. Without an override, every engine-style consumer must paper over the gap with a post-link cp chore or symlink — a band-aid that fails reproducibility (the artifact ledger no longer reflects the loadable file) and forces an out-of-band convention. Promoting output to a documented surface field codifies the only knob authors actually need and keeps the artifact ledger honest.

Backward compatibility. Purely additive. No existing Cookfile becomes non-conforming. Implementations that already accept arbitrary unknown option keys (rather than erroring) are out of compliance with §28.5 independently of CS-0084; this entry does not change that. Default behaviour (no output) is byte-identical to v0.7.

Conformance impact. A conforming implementation MUST honour opts.output verbatim when present and MUST flow the override through cook.export’s lib_path so consumers’ transitive link steps see the right artifact location. No new positive conformance fixture is required at the parse layer (the field is a Lua table key); the reference implementation’s busted suite locks the behaviour with a targets.shared("plug", { output = "build/bin/plug.so" }) case asserting units[#units].output == "build/bin/plug.so". Windows and macOS authors are responsible for authoring the platform-correct suffix in output; the implementation MUST NOT replace it.

CS-0085 — outputs[] accepts glob patterns; terminal-output rule [#changes.cs-0085]

Version: v0.13

Date: 2026-05-21

Sections affected: §22.1 (path-shape paragraph amended), §22.1.2 (new), §17.6 (new, follow-up commit), App. C.17.1 (new rationale, follow-up commit).

§22.1 widens outputs[] entries to allow glob patterns alongside literal paths. §22.1.2 specifies the Lua-level field syntax and the cross-recipe terminal-output rule. §17.6 specifies post-execute resolution and the cache-record shape (lands in the §17.6 commit). App. C.17.1 explains the rationale for terminality (lands in the rationale commit).

Glob metacharacters are limited to *, ?, [ to match the reference implementation’s glob = "0.3" pattern parser; brace alternation ({a,b}) is not part of the surface in v0.13 and MAY be added in a future CS once the reference engine supports it. A pattern with a trailing ** segment (e.g. .next/**) is normalised to **/* form so that it matches files at all depths under the prefix; this matches the Turborepo/bash-globstar convention authors expect.

Motivation: enables cook_pnpm v0.2 caching parity and any future blessed module whose output set is determined by the wrapped tool rather than declared by the Cookfile author.

Backward compatibility: today’s Cookfiles with literal outputs[] entries behave bit-for-bit identically; CACHE_VERSION does not bump. The check has_glob_meta(s) returns false for every literal entry; the post-execute resolver is a pass-through in that case.

CS-0086 — tree-sitter-cook v0.12 conformance audit (closes CS-0002 again) [#changes.cs-0086]

Date: 2026-05-21 Version: N/A (no normative grammar change; conformance audit only) Sections affected: none in the Standard itself. App. E (this entry); CS-0002 is now closed by this CS (analogous to CS-0014).

Summary. Brings tree-sitter-cook/grammar.js and its external scanner (tree-sitter-cook/src/scanner.c) into syntactic conformance with Cook Standard v0.12 (App. A), addressing divergences accumulated since CS-0014’s v0.2 audit. No normative grammar change — tree-sitter-cook was simply lagging the Rust parser, which has been the v0.x reference implementation per pre-v1 checklist E.4 throughout.

Grammar changes since v0.2. Each bullet corresponds to one sub-issue in the tree-sitter-cook v0.12 conformance milestone.

  • CS-0072. Top-level register block production added to _toplevel_item; new external REGISTER_BLOCK_CONTENT token shares the LUA_SOURCE scanner with CONFIG_BLOCK_CONTENT, with register joining the toplevel-keyword stop set so register blocks split from config and from each other. Recipe-body scanner extended to terminate on a column-0 register keyword so the interleaved-register-between-recipes form parses correctly. queries/injections.scm injects Lua into register block bodies; queries/highlights.scm highlights register as a keyword.
  • CS-0072. Top-level module_call production added to _toplevel_item via a new external TOP_LEVEL_MODULE_CALL_TEXT token that brace-balances across newlines per § 3.9.1, so multi-line table-arg forms (cook_cc.bin("game", {\n …\n})) parse as a single statement. Concurrently, the module_call arm is removed from _recipe_item and _chore_item (and the recipe-body shell scanner stops deferring LUA_IDENT.IDENT_START… lines) — recipe-body bare module-calls now resolve as shell_command, in line with App. A.4’s revised step-dispatch priority order. The shared LUA_SOURCE scanner also terminates on a column-0 module-call shape so register / config bodies end cleanly when followed by a top-level module call.
  • CS-0078. Multi-line cook outputs + ingredients continuation. New external STEP_CONTINUATION_NEWLINE token absorbs a newline + leading whitespace between successive STRING (or !STRING) tokens iff the next non-whitespace byte is " (or !" for ingredients); otherwise it returns false and the declaration terminates silently per App. A.4. Grammar’s cook_step and ingredients_step rules thread the new token through their pattern collections. The recipe-body shell scanner’s c == '"' early-bail is removed — a leading " is now consumed as shell content (the previous bail caused echo "x: $<dep>" to ERROR after the embedded placeholder because the resumed scan started at the closing ").
  • CS-0035. Multi-line Lua opaque-span tracking inside _lua_block_content. New helpers try_long_string_opener and skip_long_string_body recognise leveled long-strings [==[ … ]==] (any =-level) and leveled block comments --[==[ … ]==] across newlines; braces and quotes inside an opaque span are inert, so brace-balanced lua_block bodies containing } literals in long-strings or block comments now parse cleanly.
  • CS-0035. POSIX heredoc opaque-span tracking inside _shell_block_content. New helpers try_parse_heredoc_opener and heredoc_try_close_at_line_start recognise <<TAG, <<-TAG, <<'TAG', <<"TAG"; while in the body, braces are inert. ShellBlockState extended with in_heredoc (0/1/2 state machine for pending/in-body), heredoc_dash, and an inline heredoc_tag[31] buffer; the external scanner’s serializer round-trips the new state across resumes (e.g., placeholders on the opener line). Leading whitespace on the closing TAG line is stripped unconditionally — Cook’s SHELL_BLOCK_CONTENT normalises indentation per App. A.5, so the indented EOF style used in fixtures matches.
  • CS-0061 + CS-0035. Three coupled grammar tightenings: (a) STRING admits both double- and single-quoted forms — placeholder substitution $<IDENT> works in both via new _sq_string_placeholder / _sq_string_chunk rules paralleling the double-quoted ones; (b) use_declaration name constrained to _lua_ident_name (/[A-Za-z_][A-Za-z0-9_]*/), rejecting hyphens, dots, and spaces at the grammar level; (c) recipe_header / chore_header name field constrained to _decl_name (no dots, hyphens OK), while dependency_list keeps the relaxed _name so dotted cross-recipe refs (CS-0009) stay legal there. Five negative fixtures (040/041/042 use_name, 049/050 dotted recipe/chore names) move from “tree-sitter accepts” to “tree-sitter rejects”.
  • Banner + harness. tree-sitter-cook/grammar.js, package.json#version+description, and tree-sitter.json#version+description claim cs-standard/v0.12. scripts/conformance.mjs is tightened: KNOWN_STALE_POSITIVES is empty; SEMANTIC_ONLY_NEGATIVES is audited (cc-* / probe-* / declaration-name-collision entries documented with the precise spec rule); the harness now treats STALE entries or NOTE-flagged stale-list-shrink signals as non-zero exits. standard/public/tree-sitter-cook.wasm is rebuilt from the new grammar.

Conformance result. node scripts/conformance.mjs from tree-sitter-cook/ exits 0 with 129 OK / 0 FAIL / 0 STALE / 0 NOTE / 37 SKIP. tree-sitter test exits 0 across 69 corpus tests. CS-0002’s “tree-sitter-cook is known stale” note is superseded by this entry, mirroring the role CS-0014 played for the v0.2 cycle. A re-divergence will reopen CS-0002 again in a subsequent audit cycle.

CS-0087 — Chore parameters [#changes.cs-0087]

Date: 2026-05-22

Version: v0.13

Sections affected: §6.1 (recipes forbid params), §7.1.1 (new — chore-header parameter grammar, ordering rule, reserved-name ban, dot-ban), §7.1.2 (new — parameter binding inside the body, Lua-locals + invocation-time rules + diagnostic table + Lua-default coercion rules), §7.1.2.1 (new — env-var export to shell children), §7.1.3 (new — variadic parameters +NAME/*NAME), §7.5.1 (new — dependencies on parametric chores), §9 (placeholder table grows $<param-name> row + chore-body resolution-order paragraph + scalar POSIX shell-quoting clarification), §13.2 phase-classification table (Lua-expression default is load-phase), §17.5.1 (informative cache note), App. A.3.1 (grammar productions param_list, param, default_value).

Surface change. Chore headers grow a parameter list between the chore name and the optional : dep list:

chore deploy target host="prod.example.com" version=(cook.git.head_tag() or "v0") *extras: build
> deploy.run(target, host, version, extras)

Three param kinds:

  • Required (BARE_IDENT) — bound from positional argv at chore invocation; missing argv is a diagnostic per §7.1.2.
  • Defaulted (BARE_IDENT "=" STRING or BARE_IDENT "=" "(" LUA_EXPR ")") — STRING default binds verbatim; Lua-expression default evaluates against the Cookfile-scope VM (load phase, §13.2) when argv is exhausted, coerced to a string via Lua tostring rules for string/number/boolean returns; other Lua types raise the non-coercible diagnostic.
  • Variadic ("+" BARE_IDENT or "*" BARE_IDENT) — collects remaining argv into a Lua sequence; +NAME requires ≥1, *NAME allows 0 (binds {}).

Ordering (normative): required → defaulted → at-most-one variadic at the tail. Parameter names follow the §A.2 reserved-segment ban (stem, name, ext, dir, in, out, all, env), MUST NOT contain ., and MUST be unique within a header. Recipes do not take parameters; a recipe header with parameter tokens is rejected at parse time.

Three surfaces in the body. A bound parameter is available as: (a) a Lua local visible to every body step; (b) a $<name> placeholder in shell steps, POSIX single-quote-escaped to a single shell word (scalar) or the space-joined individually-quoted form (variadic); (c) an environment variable exported to every shell child process spawned by the body, name-for-name, with variadics joined by single spaces.

CLI dispatch (informative, ref-impl). Argv after the chore name is partitioned in one left-to-right pass: @PRESET sigil and --config NAME/-c NAME/--config=NAME flag select a config preset (at most one per invocation); -- ends partitioning and treats subsequent tokens as literal parameter values regardless of sigil prefix; all other tokens are parameter values. The legacy “second bare positional = config preset” form is removed; the diagnostic for a now-broken legacy invocation includes a migration hint. The Standard does not prescribe a CLI surface for these rules — see §7.1.2 normative text and cli/CHANGELOG.md in the reference implementation for the exact wording.

Conformance fixtures. 12 positive (chore-param-required, chore-param-defaulted-string, chore-param-defaulted-lua, chore-param-variadic-plus, chore-param-variadic-plus-zero, chore-param-variadic-star-empty, chore-param-with-deps, chore-param-shell-substitution, chore-param-too-few-argv, chore-param-too-many-argv, chore-param-default-lua-error, chore-param-default-lua-non-string) + 7 negative (recipe-with-params-rejected, chore-param-defaulted-before-required, chore-param-variadic-not-last, chore-param-multiple-variadics, chore-param-variadic-with-default, chore-param-duplicate-name, chore-param-reserved-name, chore-param-dotted-name).

Tree-sitter implementation (CS-0086 follow-up). tree-sitter-cook/grammar.js grows chore_param_list, chore_param, required_param, defaulted_param, variadic_param, and lua_expr_default productions; chore_header threads the params field between name and the optional :/dependency_list. The =(EXPR) Lua-expression default is parsed via a recursive lua_expr_default rule with string-aware paren balancing — nested calls (cook.git.head_tag()) and parens-in-strings (error("(boom)")) parse correctly without an external scanner. Parameter names use a strict /[A-Za-z_][A-Za-z0-9_]*/ regex, so the dot-ban is enforced syntactically; ordering, duplicates, reserved names, and the at-most-one-variadic rule are semantic and remain tagged SEMANTIC_ONLY_NEGATIVES per the audit policy established in CS-0014/CS-0086. queries/highlights.scm highlights param names as @variable.parameter, sigils as @operator, and lua_expr_default brackets as @punctuation.bracket. The grammar banner, package.json#version, and tree-sitter.json#version claim cs-standard/v0.13.

Reference implementation. cli/crates/cook-lang (lexer + AST + diagnostics), cli/crates/cook-luagen (__params metadata + body-fn local prelude + closure-based Lua-expression defaults), cli/crates/cook-register (runtime argv→params binding, env-var export, shell-sigil resolver), cli/crates/cook-cli (argv partitioning), 30+ integration tests in cli/crates/cook-cli/tests/chore_params_test.rs, 25-scenario cli/e2e-fixtures/chore_param_benchmarks/.

Backward compatibility. Breaking on the CLI surface only: any cook NAME PRESET invocation (where PRESET was the legacy second-positional config-preset selector) now needs cook NAME @PRESET or cook NAME --config PRESET. The diagnostic at the failure point includes a migration hint. No Cookfile becomes non-conforming.

Tree-sitter conformance result. node scripts/conformance.mjs from tree-sitter-cook/ exits 0 with 145 OK / 0 FAIL / 0 STALE / 0 NOTE / 42 SKIP. tree-sitter test exits 0 across 75 corpus tests (69 + 6 chore-param cases).

CS-0088 — Scope-of-register-time-parameter-checks note for §7.5.1 [#changes.cs-0088]

Date: 2026-05-23

Version: v0.13

Sections affected: §7.5.1 (Note 7.5.1.1 added — clarifies that the register-time parameter validation in §7.5.1 applies only to chores reachable from the dispatch target via requires, not to arbitrary siblings in the same Cookfile).

Surface change. Informative clarification only. The §7.5.1 rule (“a parametric chore depended on by the dispatch target runs with no argv; a required parameter without a default is a register-time configuration error”) was implicitly scoped to the reachable set — a chore that is not in the dispatch target’s transitive requires closure is not invoked, so its parameters are not bound. The new Note 7.5.1.1 makes this scoping explicit. Implementations MAY still register, list, and surface unreachable parametric chores in cook list, cook menu, and cook dag; only the bind-argv-to-parameters step is scoped.

Reference implementation. cli/crates/cook-register/src/engine.rs previously applied the §7.5.1 check to every parametric sibling during targeted dispatch, surfacing a sibling’s required-param diagnostic on every unrelated invocation (e.g. cook greet alice errored on a sibling chore demo target). Fix computes the reachable set from the dispatch target via DFS over the local requires graph and skips body invocation (mirroring the no-target path) for parametric chores outside that set. Regression test: cli/crates/cook-cli/tests/chore_params_test.rs::sibling_chore_required_param_does_not_block_unrelated_target.

Conformance fixtures. None added (register-time semantics; no parser-surface change).

Backward compatibility. Non-breaking. The fix is purely additive — previously-erroring sibling invocations now succeed; previously-succeeding invocations behave identically. Authors who had worked around the bug by giving every required param a ="" default may tighten back to required-no-default.

CS-0089 — cook (LUA_EXPR) using ... Lua-expression output form [#changes.cs-0089]

Date: 2026-05-23

Version: v0.13

Sections affected: §8.4.2 (new — Lua-expression output: per-ingredient evaluation, input binding, return-value coercion, one-to-one-only restriction, mutual exclusion with quoted outputs, using_clause admission, Example 8.4.2.1 / 8.4.2.2, Note 8.4.2.1 cache-key impact, Note 8.4.2.2 one-to-one rationale), App. A.4 (grammar production cook_step factored through cook_output ::= STRING+ | "(" LUA_EXPR ")").

Surface change. A cook_step MAY substitute a parenthesised Lua expression for the quoted output-path string. The expression evaluates once per ingredient at register time against the Cookfile-scope Lua VM (the same VM that hosts chore Lua-expression defaults, §7.1.1), with input bound to the current ingredient’s path. The expression’s return value is coerced to a non-empty string under the §7.1.1 coercion rules; that string becomes the unit’s outputs[1].

recipe translate-fr
ingredients "docs/en/**/*.md"
cook (input:gsub("/en/", "/fr/")) using >{
fs.write_file(output, transform(fs.read_file(input)))
}

Restrictions (normative). The Lua-expression form is admitted only when the resulting cook_step is in one-to-one over own inputs mode — dep-driven (§ 10.4) and many-to-one (§ 8.5) modes have no addressable per-ingredient evaluation. The form is mutually exclusive with quoted-string outputs within a single cook_step: an output declaration is either STRING+ or exactly one "(" LUA_EXPR ")". A recipe whose ingredients_step is absent or empty MUST be rejected at register time.

Cache key impact. The expression’s evaluated string is recorded as outputs[1] and participates in the cache key via §17.1 observable #3. The expression source text is part of the parsed recipe body and is already folded into the command-text / Lua-chunk fingerprint per §17.1 observable #1 — no separate fingerprinting pass is required.

Conformance fixtures. One positive (cook-lua-expr-output) — the canonical §8.4.2 surface: a one-to-one cook_step whose output slot is (input:gsub("/en/", "/fr/")) and whose using_clause is a shell_block. The fixture is parse-failing in this commit (the v0.12 parser rejects the form with expected quoted output pattern); Task 2 (parser support, AST OutputPattern::Quoted | OutputPattern::LuaExpr) and Task 3 (codegen) green it. The parse.txt is the authoritative target — Task 2 SHOULD extend cli/crates/cook-lang/tests/conformance.rs::format_step to render the LuaExpr(...) discriminator as the fixture spells it.

Reference implementation. Tasks 2 and 3 land the parser, AST (OutputPattern::Quoted | OutputPattern::LuaExpr), and codegen for the form in cli/crates/cook-lang, cli/crates/cook-luagen, and cli/crates/cook-register. Task 2 (parser support) landed in cli/crates/cook-lang/src/cook_line.rs (balanced-paren expression scan mirroring § 7.1.1‘s =(EXPR) form) and cli/crates/cook-lang/src/ast.rs (OutputPattern enum with Quoted | LuaExpr arms; CookStep.outputs now carries Vec<OutputPattern>). Task 3 (codegen) added the dedicated CookMode::LuaExprOneToOne arm in cli/crates/cook-luagen/src/cook_step.rs: a per-ingredient loop binds input and evaluates the parenthesised expression in a scoped do … end block, guards the resolved value against non-string / empty-string returns (Note 8.4.2.3), and reuses the existing one-to-one using_clause emission path for both shell_block and using_lua_block bodies; the bare-accessor and driver-collection passes in cli/crates/cook-luagen/src/recipe.rs short-circuit on OutputPattern::LuaExpr so a Lua expression source is never reinterpreted as a sigil template. After Task 3 the cook-lua-expr-output conformance fixture is green. The conformance claim (COOK_STANDARD_VERSION in cli/crates/cook-lang/src/lib.rs) bumps with the v0.13 cut after both tasks land.

Backward compatibility. Non-breaking. The grammar gains a new alternative cook_output ::= "(" LUA_EXPR ")" that was previously a parse error; no existing Cookfile becomes non-conforming. Cookfiles that adopt the new form are not parseable by pre-v0.13 implementations — authors who need pre-v0.13 portability MUST continue to use the quoted-string form.

CS-0090 — Lua reads of cook.env track to consulted_env_keys [#changes.cs-0090]

Date: 2026-05-23

Version: v0.13

Sections affected: §17.1.2 (new — tracking Lua reads of cook.env: dot-access and string-indexed read recognition, comment/string-literal exclusions, dynamic-key opt-out, assignment-write opt-out, over-recording allowance, recording-mechanism parity with the shell-side $<KEY> sigil scanner).

Observable change. A cook step whose using >{ … } body is a Lua block previously registered no consulted_env_keys entry on a per-key basis — reference implementations emitted a sentinel (”*” or the empty set, implementation-defined) that did not, in practice, bind cook.env.<KEY> reads to the unit’s cache fingerprint. CS-0090 closes that gap by requiring static analysis of the Lua source and folding the recognised key names into the unit’s consulted_env_keys set, on the same footing as a shell-body $<KEY> sigil.

recipe touch
ingredients "Cookfile"
cook (input .. ".out") using >{
local f = io.open(output, "w")
f:write("FOO=" .. tostring(cook.env.FOO))
f:close()
}

Per §17.1.2, an implementation MUST record FOO in the unit’s consulted_env_keys; a value change in FOO between runs MUST invalidate the cache entry.

Recognised forms (normative). Two syntactic shapes:

  1. cook.env.IDENT where IDENT matches [A-Z_][A-Z0-9_]* (the conventional environment-key shape).
  2. cook.env["KEY"] and cook.env['KEY'] where KEY is a literal string.

Occurrences inside Lua strings ("…", '…', [[…]], [==[…]==]) and Lua comments (-- …, --[[…]], --[==[…]==]) MUST be ignored.

Excluded forms (normative).

  • Dynamic-key reads (cook.env[var], cook.env[string.upper(x)]) MUST NOT be recorded — the key is not statically resolvable from the source text. Authors who need cache invalidation on a dynamic-key read SHOULD assign through a local literal first (local v = cook.env["KEY"]).
  • Writes (cook.env.X = …, cook.env["X"] = …) MUST NOT be recorded as reads. The pattern is distinguished from a read by an = immediately following the access that is not part of ==, ~=, <=, or >=.

Over-recording allowance. An implementation MAY over-record (e.g., recognise additional access shapes). Over-recording only widens the cache-invalidation footprint and never silently serves stale output. Under-recording — ignoring a syntactically recognisable read of one of the recognised forms — is non-conforming.

Conformance fixtures. One positive (cook-lua-env-tracking) — the canonical §17.1.2 surface: a one-to-one cook_step whose Lua using_clause body reads both cook.env.FOO (dot access) and cook.env["BAR"] (string-indexed access). The fixture’s parse.txt pins the AST shape the codegen depends on; the codegen-side recording is asserted by cli/crates/cook-luagen/src/tests.rs::lua_block_step_records_static_cook_env_reads.

Reference implementation. cli/crates/cook-luagen/src/lua_env.rs (new — static scanner with the §17.1.2 matching rules, full string- and comment-skipping, and 28 unit tests covering the recognised and excluded forms). The scanner is invoked from every LuaBlock arm in cli/crates/cook-luagen/src/cook_step.rs (LuaExprOneToOne, OneToOne, ManyToOne, OneToMany, BlockStep), with the matched keys threaded through the shared ConsultedEnv accumulator (matching the shell-template emission path one-for-one). cli/crates/cook-register/src/unit_api.rs falls back to the §17.1 baseline denylist (cook_cache::envkey::EnvDenylist::baseline()) when no CacheContext has been threaded into the register VM, so the captured keys’ values still contribute to the cache fingerprint along the introspection-only register path (CLI commands that have not yet plumbed the executor’s cache_ctx into the register pass — see the cache_ctx lifting follow-up noted in cli/crates/cook-cli/src/pipeline.rs::build_registered_workspace).

Backward compatibility. Non-breaking surface. Implementations that previously emitted the consulted_env_keys = "*" sentinel for Lua bodies (the reference implementation’s pre-CS-0090 behaviour) widened the cache-invalidation footprint to “every key declared in cook.env”; CS-0090 narrows it to the statically-recognisable reads. A Cookfile whose Lua body reads no env keys at all now produces a stable cache key across cook.env value changes that the body never consulted; this is a strict improvement and matches the shell-body behaviour. A Cookfile whose Lua body relied on a dynamic-key read for cache invalidation is non-conforming under CS-0090 and MUST be rewritten to surface the key statically.

CS-0091 — for_each data-member iteration source [#changes.cs-0091]

Date: 2026-05-25

Version: v0.13

Sections affected: §3.4 (for_each added to the recipe-body keyword set, not subject to the column-0 rule); §8.1 (step-dispatch cascade gains a for_each rule; the at-most-one + mutual-exclusion-with-ingredients rule; the region-ordering declarative-step list); §8.3 (new — for_each data-member iteration source: source forms probe_ref / $(SHELL) / reserved (LUA_EXPR), the as lines modifier, JSON-default member typing, the item / $<item> / $<item.ACCESSOR> binding, one-to-one-over-data-members mode across cook / plate / test, the static-input rule, empty/duplicate diagnostics, Examples 8.3.1–8.3.2, Notes 8.3.1–8.3.2); §8.4 (overview exclusion + reproduced cascade); §8.5.1 (cook iteration-mode table gains the data-member row + precedence note); §8.5.2 (Lua-expression output admitted in data-member mode, item binding); §8.7.1 (plate/test iteration mode gains the data-member source); §9.3 / §9.4 (the $<item> / $<item.FIELD> placeholders, record-key semantics, and rejection rules); §22.5.10 (new — probe values as for_each sources: pre-pass evaluation, demand-driven preservation, static-input rule, non-array diagnostic, per-member units); §17.1 (observable #5 — per-member materialised-element fold). Also repairs the dangling §22.5.7 §{phl.token} → §{phl.token} reference.

Surface change. A recipe MAY drive its per-member steps from a data source instead of a filesystem glob, via a new register-phase for_each_step that is the data-driven counterpart to ingredients. The current member binds as item (Lua) and as the $<item> / $<item.FIELD> sigils; cook, plate, and test steps each produce one work unit per member.

register
cook.probe("cards", {
inputs = { files = {"cards/divine.yaml"} },
produce = [[ return cook.json(cook.sh("yq -o=json '.cards' cards/divine.yaml")) ]],
})
recipe art
for_each cards
cook "build/art/$<item.id>.png" using {
comfy-gen --prompt "$<item.name>, $<item.description>" --out $<out>
}

Restrictions (normative). A recipe carries at most one for_each_step and MUST NOT carry both for_each and ingredients (§8.1, §8.3). The blessed source is an array-shaped probe value (§22.5.5); $(SHELL) capture is admitted as an uncached register-time source; the (LUA_EXPR) source is reserved. A for_each-feeding probe MUST be statically evaluable — its inputs MAY reference files/env/tools but MUST NOT depend on a build artifact (§22.5.10). The dynamic codegen-then-process pattern (a for_each over an upstream recipe’s outputs) and the many-to-one aggregate-over-members step are both reserved for a future amendment.

Cache key impact. Each per-member unit’s fingerprint folds the materialised data member (§17.1 observable #5), so editing one member re-runs only that unit. This is independent of the probe-value cache (§22.5.10), which fingerprints the member set; the two layers compose.

Conformance fixtures. The parser slice lands the parse-level corpus: positive positive/for-each-probe (probe-sourced, cook consumer) and positive/for-each-shell-as-lines ($(cmd)-sourced with as lines), plus negative negative/for-each-with-ingredients (the mutual-exclusion diagnostic). The runtime slice adds the register-phase negatives, each carrying a register_error.txt: negative/for-each-non-array-probe (a non-array probe value), negative/for-each-probe-artifact-dep (a for_each probe that depends on a build artifact), and negative/for-each-undeclared-probe (for_each names a probe that was never declared). plate/test data-member codegen is covered by cook-luagen unit tests, and the per-member cache behaviour by the cook-cli end-to-end integration test for_each_per_member_invalidation.

Reference implementation. Spec-first: this entry is one slice of the “Data-driven fan-out — for_each” milestone. An earlier commit landed the parser + codegen (cli/crates/cook-lang: for_each lexing/parsing, the ForEachSource/ForEachStep AST, the region + mutual-exclusion rules; cli/crates/cook-luagen: $<item> / $<item.FIELD> resolution via match_item_sigil, the expand_for_each_template member-aware expander, and the register-phase data-member fan-out lowering for cook / plate / test). A later commit landed the register runtime slice that the generated Lua targets: register-time probe-value materialisation via the §22.5.10 demand-driven pre-pass and the rebound cook.cache.get (cli/crates/cook-register: __for_each surface capture, run_for_each_prepass mirroring the execute-phase probe path, msgpack_to_lua); the $(cmd) register-time capture (already bound cook.sh); the whole-member cook.member_to_string rendering (cli/crates/cook-contracts) and the per-member fingerprint fold via command_hash (cli/crates/cook-register); and the §22.5.10 register diagnostics — undeclared probe, non-array source, and the static-input rule — surfaced through cli/crates/cook-engine’s error mapping. Per-member invalidation is proven end-to-end (cook-cli integration test: edit one data member, only its unit re-runs). The demos + broader end-to-end smoke land in a follow-up slice. The reserved (LUA_EXPR) source parses and is rejected at codegen with the §8.3 “not yet supported” diagnostic. The conformance claim (COOK_STANDARD_VERSION in cli/crates/cook-lang/src/lib.rs, mirrored in standard/VERSION) bumps to "0.13" with this slice — this closes the last CS-0091 gap of the v0.13 cut. tree-sitter-cook follows in a separate conforming pass per the established stale-then-conform cadence.

Backward compatibility. Non-breaking in practice. for_each becomes a recipe-body keyword when followed by a separator; previously such a line dispatched to shell_command. A Cookfile that intentionally ran a shell command literally named for_each (vanishingly rare) would change meaning; no other conforming Cookfile is affected. Cookfiles that adopt for_each are not parseable by pre-v0.13 implementations.

v0.14 (unreleased) [#changes.v0-14]

CS-0092 — Native probe declaration surface [#changes.cs-0092]

Date: 2026-05-31

Version: v0.14

Sections affected: §22.5.2 (new — native probe declaration: body region grammar, ingredients section with glob + !-exclude, produce block forms, as typing for shell blocks, diagnostics, lowering to cook.probe(), coexistence with the API form); §4 (top-level item enumeration, ordering rule, and termination set each gain probe_decl); App. A §A.3.2 (new grammar productions: probe_decl, probe_body, produce_step, probe_ref); App. C (new rationale for the probe surface design). §22.5 was renumbered when §22.5.2 was inserted: the pre-existing §22.5.2–§22.5.9 entries became §22.5.3–§22.5.10 (CS-0091’s for_each-feeding-probe section shifted from §22.5.9 to §22.5.10).

Surface change. A Cookfile MAY declare a probe at top level with the probe keyword, as a named sibling of recipe, register, and chore. The declaration is implicitly register-phase and lowers to cook.probe(). The header names the probe and optionally lists dependency probes; the body carries an optional ingredients section (file globs with !-prefixed excludes, lowered to inputs.files) followed by exactly one produce block. The produce block is either a Lua block (>{ … } — the return value is the probe value) or a shell block ({ … } — stdout is captured and typed by the optional as modifier: bare = string, as lines = array of lines, as json = JSON-decoded). Module-prefixed probe names use a single colon as a namespace separator (module:name); names that contain a double colon or other punctuation require the quoted-name form.

probe host-os
produce >{
return cook.sh("uname -s"):gsub("%s+$", "")
}
probe sources: host-os
ingredients
src/**/*.c
!src/vendor/**
produce {
find src -name '*.c' | sort
} as lines

Restrictions (normative). The probe keyword becomes a column-0 top-level keyword. The v1 body admits ingredients and produce only; env and tools fingerprint inputs are NOT yet expressible in the native surface (deferred, not in v1.0); probes that require those inputs MUST continue to use the cook.probe() API form. JSON/YAML-decoding produce blocks (the as json form) are evaluated on the for_each pre-pass path; demand-driven worker-VM consumption of a JSON-produce probe is a reference-implementation gap and MAY be deferred by conforming implementations. Hyphenated probe-key name segments are not expressible in the bare identifier scanner; authors MUST use the quoted-name form or cook.probe() for such names. Exactly one ingredients section and exactly one produce block per declaration; produce MUST follow ingredients if both are present.

Conformance fixtures. Positive: positive/probe-lua-block (Lua >{ } produce, bare probe name); positive/probe-shell-string (shell { } produce, default string capture); positive/probe-shell-as-lines ({ } as lines shell produce); positive/probe-shell-as-json ({ } as json shell produce); positive/probe-header-deps (probe with a dependency on another probe in the header); positive/probe-quoted-colon-name (quoted probe name containing a colon); positive/probe-module-prefixed-name (module-prefix colon syntax). Negative: negative/probe-as-on-lua-block (as modifier on a >{ } Lua produce — rejected); negative/probe-triple-colon-name (three colons in a probe-ref — rejected); negative/probe-missing-produce (body with no produce block — rejected); negative/probe-two-ingredients (two ingredients sections in one probe — rejected); negative/probe-ingredients-after-produce (ingredients section appearing after produce — rejected).

Reference implementation. An earlier commit landed lexer support (Token::ProbeHeader with scan_probe_ref colon-disambiguation) and the probe.rs parser in cli/crates/cook-lang, producing the Probe / ProbeBody / ProduceBlock AST nodes. A follow-up commit landed cli/crates/cook-luagen/src/probe.rs (emit_probe / lower_produce) which lowers the declaration to a cook.probe() call in generated Lua, and the complementary cli/crates/cook-register wiring. A third commit added the full conformance corpus (positive + negative fixtures above), migrated the for_each_probe_chain, for_each_benchmarks, and probe-cache-share examples to the native probe surface, and wired the conformance_summary test. The probe-basic and probe-chain examples remain on cook.probe() pending a native env/tools surface. The conformance claim (COOK_STANDARD_VERSION in cli/crates/cook-lang/src/lib.rs, mirrored in standard/VERSION) bumps to "0.14" with this entry — this closes the last CS-0092 gap of the v0.14 cut. tree-sitter-cook follows in a separate conforming pass per the established stale-then-conform cadence.

CS-0093 — Stale-output reconciliation [#changes.cs-0093]

Date: 2026-05-31 Version: v0.14 Sections affected: §17.7 (new); §8.3 and §22.5.10 (cross-references to the canonical for_each shrinking-output case); App. E (this entry). Summary: Cook never removed an output file, so when a recipe’s declared output set shrinks between runs (paradigmatically a for_each whose data source loses a member), the dropped artifact was orphaned on disk — a build whose output tree accumulates files nothing declares misrepresents its own graph. §17.7 makes reconciliation normative and default-on: for each recipe reached this run, after its units complete, Cook computes orphans = prior_declared_outputs − current_declared_outputs and, for each orphan, removes it iff its on-disk content still matches the hash Cook recorded when it wrote it (a user-modified file is kept and a warning emitted); the recorded set then advances. The sweep is scoped to recipes reached this run (a targeted cook <recipe> never touches another recipe’s outputs), never removes a path that is a live output of another reached recipe, and reconciles files only. It reuses the output paths + content hashes the cache already records (§17.1, §17.6) — no new on-disk ledger. A --no-prune flag / COOK_NO_PRUNE environment variable opts out. Reference implementation: cli/crates/cook-engine/src/reconcile.rs holds the pure hash-guarded sweep; cli/crates/cook-engine/src/run.rs snapshots each reached recipe’s prior recorded outputs before execution and runs the cross-recipe sweep afterwards, then prunes now-dead cache steps via ThreadSafeCacheManager::retain_steps. The --no-prune global flag (cli/crates/cook-cli/src/cli.rs) and COOK_NO_PRUNE thread through cook_engine::run::run. Tests: cli/crates/cook-engine/tests/reconcile_e2e.rs (shrink → sweep, modified-orphan → kept, --no-prune → retained) plus unit tests in reconcile.rs; cli/e2e-fixtures/for_each_benchmarks/verify.sh gains a dropped-member assertion. tree-sitter-cook is unaffected (execution semantics, no grammar change).

Backward compatibility. Additive. The token probe at column 0 followed by a keyword separator was previously dispatched as a shell line or Content line in any context where it could appear (vanishingly rare in practice). Cookfiles that adopt the native probe declaration surface are not parseable by pre-v0.14 implementations; authors who need pre-v0.14 portability MUST continue to use the cook.probe() API form.

CS-0094 — $<NAME> resolves module-registered recipes [#changes.cs-0094]

Date: 2026-06-01 Version: v0.14 Sections affected: §10.2 (resolution step 2 — recipe-in-scope set is the full register-phase recipe set, determinable at register time); §10.4.1 (terminal output of a recipe assembled from register-phase cook.add_unit work units); App. E (this entry). Summary: A $<NAME> placeholder (or dep-list reference) whose NAME is a recipe registered during the register phase by a top-level module_call (a target maker such as cook_cc.bin("x"), § 4.4) is, per §10.2 step 2, a recipe in scope and MUST resolve to that recipe’s terminal output(s). This entry makes two latent under-specifications explicit. First, the recipe-in-scope set of step 2 is the full register-phase recipe set, not the set of statically parsed recipe blocks; a conforming implementation MAY determine membership at register time (mirroring step 3’s allowance) and MUST NOT restrict step 2 to native recipe declarations — doing so mis-resolves $<x> for a module-registered x to the env-var rule (step 3) and then to a hard error (step 4). Second, a recipe whose body registers its work units via cook.add_unit (rather than native cook steps) has, as its terminal output, the output list of its last registered unit (§10.4.1’s “last step” rule); an implementation MUST record that output for cross-recipe resolution, else $<x> substitutes the empty string. Together these make the “build the tool, then use it in the same DAG” pattern — e.g. an asset step that invokes a just-built engine binary via $<engine> — resolve and order correctly. No new surface syntax; this is a conformance correction. Reference implementation: COOK. The first codegen pass (cli/crates/cook-engine/src/pipeline/parse.rs) classifies $<NAME> against only the statically parsed recipe blocks (cook_luagen::dep_ref::extract_recipe_names), so a module-registered name mis-lowered to cook.require_env. cli/crates/cook-engine/src/pipeline/registers.rs::codegen_with_module_recipes_single re-runs codegen against the full register-phase recipe set discovered by the body-free cook_register::list_names pass (the same surface cook list / cook menu enumerate), and cli/crates/cook-cli/src/pipeline.rs::build_registered_workspace uses it on the single-Cookfile path. cli/crates/cook-register/src/unit_api.rs records a Sequential cook.add_unit’s outputs as the recipe’s running terminal output (last-wins), mirroring the cook.step_group capture that native steps get. A third site: cli/crates/cook-luagen/src/recipe.rs::compile_chore lowered chore-body shell commands (a bare shell_command per §10.5) against an empty recipe-name set, so $<recipe> in a chore body (e.g. a play/game chore launching a just-built binary via $<engine>) always fell to require_env; it now threads the in-scope recipe names through, resolving such references to cook.dep_output. Tests: cli/crates/cook-cli/tests/module_recipe_dep_ref.rs (native recipe → module-registered recipe via $<NAME>, builds end-to-end) and cli/crates/cook-luagen/src/tests.rs::test_compile_chore_resolves_recipe_ref_to_dep_output. tree-sitter-cook is unaffected (resolution semantics, no grammar change).

Backward compatibility. Bug fix. A Cookfile in which a $<NAME> (or dep-list NAME) referenced a module-registered recipe previously failed at register time with placeholder $<NAME>: env var 'NAME' was not declared … (or, where the name happened to be a declared env var, silently substituted the env value); both outcomes were non-conforming under §10.2. Such a Cookfile now resolves per §10.2 step 2. No conforming Cookfile changes observable behaviour. The workspace-import codegen path and the emit-lua / affected introspection surfaces apply the same discovery in a follow-up.

CS-0095 — ingredients <probe> data-member source [#changes.cs-0095]

Date: 2026-06-05 Version: v0.14 Sections affected: §8.1 (step-dispatch cascade: ingredients <probe> added as an alternative iteration-driver form alongside for_each; the at-most-one + mutual-exclusion rule updated); §8.3 (ingredients <probe> is a surface alias for a for_each step with a ProbeKey source — same semantics, same parse/codegen output); App. A §A.3.1 (grammar production for the probe-source branch of ingredients); App. E (this entry). Summary: A bare probe key written after ingredients (e.g. ingredients cards) is now accepted as a data-member iteration source (§8.3), parse- and codegen-equivalent to for_each cards. The recipe’s member-driven steps bind the current member as $<in> / $<in.FIELD> (§9). The form is mutually exclusive with glob-pattern ingredients and for_each within the same recipe body. This makes the data-member source visually consistent with the probe-name convention authors already use in for_each, while also fitting naturally into recipes where the ingredients keyword is the primary iteration concept. Conformance fixtures. Positive: positive/ingredients-probe (probe-sourced ingredients, cook consumer — identical parse.txt to positive/for-each-probe, confirming the desugaring). Reference implementation. cli/crates/cook-lang/src/cook_line.rs (parse_ingredients_probe_source) and cli/crates/cook-lang/src/recipe.rs (the ingredients <PROBE> branch that pushes a Step::ForEach with ForEachSource::ProbeKey). The mutual-exclusion check (at-most-one driver, no mixing glob patterns with a probe source) mirrors the existing for_each guard. Grammar appendix (App. A §A.3.1) updated to reflect the probe-source branch. Backward compatibility. Non-breaking in practice. A bare identifier after ingredients (with no glob metacharacters) was previously a parse error. Cookfiles that adopt the new form are not parseable by implementations predating this change.

CS-0096 — Member-binding sigil renamed $<item> → $<in> [#changes.cs-0096]

Date: 2026-06-05 Version: v0.14 Sections affected: §8.3 (member-binding name: item → in; $<item> / $<item.FIELD> → $<in> / $<in.FIELD>); §9.3 / §9.4 (placeholder table: $<in> and $<in.FIELD> replace $<item> and $<item.FIELD>); App. A §A.3.1 (member-sigil token updated); App. E (this entry). Summary: The current data member in a for_each (or ingredients <probe>) recipe body now binds as in (Lua) and as $<in> / $<in.FIELD> (§9). The previous spelling item / $<item> / $<item.FIELD> is retired. The rename reduces ambiguity with plain identifiers named item and aligns with the natural reading “the current input member”. All conformance fixtures and examples on this branch use the new sigil. Reference implementation. cli/crates/cook-luagen/src/template.rs (match_member_sigil) updated to recognise in / in.FIELD at the surface sigil level; the internal Lua loop variable remains item (because in is a Lua reserved keyword — for k,v in pairs()). cli/crates/cook-luagen/src/resolver.rs and cli/crates/cook-luagen/src/cook_step.rs updated accordingly. Conformance fixtures (positive/for-each-probe, positive/for-each-shell-as-lines, positive/ingredients-probe) and examples (examples/05-data-fanout, cli/e2e-fixtures/for_each_benchmarks) migrated. tree-sitter-cook sigil highlights follow in the established stale-then-conform cadence. Backward compatibility. Breaking. Any Cookfile using $<item> or $<item.FIELD> in a for_each body will no longer parse/lower correctly under this rename and MUST be updated to $<in> / $<in.FIELD>. Because for_each itself was introduced in CS-0091 (v0.13, unreleased), the affected Cookfile population is limited to authors on the pre-release track.

CS-0097 — Remove for_each keyword and non-probe member sources [#changes.cs-0097]

Date: 2026-06-05 Version: v0.14 Sections affected: §8 (step kinds: for_each keyword removed as a surface declaration form; the internal desugar node and ingredients <probe> remain — §8.3 updated to probe-only member source); §8.3 ($(cmd) shell-capture and (LUA_EXPR) reserved sources removed from the ForEachSource enumeration; only ProbeKey is retained); App. A §A.3 (grammar: for_each_step production removed; for_each_source simplified to probe_ref only — no $( ) or ( ) form); App. E (this entry). Summary: The for_each surface keyword is retired. Authors MUST use ingredients <probe> (CS-0095) to attach a data-member iteration driver; the for_each spelling no longer parses. Internally, the AST retains Step::ForEach / ForEachSource::ProbeKey as the desugar target of ingredients <probe> — this is an implementation-internal node and is not part of the observable Cook surface. The two non-probe source forms, for_each $(cmd) (shell capture) and the reserved for_each (LUA_EXPR), are removed entirely: the former required uncached register-time shell execution with associated hermeticity problems; the latter was always parse-only (codegen rejected it). Both forms are superseded by a planned anonymous-source family (a future follow-up). The as lines modifier (which only applied to shell-capture sources) is likewise removed. Reference implementation. Two commits: (1) ac4a155 removes the for_each keyword from the lexer, parser, and diagnostics (cli/crates/cook-lang); (2) this commit drops the now-dead ShellCapture / LuaExpr ForEachSource variants, the as_lines: bool field on ForEachStep, CodegenError::ForEachLuaReserved in cook-luagen, the shell/lua ForEachDescriptor variants and their parse_for_each_meta arms in cook-register, and the corresponding for_each_meta_field and emit_for_each_items arms. The ingredients-probe conformance golden (standard/conformance/positive/ingredients-probe/parse.txt) is updated to remove the now-absent as_lines=false token. Future anonymous-source work will land under a follow-up. Backward compatibility. Breaking. Any Cookfile using the for_each keyword no longer parses and MUST be rewritten using ingredients <probe>. Any Cookfile using for_each $(cmd) or for_each (LUA_EXPR) has no direct replacement until the anonymous-source follow-up lands. Because for_each was introduced in CS-0091 (v0.13, unreleased) and $(cmd) / (LUA_EXPR) were never reachable at runtime (the latter was codegen-rejected; neither appeared in any shipped example), the affected Cookfile population is limited to authors on the pre-release track.

CS-0098 — $<NAME[]> per-member cross-recipe output accessor [#changes.cs-0098]

Date: 2026-06-05 Version: v0.14 Sections affected: §8.10 (new — cross-recipe per-member output accessor: same-probe precondition, three jobs, fan-out-body-only constraint, relation to bare $<NAME>); §9.3 (cook-step placeholder table: $<NAME[]> row — already present as a stub; updated to match finalized semantics); App. E (this entry). Summary: Inside an ingredients <probe> fan-out body, $<NAME[]> resolves to the named recipe’s terminal output for the current iteration member. It performs three jobs: (1) substitutes the per-member path (cook.dep_output_member("NAME", cook.member_to_string(item)) in the Lua lowering); (2) records a recipe-level DAG ordering edge (producer builds before consumer, identical edge to bare $<NAME>); (3) folds the referenced output into the current member’s cache fingerprint, not the recipe’s aggregate fingerprint — so editing one upstream member rebuilds only its downstream member. The same-probe precondition applies: consumer and producer must iterate the same probe (v1 restriction; cross-probe joins via declared alignment keys are a tracked follow-up, not in v1.0). $<NAME[]> outside a fan-out body is a load-time error. Bare $<NAME> (all terminal outputs, space-joined) is unchanged. Conformance fixtures. Positive: positive/recipe-member-accessor (three-recipe mux/render/tts Cookfile; parse.txt confirms verbatim preservation of $<render[]> and $<tts[]> in the ShellBlock strings; codegen_positive_conformance_corpus confirms the fixture passes generate_with_names_checked). Reference implementation. (this branch) cli/crates/cook-luagen/src/resolver.rs resolves $<NAME[]> to Resolved::RecipeMember when inside a ForEach context; cli/crates/cook-luagen/src/template.rs lowers it to cook.dep_output_member("NAME", cook.member_to_string(item)); cli/crates/cook-luagen/src/dep_ref.rs extracts the recipe-level DAG edge. cli/crates/cook-register/src/unit_api.rs records each fan-out unit’s member + output paths, cli/crates/cook-register/src/engine.rs builds the SharedMemberOutputs map, and cli/crates/cook-register/src/dep_output_api.rs registers cook.dep_output_member (folding the per-member path into that unit via a per-add_unit buffer for fingerprint isolation). Tests: cli/crates/cook-luagen/src/tests.rs (lower + recipe-level edge); cli/crates/cook-register/src/tests.rs (per-member attribution, mux join, fingerprint isolation). Backward compatibility. Additive. $<NAME[]> was previously unrecognised (fell through to env-var lookup, which would produce a missing-env error at register time); there are no known Cookfiles relying on that fallthrough. The three-jobs semantics are new behaviour with no prior conforming interpretation.

CS-0099 — Remove the using keyword from cook steps [#changes.cs-0099]

Date: 2026-06-09 Version: v0.14 Sections affected: §3 (lexical: using removed from the keyword list); §8.4 / §8.4.2 / §8.5 (cook single-output, Lua-expression-output, and multi-output forms: the body follows the output declaration directly; prose retitled from “using clause” to “body”); §8.6 (plate note: cook and plate body grammars are now fully parallel); §9, §10, §12, §13, §21, §22.5, §23 (prose alignment — “using clause” → “cook-step body”; the lua.using-block-globals slugs are retained as stable anchors); App. A §A.4 (grammar: using_clause production removed; cook_step ::= "cook" cook_output body? NEWLINE; using_lua_block renamed exec_lua_block; new normative paragraph “using is rejected (CS-0099)”); App. B, App. C (examples and rationale aligned); App. E (this entry). Summary: The using introducer between a cook step’s output declaration and its body is removed: cook "out" using { … } becomes cook "out" { … }, and cook "out" using >{ … } becomes cook "out" >{ … } (likewise for the §8.4.2 Lua-expression-output form). The keyword carried no information — the output position is always a quoted STRING (or parenthesised Lua expression), and the body is always brace-anchored ({ / >{), so the grammar remains unambiguous without it. A conforming implementation MUST reject using at the body position with a migration diagnostic naming CS-0099 and stating that the keyword was removed. One consequence is noted in §8.5: cook "a" "b" "cmd" is a three-output declaration-only step — the CS-0024 bare-string diagnostic is no longer reachable from the cook surface (it remains reachable from plate and test). Conformance fixtures. New negative: cs0099-using-keyword-rejected (pins the migration diagnostic). Removed as superseded: negative/005-multi-output-bare-string, negative/018-using-string-form-rejected (both pinned the CS-0024 diagnostic through a using spelling that now fails earlier at CS-0099; the bare-string rule stays pinned by 029-plate-string-form-rejected / 030-test-string-form-rejected). Renamed: positive/005-cook-single-output-with-shell-using → 005-cook-single-output-with-shell-block, negative/006-accessor-in-using-string → 006-accessor-in-cook-body, negative/022-lib-accessor-in-using-rejected → 022-lib-accessor-in-cook-body-rejected, negative/009-using-register-block → 009-register-block-as-cook-body. Every other fixture’s cook steps migrated mechanically; positive goldens now print Cook outputs=… body=…. Reference implementation. cli/crates/cook-lang/src/cook_line.rs parses the body directly after cook_output and emits the CS-0099 migration diagnostic on a leading using; the AST enum UsingClause is renamed Body (the former Body alias is collapsed) and CookStep::using_clause is renamed CookStep::body; the CS-0022 accessor diagnostic in cli/crates/cook-luagen/src/template.rs now reads “inside a cook-step body”. The conformance harness summary key using= is renamed body=. Backward compatibility. Breaking. Every Cookfile with a body-bearing cook step must drop the using token (a one-token deletion per step; the migration diagnostic names this record). The change is pre-release-track only: no using-bearing surface survives in examples/, the repository Cookfiles, or the conformance corpus after this commit.

CS-0100 — Test-unit fingerprint conformance fix: transitive-source hashing [#changes.cs-0100]

Date: 2026-06-12 Version: v0.14 Sections affected: App. E (this entry) only — no normative text changes: §17.4.1 already requires the test cache key to be derived from the content fingerprints of consumed cook-step outputs, referenced recipe outputs, the filtered env-key set, tool hashes, timeout, and should_fail. Summary: The reference implementation recorded test results under a fingerprint that omitted every file-system observable required by §17.4.1 — cook.add_test dropped the recipe’s ingredients and dep paths, and the engine fed empty cook_outputs/dep_outputs lists into the fingerprint — so a recorded passed replayed forever regardless of changes to the code under test. The implementation now derives the file-system contribution by transitive-source hashing, computed upfront before any dependency builds: the fingerprint folds (1) the content hashes of the test’s own declared input files (resolved ingredients ∪ $<NAME> dep paths, carried on the test work unit), excluding files produced by upstream units (whose on-disk state is stale at upfront-fingerprint time), and (2) for every work unit in the test’s transitive predecessor closure, the content hashes of that unit’s recorded input files plus its command-hash/env-contribution identity. This satisfies the §17.4.1 floor conservatively: under the determinism assumption the build cache already makes, the source-closure fingerprint changes whenever a consumed output fingerprint changes, and any additional re-runs are conforming because test-result caching is itself a MAY. Reference implementation. cook-contracts lib.rs (WorkPayload::Test.input_paths), cook-register test_api.rs (inputs ∪ step-group dep paths), cook-luagen test_step.rs (inputs = ingredients emission), cook-dag lib.rs (Dag::deps), cook-engine run.rs (upfront closure hashing replacing the empty-list stub); E2E cli/crates/cook-engine/tests/test_invalidation_e2e.rs; cli/e2e-fixtures/test_caching/walkthrough.sh gains the source-edit scenario. Backward compatibility. Implementation-only; every previously recorded test-cache entry is invalidated once; first post-upgrade cook test re-runs all tests and re-records.

CS-0101 — $<file:PATH> file-reference placeholder [#changes.cs-0101]

Date: 2026-06-12 Version: v0.14 Sections affected: §9.1 (file_ref grammar alternative in the placeholder token production); §9.3 / §9.4 (placeholder tables: file-reference row; output-pattern rejection; missing-file / empty-glob load-time diagnostics); §10.8 (new — the reserved file: placeholder namespace); §17.1 (observable 6 — resolved path list + referenced-file content in the unit’s observable input set); §22.5.7 (colon-dispatch carve-out: a file:-prefixed sigil is never a probe-value reference); App. A (grammar: file_ref / file_path_char productions); App. E (this entry). Summary: $<file:PATH> interpolates the resolved path list — the literal path, or for a glob the sorted matches, space-joined, resolved against the same base as ingredients patterns (the declaring Cookfile’s directory) — into shell text AND folds each resolved file’s content into the consuming unit’s cache fingerprint (§17.1 observable 6). Surfaced by cook-video dogfooding: a recipe that reads a stylesheet/template/reference asset it does not iterate over previously had no way to make edits to that file invalidate its units. A literal PATH naming no regular file, or a glob matching no files, is a load-time diagnostic (no silent empty expansion); PATH MUST be relative (no leading /, no .. segments). The file: prefix is a reserved namespace (§10.8) dispatched before every step of §10.2 resolution and before the §22.5.7 probe-value colon dispatch. v1 boundaries: rejected in cook output patterns (a file reference is an input, not an iteration driver); substitution-only in plate/test bodies (non-cacheable units — no fingerprint contribution); not supported in raw register-block cook.add_unit command strings (loud rejection); no cross-recipe edge when PATH happens to be another recipe’s output (deferred); no nested sigils inside PATH. Conformance fixtures. New positive: file-ref-cook-body (literal + glob in a cook body; register_ok), file-ref-plate-body, file-ref-fan-out. New negative: cs0101-file-ref-in-output-pattern (codegen), cs0101-file-ref-missing-file (register). Reference implementation. cook-luagen sigil/resolver/template lowering (Resolved::FileRef, hoisted cook.file_ref locals, file_refs unit field); cli/crates/cook-register/src/file_ref.rs (resolution + register API + cache_input_paths plumbing); tree-sitter-cook grammar + scanner bump. Backward compatibility. Additive at the placeholder surface: $<file:PATH> previously fell to the §22.5.7 colon dispatch as a probe-value reference and failed at register time unless a probe key literally beginning with file: was declared. The §10.8 reservation makes such probe keys unaddressable from the sigil surface (implementations SHOULD warn at cook.probe registration); no known Cookfile declares one. No other conforming Cookfile changes observable behaviour.

CS-0102 — Probe values: msgpack → canonical JSON [#changes.cs-0102]

Date: 2026-06-12 Version: v0.14 Sections affected: §17.5 (probe cache participation: msgpack → canonical JSON); §22.5.4 (fingerprint marker COOK_PROBE_FP_V1 → COOK_PROBE_FP_V2); §22.5.5 (canonical-JSON rendering rules; strings tightened to UTF-8, numbers to finite); §22.5.8 (cache hit/miss semantics; mandatory .cook/probes/<key>.json materialisation); §22.5.11 (historical note); App. E (this entry). Summary: Probe values are re-encoded from msgpack to canonical JSON: pretty-printed with two-space indentation, object keys sorted bytewise ascending, exactly one trailing LF. There is ONE canonical rendering per value, written to .cook/probes/<key>.json under the project root on every probe completion (hit or miss, demand-driven or pre-pass), stored verbatim as kind: "probe_value" CAS artifacts, and carried verbatim on the wire — no serialising site re-encodes. The per-run probe-value store is reframed as a read-through cache of the probe file; the cross-VM shared-memory framing is deleted. The fingerprint marker bump V1→V2 makes every pre-CS-0102 msgpack artifact an unreachable key, and cache readers MUST additionally treat non-JSON bytes as a miss. The never-implemented schema_version envelope clause of §22.5.8 is removed. The §22.5.5 value contract tightens with JSON: strings MUST be valid UTF-8, numbers MUST be finite; violations fail the probe with the non-serialisable diagnostic. Probe memoization remains always-on. Reference implementation. cli/crates/cook-contracts/src/probe_value.rs provides encode_canonical_json / decode_json / probe_file_name / write_probe_file; cli/crates/cook-luaotp and cli/crates/cook-register carry validated Lua↔JSON walkers; cli/crates/cook-engine’s executor materialises .cook/probes/<key>.json on both the cache-hit and worker-result paths; cli/crates/cook-fingerprint bumps the probe fingerprint marker; the rmpv dependency is removed workspace-wide. Backward compatibility. Breaking at the cache layer only; no Cookfile surface changes. Pre-CS-0102 msgpack artifacts (local and Cook Cloud) become unreachable keys and read as a clean miss — probes re-run once and re-populate under the new encoding; no cache wipe is required. Probes returning non-UTF-8 strings or non-finite numbers, previously representable in msgpack, now fail with the §22.5.5 non-serialisable diagnostic.

v0.15 [#changes.v0-15]

CS-0103 — Engine alignment: remove implicit machine/tool determinant inference (Cache-trust v3) [#changes.cs-0103]

Date: 2026-06-23 Version: v0.15 Sections affected: App. E (this entry) only — implementation alignment ahead of the §17 normative rewrite. The §17.1.1 “Tool-binary fingerprinting” subsection, the §17.1 ¶ “MAY consider … the identity of the host machine … and the resolved content of the binaries invoked” sentence, and the §17.4 test-unit “tool-binary hash set” key input describe reference-implementation behaviour this commit removes; the corresponding normative prose is rewritten to the single-key model under a separate Cache-trust v3 (§8) Standard change, which owns §17. This entry records the engine-side removal; it makes no normative text change here. Summary: Cache-trust v3 §1 holds that the engine infers nothing: every ambient determinant a unit depends on is author-declared (as a probe). The reference implementation previously folded engine-inferred determinants into each cacheable unit’s key — host machine identity (target triple, libc version, locale baseline), the content hashes of binaries recognised by walking the command text, and an optional project-level declared toolchain ([cache] tools in .cook/cloud.toml). All three are removed: the per-step context_hash field and its threading through the executor, dag-builder, register key derivation, cloud key, artifact metadata, and persisted StepEntry are gone; the MachineIdentity / ToolHash / ExecutionContext / declared-toolchain machinery in cook-fingerprint is deleted; the test-unit fingerprint no longer folds a tool-binary hash set. Host/tool identity is now declared by the author via a probe and sealed on (seal host, Cache-trust v3 §7) rather than baked into every key. The probe fingerprint (§22.5.3) — which content-hashes a probe’s declared tools/env/files — is unaffected: that is author-declared, not engine-inferred. Reference implementation. cook-fingerprint: context.rs reduced to the probe fingerprint; StepEntry/CloudKeyInputs/ArtifactMeta lose context_hash; needs_rebuild_cook/needs_rebuild_plate lose the context_hash parameter and RebuildReason::ContextChanged; FingerprintInputs loses tool_hashes; build.rs/COOK_TARGET_TRIPLE removed. cook-cache: CacheContext.exec_ctx and the [cache] tools config removed. cook-contracts: CacheMeta.context_hash removed. cook-register, cook-engine, cook-dag-viewer: context_hash threading dropped; the local cache key derives from command + env contribution only. Backward compatibility. Implementation-only; no Cookfile surface change. The key derivation changes, so every previously recorded cache entry becomes an unreachable key and reads as a clean miss — a one-shot rebuild on the upgrade boundary. Units that genuinely depend on host/tool identity but do not yet declare a host probe can serve a wrong artifact across machines until they seal host; this transitional sharp edge is the §9 operational posture of the Cache-trust v3 design, closed by module auto-sealing and the divergence oracle (deferred). The CACHE_VERSION bump that fences the old entries is owned by a separate follow-up change.

CS-0104 — Cache-trust v3: single content-addressed key; remove two-tier / context_hash / cache.tools [#changes.cs-0104]

Date: 2026-06-23 Version: v0.15 Sections affected: §17.1 (the engine-inferred host/tool determinant clause removed; new §17.1.1 single-key statement [#exec.cache.single-key]; env-reads renumbered §17.1.1 → §17.1.2); §17.1.1 “Tool-binary fingerprinting” + the cache.tools / .cook/cloud.toml declared-toolchain mechanism — removed, retiring the exec.cache.tool-binary slug (mapped to exec.cache.single-key in scripts/slug-renames.ts); §17.4.1 (test-unit key inputs repointed off the removed tool-binary set onto the declared-determinant fold); §22.5 (tools-field cross-reference repointed); App. A §A.4 (disposition grammar skeleton: disposition_line / disposition_block); App. E (this entry). Per-feature normative prose lands in sibling entries: §8.4.3 disposition semantics + full App. A grammar, §22.5.2 produce as tools|env, §6 record, §7 cook cache verify. Summary: Supersedes the v2 two-tier hermeticity model (loose-local / strict-shared keys, baked machine identity, auto-detected tool-binary fold, the cache.tools declared toolchain). Cache-trust v3 collapses caching to one content-addressed key per cacheable unit — H(declared input contents, command/Lua chunk, declared output paths, consulted env values, declared probe values) — addressing the local cache and the cross-machine shared store identically. The engine infers no determinant of its own: host/machine identity (target triple, libc version, locale) participates only when the author declares it as a probe and seals on it (seal host). Determinant fidelity is the trust mechanism — cook why (per-step hit/miss attribution) and the shadow-divergence oracle (cook cache verify, classifying a divergence as an under-keyed determinant versus benign non-determinism) replace the v2 sharing gate. Authors control sharing and determinants through the disposition surface: seal probe… (additive purity assertion, folding the named probe values into the key), local (cache locally, opt out of sharing), pinned (fetch-only designated-producer — a miss is a hard error), and record (a non-reproducible recording). There is no project-default (“global”) seal set and no seal only narrowing form. This entry frames the milestone and records the §17 + App. A skeleton changes; the per-feature normative sections land under the sibling CS-IDs listed above. Reference implementation. Spec-only in this entry. Engine alignment lands separately: removal of the implicit fingerprint machinery (MachineIdentity / ToolHash / context_hash / cache.tools); the single StepEntry key with CACHE_VERSION 4 → 5; sharing on the one key (publish/fetch, local opt-out, pinned designated-producer). Disposition surface parsing, probe determinant inputs (produce as tools|env), and the verifier (cook cache verify) land in the following entries. Backward compatibility. Breaking at the cache layer when the engine alignment lands (CACHE_VERSION bump → a one-shot rebuild on the upgrade boundary). No conforming Cookfile surface changes from this Standard entry alone; the disposition surface is additive — an unannotated cook step keeps single-key caching with an empty effective seal set. The removed cache.tools / .cook/cloud.toml toolchain declaration has no drop-in replacement: a project that relied on it declares a host (or per-tool) probe and seals the affected units on it instead.

CS-0105 — Cook-step disposition surface: seal/local/pinned/record (decorator lines + blocks) [#changes.cs-0105]

Date: 2026-06-23 Version: v0.15 Sections affected: §8.4.3 (new — cook-step disposition: the four dispositions, decorator-line and block surfaces, parse-time rules); App. A.3 (declarative_item gains disposition_block); App. A.4 (cook_step gains a leading disposition_line*; new disposition_line, disposition_block, disposition, seal_ref productions; cook-step-disposition normative rule; step-dispatch-priority rule 5); App. E (this entry). Summary: A cook step MAY carry a disposition declaring how its artifact participates in the cache, expressed as one or more decorator lines immediately above the step (seal seal_ref*, local, pinned, record) or as a block (disposition { … }) wrapping several cook steps. seal is additive and stackable; its seal_ref set (bare BARE_PROBE_KEY, no quoted form, no third :IDENT segment) is sorted and de-duplicated. local and pinned are mutually exclusive and take no refs; record takes no refs. A decorator-line run MUST be immediately followed by a cook step (else a dangling-decorator error). A disposition block inherits its disposition additively onto each enclosed cook, nests, and requires at least one enclosed cook; a local/pinned disposition nested under a seal block overrides it (the step is unsealed). There is NO project-default (column-0) seal set and NO seal only form. seal/local/pinned/record are NOT reserved words. This change is the parser/grammar surface ONLY; the cache-key effect of each disposition is deferred to the Cache-trust v3 § 17 work. Reference implementation. cli/crates/cook-lang/src/ast.rs adds the Disposition struct (local/pinned/record booleans + a sorted BTreeSet<String> seal set) and a disposition field on CookStep; cli/crates/cook-lang/src/disposition.rs provides the line classifier, the seal_ref validator, and the additive/override fold helpers; cli/crates/cook-lang/src/recipe.rs wires decorator lines and disposition blocks into recipe-step dispatch. Conformance fixtures land under standard/conformance/{positive,negative}/cook-disposition-*. Backward compatibility. Purely additive at the surface. Existing Cookfiles are unaffected: an unannotated cook step has the all-default disposition, which renders identically to the pre-CS-0105 form. seal/local/pinned/record remain ordinary shell_command first tokens except in the disposition position, so no prior shell command is reinterpreted.

CS-0106 — produce as tools | env probe source lists [#changes.cs-0106]

Date: 2026-06-23 Version: v0.15 Sections affected: §22.5.2 (two new produce source-list kinds as tools / as env; the brace content is a name list, not a body); App. A §A.3.2 (produce_step admits source_list_kind name_list; name_list production); App. E (this entry). Summary: Extends the produce as json | lines convention with two source-list kinds. produce as tools { cc, ld } PATH-resolves each named tool and content-hashes its binary; the probe value is { NAME = { path, hash } } and the hash is both the value and the re-run trigger — host-tool freshness without volatile. produce as env { SDKROOT } reads each named environment variable (recording it as a consulted-env determinant), value { NAME = value } with unset variables omitted. In both, the { … } is a comma/whitespace-separated list of bare IDENT names, NOT a shell or Lua body: an empty list and a >{ … } Lua block are rejected at parse time. Reference implementation. cli/crates/cook-lang adds ProbeProduce::Tools / ProbeProduce::Env and the as tools|env name-list parser; cli/crates/cook-luagen lowers them to cook.sh (command -v + sha256sum) and cook.env reads respectively — runtime unchanged. Conformance: probe-as-tools, probe-as-env (positive); probe-as-tools-lua-block, probe-as-tools-empty (negative). Backward compatibility. Purely additive surface; no existing Cookfile changes meaning. The shell-out default (produce { … }) is unaffected.

CS-0107 — Sealed probe values fold into the single cache key; cache schema version bump [#changes.cs-0107]

Date: 2026-06-23 Version: v0.15 Sections affected: §17.1.1 (informative note — the seal_contribution realisation of the declared-probe-values determinant, its execute-phase fold, and the schema-version bump); App. E (this entry). Summary: Completes the Cache-trust v3 collapse to one key (CS-0103) by giving the effective seal set’s probe values their cache-key effect. A cacheable unit’s single content-addressed key now folds the canonical values (§ 22.5.5) of every probe in its effective seal set — the per-unit seal disposition of §8.4.3 (CS-0105), an additive set. The local and shared stores remain addressed by this same key, so a machine-independent unit (no host probe sealed) shares fleet-wide, while a seal host unit’s key tracks the host probe’s value and so misses across a host change. Host/machine identity is folded in only when the author seals on a host probe; the engine still infers no determinant of its own. This is the engine realisation of the §17.1.1 single-key model (the normative prose landed under CS-0103 / the §17 rewrite); the global (column-0) seal and a built-in host convenience probe remain follow-ups. Reference implementation. cli/crates/cook-fingerprint adds StepEntry.seal_contribution, threads it through CloudKeyInputs / cloud_key / ArtifactMeta and the rebuild checks (new RebuildReason::SealChanged), and bumps CACHE_VERSION 4→5; cli/crates/cook-contracts carries the effective seal key set on CacheMeta; cli/crates/cook-luagen emits the effective seal set as the optional seal = {...} field across every cook.add_unit emission point (Task 5), omitting it for unsealed steps so existing goldens stay byte-identical; cli/crates/cook-engine computes the seal_contribution value fold at execute-phase from the materialised probe values and wires sealed probes as unit dependencies. Register surface (Task 4): CacheMeta.seal_keys (a BTreeSet<String>) carries the register-time seal key set parsed from the cook.add_unit seal field, and those sealed keys are unioned into the unit’s probes dependency vec so the unit runs after its sealed probes are materialised. Backward compatibility. The cache-key composition changes, so the schema-version bump forces a one-shot rebuild on the upgrade boundary (per § 17.5); no Cookfile changes meaning. An unsealed unit contributes the zero seal digest, so its key is unchanged apart from the version bump.

CS-0108 — Sharing on the one key: local opt-out, pinned fetch-only / hard cold-miss [#changes.cs-0108]

Date: 2026-06-23 Version: v0.15 Sections affected: §17.1.3 (new — sharing and disposition effect); §8.4.3 (the local/pinned rows now cross-reference the normative sharing effect); App. E (this entry). Summary: Gives the §8.4.3 dispositions their sharing effect on the single content-addressed key (CS-0107). Caching over the local index and sharing — publishing to and fetching from the shared content-addressed store — are both on by default, and both are addressed by the same key (§17.1.1). A local unit is cached in the local index only and is never published to or fetched from the shared store, neither on a cold miss nor on a drifted-output restore. A pinned 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 — a cold miss (neither store holds the key) is a hard build error that names the pinned unit, not a rebuild. An unannotated (or seal’d) unit publishes each declared output after a successful run and fetches its outputs by key on a cold miss as well as on a drift restore. Every shared-store fetch is subject to the §17.2 integrity verification. The key derivation and the cache schema version are unchanged. Reference implementation. cli/crates/cook-contracts carries CacheMeta.local / CacheMeta.pinned alongside seal_keys; cli/crates/cook-register parses the local/pinned booleans from the cook.add_unit spec table (§22.1) into that metadata; cli/crates/cook-luagen emits the optional ["local"] = true / pinned = true fields (renaming seal_field → disposition_field), bracket-quoting local because it is a reserved Lua keyword and cannot be a bare table key, and omitting both for plain steps so existing goldens stay byte-identical; cli/crates/cook-fingerprint adds fetch_by_key (cold key recomposition with §17.2 verify-on-restore) and hash_input_paths; cli/crates/cook-engine returns a three-state CacheDecision (Hit / Miss / PinnedColdMiss) from its per-node cache check — a local unit never consults the backend and is never published (both artifact-upload paths — the restored-completion and the freshly-executed-completion paths — honour the opt-out), a non-local cold miss attempts a fetch-by-key, and a pinned cold miss raises a hard task failure that cancels dependents. Backward compatibility. Additive at the surface and at the cache layer. Existing Cookfiles carry all-default dispositions and behave exactly as before — publish after a successful run, fetch by key — with no cache-format or key change.

CS-0109 — record disposition cache effect [#changes.cs-0109]

Date: 2026-06-23 Version: v0.15 Sections affected: §17.1.4 (new — the record cache effect: keyed normally, byte-equivalence waived, composition with seal/local/pinned, verifier exemption); §8.4.3 (the record disposition-table meaning cell now points at §17.1.4 and states the waiver); App. E (this entry). Summary: Gives the record disposition (CS-0105) its cache effect. A record step’s artifact is cached, shared, and keyed by the single cache key (§17.1.1) EXACTLY as an unannotated step — record adds no determinant and changes no key component. A record step asserts its output is intrinsically non-reproducible (an LLM completion, a generative image), so re-running it in an identical context is not guaranteed to be byte-equivalent; byte-equivalence is therefore waived. When the cache holds an artifact for the step’s key, that stored artifact is authoritative and the producer MUST NOT be re-executed to reconcile a content difference; a cold miss produces it once. record composes with seal (a sealed-determinant change re-keys and re-generates) and with local/pinned (sharing/fetch policy is orthogonal to the waiver). The divergence oracle (cook cache verify) MUST NOT classify a content difference on a record step as an under-keyed determinant. Reference implementation. A record boolean is carried on CacheMeta and threaded codegen → register → executor: cli/crates/cook-luagen emits the record flag across the cook.add_unit emission points (omitted for non-record steps so existing goldens stay byte-identical); cli/crates/cook-contracts carries it on CacheMeta; cli/crates/cook-engine waives the output-drift rebuild for a record unit at the cache decision — a present-but-content-drifted output is not scheduled for restore or rebuild. The single cache key and the cache schema version are unchanged. Backward compatibility. Purely additive. The record keyword already existed as a parsed disposition (CS-0105); this change only supplies its execute-phase semantics. The cache key is unchanged and the schema version is not bumped, so no rebuild is forced. An unannotated or non-record step is unaffected.

CS-0110 — Producer determinant manifest in the shared store [#changes.cs-0110]

Date: 2026-06-23 Version: v0.15 Sections affected: §17.1.5 (new — the producer determinant manifest: what it records, deterministic serialisation, retrievable by the single key, not a producer-identity attestation, best-effort); App. E (this entry). Summary: When an implementation publishes a unit’s outputs to the shared content-addressed store, it SHOULD also persist — retrievable by the same single cache key K (§17.1.1) — a producer determinant manifest recording the resolved values that formed K: declared input contents as path→hash, the command/Lua-chunk hash, the declared output paths, the consulted environment values, and the effective-seal-set probe values. The manifest serialises deterministically (same K → byte-identical content). It powers cook why-on-miss and the divergence verifier by exposing the producer’s determinants for diff and recomposition. It is NOT an attestation of which producer ran (deferred); a local unit persists no manifest; a missing or malformed manifest is never a cache error. Reference implementation. cli/crates/cook-fingerprint adds the DeterminantManifest type (ordered BTreeMap collections, hex-u64 serde for high-bit-safe JSON round-trips) and the CacheBackend::put_manifest / get_manifest trait methods; cli/crates/cook-cache implements them on LocalBackend as a {key}.provenance.json sidecar adjacent to the artifact bytes (atomic temp+rename) and gives CloudBackend a deferred no-op; cli/crates/cook-engine builds the manifest at both shared-artifact upload paths (the restored-completion / interactive path and the freshly-executed worker path) from the already-resolved publish-site values and writes it under the unit’s cloud_key, gated by the same !CacheMeta.local publish_to_backend flag as the artifact upload. The single cache key and the cache schema version are unchanged. Backward compatibility. Purely additive. The manifest is sidecar data; the artifact-store byte layout, the single cache key, and the cache schema version are unchanged. Existing caches without manifests read back as “no manifest” (best-effort), and the engine behaves exactly as before.

CS-0111 — Publish-off / read-only client mode [#changes.cs-0111]

Date: 2026-06-23 Version: v0.15 Sections affected: §17.1.3 (new normative paragraph — publish-off / read-only client mode); App. E (this entry). Summary: Adds a per-client publish-off mode to §17.1.3: an invocation switch (--no-publish / COOK_NO_PUBLISH) or [cloud] publish = false in .cook/cloud.toml under which the client fetches/drift-restores/serves pinned by key normally but publishes NO artifact for ANY unit. Orthogonal to disposition; changes no key; CACHE_VERSION unchanged. Write-authorization to a shared store remains the backend’s concern, not a client assertion. Reference implementation. cli/crates/cook-engine checks a publish_off flag (sourced from --no-publish / COOK_NO_PUBLISH / [cloud] publish = false in .cook/cloud.toml) at every shared-store upload site and skips the upload when set; fetch-by-key, drift-restore, and pinned cold-fetch paths are unaffected. No cache-key change; no schema-version bump. Backward compatibility. Purely additive: the flag defaults to off and existing clients that do not set it behave identically to before.

CS-0112 — cook why key explanation and shared-miss determinant diff [#changes.cs-0112]

Date: 2026-06-23 Version: v0.15 Sections affected: §17.1.6 (new — the read-only cook why key-explanation query: per-unit single key K and its resolved determinants, hit/miss classification, the shared-miss producer-manifest diff, strict read-only, --json shape); §17.1.5 (cross-reference to the new query); App. E (this entry). Summary: Adds §17.1.6, a normative description of the reference implementation’s read-only cook why <recipe> query. For each cacheable unit in the recipe’s reachable closure it reports the single cache key K (§17.1.1) and every resolved determinant behind it — declared input contents as path→hash, the command/Lua-chunk hash, the declared output paths, the consulted environment values, and the effective-seal-set probe values (surfacing the resolved tool path for tools probes) — plus whether the unit would be served from cache (local or shared) or rebuilt. On a shared miss, when a producer determinant manifest (§17.1.5) exists for K, the query diffs the consumer’s determinants against it and names the differing determinant(s), so “why did I miss the shared artifact” is answerable from the output alone. The query executes nothing, publishes nothing, and writes no declared output; a missing or malformed manifest is never an error. No cache-key change; CACHE_VERSION unchanged. Reference implementation. cli/crates/cook-engine adds a read-only why module (explain builds the reachable DAG without executing, resolves each unit’s determinants, recomposes K, classifies the cache decision by mirroring the executor’s check_node_cache, and on a shared miss fetches the producer manifest via CacheBackend::get_manifest and diffs it) plus the DeterminantDiff / UnitDeterminants / WhyReport types; cli/crates/cook-cli adds the cook why subcommand with human-readable and --json renderers. The shared-store hit probe checks artifact presence by key without restoring bytes (read-only). No cache-key change; no schema-version bump. Backward compatibility. Purely additive: a new read-only query and its output surface. No change to the cache key, the artifact-store layout, the manifest format, or the cache schema version.

CS-0113 — cook cache verify determinant-fidelity verifier [#changes.cs-0113]

Date: 2026-06-23 Version: v0.15 Sections affected: §17.8 (new — the determinant-fidelity verifier: re-run-and-byte-compare under a matching key, the divergence verdict and non-zero exit, the record byte-exemption cross-referencing §17.1.4, and manifest attribution cross-referencing §17.1.5); App. E (this entry). Summary: Adds §17.8, the normative definition of the opt-in cook cache verify command. For each cacheable unit the cache holds an artifact for under its single key K (§17.1.1), the verifier re-executes the unit and compares the re-produced output bytes against the bytes the cache would serve for K. A non-record unit whose re-run produces different bytes under the matching key — or fails to re-produce a recorded output — is a divergence (an under-keyed determinant or a non-reproducible producer), and the command exits non-zero. A record unit (§17.1.4) is exempt from the byte comparison; a record content difference MUST NOT be classified as a divergence. A faithfully-keyed reproducible unit passes. Where a producer determinant manifest (§17.1.5) is retrievable for K, the implementation SHOULD attach its determinants to a divergence report for attribution. The verifier is framed as a fidelity / diagnostic tool, not a trust gate — trust rests on author-declared determinants; the oracle observes their completeness (this supersedes the design’s earlier “oracle can die” framing). CACHE_VERSION is unchanged. Reference implementation. cli/crates/cook-engine adds a verify module (verify_cache driver that populates the cache via the normal run() then re-runs each cacheable shell unit in a throwaway sandbox copy of its working directory, hashing the produced outputs and comparing against the recorded StepEntry.outputs; the classify verdict applies the record byte-exemption). cli/crates/cook-cli adds the cook cache verify [RECIPE] [CONFIG] [--json] subcommand, exiting non-zero on any divergence or error. The single cache key and the cache schema version are unchanged. Backward compatibility. Purely additive observable tooling — a new opt-in command. No Cookfile surface change, no cache-key change, no schema-version bump; existing builds are unaffected.

CS-0114 — Cache-trust v3 maintainability refactor: Sharing enum, single-source publish + cloud-key derivation [#changes.cs-0114]

Date: 2026-06-24 Version: v0.15 Sections affected: App. E (this entry) only — implementation-conformance record; no normative change. §8.4.3 keeps local / pinned as mutually-exclusive surface decorators with the same mutual-exclusion diagnostic; §17.1.1 keeps the single content-addressed key with its existing composition; §17.1.5 keeps the producer determinant manifest shape. The surface grammar, execution semantics, the Cook Lua API, and the module system are unchanged. Summary: A maintainability pass over the trust-critical cache surface, all behaviour-preserving (the cache key VALUE for every unit is byte-identical; CACHE_VERSION is unchanged). Three changes: (1) the two near-identical ~210-line completion/publish blocks in the engine executor (the restored/interactive path and the freshly-executed worker path) are collapsed to one publish_completion helper so the record → cloud-key → artifact-upload → depfile-upload → determinant-manifest contract is single-source. (2) The cloud-key composition, previously hand-transcribed at three sites that had drifted, is unified: a recipe_namespace(project_id, cookfile, recipe) helper feeds every key/namespace site, hex::encode/hex::decode replace the hand-rolled hex in cook why, and a single sealed-probe resolver fixes a real producer/consumer drift — the producer persisted an absent sealed probe as the empty string while cook why recomposed it as "<unmaterialised>", which could make a shared-miss diff falsely report a probe difference; both sides now share the producer’s empty-string encoding (consistent with the empty-bytes fold in seal_contribution). (3) The spec-mutually-exclusive local / pinned booleans are replaced by a single internal enum Sharing { Shared, Local, Pinned } (in cook-contracts) threaded through the AST disposition, codegen, register parse, and CacheMeta, making the impossible (local, pinned) = (true, true) state unrepresentable; record stays a separate orthogonal bool. The change dissolves the reserved-Lua-keyword ["local"] = true bracket-quote hack at the codegen↔register boundary — Sharing serialises as a plain sharing = "local" / "pinned" field, omitted for Shared so plain-step goldens stay byte-identical. Reference implementation. This maintainability-refactor commit’s cook-contracts lib.rs adds Sharing and folds CacheMeta.local/.pinned into CacheMeta.sharing; cook-fingerprint backend.rs adds recipe_namespace; cook-lang ast.rs/disposition.rs carry Disposition.sharing; cook-luagen cook_step.rs emits the sharing string field; cook-register unit_api.rs reads it; cook-engine executor.rs extracts publish_completion and shares the cloud-key/namespace helpers, seal.rs adds the canonical resolve_sealed_probes, and why.rs consumes both. Covered by the existing cache E2E suite (sharing_disposition_e2e, record_disposition_e2e, seal_host_key_e2e, cache_trust_cross_machine_e2e, provenance_manifest_e2e, publish_mode_e2e, why_e2e, cache_verify_e2e) and the conformance disposition fixtures, all unchanged and green. Backward compatibility. Implementation-only; no Cookfile surface change, no cache-key change, no schema-version bump. The codegen↔register wire detail changes (["local"] = true / pinned = true → sharing = "local" / "pinned"), but generated register Lua is a regenerated build artifact, never authored or persisted, so no conforming Cookfile or stored cache entry is affected.

CS-0115 — Collapse disposition grammar → seal/unseal steps + trailing cook_mods; record → nondet [#changes.cs-0115]

Date: 2026-06-27 Version: v0.15 Sections affected: §8.4.3 (rewritten — the disposition surface: seal/unseal recipe-body steps, trailing cook_mods, the single share_mod slot, the renamed nondet disposition, new conformance-fixture list); §17.1.4 (the record disposition renamed nondet, anchor exec.cache.record retained; composition with local/pinned is now surface-mutually-exclusive via the single share_mod slot); §17.8 (verifier prose record → nondet); App. A §A.3 (declarative_item: disposition_block → seal_step); App. A §A.4 (new seal_step and cook_mods/share_mod productions; disposition_line/disposition_block/disposition/seal_ref deleted; cook_step gains a trailing cook_mods; the multi-line-patterns note and the step-dispatch-priority list updated); App. E (this entry). Summary: Collapses the v3 disposition_line / disposition_block decorator grammar entirely. The four v3 dispositions split along the seam that was already there — seal adds an input, local/pinned/record set an output policy — and become two crisp surfaces. (1) seal / unseal are now recipe-body steps, a determinant input stream sibling to ingredients: a recipe-level seal (≥1 named ref; bare seal rejected) contributes to a recipe-wide baseline applied to every cook regardless of textual position (declarative, order-independent); a recipe-level unseal is rejected (trailing-only). (2) A cook step carries trailing cook_mods — (seal R+ | unseal R+)* share_mod? — where the per-unit effective seal set is (baseline ∪ trailing seals) − trailing unseals, and share_mod is a single optional last slot collapsing local / pinned / nondet (mutual exclusion grammar-enforced). (3) The v3 record disposition is renamed nondet — a fact declaration (“this output is nondeterministic / not byte-reproducible”) rather than an imperative policy verb; the old record keyword is rejected with a migration diagnostic. seal/unseal/local/pinned/nondet remain non-reserved words, recognised only in their respective positions. This is a pure SURFACE change: there is no change to the v3 key model, the cache-key value for every unit, the divergence oracle, the verifier, the provenance manifest, or CACHE_VERSION. Internally nondet maps to the unchanged record boolean and local/pinned to the unchanged Sharing enum; the effective seal set is computed exactly as before. The single share_mod slot does narrow the surface relative to v3 (a unit can no longer be both nondet and local/pinned in source text), which is the deliberate ergonomics decision — the internal byte-equivalence waiver remains independent of the sharing policy. Reference implementation. cli/crates/cook-lang/src/disposition.rs is gutted to a parse_cook_modifiers (the (seal|unseal)+ share_mod? tail parser) plus the retained parse_seal_refs validator; the DispLine classifier, block parser, and fold helpers are deleted. cli/crates/cook-lang/src/cook_line.rs parse_cook_line parses the trailing cook_mods (after the body’s } or after the output patterns for a declaration-only cook) and returns the per-unit unseal set. cli/crates/cook-lang/src/recipe.rs dispatches seal as a recipe-body step (rejecting recipe-level unseal and bare seal), drops the pending-decorator / disposition-block machinery, and folds the recipe baseline into each cook’s effective seal set at finalize (apply_base_seal). Codegen (cook-luagen), the register reader (cook-register), cook-contracts::Sharing, and the engine cache path are unchanged — they consume the same Disposition { seal, sharing, record }. Conformance fixtures migrated under standard/conformance/{positive,negative}/cook-disposition-*. tree-sitter-cook is out of scope (a separate follow-up). Backward compatibility. Surface-breaking for authored dispositions: the decorator-line form (seal … / local / pinned / record above a cook), the disposition { … } block form, and the record keyword are all removed in favour of recipe-level seal steps and trailing cook_mods (… local / … pinned / … nondet / … seal R / … unseal R). No cache-key change, no schema-version bump, no stored cache entry affected; migration of in-tree Cookfiles and examples is tracked as a separate ticket.

CS-0116 — Token-free probe value surface: drop produce/as, producer kind leads, envs reinstated [#changes.cs-0116]

Date: 2026-06-27 Version: v0.15 Sections affected: § 22.5.2 (§22.5.2) rewritten; App. A § A.3.2 (§A.3.2) grammar replaced; §8.10.1 example and App. C rationale table updated for consistency. Pure surface — no semantic change. Summary: Drops the produce introducer token AND the as connector from the native probe declaration. The producer KIND now leads and the braces are that kind’s body, giving a single grammar with six forms: { … } (bare shell block → string), json { … } (shell block → parsed + validated JSON), lines { … } (shell block → array of stdout lines), tools { cc, ld } (name list → cached toolset fingerprint), envs { CFLAGS } (name list → cached env-set fingerprint), and >{ … } (Lua block → structured value). json, lines, tools, and envs are contextual keywords recognised only in probe-producer position. The env producer kind — momentarily cut during an earlier design pass — is reinstated spelled envs (plural, sidestepping the reserved env collision): a named, cached, shareable env-set fingerprint, the env-twin of tools, reusing the tools name-list rules unchanged (non-empty, bare IDENT, comma/whitespace-separated, single physical line). json stays explicit: it decodes AND validates, failing the probe loudly on malformed JSON rather than silently falling back to a string (auto-detect rejected for run-to-run type instability and silent-failure swallowing). No codec slot follows >{ … } — a Lua block already returns a structured value, so json/lines applied to one MUST be rejected. This is a token/keyword-density pass only: probe-value semantics, the canonical-JSON value encoding, and the probe fingerprint algorithm are all unchanged; the lowering to cook.probe(key, opts) and the opts.produce Lua source string (the function body wrapped at execute phase) are structurally identical. Reference implementation. cook-lang probe.rs rewrites the probe-body parser to detect the leading producer kind (bare >{ … } recovered from the LuaBlockOpen opener token); ast.rs renames the ProbeProduce::Env variant to Envs with refreshed doc comments; cook-luagen probe.rs matches the renamed variant (lowered inputs.env / cook.env.<NAME> worker schema unchanged). Conformance probe fixtures migrated to the new surface; the unit and conformance suites pass. Backward compatibility. Surface-breaking for authored Cookfiles: produce/as are removed and existing produce as json|lines|tools|env { … } / produce { … } / produce >{ … } forms must be rewritten to the leading-kind forms (env → envs). The pre-1.0 lockstep policy applies; a separate migration ticket sweeps the in-repo Cookfiles and examples. No stored cache entry is affected — the cache key value for every probe is byte-identical (the lowered opts.produce source is unchanged).

CS-0117 — Seal/unseal scope semantics pinned: declarative two-tier, recipe-boundary reset, probe-name refs [#changes.cs-0117]

Date: 2026-06-27 Version: v0.15 Sections affected: § 8.4.3 (§8.4.3) — new “a seal/unseal ref is ALWAYS a probe name” paragraph (env/tool ambient determinants fold through named envs/tools probes; no env.X/tool.X ref form) and new Note 8.4.3.1 fixing the boundary a future blanket seal would draw; the pre-existing fixtures note renumbered 8.4.3.1 → 8.4.3.2 with five new positive fixtures listed. § 17.1.2 (§17.1.2) — new Note 17.1.2.1 warning against redundantly sealing an envs probe over a variable already auto-folded by consumption. App. E (this entry). Surface/semantics pin only — no change to the v3 key model, key values, or grammar. Summary: Pins the seal/unseal scope model that CS-0115 built but left under-specified. Scope is declarative, not positional: a directive’s reach is determined by WHERE it is written — a recipe-body seal/unseal step sets the recipe baseline, a trailing cook_mods seal/unseal tunes one unit — never by what textually follows it, with no set -e/set +e-style positional cascade. The effective determinant set of a unit is (recipe baseline ∪ trailing seals) − trailing unseals, computed per unit and order-independently (a seal step after a cook still applies to it). Recipe boundaries break seals implicitly: the recipe is the outermost seal scope and nothing propagates across recipe dependency edges, because a downstream recipe already folds an upstream’s determinants transitively through the upstream’s output-content hashes (consumed as ingredients), so re-sealing downstream would double-count — hence exactly two tiers (recipe + step) and no config-default inheritance tier. unseal is trailing-modifier-only; a recipe-level unseal is rejected (nothing inherited to release). A seal ref is always a probe name (Session-3 decision): the considered env.X/tool.X ambient-ref forms are dropped — envs (CS-0116) subsumes them — so probe_ref needs no extension; unconsumed environment folds by declaring an envs probe and sealing it by name, and tool determinants fold by sealing a tools probe by name. Note 8.4.3.1 fixes the universe a future (if ever revived) blanket seal would have to cover (visible-but-unconsumed probes plus ambient determinants) so such a form cannot silently re-read today’s named-only keys. Reference implementation. CS-0115 already realises the fold (cook-lang recipe.rs apply_base_seal: baseline ∪ trailing seals, minus per-unit trailing unseals, applied at recipe finalize so the baseline is order-independent; recipe-level unseal and bare recipe seal rejected; bare trailing seal/unseal rejected by disposition.rs parse_cook_modifiers). This entry pins the semantics in normative text and adds conformance fixtures (cook-disposition-seal-order-independent, cook-disposition-seal-multi-cook-baseline, cook-disposition-unseal-one-unit-only, cook-disposition-seal-shell-probe, cook-disposition-seal-tools-probe); no engine change was required. Backward compatibility. None — this is a specification pin of already-shipped behaviour. No authored Cookfile changes meaning, and no stored cache entry is affected (the cache key value of every unit is unchanged).

CS-0118 — Unify the nondet double-home: one normative definition in §17.1.4, “recording” named as the stored artifact [#changes.cs-0118]

Date: 2026-06-27 Version: v0.15 Sections affected: §17.1.4 (the noun “recording” introduced explicitly at its first use). §8.4.3 and §6 audited and left unchanged. Pure prose/definitional — no semantic change. Summary: Reconciles the “double-home” the record → nondet rename (CS-0115) left behind, confirming there is exactly ONE normative definition of the disposition’s cache effect — §17.1.4 (anchor exec.cache.record) — and that §8.4.3 is surface/grammar-only, deferring the cache-key effect to §17 and cross-referencing it via §{exec.cache.record} (item 6 of the cook_mods rules). The premise of a competing §6 recipe-level definition is stale: the milestone-framing entries (CS-0103 / CS-0104) listed a planned “§6 record” section, but CS-0109’s cache effect actually landed in §17.1.4 and §6 (Recipes) carries no cache/disposition concept at all — so there was never a second normative definition to remove, only a stale forward-reference in the framing prose. Concept-noun decision (recorded here as the normative resolution): “recording” is the noun for the stored artifact a nondet unit reuses; nondet is the disposition keyword. They name different things — the artifact versus the policy — so there is no collision and no rename of “recording” is warranted; one definition each, cross-referenced. The only prose gap closed is that §17.1.4 previously used “recording” (“re-generates the recording”) without first introducing it; the noun is now named explicitly at first use (“the recording the nondet unit reuses”). The deliberate “renamed v3 record” / migration-diagnostic references (§8.4.3 item 7, §17.1.4, App. A §A.4, and the earlier entries in this appendix) are retained as historical / migration aids and are NOT stale keyword uses. Reference implementation. None — spec-only. No parser, codegen, engine, or conformance-fixture change; the existing cook-lang conformance suite passes unchanged. Backward compatibility. None affected. Purely definitional prose; no grammar, key model, section anchor, or CACHE_VERSION change.

CS-0119 — directory outputs: dir/ build-owned terminal cached artifacts [#changes.cs-0119]

Version: v0.15

Date: 2026-06-27

Sections affected: §22.1.2 (terminal-output rule generalised to directory outputs), §17.6 (build-owned reconciliation), §18 (parent creation note), App. C.17.1 (rationale), App. E (this entry).

An outputs[] entry written with a trailing slash (dir/) declares a directory output: Cook owns the entire subtree. It reuses the CS-0085 glob machinery (terminality §22.1.2, post-execute resolution §17.6) — the file set is resolved by walking the subtree after the command runs — and adds build-owned reconciliation: on a rebuild and on a cache-hit restore the subtree is reconciled to exactly the produced/recorded file set (strays and orphans deleted, empty directories pruned), guaranteeing hit-equals-miss. Glob entries (dir/**, dir/*.js) and literal entries are unchanged (additive). Symbolic-link handling within a directory output is deferred to a future revision: directory pruning does not follow symlinks, but the file-enumeration the deletion is driven from does not specially handle them, so 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.

Motivation: dogfooding (ppu-toys) surfaced a silent partial-restore footgun — a recipe writing files it did not enumerate produced a cache hit that differed from a fresh build. Subsumes the orphan-on-shrink stale-output sweep (CS-0093) for the directory case.

Backward compatibility: outputs[] entries without a trailing slash behave bit-for-bit identically; CACHE_VERSION does not bump (the cache-record shape is unchanged — directory outputs record the same concrete FileRecord set glob outputs already record).

CS-0120 — Entry-point selection: upward discovery, cwd-scoped bare names, root-anchored cache identity, reserved // targets [#changes.cs-0120]

Date: 2026-07-03 Version: v0.15 Sections affected: §20 (new §20.2 + chapter callout). Summary: Invocations made from a directory without a Cookfile now select their entry Cookfile by walking upward to the nearest Cookfile, bounded by the workspace boundary (.cookroot, --root, or the filesystem root) — never escaping into an unrelated enclosing project. The selected Cookfile behaves exactly as if invoked from its own directory: bare names resolve against it and no enclosing-workspace alias qualification is applied (alias namespaces are declaring-Cookfile-scoped and a directory may be imported under multiple aliases, so an “own” namespace is not well-defined from inside). Cache identity is anchored at the workspace root resolved from the selected entry: the invocation directory MUST NOT influence cache keys, so the same work yields the same key from anywhere in the workspace. CLI targets beginning with // are reserved for workspace-root-anchored resolution; implementations MAY defer support but MUST reject rather than misparse. Reference implementation. cook_engine::pipeline::discover_entry_cookfile (the bounded upward walk, cli/crates/cook-engine/src/pipeline/workspace.rs) with CLI wiring and the reserved-// rejection in cook-cli/src/main.rs; unit tests alongside each.

CS-0121 — Chore-body $<recipe> references establish the dependency edge [#changes.cs-0121]

Date: 2026-07-03 Version: v0.15 Sections affected: §10.6 (name-reference edge rule extended to chores). Summary: A $<recipe> reference in a chore step now establishes the §10.6 cross-recipe dependency edge, pulling the referent into the build closure — the referenced recipe builds before the chore step runs, with no explicit : dep entry required. Previously the reference resolved to the recipe’s terminal output path (CS-0094) but contributed no edge, so cook <chore> scheduled only the chore node and the body ran against a missing artifact (observed as exit 127 launching a never-built binary). Chore edges are whole-recipe coarse edges; the §10.6 fine-grained refinement remains a placeholder-sourced optimisation for cached units and does not apply to chores, which are never cached (§ 7.4). Reference implementation. cook_luagen::recipe::chore_metadata_fields merges $<NAME> body references — found by the shared dep_ref::extract_dep_refs_from_steps walk over the chore’s steps — into the chore’s emitted requires, exactly as recipe_metadata_fields does for recipes (explicit deps first, inferred appended, de-duplicated). Locked by cook-luagen/src/tests.rs (test_compile_chore_infers_requires_from_body_dep_ref, test_compile_chore_merges_explicit_and_inferred_requires) and the end-to-end regression cook-cli/tests/chore_dep_closure.rs.

CS-0122 — Probe-value references in native cook bodies substitute via register-time capture [#changes.cs-0122]

Date: 2026-07-07 Version: v0.15 Sections affected: §22.5.7 (command-template desugaring — uniform native-body/raw-string rule; non-string command rejection). Summary: A $<key:field> probe-value reference in a native cook-step shell body now substitutes the probe’s value at execute time, matching the raw cook.add_unit command-string behavior of CS-0074. §22.5.7 gains the uniform rule: probe references MUST reach register-time unit capture as literal $<...> text inside a string-valued command, and cook.add_unit MUST reject a non-string command with a register-phase diagnostic. Previously the reference implementation lowered probe-referencing native bodies to a deferred function() ... end command which register capture coerced to the empty string — the unit “ran” in 0.00s, produced no output, and reported success. Reference implementation. cook_luagen::template::expand_command_template and the for_each cook-body path emit probe sigils as literal text (no closure); cook_register::unit_api add_unit rejects non-string command values. Locked by cook-luagen/src/template.rs probe-lowering tests, cook-register/src/unit_api.rs rejection tests, the conformance case positive/probe-ref-in-cook-body, and the end-to-end regression cook-engine/tests/probe_in_body_e2e.rs.

CS-0123 — Codecs are both-phase API: cook.json_decode / cook.yaml_decode on the worker VM; Example 8.4.2.1 fs.* name fix [#changes.cs-0123]

Date: 2026-07-06 Version: v0.15 Sections affected: §24.8 (new — cook.json_decode / cook.yaml_decode catalogued as both-phase); §22.5 (informative reference-implementation-gap note replaced: codecs now available on both consumption paths); §8.4.2 Example 8.4.2.1 (fs.write_file/fs.read_file corrected to the §25.4/§25.5 names fs.read/fs.write — the _file names were never defined anywhere in the Standard or the implementation); App. E (this entry). Summary: The JSON/YAML codecs were implemented on the register-phase VM (and therefore on the ingredients <probe> pre-pass path) but not on the demand-driven execute-phase worker VM, so a probe whose produce calls cook.json_decode failed with a nil-call error when reached through a consumer unit’s probes field — including the faithful lowering of the native probe … json { … } form. The codecs were also never normatively catalogued. CS-0123 adds §24.8 defining both functions as both-phase, pure, and mandatory on every VM that runs Cookfile-derived Lua. A worker-VM API audit against §21/§24/§25/§26 accompanies the change: fs.*, path.*, cook.sh, cook.env, cook.platform, cook.load_module, cook.cache.get/scope, cook.export/cook.import were already present; cook.dep_output/cook.dep_output_list (§24.7 “both”) remain a known reference-implementation gap on the worker VM, since closed by CS-0129. Reference implementation. The codec helpers and the shared JSON→Lua walker move from cli/crates/cook-register/src/codec_api.rs to cli/crates/cook-lua-stdlib/src/codec_api.rs (the crate shared by both VMs); cook-register re-exports the walker and the register VM keeps byte-identical behaviour; cli/crates/cook-luaotp/src/pool.rs registers the codecs on each worker VM. Unit lock: cli/crates/cook-luaotp/src/pool.rs (probe_produce_can_call_codecs_on_worker_vm). E2E: cli/crates/cook-cli/tests/probe_integration.rs (json_decode probe → demand-driven consumer), cli/crates/cook-cli/tests/worker_vm_api_integration.rs (Example 8.4.2.1’s shape end-to-end; codecs in a >{ … } body). Backward compatibility. Purely additive at the surface (two previously-nil fields on the worker VM’s cook table now resolve). No cache-key impact. The Example 8.4.2.1 correction changes informative text only; no conforming Cookfile could have used the _file names.

CS-0124 — Normative process exit status (§19.2): failing test steps fail the run; App. D.11 closed [#changes.cs-0124]

Date: 2026-07-06 Version: v0.15 Sections affected: §19 (chapter retitled “Diagnostics and exit status”; new §19.2 process exit status); App. D.11 (closed — graduated to §19.2); App. E (this entry). §17.8’s divergence exit-status rule is unchanged and now cross-referenced from the §19.2 table. Summary: The CLI exit-code taxonomy, informative-only since CS-0041 (App. D.11), becomes normative: a conforming implementation MUST exit 0 if and only if the invocation succeeded. The load-bearing new rule is that a failing test step fails the run — under the test runner and under a recipe run (cook RECIPE). The reference implementation’s engine records test failures as “soft” results (dependents not cancelled, so a suite runs to completion); only the cook test entry point inspected those outcomes, so cook RECIPE with a failing test step printed FAILED yet exited 0 and CI went green on failing tests. §19.2 also normatively restates that cook cache verify exits non-zero on any divergence or verification error (already mandated by §17.8) and pins the reference taxonomy table (0 success / 1 execution or test failure / 2 parse error / 3 recipe not found or collision). Pre-1.0, test failures share exit 1; a distinct status is deferred to the v1.0 test-surface redesign, which closes App. D.11’s open question. Reference implementation. cli/crates/cook-cli/src/pipeline.rs::cmd_run now inspects RunResult.test_results and returns CookError::TestFailure (exit 1) when any executed test step failed, timed out, or was blocked, printing a one-line cook: N failing test step(s) summary to stderr. Pinned by cli/crates/cook-cli/tests/runner_exit_codes.rs and the extended cli/e2e-fixtures/cli_audit_exit_codes/walkthrough.sh (failing/passing test under the runner, cache-verify clean + divergence, upstream-failed skip, cook serve register-phase error, plus the pre-existing 0/1/2/3 scenarios). Backward compatibility. Behavioural change at the CLI boundary: cook RECIPE whose executed test steps fail now exits 1 (previously 0). Any pipeline that depended on the old behaviour was passing on failing tests. No Cookfile surface change; cook test exit behaviour is unchanged.

CS-0125 — ingredients <probe> source-ref resolution: exact probe key wins over field-selector split [#changes.cs-0125]

Date: 2026-07-06 Version: v0.15 Sections affected: §22.5.10 (source-ref resolution rule added). Summary: An ingredients <probe> source ref is lexically ambiguous when it contains : — probe keys are canonically two-segment (ns:name, §22.5.2), and §22.5.10 also sanctions a <key>:<field> selector. The implementation previously split every ref at the first colon at code-generation time, so a two-segment probe key was unconditionally misread as key + field selector and rejected with “no such probe was declared”. §22.5.10 now specifies resolution against the declared-probe set at registration time: an exact whole-ref key match wins; otherwise the segment after the final : is a field selector whose prefix must name a declared probe; a ref naming no declared probe under either interpretation is rejected naming the full ref. Refs of four or more segments are rejected at parse time. Reference implementation. cook_luagen::recipe::{emit_for_each_items, for_each_meta_field} carry the ref verbatim (__for_each = {kind = "probe", ref = …}); cook_register::engine::resolve_probe_ref resolves it in the register pre-pass, which stores the resolved member array under the verbatim ref for the body’s cook.cache.get. Locked by cook-register/src/tests.rs (for_each_two_segment_probe_key_fans_out, for_each_exact_probe_key_wins_over_field_selector_split, for_each_undeclared_two_segment_ref_error_names_full_ref, for_each_two_segment_key_with_field_selector_fans_out), cook-lang/src/tests.rs ref-shape tests, conformance positive/ingredients-probe-two-segment + positive/ingredients-probe-key-field-selector + negative/ingredients-probe-ref-malformed, and the end-to-end regression in cook-cli/tests/for_each_integration.rs.

CS-0126 — Diagnostics contract: config did-you-mean, no tracebacks by default, closest-match env hints [#changes.cs-0126]

Date: 2026-07-07 Version: v0.15 Sections affected: §5 (config blocks — new parse-time rejection of the bare NAME "value" / NAME value statement shape); App. E (this entry). Informative elsewhere: diagnostics presentation is implementation quality-of-output, not language surface. Summary: A config-block body line consisting of a bare name followed by a value (the pre-CS-0011 VarDecl shape every make/just refugee types) is rejected at parse time with “config values are Lua assignments — did you mean NAME = “value”?”. A conforming implementation SHOULD present Lua-originated errors as a single source-mapped FILE:LINE message without an implementation traceback unless explicitly requested. The shape check also rejects paren-less Lua call statements (print "x") inside config blocks; write the parenthesized form. Reference implementation. cook_lang::recipe::detect_bare_config_value (parse-time check, exact source lines); traceback suppression behind -v / COOK_BACKTRACE=1 in cook-cli/cook-luaotp; conformance case negative/056. Config-block bodies are line-aligned in the generated chunk (best-effort padding; #-comment lines become blank lines) so runtime errors report exact Cookfile lines. Execute-phase Lua chunks carry their step’s Cookfile line (WorkPayload::LuaChunk.line) and are newline-padded and named @Cookfile on the worker VM, so execute-phase errors are source-mapped. Undeclared $<NAME> suggests the 3 closest declared names (case-insensitive edit distance) instead of dumping the full declared/host set. The checked codegen path hard-errors on any unresolved-placeholder sentinel instead of emitting it into generated Lua. Backward compatibility. A config-block line of this shape previously loaded as Lua and failed at register time with a nil-call error; no working Cookfile is affected. Paren-less call statements in config blocks previously executed; they now require parentheses.

CS-0127 — Fail-loud boundary contracts on the luagen-register-engine path [#changes.cs-0127]

Date: 2026-07-07 Version: v0.15 Sections affected: §22.1 (new field-typing rule for cook.add_unit); §22.4 (cook.add_test gains lua_code XOR command, plus the same field-typing rule); §22.5.7 (step-kind fidelity through command-template desugaring). Summary: The register-phase Lua boundary historically read most cook.add_unit fields with permissive helpers (.ok(), .unwrap_or(None).unwrap_or(default), filter_map(Result::ok)) that silently coerced a wrong-typed value to the field’s default — or, for list fields, silently dropped non-conforming elements — rather than raising a diagnostic. §22.1 now normatively requires that every documented cook.add_unit field have exactly one accepted Lua type, and that a value of any other type raise a register-phase Lua error naming the field, the received type, and the accepted type; absence (nil/missing) is not a type error and still resolves to the documented default. This entry covers the first field-typing pass, over cook.add_unit’s lua_code, interactive, line, cache, inputs, output, outputs, ingredient_groups, step_kind, member, sharing, env, file_refs, and consulted_env_keys fields (the command field’s equivalent strict match landed under CS-0122). The strictness reaches inside collection fields: a non-string element of a string list (inputs, outputs, file_refs, consulted_env_keys), and a non-string key or value of the env string→string map, is itself a type error rather than being coerced (mlua’s String conversion would otherwise silently render a number as its decimal text). It also extends §22.5.7’s command-template desugaring: a plate body inside a for_each recipe now lowers a probe-value reference the same literal-sigil way as every other shell-command body (rather than the prior deferred-function wrapping, which the CS-0122 command-type guard rejected outright), and the register-time rewrite of such a command into a Lua chunk now carries the originating step_kind through instead of hardcoding the cook-step sandbox policy, so a rewritten plate command stays unsandboxed. A second pass applies the same field-typing rule to cook.add_test (§22.4) and closes a dormant contract mismatch: codegen already lowered a Lua test body to cook.add_test({lua_code = ...}), but cook.add_test unconditionally required a non-empty command, so every Lua test body hard-errored at register time. §22.4 now specifies command/lua_code as an exclusive pair — exactly one MUST be provided and non-empty, lua_code runs on an execute-phase worker VM under the cook-step sandbox policy, and should_fail inverts either form’s pass/fail rule. Because WorkPayload::Test has no execute-phase probe-substitution machinery for command the way cook.add_unit rewrites a probe-bearing command into a LuaChunk, a probe-value reference in a for_each recipe’s test shell-command body — previously miscompiled to a deferred function() return ... end closure that the new strict command typing now rejects outright — is instead a targeted codegen-time error naming the probe key(s) and the offending line, directing the author to a Lua test body instead. Codegen now also treats unresolved placeholders and unhandled future step variants as load-time diagnostics: a malformed sigil such as $<out_0> and any Step variant without a lowering arm fail codegen instead of emitting a [[SIGIL_ERROR: ...]] sentinel or silently dropping the step from generated Lua. Finally, §7.1.2.2 unifies chore-body $<NAME> resolution: declared parameters shadow, but non-parameter tokens fall through to the normal recipe/env placeholder resolver, so parametric chores no longer lose $<MODE> env refs or $<build> recipe refs to a params-only helper. This entry will grow as later commits extend the fail-loud sweep to further boundary surfaces. Reference implementation. cook_register::unit_api::register_unit_api’s add_unit closure replaces each permissive read with an explicit LuaValue match, using the shared type_err / collect_string_list helpers (cli/crates/cook-register/src/unit_api.rs) so every rejection has a uniform message shape. Locked by cook-register/src/tests.rs (test_add_unit_rejects_* — one test per field, asserting the error names the field). cook_register::test_api::register_test_api’s add_test closure gains the same shaped type_err helper and an explicit command/lua_code exclusivity match (cli/crates/cook-register/src/test_api.rs); WorkPayload::Test (cli/crates/cook-contracts/src/lib.rs) gains a lua_code: Option<String> field. cook_luagen::test_step::generate_for_each_test_step (cli/crates/cook-luagen/src/test_step.rs) now returns Result<(), CodegenError> and raises the new CodegenError::ProbeRefInTestCommand variant (cli/crates/cook-luagen/src/plate_step.rs) instead of emitting the now-rejected deferred-function wrap. cook_luagen::recipe::generate_with_names now propagates sigil-resolution failures through CodegenError::SigilResolve and reserves the non-exhaustive step fallback for CodegenError::UnknownStep, while cook_luagen::plate_step::generate_for_each_plate_step and cook_luagen::test_step::generate_for_each_test_step also propagate unresolved sigils instead of embedding sentinels. cook_luagen::template::expand_sigil_template_with_chore_params gives chore shell steps a parameter binding layer before the ordinary resolver, and cook_register::capture replaces the old raw-command scanner with cook.__quote_param(value, name) for strict shell quoting of known parameter values. Locked by cook-register/src/test_api.rs (add_test_accepts_lua_code_without_command, add_test_rejects_both_command_and_lua_code, add_test_rejects_non_string_command, add_test_rejects_non_string_lua_code, add_test_rejects_non_integer_timeout), cook-luagen/src/tests.rs (for_each_test_probe_ref_in_shell_command_is_codegen_error, malformed_shell_sigil_is_codegen_error_not_emitted_sentinel, checked_codegen_covers_every_current_step_kind), and cook-cli/tests/chore_params_test.rs (parametric_chore_env_placeholder_resolves, parametric_chore_recipe_placeholder_creates_edge).

CS-0128 — Context-aware chore-parameter interpolation quoting [#changes.cs-0128]

Date: 2026-07-07 Version: v0.15 Sections affected: §7.1.2.2 (chore-body $<NAME> placeholder substitution — the single-word POSIX-quote rule of clause 1 is refined to be quote-context-aware); App. E (this entry). Summary: A chore-body $<NAME> placeholder for a declared parameter previously expanded to its bound value POSIX single-quote-escaped in every position, so a placeholder written inside an author-supplied double-quoted region leaked literal single quotes into the output (echo "hello $<who>" with who="world" printed hello 'world'). This entry refines the substitution to be quote-context-aware, classifying the shell quoting context of the placeholder span within the step text before quoting: (1) an unquoted placeholder is single-quoted to a single shell word (scalar) or the space-joined individually-quoted form (variadic), preserving word-safety exactly as before; (2) a placeholder inside a double-quoted region expands to the value escaped for double-quote context (backslash-escaping \, ", $, and backtick), with the author’s surrounding "…" providing the quoting, so echo "hello $<who>" now yields hello world; (3) a placeholder inside a single-quoted region expands to the raw value verbatim, the author’s '…' providing the literal region (a value containing ' is the author’s responsibility — a documented edge). This refines the CS-0127 cook.__quote_param reference from an always-single-quote helper to a context-aware one that takes the scanned context as a third argument. This is a surface-semantics change reserved-flagged for the v1.0 language cut. Reference implementation. The whole law lives in cook_contracts::quoting since COOK-389 (it was split across cook-luagen and cook-register, joined by a bare tag string with a silent bare fallback): quote_context scans the command prefix up to the placeholder span, tracking single/double quote state and backslash escapes (POSIX: backslash inert inside single quotes), and classifies it as typed QCtx, lowered to bare / dquote / squote via QCtx::tag; cook_luagen::template::expand_sigil_template_with_chore_params threads that tag into cook.__quote_param(value, name, ctx) as a third argument. cook_register::capture’s __quote_param binding parses it back with QCtx::from_tag — an unknown tag is a runtime diagnostic naming the drift, never silently bare — and dispatches to quote_for_ctx: bare keeps the shell_quote single-quote wrap, dquote backslash-escapes \ " $ and backtick, squote emits the value verbatim; both the scalar String arm and each Table (variadic) element route through it; the ctx argument is optional and defaults to bare. Locked by cook-cli/tests/chore_params_test.rs (dquote_context_param_does_not_leak_single_quotes, bare_context_param_stays_single_word) and the cook_contracts::quoting unit suite (classifier, tag round-trip, unknown-tag rejection). Backward compatibility. A chore shell step with a $<param> placeholder inside a double-quoted region previously emitted the value single-quoted (the literal quotes 'value' appeared in the shell text and, being inside double quotes, survived to the output); it now emits the bare escaped value. This changes observable output for that shape — it fixes the leaked-quote defect. Unquoted and single-quoted positions are unaffected in the common case (an unquoted no-special-character value renders identically after the shell strips the single quotes it was already given). Flagged for v1.0 review.

CS-0129 — cook.dep_output / cook.dep_output_list conform on the execute-phase worker VM [#changes.cs-0129]

Date: 2026-07-07 Version: v0.15 Sections affected: App. E (this entry) only — no normative text changes: §24.7 already declares cook.dep_output and cook.dep_output_list “Both”-phase, and Example 24.7.1 already shows cook.dep_output_list called from a >{ … } execute-phase cook body. Summary: §24.7 declares cook.dep_output and cook.dep_output_list “Both”-phase, and Example 24.7.1 uses cook.dep_output_list inside a >{ … } execute-phase cook body — but the reference implementation registered them only on the register-phase VM, so an execute-phase body calling either failed with attempt to call a nil value (field 'dep_output') (the gap CS-0123’s worker-VM audit flagged as tracked separately). CS-0129 records the closure of that gap: the engine snapshots the register session’s terminal-outputs map (recipe qualified-name → output paths, closed before execute phase) and threads it into every worker VM, which installs read-only cook.dep_output / cook.dep_output_list. The execute-phase forms resolve a bare name against the consumer recipe’s own Cookfile (its fully-qualified prefix) and return the referent’s output paths space-joined (dep_output) or as a 1-indexed table (dep_output_list); an unknown referent raises a Lua runtime error and an empty output list yields the empty string / empty table with a warning, per §24.7’s error model. Unlike the register-phase implementation the execute-phase forms are pure reads: they record no DAG edge (the DAG is closed before execute phase), so a >{ … } body that must observe a referent’s file contents still declares the ordering edge by the ordinary means (an explicit recipe app: lib dependency or a shell-step $<lib> reference). No normative §24.7 text changes; this entry certifies Example 24.7.1 now executes as written on the demand-driven path. Reference implementation. cook_luaotp::pool::WorkerDepOutputs (Arc<BTreeMap<String, Vec<String>>>, cli/crates/cook-luaotp/src/pool.rs) is threaded through WorkerPool::spawn_with_dep_outputs into each worker thread’s VM setup; install_worker_dep_output_api installs the read-only cook.dep_output/cook.dep_output_list pair, resolving via resolve_worker_dep_output (exact match, then the consumer recipe’s fully-qualified prefix). cook-engine/src/run.rs builds the snapshot from registered_workspace.terminal_outputs and passes it to cook-engine/src/executor.rs, which forwards it into the worker pool. Locked by cook-luaotp/src/pool.rs (dep_output_resolves_root_recipe, dep_output_resolves_same_cookfile_prefix, dep_output_unknown_recipe_errors, dep_output_empty_list_is_empty_string). E2E: cli/e2e-fixtures/surface/18-dep-output-execute-body (a cook.dep_output("lib") call inside a >{ … } execute-phase body, plus an unknown-referent negative case). Backward compatibility. Purely additive: two previously-nil fields on the worker VM’s cook table now resolve. No cache-key impact — the resolved values are read, not recorded as a fingerprint input, matching the register-phase implementation’s behaviour.

CS-0130 — $<in> unified as the sole cook-step iteration placeholder; the all-spelled sigil hard-removed; all un-reserved as a recipe/chore-param segment [#changes.cs-0130]

Date: 2026-07-08 Version: v0.15 Sections affected: §3.4 (reserved recipe segments — seven identifiers, not eight); §8.4.1 (Example 8.4.1, Example 8.4.1.1, Note 8.4.1.1); §8.6 (Example 8.6.2, §8.6.1 iteration-mode table and invalid-shape rules); §9.3 (phl.cook-step placeholder table and invalid-shape rules); §9.4 (phl.plate-test placeholder table and invalid-shape rules); App. A.2 (reserved recipe name identifiers, chore parameter reserved-name rule, plate/test mode coherence); this entry. Summary: This entry completes the unification CS-0022 began and supersedes the batched-source framing CS-0024 introduced for plate/test. Three surface changes, one behaviour preserved, one behaviour removed:

  1. The all-spelled placeholder sigil is hard-removed. It is not aliased, not deprecated-with-a-diagnostic — it simply stops being a recognised placeholder shape. A sigil written with the identifier all inside the angle brackets no longer matches any row of the phl.cook-step or phl.plate-test tables; it falls through to ordinary name resolution (§ 10.2) like any other unrecognised IDENT — a recipe reference if a recipe named all is in scope, otherwise an environment-variable lookup (cook.require_env("all")). There is no migration diagnostic naming CS-0130, because there is nothing left to migrate to that isn’t already the fallthrough behaviour every other unreserved IDENT gets.
  2. $<in> becomes unit-centric and is admitted in one more mode. In a cook_step in one-to-one mode (own-input, dep-driven, or data-member), $<in> continues to mean the current loop member, unchanged. In a cook_step in many-to-one mode, $<in> is now legal and means the unit’s full input set, space-joined — byte-identical to the string the removed all-spelled sigil used to produce in that same position. The accessor form $<in.ACCESSOR> remains one-to-one-only: an accessor selects a field or path component of a single item, and the many-to-one input set has no singular member to select from, so $<in.ACCESSOR> is rejected in many-to-one with a load-time diagnostic (and, as before, in a step with no iteration source at all). Iteration-mode inference is unchanged by this entry — §8.4.1’s rule that the output pattern list alone decides one-to-one vs. many-to-one still holds; $<in>’s presence in a body has never been an iteration-mode signal (CS-0022) and remains so. What changes is only which values $<in> is a legal placeholder in.
  3. all is un-reserved as a recipe-name / dependency-name / chore-parameter segment. Before this entry, recipe all, chore all(...), and a dependency segment all were all rejected under the eight-identifier reserved-segment rule of §3.4 — the “make-refugee” complaint, since make all is the most common target name in the wild. §3.4 now reserves seven identifiers (stem, name, ext, dir, in, out, env); all is an ordinary BARE_IDENTIFIER in every syntactic role. The other seven reserved words are untouched by this entry — a narrower follow-up ticket to review them individually is tracked separately and MUST NOT be conflated with this change.
  4. The plate/test shell surface collapses to per-item or one-shot; the batched form moves to Lua. CS-0024 gave plate/test three body-content-inferred modes (one-to-one via $<in>/input, many-to-one via the all-spelled sigil/inputs, one-shot via neither) across both the shell-block and Lua-block body forms. Because the all-spelled sigil no longer exists, a shell-block plate/test body can no longer express many-to-one at all: detect_plate_test_mode returns only OneToOne (body contains $<in> / $<in.ACCESSOR>) or OneShot (neither) for a ShellBlock body. The many-to-one mode survives exclusively through the using_lua_block body form, where the collected source is bound to the Lua local inputs (§ 23.1), unchanged from CS-0024. The mixed-iteration-signal diagnostic (§8.6.1, App. A.2) is consequently Lua-only now: a shell-block body cannot mix an iteration-item placeholder with a batched-source placeholder because the batched-source placeholder no longer exists in that surface; only a Lua-block body referencing both input and inputs triggers the diagnostic.

This entry cross-references CS-0024 (§ D.24), whose batched-source shell placeholder this entry removes outright, and CS-0022 (§ D.22), whose “the output pattern is the sole iteration-mode driver, not body content” principle this entry extends to $<in>’s newly widened validity without altering the driver itself. Why: Two independent frictions motivated this change. First, the eight-identifier reserved-segment rule made recipe all — the single most common top-level target name inherited from decades of make-based build tooling — a parse-time rejection, which was a persistent point of friction for anyone porting a Makefile-shaped project. Second, having two placeholder spellings for “the input set” depending on iteration mode ($<in> for one-to-one, the all-spelled sigil for many-to-one) was an arbitrary vocabulary split: both spellings denote “the input(s) this unit consumes,” and a mode-conditional name for the same concept is exactly the kind of surface inconsistency the CS-0022 unification was meant to eliminate — CS-0022 unified iteration mode inference, but left the placeholder vocabulary split. Unifying on $<in> for both modes, and hard-removing the redundant spelling rather than aliasing it, means all is free to mean what most projects already expect it to mean: an ordinary recipe name. Reference implementation. cli/crates/cook-lang/src/lexer.rs drops all from the reserved-recipe-segment identifier set. cli/crates/cook-luagen/src/cook_step.rs, dep_ref.rs, resolver.rs, and template.rs remove the all-spelled sigil’s resolution arm from the phl.cook-step resolver and extend the many-to-one cook_step codegen path to accept $<in> (previously accepted only in one-to-one codegen), substituting the same space-joined input-set expression the all-spelled arm used to emit. cli/crates/cook-luagen/src/plate_step.rs and test_step.rs restrict detect_plate_test_mode’s ShellBlock arm to OneToOne / OneShot only (many-to-one is unreachable for shell bodies post-removal; the match arm is retained for exhaustiveness with an explanatory comment) and drop the now-impossible shell-side mixed-signal branch. Conformance fixtures: standard/conformance/negative/025-in-accessor-rejected-in-many-to-one (replaces the former 025-plate-mixed-in-and-all) pins the $<in.ACCESSOR>-in-many-to-one diagnostic; standard/conformance/negative/033-plate-empty-source-rejected (replaces 033-many-to-one-empty-source-rejected) pins the empty-source rule under the new mode set; the former negative/039-in-in-many-to-one-sigil fixture is removed outright because the shape it pinned ($<in> rejected in many-to-one) is no longer an error. Test plan. cook-lang lexer tests confirm all parses as an ordinary BARE_IDENTIFIER in recipe-header, dependency-list, and chore-parameter positions, and that the remaining seven reserved segments are unaffected. cook-luagen codegen tests (cook_step.rs, plate_step.rs, test_step.rs, tests.rs) pin: $<in> resolving to the space-joined input list in a many-to-one cook_step; $<in.ACCESSOR> rejected with a load-time diagnostic in many-to-one; a bare all-spelled sigil resolving as a recipe reference when all is in scope and as an env-var lookup otherwise; a shell-block plate/test body with only-$<in> classified OneToOne and with neither classified OneShot; a Lua-block plate/test body with inputs still classified ManyToOne. The conformance corpus’s updated negative fixtures (above) and the surviving positive fixtures exercising cook "archive" { ar rcs $<out> $<in> }-shaped many-to-one steps are the end-to-end gate. Backward compatibility. Breaking, pre-1.0. Any Cookfile using the all-spelled sigil in a cook, plate, or test shell body must migrate to $<in> (byte-identical substitution result in cook many-to-one bodies; for plate/test many-to-one bodies, migrate to the Lua-block inputs form since the shell surface no longer expresses that mode at all). Any Cookfile that avoided declaring a recipe, chore, or dependency named all because it was rejected may now do so. There is no migration diagnostic for the removed sigil (see point 1 above) — a leftover use silently resolves as a name reference instead of failing loudly, which is a deliberate acceptance of this entry’s design (the alternative, keeping a special-cased rejection solely to explain the removal, would have reintroduced the seven-vs-eight reserved-word asymmetry this entry closes). Flagged for the v1.0 language cut audit.

CS-0131 — Two-category quoting law; widened bare probe-key charset [#changes.cs-0131]

Date: 2026-07-08 Version: v0.15 Sections affected: §A.3.2 (BARE_PROBE_KEY segment widened to PROBE_SEG; charset + dep-list STRING notes), §22.5.2 / §22.5.10 (probe-key charset; two-category ingredients discriminator), §9 (probe-value reference sigil charset note), App. E (this entry). Summary: Affirms the two-category quoting law. The law: a double-quoted operand is a literal filesystem string (paths, globs, output patterns — cook "a.o", the ingredients "glob" form); a bare operand is a namespace identifier (module name, probe key, recipe dependency). The law is explicitly NON-flattening at the ingredients boundary — bare ingredients cards (a probe member source) and quoted ingredients "cards" (a filesystem glob) are different KINDS and MUST NOT be unified; import and use names stay bare. Distinctly, in probe_name / probe_ref positions bare and STRING denote the same probe key and are interchangeable — there the quotes are only an escape hatch. Two surface gaps are closed: (1) the bare probe-key scanner is widened from [A-Za-z0-9_] to the recipe-name character set (is_ident_char / BARE_IDENTIFIER’s inner class, internal - and .), so probe demo:cc-version and a hyphenated bare probe_ref in the dependency list now tokenise; the grammar names the widened segment PROBE_SEG, leaving the narrow IDENT for the tools/envs name_list. (2) The probe_dep_list gains the STRING escape-hatch arm that §A.3.2 already blessed — probe x: "demo:cc-path" — so a key outside PROBE_SEG remains expressible in dependency position. The two-:-segment maximum for bare keys is unchanged; -/. never terminate a segment (the module-prefix colon remains the sole separator). Reference implementation. cook_lang::lexer::scan_probe_ref (cli/crates/cook-lang/src/lexer.rs) now segments on is_ident_char (shared with recipe names); parse_probe_dep_list dispatches a leading " to parse_name (the quoted arm), mirroring the probe-NAME dispatch. Locked by cook-lang/src/lexer.rs tests probe_header_hyphenated_bare_name, probe_header_hyphenated_bare_dep, probe_header_dotted_bare_name, probe_header_quoted_hyphenated_dep, probe_header_mixed_bare_and_quoted_deps, and conformance fixtures standard/conformance/positive/{probe-hyphenated-bare-name,probe-hyphenated-bare-dep,probe-quoted-hyphenated-dep}. Backward compatibility. Purely additive at the surface: every previously-valid probe key/ref still parses identically, and inputs that previously errored (ProbeExtraTokens on a hyphen, or a quoted dep) now succeed. No cache-key, codegen, or register-phase behaviour changes. Known residual (tracked separately, not CS-0131): the ingredients <probe> consumption scanner (cook_line::parse_ingredients_probe_source) still uses a narrow [A-Za-z0-9_:] class, so a hyphenated key is declarable but not yet consumable via a bare ingredients <key> — a §22.5.10 follow-up.

CS-0132 — Reserved-word ban narrowed: freed at declaration names and chore parameter names [#changes.cs-0132]

Date: 2026-07-08 Version: v0.15 Sections affected: §3.4 (declaration-site parenthetical narrowed — the reservation binds only reference surfaces); §7.1 (chore-name reserved note) and §7.1.1 (chore parameter-name rule) narrowed; §9 (chore-body resolution-order note — collision-freedom re-attributed off the removed ban); App. A.2 (reserved-segment identifiers + chore parameter-name rule); App. E (this entry). Summary: Narrows the seven-identifier reserved-segment ban (stem, name, ext, dir, in, out, env; all already freed by CS-0130) to the surfaces where it disambiguates a resolution: the final dot-separated segment of a dependency-list entry or a $<...> placeholder, plus the env-first prefix. The ban NO LONGER applies to (1) a recipe/chore declaration name — recipe out, chore name, recipe env are now well-formed (a dotted declaration name remains rejected outright by the no-dots rule, so the reservation was never load-bearing there beyond the diagnostic); or (2) a chore parameter name — chore deploy name in out is now well-formed, freeing name, the 2026-07-06 dogfood repro. A dotted reference whose final segment is reserved, and an env-first dotted reference, remain rejected unchanged. This is a backward-compatible loosening: every previously-valid Cookfile still parses, and inputs that previously errored now succeed. No migration. Why: The declaration-site and parameter-name bans were over-broad. A declaration name is always undotted (a dotted one is a separate, unconditional rejection), so banning undotted name/out/env bought nothing but friction — name in particular is a natural chore parameter, and its rejection was surfaced by dogfooding (2026-07-06). The reservation only needs to hold where two resolutions would otherwise collide: a dotted $<x.out> accessor vs. a recipe segment, and the $<env.X> env-lookup prefix. Reference implementation. cli/crates/cook-lang/src/lexer.rs keeps the single RESERVED_RECIPE_SEGMENTS list and check_reserved_recipe_name helper but gates them by call-site: the reserved check is removed from parse_chore_params (and the dead LexError::ReservedChoreParam variant deleted), and moved INSIDE the name.contains('.') guard at both the recipe and chore declaration reductions — so it fires only on a dotted declaration name (preserving the specific “‘env’ is a reserved word” diagnostic for env.foo) and never on an undotted one. Locked by cook-lang lexer tests (former_reserved_words_allowed_as_chore_params, former_reserved_words_allowed_as_undotted_decl_names, dotted_env_decl_name_still_reserved_diagnostic), the CS-0128 regression e2e cook-cli/tests/chore_params_test.rs::chore_param_named_name_substitutes_in_body, conformance positives positive/chore-param-name-allowed + positive/recipe-name-was-reserved, and the retained negative negative/037-reserved-env-recipe-rejected (dotted env.foo); the stale negative/chore-param-reserved-name fixture is removed. Backward compatibility. Purely a loosening. Every previously-valid Cookfile parses identically; a declaration or chore parameter named with a former reserved identifier — previously a parse error — now parses. No cache-key, codegen, or register-phase change. CACHE_VERSION unchanged.

CS-0133 — Remove declaration-only cook steps: a cook body is mandatory [#changes.cs-0133]

Date: 2026-07-08 Version: v0.15 Sections affected: §8.4 (drop the declaration-only row + delete Example 8.4.2), §8.4.2 rule 6 (body mandatory), §8.4 cook-disposition prose (cook_mods follow the body only), §8.5 (multi-output decl-only removed), App. A.4 (cook_step ::= "cook" cook_output body cook_mods NEWLINE — body? → body; “Declaration-only cook step” paragraph replaced by “Cook body is mandatory”; multi-output rule + step-dispatch note updated; CS-0078 carve-out justification narrowed to the ingredients idiom), App. E (this entry). Summary: The body-less cook "out" form (declaration-only cook_step) is removed. It registered no work unit — a cook with no body produced zero DAG nodes — and its one documented purpose (Example 8.4.2: declare the output, then supply the build command via a following shell_command or preceding register-phase Lua) was never wired to execute; it also enabled a latent runtime trap (a declaration-only cook mapped to OneShot iteration mode, in which $<in> resolves to 'In' is not valid in OneShot mode). CS-0133 converts this dead surface into a compile-time diagnostic: a conforming implementation MUST reject a cook_step whose output pattern(s) are not followed by a body opener ({ / >{), naming CS-0133. This covers all three body-less shapes — cook "x", the multi-output cook "a" "b", and cook "x" <cook_mods> (a trailing local/seal … no longer keeps a body-less cook alive, since the modifier annotated a unit that never registered). A tail that is neither a body opener nor a clean cook_mods token still yields the sharper cook: unexpected modifier diagnostic. Because the declaration-only form is gone, the CS-0078 silent-termination carve-out is narrowed: silent termination of pattern collection on a non-quote continuation line is now justified solely by the analogous “ingredients without trailing patterns” idiom; for cook_step the body-mandatory rule then rejects a cook left without a body opener on its final pattern line. Reference implementation. cli/crates/cook-lang/src/cook_line.rs::parse_cook_line replaces the declaration-only branch (which built CookStep { body: None }) with a rejection: the trailing tail is first run through cook_disposition_from_tail so garbage-before-body keeps its unexpected modifier diagnostic, and a clean/empty tail then raises the CS-0133 body-required error. The luagen CookMode::DeclarationOnly codegen arm is left in place as unreachable-from-parser scaffolding (luagen unit tests still construct body: None cook steps directly to give plate/test an iteration source). Conformance: negative fixture cs0133-declaration-only-cook-rejected; positive fixture 019-chore-with-deps migrated to a body-bearing cook; e2e fixture cli/e2e-fixtures/v04-chores migrated to a shell body. Backward compatibility. Breaking for any Cookfile using the declaration-only cook form. Migration: give the cook a body — fold the following shell_command(s) into cook "out" { … }, or the register-phase Lua into cook "out" >{ … }. Since the form registered no work and never executed its documented follow-on command, no working build relied on it; the change makes the previously-silent no-op a loud parse error.

CS-0134 — Purely-declarative recipe body; remove >> sigils and @ [#changes.cs-0134]

Date: 2026-07-09 Version: v0.15 Sections affected: §3.7 / §3.10 (>> / >>{ and the @ prefix retired from the line-prefix and classification layers), §4.3 (body-termination examples reframed to chores/cook bodies), §5.4 / §12 / §14 / §25 (stale inline_lua references reworded to recipe-body module_call), §6.3 (two-region ordering rule collapsed — recipe body purely declarative), §8.1 / §8.3 (declarative step-dispatch cascade; three copies incl. App. A.4 realigned to one seal-inclusive sequence), §8.8 (Lua-step prefixes scoped to body-opener + chore position), §8.9 (shell_command is a chore-body kind; interactive_command deleted), §13 (phase-classification table), §15.1 / §15.2 (body-bundling vacuous for recipes; never governed chores), §16.2 (interactive-drain reframed to chore bodies), §7 (chores lose >> / >>{ / @; every chore shell command is implicitly interactive), §22.5.8 (probe-consumption example migrated), §24.1 (execute-phase cook.sh phrasing), App. A.3 / A.3.1 / A.4 (grammar: recipe_body ::= recipe_item*; module_call reinstated; inline_lua_line / inline_lua_block / interactive_command productions deleted; lua_line / lua_block kept for chore/body position), App. B.2 (example), App. C.3.7 / C.6.8–C.6.12 / C.13.2 (rationale), App. E (this entry). Summary: CS-0134 is the RFC-7 work-package of the v1.0 language cut. It makes the recipe body purely declarative and retires two whole prefix families. (1) The >> / >>{ register-phase Lua sigils are removed from the language entirely. (2) The @ interactive prefix and its interactive_command step kind are removed entirely — recipes have no imperative steps and every chore-body shell_command is implicitly interactive (§7.3, the single-drain model), so the @ marker was a redundant no-op. A conforming implementation MUST accept, inside a recipe body, only ingredients / seal / cook / plate / test steps and a bare module_call line (BARE_IDENTIFIER "." BARE_IDENTIFIER(…)), which is auto-classified as register-phase Lua — reinstating the recipe-body module-call rule that CS-0072 had removed (a bare module call had, since CS-0072, mis-classified as a shell command). A conforming implementation MUST reject, in a recipe body, a loose/anonymous shell_command, an @ line, a standalone > / >{ execute-Lua step, and a >> / >>{ line — each with a diagnostic naming CS-0134 and directing the author to a cook "out" >{ … } / { … } (or test) block body, or to a chore. Because the recipe body carries no imperative region, the two-region ordering rule (former §6.3 / App. A.3) is removed as vacuous, and §15.2 body-bundling — which only ever coalesced a recipe’s imperative region — becomes vacuous (it never governed chore bodies, which run under the §7.3 single drain). > / >{ execute-Lua survive only as the body opener of a cook / test / plate / probe step and as steps inside a chore body. This absorbs the surface half of the recipe-body declarative-purity change: the construction/execution boundary is now positional — register phase is the top-level register block plus auto-classified recipe-body module calls; execute phase is > / >{ block bodies plus chore bodies. The export-lowering half of that change is deferred. Reference implementation. cli/crates/cook-lang/src/ast.rs drops Step::InlineLuaBlock and Step::is_imperative; Step::InlineLua is repurposed as the auto-classified recipe-body module-call node. cli/crates/cook-lang/src/recipe.rs::parse_recipe collects an indented bare is_module_call line via collect_module_call into Step::InlineLua, removes the region-ordering machinery, and rejects @ / loose shell / > / >{ / >> / >>{ with CS-0134 migration diagnostics; parse_chore removes the region machinery, rejects @ and >> / >>{, and keeps loose shell (interactive: true), >, and >{. The lexer is deliberately unchanged — it still tokenises >> / >>{ (as InlineLuaLine / InlineLuaBlockOpen) and @ (as a Content line); rejection is performed at the parser dispatch layer so a >> or @ inside a brace-block/heredoc interior (read from source_lines) stays harmless. cook-luagen drops the InlineLuaBlock arm and emits Step::InlineLua verbatim into the register-phase closure (module-call orchestration); the chore body-unit machinery is retained. Conformance: negative fixtures cs0134-{loose-shell,interactive-at,exec-lua-line,exec-lua-block,register-sigil-line,register-sigil-block}-under-recipe-rejected; positive fixture cs0134-recipe-body-module-call-register; the cc-bin/lib/headers/… positive fixtures re-blessed (a bare cook_cc.X(…) line now parses as InlineLua rather than Shell); the > / >{ / >>-bearing positive fixtures migrated to test >{ … } bodies or bare module calls. Backward compatibility. Breaking for any Cookfile using >> / >>{, the @ interactive prefix, or a loose shell / standalone > / >{ step at recipe-body level. Migration: (a) a recipe-body >>{ cook.add_unit(…) } or >> module.call() → a bare module.call() / cook.add_unit(…) line, or a top-level register block for non-call register Lua; (b) a recipe-body loose shell command → a cook "out" { … } body or a chore; (c) a recipe-body > / >{ execute-Lua step → a cook "out" >{ … } / test >{ … } body or a chore; (d) an @-prefixed command → drop the @ (a chore command is interactive by default; move a recipe-level interactive command into a chore). A literal @ inside a cook { … } shell block (e.g. rsync src user@host:) is unaffected — only the @ step prefix is removed.

CS-0135 — v1.0 language cut: remove the plate step, strip test to a two-mode no-modifier form [#changes.cs-0135]

Date: 2026-07-09 Version: v0.15 Sections affected: §8 (§8.6 plate deleted; the test step reshaped and renumbered §8.7→§8.6 with §8.6.1 test iteration; §8.1/§8.3 dispatch cascade drop the plate rule; §8.7–§8.9 renumbered to close the gap), §3 (lexical keyword lists), §7 (chore step-kind ban), §9.4 (retitled test-only, phl.plate-test anchor kept), §10 (cross-recipe accessor/passthrough rules), §13 (two-phase classification table), §14, §15, §17, §18, §22.4 (cook.add_test drops name/timeout/should_fail), §22.5.7, §23, §24, §25, App. A.4 (drop plate_step, test_modifiers, the as-only-on-test rule), App. E (this entry). Retires the steps.plate and steps.plate-not-sandboxed anchors (registered null in scripts/slug-renames.ts). Summary: The v1.0 language cut removes the plate step kind entirely and strips the test step to test { … } / test >{ … } with no trailing modifiers. plate’s only deltas over a cook step were (a) no declared output and (b) an un-sandboxed body — both of which are exactly what a chore provides (non-cacheable, side-effecting); deploys migrate to chores. test survives the cut because its identity is a DAG-positioned, reported pass/fail node that a chore cannot give, and — when it declares a file source — a per-item cached one. The three test modifiers as / timeout / should_fail are removed; this dissolves the originating as single-quote quoting divergence by deletion. Test iteration is now decided by the body (the one surface a test has): a body that references the iteration item ($<in> / $<in.ACCESSOR> in a shell body, input in a Lua body) fans out one-to-one over the source — the preceding cook step’s outputs, else the recipe’s ingredients — producing one cached pass/fail node per item; a body with no such reference is a single unit (many-to-one, cached on the whole source set, when a source exists; one-shot, always-run-uncached, when no source exists). This is the deliberate parallel to cook, whose iteration accessor lives in the output pattern: the accessor lives wherever the step’s defining payload is — the output for cook, the body for test. Caching is derived from the recipe’s declared file source, never declared on the test; a source-less test (test { cargo test } with no ingredients, no upstream cook) is admitted and always runs uncached rather than erroring — forcing a fake ingredients glob would key on wrong inputs and a stale green is worse than a slow re-run. Because tests produce no outputs, an always-run test invalidates nothing downstream. The removed modifier keywords were never reserved words; after a test body, should_fail / timeout / as now fall through to trailing-content rejection, and a conforming implementation SHOULD surface a did-you-mean diagnostic naming the removed modifier and the v1.0 migration (e.g. should_fail → invert the check in the body). All four removals (as, timeout, should_fail, plate) are additive re-adds later if a genuine need appears; removing them now, before the go-public freeze, is the cheap sequencing direction. Lands on top of CS-0130 ($<all> removal — the two-mode rule assumes it is gone) and CS-0134 (purely declarative recipe body — whose surviving imperative-work homes are now correctly “cook/test block bodies or chores”). Backward compatibility: Breaking. A Cookfile using a plate step no longer parses (migrate to a chore for un-sandboxed side effects, or a cook step if the work produces a declared output); a test step carrying as / timeout / should_fail no longer parses (drop the modifier — invert the check in the body for should_fail; tests auto-name in place of as; there is no per-test time bound until the planned 1.x re-add). All four removals are additive to re-add, so a future Cookfile that avoids these forms is forward-compatible with any re-add. CACHE_VERSION does not bump: the cache-record shape is unchanged, and because the removed modifiers can no longer be expressed on the surface there is no prior test-cache entry to reconcile against the reshaped §17.4 key derivation. Reference implementation. The cook-lang parser removes the Plate AST variant and the plate dispatch, strips TestStep of its as_name/timeout/should_fail fields, and rejects trailing content after a test body with the did-you-mean diagnostic. cook-luagen deletes the plate codegen path and stops emitting name/timeout/should_fail in cook.add_test. cook-contracts / cook-register drop StepKind::Plate and its register-phase string mapping; cook-luaotp drops the plate SandboxPolicy::Off branch (a test body is sandboxed exactly like a cook body). The two-mode iteration (OneToOne / ManyToOne / OneShot) reuses the existing engine modes unchanged. Conformance: plate and test-modifier fixtures deleted, incidental users rewritten, positive naked-single-unit and three did-you-mean negatives added.

CS-0136 — v1.0 language cut: rename the cook.cache.* probe-value API to cook.probes.*; delete invalidate/clear [#changes.cs-0136]

Date: 2026-07-09 Version: v0.15 Sections affected: §24.4 (cook.cache → cook.probes; the #lua.cook-cache* anchors retired to #lua.cook-probes*, registered in scripts/slug-renames.ts), §21 (cross-phase note), §22.5 (codegen-rewrite prose + G2/H3/H4 impl notes), §12 (module init cache-read prose), §28 (cc rehydration/caching prose), App. E (this entry). The register-phase invalidate/clear methods — never specified in normative prose, present only in the reference implementation — are removed with no replacement. Summary: The both-phase Lua accessor namespace cook.cache.{get,set,scope} is renamed to cook.probes.{get,set,scope} with identical semantics. What the store holds is a probe value (the register-time probe-value store that $<probe> sigils and for_each lower to), not a generic build-artifact cache; the cache spelling predated the first-class native probe DSL (CS-0092) and now reads as a category error. The two vestigial register-phase methods cook.cache.invalidate(key) and cook.cache.clear() — zero callers anywhere in examples, fixtures, or cook-modules — are deleted rather than carried into the frozen 1.0 surface. This is a hard cut with no alias: cook.cache is now a stub whose every field access raises 'cook.cache' was renamed to 'cook.probes' in v1.0 (use cook.probes.<method>) on both the register-phase and execute-phase VMs. Consistent with the v1.0 milestone’s hard-cut-over-aliases convention (CS-0130/0133/0134): freezing two spellings of one surface at 1.0 is the anti-goal. Backward compatibility: Breaking. Every cook.cache.* call site must migrate to cook.probes.*; the old spelling raises a runtime error with a did-you-mean rather than silently working. CACHE_VERSION does not bump — the rename changes only the Lua identifier the codegen emits and the VM installs, not the cache-record shape or key derivation; a re-run recomputes the same probe values under the same keys. Downstream: the external cook-modules repo (cook_cc toolchain.lua/checks.lua/finders/*, cpp init.lua) calls cook.cache.get/set and breaks under this hard cut until migrated to cook.probes and republished — tracked as a paired follow-up; not part of this change. Reference implementation. cook-register/src/module_loader.rs: register_cache_api registers the live table under cook.probes, drops the invalidate/clear functions, and installs a __index-erroring cook.cache stub. cook-luaotp/src/pool.rs: install_execute_phase_cook_probes (renamed) attaches get/set/scope to cook.probes and installs the same stub. cook-luagen (resolver.rs, recipe.rs) and cook-register (unit_api.rs) emit cook.probes.get(...) for probe-ref lowering and for_each; unit_api.rs’s work-payload probe-read predicate matches the new literal. Conformance: probe-requires-chain re-blessed; surface fixtures 07/09 and probe-chain migrated; e2e for_each_benchmarks verify.sh assertions updated.

CS-0137 — v1.0 language cut: respell the per-member cross-recipe accessor $<NAME[]> as $<NAME[in]> [#changes.cs-0137]

Date: 2026-07-10 Version: v0.15 Sections affected: §8.9 (bracket-index rule, did-you-mean, uniformity of bare $<NAME>), §9.1 (recipe_member / member_index productions), §9.3 (placeholder-table row), App. A (production mirror), App. C.6.27 (new rationale — why [in], why no inference and no rejection for bare $<NAME>), App. F (corpus note), App. E (this entry). Summary: The CS-0098 per-member cross-recipe accessor $<NAME[]> is respelled $<NAME[in]>; semantics are unchanged (fan-out-body-only validity, same-probe precondition, recipe-level ordering edge, per-member fingerprint fold, cook.dep_output_member lowering). The empty bracket was shorthand for a keyed-join surface dropped from v1.0; [in] names what fills the slot — the current member binding — and reserves the bracketed-index position so 1.x keys land additively. This is a hard cut with no alias: $<NAME[]> is rejected with the did-you-mean “$<NAME[]> was respelled $<NAME[in]> in v1.0”; any bracket content other than the literal in is rejected with a diagnostic noting member-field joins are not part of v1.0; $<NAME[in]> on an unknown recipe is a load-time error (a trailing bracket group never falls through to env-var lookup). Bare $<NAME> keeps its single uniform all-outputs meaning in every position — no same-probe inference, no in-fan-out rejection (App. C.6.27). Consistent with the v1.0 hard-cut convention (CS-0130/0133/0134/0136). Backward compatibility: Breaking for the [] spelling (the did-you-mean names the fix); mechanical migration [] -> [in]. One behaviour narrowing: an unknown name with a trailing bracket group previously fell through to env-var lookup and now errors at load time. CACHE_VERSION does not bump — the respell changes only placeholder surface, not lowering, keys, or record shape. Downstream: ~/dev/cook-video uses the old spelling and must be swept after this lands (a tracked follow-up). Conformance fixtures. Positive: positive/recipe-member-accessor respelled and re-blessed. Negative (new): negative/cs0137-recipe-member-empty-index-respelled (did-you-mean), negative/cs0137-recipe-member-nonliteral-index (reserved index content) — both codegen-phase (codegen_error.txt). Reference implementation. cook-luagen/src/resolver.rs (trailing-bracket-group dispatch: [in] -> Resolved::RecipeMember; RecipeMemberEmptyIndex / RecipeMemberBadIndex / RecipeMemberUnknownRecipe diagnostics), template.rs (lowering unchanged; sentinel arms so bracket errors never fall back to env in output patterns), dep_ref.rs ([in] edge extraction). Surface fixture 09-recipe-member-accessor migrated; register-phase member-map machinery untouched.

CS-0138 — Implementation conformance: test units fold consumed dependency OUTPUT content, computed at ready time [#changes.cs-0138]

Date: 2026-07-11 Version: v0.15 Sections affected: §17.4 (new informative realisation note; the normative rules are unchanged). No other section changes — this is an implementation-conformance record in the mould of CS-0100, not a language amendment. Summary: Realigns the reference implementation with the §17.4 rule 1 mandate that a test unit’s cache key fold the content fingerprints of the recipe/cook-step outputs it consumes. The previous engine computed the test fingerprint upfront, before any dependency executed — so it could not content-hash the not-yet-produced $<dep> output. It instead substituted the dependency’s execution identity (its command_hash/env_contribution plus the dependency’s own source inputs). The two folds diverge exactly at early cutoff: a sibling cook step consuming $<lib> stays cached when lib re-runs but produces byte-identical output (it content-hashes lib’s output), whereas the test lost its cached result on any change that re-ran lib — even a command edit yielding identical bytes. Tests therefore systematically over-invalidated relative to cook-step siblings consuming the same dependency, erasing the per-suite caching value that is the point of test-result caching. The fix moves test-fingerprint computation to ready time — the executor point where every DAG predecessor has completed and its declared outputs are materialised on disk (§18) — and folds the content of the test’s immediate-predecessor outputs (the $<NAME> cross-recipe outputs it references and the preceding cook step’s outputs that form its iteration source, §8.6.1) together with the content of the test’s own ingredients. Hashing only the immediate predecessors — not the transitive closure — is what makes early cutoff chain: a transitive dependency whose output stays byte-identical does not re-key the test, because its effect is already captured in the (unchanged) immediate predecessor’s output. A source-less test still yields no key and always runs (§8.6.1). Over-invalidation was the safe direction (a stale-green false HIT was never possible), so this is a caching-efficiency conformance fix, not a correctness repair. Backward compatibility: No surface, grammar, or normative change; §17.4’s rules already required this behaviour. The test fingerprint formula changes, so test-result cache entries recorded by a prior build are keyed under the old scheme and simply never match again — they become unreachable orphans and the affected tests re-run once, then re-record under the new key. No false hits are possible across the boundary (a changed formula yields disjoint keys). CACHE_VERSION (the cook-unit artifact cache) does not bump — it is untouched; the test-result store is separate and self-heals by cold miss. Reference implementation. cook-engine/src/run.rs: the upfront fingerprint_by_node pre-pass and collect_test_file_inputs (execution-identity fold) are replaced by compute_ready_test_fingerprint, called at ready time from cook-engine/src/executor.rs (process_ready test-lookup site and the post-run cache-write site); the execute_dag / process_ready fingerprint_by_node parameter is removed. E2e regression cook-engine/tests/test_dep_output_early_cutoff_e2e.rs (byte-identical dep output keeps a consuming test cached, with a sibling cook step as the control); the CS-0100 guard test_invalidation_e2e.rs is unchanged and still green (a source edit that does change the dep output still invalidates). Unit tests for the ready-time fold, including two-level-chain early cutoff, in run.rs.

CS-0139 — Module-authoring contract: §22.1 cook.add_unit field-table reconciliation + normative §12.7 authoring section [#changes.cs-0139]

Date: 2026-07-11 Version: v0.15 Sections affected: §22.1 (cook.add_unit field table — the stale boolean local/pinned disposition rows replaced by the single sharing string field; the engine-read fields seal, record, probes, consulted_env_keys, file_refs, env, member, step_kind added; a “Field provenance” note recording the codegen-populated fields and the requires→probes rejection), §12 (new §12.7 module-authoring contract with subsections §12.7.1 phase obligations, §12.7.2 unit registration, §12.7.3 probe registration, §12.7.4 probe-value/export/dep_output reads, §12.7.5 seal policy, §12.7.6 probe-key naming, §12.7.7 publishing), App. E (this entry). No grammar (App. A) change. Summary: Two deliverables, both spec-fidelity/assembly rather than new language. (1) §22.1’s cook.add_unit field table had drifted from the engine’s authoritative surface (the add_unit closure in the reference implementation’s cook-register): it presented the sharing disposition as two booleans local/pinned that the engine never reads, and omitted eight fields the engine does read. The table now documents disposition as the single sharing = "local"|"pinned"|"shared" string (default "shared", the effect of an absent field) and adds seal, record, probes, consulted_env_keys, file_refs, env, member, and step_kind, each cross-referenced to the section that specifies its semantics; a field-provenance note records that the codegen-populated fields are nonetheless directly accepted under the CS-0127 field-typing rule, and that the legacy requires spelling is rejected in favour of probes. (2) §12.7 assembles into one place the module-authoring contract previously smeared across the phase-classified API chapters (§21–§24), the probe and catalogue chapters (§22.5, §27), and CS history: what a module may call in each phase (§12.7.1), how it registers units and probes (§12.7.2–§12.7.3), the sanctioned probe-value / export / dep-output reads (§12.7.4), the seal policy (§12.7.5), probe-key sanitisation (§12.7.6), and blessed-module publishing (§12.7.7). The seal policy is NOT newly minted here — the seal surface, its named-probe-only rule, and its cache-key effect are already normative at §8.4.3 (CS-0107 lineage) and §17.1; §12.7.5 adds only the authoring constraint layered on top: a module seals only deterministic determinants (a lockfile content hash, a resolved toolchain identity), never a value a toolchain produces nondeterministically, and it cross-references §8.4.3 and §17 rather than restating them. Motivated by the 2026-07-09 module-hack audit: the contract graduates from tribal knowledge to normative text so the next blessed module (cook_dotnet) is written against the Standard, not by archaeology of cook_cc/cook_pnpm. Backward compatibility: No surface, grammar, or behaviour change. §22.1 is a documentation-fidelity fix — the engine already read exactly these fields; no conforming Cookfile or module changes behaviour, and no diagnostic changes. §12.7 introduces no new API and imposes MUSTs that restate or narrow existing per-API obligations for module authors; a module already conforming to §8.4.3, §17, and §22.5 already satisfies them. CACHE_VERSION does not bump — no key derivation, record shape, or determinant set changes. Reference implementation. No engine delta: the change is spec-only. The reconciled §22.1 table was cross-checked field-for-field against the add_unit closure in cook-register/src/unit_api.rs (the tbl.get(...) reads: command, lua_code, interactive, line, cache, inputs, output, outputs, ingredient_groups, discovered_inputs, step_kind, file_refs, consulted_env_keys, member, seal, sharing, record, probes, env, and the requires rejection guard) and against cook-luagen/src/cook_step.rs, which emits sharing/seal/record/probes/consulted_env_keys/file_refs in agreement with the engine. No conformance-corpus change: the field-typing rule (CS-0127) and disposition semantics (CS-0107/0108/0109) are already fixtured; this entry adds documentation, not testable behaviour. Downstream: the blessed modules cook_cc (0.11.0-1) and cook_pnpm (0.2.0-1) adopt the §12.7 contract under a separate follow-up, not here.

CS-0140 — Ingredient glob normalization and empty-expansion warnings

Date: 2026-07-12 Version: v0.15 Sections affected: §21.2.1 (new — ingredient pattern resolution, normalization, file filtering, and empty-expansion diagnostics); App. E (this entry). App. A is unchanged. Summary: Ingredient includes now have one explicit resolution contract. A final ** segment and a trailing slash normalize to recursive-file matching; directories are filtered from results. Ordinary patterns remain relative to the declaring Cookfile member, while a leading // anchor followed by a path resolves at the workspace root. Empty and escaping root anchors (//, //.., //../x) and triple-slash spellings are rejected. Each include whose normalized, directory-filtered expansion is empty produces exactly one recipe-attributed warning. Excludes never warn, and a non-empty include set reduced to empty only by exclusions does not warn. Backward compatibility: The grammar is unchanged. Valid patterns that previously resolved keep their member-relative meaning except for the newly specified recursive normalization and // workspace-root anchor. Invalid root-anchor spellings now fail loudly. Builds with a zero-match include now gain a warning but remain otherwise compatible; exclusion-caused empty results remain quiet. Reference implementation and tests: The resolver normalizes terminal ** and trailing-slash patterns before expansion, validates and resolves the // anchor, retains files only, and reports exactly one warning for each empty include with recipe attribution. Unit and end-to-end tests cover recursive normalization, member-relative and workspace-root resolution, rejected root-anchor spellings, directory filtering, one-warning-per-empty-include behaviour, exclude silence, and exclusion-caused empty results. The shared numeric CS identifier is assigned at integration time.

CS-0141 — cook.recipe_name: register-phase API returning the enclosing recipe’s fully-qualified name [#changes.cs-0141]

Date: 2026-07-16 Version: v0.15 Sections affected: §22.7 (new — cook.recipe_name signature, phase, qualified-name semantics, its disambiguation from §21.2’s recipe.name, and the outside-a-recipe-body error rule); App. E (this entry). §21.2 is cross-referenced but not modified. App. A is unchanged. Summary: Adds cook.recipe_name() -> string, a register-phase-only API that returns the fully-qualified name of the recipe whose body is currently executing, mirroring §22.4’s already-normative qualified suite default for cook.add_test (e.g. "lib.build" for a recipe build imported under prefix lib, not the bare declared name). A conforming implementation MUST raise a register-phase Lua error naming the API and stating the inside-a-recipe-body requirement when cook.recipe_name is called with no enclosing recipe body — a top-level call, a register block body, or a for_each-feeding probe’s produce body evaluated on the register-phase VM during the pre-pass (§ 22.5.10). This last case is scoped narrowly: an ordinary probe’s produce runs on an execute-phase worker VM, where cook.recipe_name is not registered at all, so the MUST does not extend to it. §22.7 also disambiguates cook.recipe_name from §21.2’s recipe global: recipe.name is bound in the same scope but holds the declared (bare) name, so the two diverge exactly under an import prefix and coincide in a root Cookfile. An informative note records the tension this creates for a module deriving artifact identity under a prefix — legal but visually ugly output (liblib.idLib.a), and disagreement with a module that resolves link references against bare names — and directs such a module to read recipe.name for filenames and reserve cook.recipe_name() for identity, rather than stripping the prefix back off the qualified form. Backward compatibility: Purely additive: a new API name on the cook global table, register-phase only. No existing grammar, surface syntax, or Cookfile that does not call cook.recipe_name changes behaviour. CACHE_VERSION does not bump — no key derivation, record shape, or determinant set changes. Reference implementation. cook-register/src/context.rs (register_recipe_name_api), wired into install_remaining_apis in cook-register/src/engine.rs. The function reads BodyCaptureState.current_recipe off the shared per-body slot — the same qualified value already computed for cook.add_test’s suite default (test_api.rs) — but, unlike that default’s .unwrap_or_default() degrade-to-empty-string behaviour, hard-errors on a None/unstamped slot rather than silently returning an empty name. Five tests in cook-register/src/tests.rs cover: own-name return, no cross-recipe leakage, qualified-name-under-prefix return, the outside-a-recipe-body top-level error, and the for_each-feeding probe produce pre-pass error. Conformance fixtures: standard/conformance/positive/recipe-name-register-phase/ (a bare cook.recipe_name() recipe-body line parses as CS-0134’s register-phase InlineLua step) and standard/conformance/negative/recipe-name-outside-recipe-body-rejected/ (a register block calling cook.recipe_name() at top level is rejected at register time with the fix-hint diagnostic).

CS-0142 — Implementation conformance: recipe-body multi-line module calls; raw-path output↔input edges are fold-only by mandate [#changes.cs-0142]

Date: 2026-07-16 Version: v0.15 Sections affected: §4.2 and Note 4.4.1 (editorial repair — two statements that CS-0134 falsified); App. E (this entry). No normative or semantic change: every rule this entry records is already in force at §8.1 (steps.dispatch rule 6), §10.6, §16.1, §17.1, and App. C.17.1. An implementation-conformance record in the mould of CS-0100 / CS-0138, with an editorial correction attached.

Summary: Records the conformance status of two constructs the step-contributor recipe model (milestone explicit-recipes) depends on, both previously unproven in the reference implementation.

(a) Multi-line recipe-body module calls conform. A bare module_call line in a recipe body (§ 8.1 rule 6, reinstated by CS-0134) is collected by brace balancing across source lines, per App. A.4’s “Braces may span subsequent lines” — the same mechanism §4.4 already specifies for the top-level form. The whole call parses to a single register-phase step, and brace-balanced collection runs before per-line keyword dispatch, so a continuation line whose key collides with a step keyword (test = { … }, ingredients = { … }) is not mis-dispatched as a step opener. Known boundary: continuation is brace-only. A call left unbalanced on an open parenthesis (mymod.lib("idlib", / newline / { … })) is not continued — the first line is collected as a complete module_call and the orphaned second line falls through the cascade to the CS-0134 loose-shell rejection. That rejection is conforming (App. A.4 defines no paren continuation), but its diagnostic explains the symptom rather than the cause; a future CS may give this shape a dedicated diagnostic without changing the outcome.

(b) A raw-path cook.add_unit input matching another recipe’s output folds into the cache key but establishes NO ordering edge — and MUST NOT. The two halves are separate rules and only one was in doubt:

  • Content fold (conforms). A literal path in inputs folds that file’s content into the consuming unit’s key (§17.1; App. C.17.1: “a downstream step that declares an upstream’s output as one of its inputs[] re-fingerprints when that file’s bytes change”).
  • Ordering (absent by mandate). §16.1 states: “No other source of cross-recipe ordering is defined. Specifically, string equality between an ingredient path literal of A and a cook output path literal of B does NOT establish an edge.” §10.6 states: “Only name references create edges. … An implementation MUST NOT infer an edge from path-string equality.” The reference implementation forms cross-recipe edges only from dep-list references and name-reference ($<NAME> / cook.dep_output) edges, and is therefore conformant. Ordering comes from a name reference or an explicit : dep, never from a matching path string. App. C.17.1 gives the rationale (the Turborepo alignment: tasks depend on tasks, not files).

(c) Recorded gap — the literal case is undiagnosed. §22.1.2 requires a register-time rejection when a recipe’s glob or directory output pattern lexically matches another recipe’s literal inputs[] entry — the footgun it closes is a downstream silently consuming stale bytes. The corresponding literal-to-literal case (recipe A declares literal output build/gen.a; recipe B declares literal input build/gen.a, with no name reference or dep-list entry between them) is neither wired nor diagnosed: §10.6 correctly declines to infer the edge, but nothing warns the author that the ordering they plainly intended does not exist. Under --jobs > 1 this is a silent stale read or a missing-file failure depending on scheduling. Extending §22.1.2’s lexical check to the literal case would be a diagnostic, not an inferred edge, and would stay §10.6-conformant; it is deferred to a follow-up along with the dependency-model decision it belongs to. Two wording residuals are deferred to the same follow-up: §16.1 and §10.6 are both phrased over surface ingredients literals and “cook output path literal” rather than cook.add_unit’s inputs/outputs (the doctrine plainly covers both, but the text does not say so), and App. C.17.1’s rationale cross-reference B.5.N does not resolve.

Editorial repair. §4.2 ¶3 asserted that the module-call rule “is removed” in recipe bodies, and Note 4.4.1 that the cascade “no longer includes the module_call rule as of CS-0072”. CS-0134 reinstated that rule; §8.1 rule 6 and App. A.4 were updated at the time and §4 was missed. Both statements are corrected to record the reinstatement. This is a documentation-fidelity fix — no implementation or conforming Cookfile changes behaviour — and it is bundled here because §4 is the chapter a reader consults to learn whether the construct (a) certifies is legal.

Backward compatibility: None affected. No grammar, surface, diagnostic, or key-derivation change; CACHE_VERSION does not bump. No conforming Cookfile changes behaviour. Every fixture and test added here asserts behaviour the reference implementation already had.

Reference implementation and tests. No engine delta — the deliverable is the conformance fence for the claims above. New positive fixture cs0142-recipe-body-multiline-maker-call (the multi-line maker call with test = and ingredients = colliding continuation keys, asserting a single parsed step); new negative fixture cs0142-recipe-body-paren-continuation-rejected (the paren-continuation boundary, asserting the line-attributed fall-through). Both are walked by the cook-lang, cook-luagen, and tree-sitter-cook corpus harnesses. cli/crates/cook-engine/tests/raw_path_cross_recipe_edge.rs pins (b) in three parts: a raw-path-only consumer does not order its producer (a tripwire — if this test ever goes red, ordering has become real, which would be a normative reversal of §16.1/§10.6 requiring its own CS, not a test to repair), the $<NAME> control does order it, and a raw-path input does fold into the consumer’s key. The dependency-model decision that (c) and the milestone turn on is deferred to a follow-up.

CS-0143 — origin annotation for module-minted recipes; an annotation does not gate dispatch [#changes.cs-0143]

Date: 2026-07-16 Version: v0.15 Sections affected: §22.3 (cook.recipe’s metadata table gains an optional origin string key — non-empty-string constraint, no effect on registration/uniqueness/dispatch/scheduling/cache, module-minted recipes only, wrong-type rejection applying the CS-0127 field-typing discipline), §22.6 (the listing-surface paragraph gains the origin-surfacing MUST; the dispatch ruling stated normatively), §12.7 (new §12.7.8 minting-recipes authoring contract — the two carve-outs, the origin and documentation MUSTs), App. C.21.10 (new rationale — annotate-not-refuse, explicit-not-inferred), App. F §F.2 (the tree-sitter skip enumeration gains this entry’s register-phase negative), App. E (this entry). No grammar (App. A) change; the origin key is reachable only from Lua, and no surface syntax gains or loses a production. Summary: cook.recipe(name, metadata, body) accepts an optional origin string on its metadata table (conventionally the module-qualified name of the minting function, e.g. "cook_pnpm.workspace"). Its sole normative effect is that a listing surface, where one exists, MUST surface a discovered recipe’s origin alongside its name; §22.6’s MAY expose a listing surface framing is unchanged, and cook list is not promoted from MAY to MUST here. The motivating rule is the module-authoring contract at the new §12.7.8: recipe identity and invocability belong to the Cookfile author, a module is ordinarily a step contributor, and the two shapes that still require a module to mint recipes — data-driven fan-out (N recipes derived from register-time parsed data, e.g. one build recipe per workspace member) and support recipes (a top-level configuration call whose work must live somewhere, e.g. a generated configuration header) — MUST pass an origin and MUST document that they mint recipes. The ruling this entry records: an origin annotation MUST NOT affect dispatch; cook NAME naming an origin-annotated recipe MUST execute identically to any other registered recipe, and MUST NOT be refused. The no-refusal half is stated in §22.6 as a clarification that reaffirms paragraph 2’s existing MUST (register-phase-Lua–registered names execute identically to surface declarations), not as an amendment to it — a reader arriving from the design phrase “non-invocable by intent” would otherwise expect a refusal. §22.6 then extends that requirement, newly, to ordering and diagnostics: an annotation records who minted a name and is not a channel for behaviour. That extension is a new (and narrow) requirement, not a restatement — paragraph 2 speaks only to execution. The reasoning, recorded at App. C.21.10: (1) refusing would amend an existing MUST rather than clarify one; (2) it would invent an invocability tier the language does not have — nothing else in Cook distinguishes “registered” from “registered but not runnable”; (3) it would make the listing lie, advertising a name that errors when typed; (4) a support recipe is a real DAG node whose direct invocation is harmless and useful when debugging; (5) “by intent” is the module author’s intent, conveyed to the user through the annotation — the annotation is what keeps the carve-outs honest (“this name is not yours; here is who made it and why”), and needs no engine-enforced prohibition behind it. origin is explicit rather than inferred (from a Lua call-stack walk or a registration-site tag) because both inference mechanisms misattribute exactly where attribution matters most — a module dispatching through a shared internal helper would be attributed to the helper, not to the surface the user called — and a wrong attribution the reader cannot detect is worse than none. This does not reverse CS-0077’s stance that the surface-vs-Lua registration distinction is implementation-defined: origin is not a property of where a registration came from, but module-author-supplied intent; an implementation may still tag registration sites however it likes and still may not surface such a tag as attribution. Backward compatibility: Nil. origin is a new optional key on a metadata table that already ignored unknown keys; absence is not an error and denotes an unannotated recipe. No existing Cookfile or module changes behaviour, no existing name’s dispatch changes, and an unannotated recipe lists exactly as before. The only new rejection is a cook.recipe call that passes a non-string or empty-string origin — a call no existing module makes, since the key did not exist. CACHE_VERSION does not bump: origin is presentation metadata and MUST NOT enter any cache key, so no key derivation, record shape, or determinant set changes and no cached artifact is invalidated. §12.7.8’s MUSTs bind module authors adopting the carve-outs; a module that mints no recipes is unaffected. Reference implementation. cook-register/src/capture.rs: parse_origin_meta parses the key into RegisteredMetadata::origin: Option<String> and is called only from the public cook.recipe closure — never from parse_meta_lists, which is shared with the codegen-private cook.__register_surface / cook.__register_surface_chore, so a surface recipe/chore declaration structurally cannot carry an origin. A wrong-typed or empty-string value is a hard register error naming the field and the accepted form, and citing this entry (cook.recipe: `origin` must be a string, got integer (Standard §22.3, CS-0143)) rather than CS-0127 — CS-0127 covers §22.1/§22.4/§22.5.7 and would send a reader to an entry that does not describe their error; it is the discipline’s precedent, not its locus for this field. parse_origin_meta matches on the Lua value rather than converting it, so a numeric origin is rejected outright instead of being coerced to its decimal string by the host binding’s implicit number-to-string conversion. cook-register/src/lib.rs carries origin on RegisteredRecipePub; cook-engine/src/pipeline/registers.rs’s list_workspace_names returns Vec<RegisteredRecipePub> (replacing a lossy (name, kind, params) tuple) so origin survives to the CLI; cook-cli/src/pipeline.rs’s cmd_menu renders the annotation, computing its column across annotated entries only so unannotated recipes render byte-identically to before. Conformance: positive/recipe-origin-annotation (both carve-outs in one fixture, register_ok.txt), negative/recipe-origin-not-a-string (register_error.txt, semantic-only — recorded in tree-sitter-cook/scripts/conformance.mjs’s SEMANTIC_ONLY_NEGATIVES and enumerated in App. F §F.2). E2e fixture cli/e2e-fixtures/surface/19-list-origin-annotation asserts the rendered listing and that dispatching the support recipe by name exits 0 and does its work — the executable form of this entry’s ruling.

CS-0144 — cook.require_recipe: a module-declarable cross-recipe dependency, named and bare [#changes.cs-0144]

Date: 2026-07-16 Version: v0.15 Sections affected: §22.8 (new — cook.require_recipe signature, phase, the register-order guarantee, the dep-list-equivalent edge, the §10.6 conformance argument, the bare-name resolution rule, the error contract — including the no-silent-skip rule that binds the forcing against an implementation’s own body-skip decisions — and Note 22.8.1’s motivating case); §16.1 (amended — its enumeration of where a name reference may appear gains “or a register-phase cook.require_recipe call (§22.8)”; its closing bullet additionally clarified, see “The §16.1.2 diagnostic” below); §16.1.2 (new — the undeclared read-after-write diagnostic, with Example 16.1.2, Note 16.1.2.1, and Note 16.1.2.2; see below); §10.6 (amended — wording clarification only, see below); App. C.16 (new — the rationale, in three subsections: C.16.1 the name-reference-as-evidence argument and the rejected path-matching alternative, C.16.2 the closure-vs-workspace scoping divergence, C.16.3 the directed-vs-undirected predicate divergence; cross-referenced from §22.8, §16.1, §16.1.2, and §10.6 — see “The B.5.N cross-references” below); App. F §F.2 (amended — the tree-sitter skip enumeration gains this entry’s semantic-only negative, and its positive-fixture list this entry’s positive); App. E (this entry). §7.5.1 is cross-referenced but not modified: the forced parametric chore reuses its existing empty-argv rule rather than adding one. §10.6’s path-equality prohibition is NOT modified — see the Summary; that it needs no amendment is the substance of this entry, not an omission from it. The §10.6 amendment recorded below touches only the enumeration of the literal surfaces that prohibition ranges over, never the prohibition itself. §21.2, §22.3, and §22.7 are cross-referenced but not modified. No grammar (App. A) change: cook.require_recipe is reachable only from Lua, and no surface syntax gains or loses a production.

Summary: Adds cook.require_recipe(name) -> nil, a register-phase-only API callable from inside a recipe body, which declares that the enclosing recipe depends on the recipe named name. It resolves the dependency-model decision CS-0142(c) escalated: a module handed a link reference in its own vocabulary (links = {"idLib"}) had no way to turn that reference into a real dependency, because the only mechanisms that create cross-recipe edges — the surface dep-list and $<NAME> placeholders — are written by the Cookfile author, not reachable from a module executing inside a recipe body it does not own.

The API has two effects. (1) The register-order guarantee: when cook.require_recipe(N) returns, N’s body has been fully evaluated in the current registration pass; a conforming implementation MUST force that evaluation to completion before returning if it has not already happened, and MUST evaluate a recipe body at most once per pass, so a repeat call does not re-evaluate. This is what makes a module’s cook.import("idLib") resolve rather than silently return nil — registration order within a pass is dependency-driven, not declaration order. (2) The edge: the call establishes a cross-recipe dependency from the caller to N identical in kind and granularity to a dep-list entry (recipe A : N) — whole-recipe granularity, MUST NOT be refined per §16.1’s dep-list-sourced rule, and N MUST join the build closure of any invocation reaching the caller.

The conformance argument, which is the whole design. cook.require_recipe(N) takes a recipe name and creates an edge from that name; it is therefore an instance of §10.6’s “Name reference ⇒ cross-recipe edge” rule, not an exception to it. §10.6’s prohibition — “An implementation MUST NOT infer an edge from path-string equality” — constrains the evidence from which an edge may be inferred, not the mechanism by which one is declared: a path string that happens to equal another recipe’s output is not evidence of a dependency, because the author never named the referent. cook.require_recipe supplies exactly the evidence §10.6 demands — the referent is named, by the author or by a module acting on a name the author wrote. The edge is authored, not inferred. §10.6 is therefore not amended, and needs no amendment: its prohibition is on path-string equality as evidence, and cook.require_recipe takes a name, never a path, so it cannot launder a path match into an edge.

§16.1, by contrast, is amended — its enumeration is, and only that. §16.1’s first sentence already scopes ordering to name references (“Cross-recipe ordering is established only by name references”), so its closer — “No other source of cross-recipe ordering is defined” — stands verbatim, and the category it enumerates is unchanged. But the enumeration itself lives inside a MUST-bearing sentence (“For each name reference from recipe A to recipe B — in a dep-list, bare placeholder, or accessor placeholder — a conforming implementation MUST treat B as a prerequisite”) and is closed by that “no other source” line, so a taxonomy with no slot for a cook.require_recipe call would tell a conformance-test author reading §16.1 alone that honouring this entry’s edge is non-conforming. The list accordingly gains a fourth spelling: “or a register-phase cook.require_recipe call (§22.8)”. This strengthens the argument above rather than weakening it — the widening is what demonstrates that only the enumeration moved while the category held. An earlier draft of this entry claimed §16.1 was not amended while also claiming §22.8 “widens the enumeration that follows it”; both cannot be true, and the bookkeeping, not the category argument, was what was wrong. The implementation reinforces the point rather than establishing it: the edge is carried by the very same requires metadata field (§22.3) that the surface dep-list lowers to and that CS-0121’s $<NAME> body references already merge into. It is not a second dependency channel.

CS-0142’s tripwire stays GREEN. cli/crates/cook-engine/tests/raw_path_cross_recipe_edge.rs’s raw_path_input_does_not_order_producer_before_consumer — flagged by CS-0142 as a tripwire whose reddening would signal a normative reversal of §16.1/§10.6 — is unaffected and MUST remain passing. Ordering under this entry comes from a name reference and never from path-string matching: a consumer that declares a raw path equal to a producer’s output, and names nothing, still gets no edge. Nothing in this entry makes path-string equality evidence of anything. This holds for §16.1.2 below as well, and for the same reason: §16.1.2 rejects that arrangement, it does not order it. The tripwire’s fixture also builds consumer alone, so §16.1.2’s closure scoping leaves it untouched — and the tripwire’s !contains("producer") assertion is precisely what a workspace-wide check would have broken.

The §16.1.2 diagnostic — closing CS-0142(c)‘s deferred gap. CS-0142(c) recorded that a literal output of A matching a literal input of B, with no name reference between them, correctly forms no edge (§10.6) but also emitted no diagnostic — leaving the author’s plainly-intended ordering silently absent and, under --jobs > 1, a silent stale read. §16.1.2 (new) closes that gap from the diagnostic side: the condition MUST be rejected at plan time with a diagnostic naming both recipes and the shared path. The implementation rejects and MUST NOT synthesise an edge — that is what keeps the rule §10.6-conformant. Inferring the ordering would have been the one move §10.6 explicitly forbids; the gap was never that the edge was missing, but that its absence was quiet.

Two scope decisions carry the rule, and both are deliberate divergences from a sibling rule that a reader will otherwise mistake for inconsistency:

  • Directed, where §16.1.1 is undirected. §16.1.1’s write-write predicate accepts an ordering path in either direction, because either ordering serialises two writes to a defined outcome. Read-after-write is asymmetric: only B requires A places the write before the read. A reverse path (A requires B) is not exculpatory — it pins the read before the write on every run, a deterministic stale read rather than an ambiguous race, and strictly worse than no ordering at all. An undirected predicate would wave it through as “dep-related”. §16.1.2 therefore passes only when B transitively requires A.
  • Closure-scoped, where §22.1.2 is workspace-wide. A literal output is not build-owned, so cook producer && cook consumer is a legitimate sequential workflow that MUST NOT be rejected; the condition is a race only when both recipes are scheduled together. §22.1.2’s terminality is by contrast an ownership claim over a pattern or directory, which holds regardless of what is being built. Terminal (glob/directory) outputs remain §22.1.2’s business and MUST NOT be double-reported.

§16.1’s and §10.6’s wording gap — clarified, no behaviour change. CS-0142 also deferred a wording defect here. Both §10.6’s “Only name references create edges” bullet and §16.1’s closing bullet were phrased over surface ingredients literals and “cook output path literal” alone, never naming cook.add_unit’s inputs[] / outputs[] (§22.1). The doctrine plainly covered both surfaces — a literal is a literal, and none of them are name references — but the text did not say so, which mattered once §16.1.2 began ruling on exactly those add_unit entries. Both bullets now enumerate all three literal surfaces explicitly: ingredients line literals, cook step output literals, and cook.add_unit’s inputs[] / outputs[]. This is a clarification of existing wording only: no conforming implementation’s behaviour changes, no edge is created or removed, and the prohibition itself is untouched.

The B.5.N cross-references — repaired, and CS-0142’s attribution of them corrected. CS-0142 deferred to this entry a dangling rationale cross-reference, recording it as “App. C.17.1’s rationale cross-reference B.5.N does not resolve.” That attribution is wrong, and this entry corrects it rather than repeating it: App. C.17.1 (glob output terminality) contains no B.5.N citation and never did. The two live danglers were in the normative text — §10.6’s “Only name references create edges” bullet and §16.1’s closing bullet, each of which ended “See rationale B.5.N.” The label is a pre-v0.10 artefact: CS-0009 added rationale sections B.5.N/B.5.O/B.5.P, and the v0.10 reorg renumbered App. B into App. C without rewriting the citations that pointed into it, so B.5.N has resolved to nothing since. Both bullets now cite App. C.16.1, which is this entry’s own restatement of the doctrine B.5.N recorded, and both slugs are verified to resolve against the harvested clause map rather than being traded for a fresh dangler. Two further B.5.N citations lived in code comments — cli/crates/cook-engine/src/analyzer.rs’s build_adjacency doc comment and its test_dependency_edges_no_implicit_via_serves test — and both were repaired in the same pass. Both also carried a second, independent staleness: each cited § 5.6 as the doctrine’s normative home, which has not existed since the CS-0073 renumbering moved it to §10.6 (§5 now tops out at §5.4). Both halves of both comments were repointed, since repairing one and not the other leaves the citation half-wrong — the precise failure the doc-side reference guard exists to prevent. Cross-references elsewhere in docs/architecture/ carry the same two stale labels and are not repaired here: they are non-normative internal architecture notes outside this entry’s surface, and are recorded so the omission is not read as an oversight.

§16.1.2’s detection scope — narrowed to the surfaces the rule can actually see. As first drafted, §16.1.2 enumerated all three literal surfaces (the same three the prohibition above ranges over) as in scope for detection. That was an overclaim, and the enumeration is now closed over two: cook.add_unit’s inputs[] / outputs[], and cook step output literals. An ingredients literal is excluded (Note 16.1.2.2). The reason is that an ingredients pattern is a filesystem glob resolved against disk at register time (§21.2.1), and a literal is merely a glob with no metacharacters: when the artifact is absent, ingredients "build/gen.a" matches zero files, contributes no input entry, and there is nothing to compare. Detection on that surface would therefore have been inverted — silent on the cold build where the race actually lives, and loud only once a stale artifact from an earlier run already sat on disk. It would also have contradicted Note 16.1.1’s own principle that the check reads canonical paths derived from the source, not from filesystem state — the property that makes both sibling rules decidable at plan time. Narrowing the enumeration costs no real coverage: every surface that can be checked without asking the filesystem what exists is still checked. This narrowing does not touch the prohibition recorded in the paragraph above, which still ranges over all three surfaces including ingredients: not inferring an edge is a rule about what MUST NOT happen and holds whatever the disk contains, whereas detection is a positive obligation that can only be discharged where the path is knowable from the source. The two are distinct, and §16.1’s and §10.6’s cross-references to §16.1.2 now say so rather than implying that the prohibition’s breadth is also the diagnostic’s.

Example 16.1.2 — rewritten to a shape the rule actually rejects. As first drafted the example’s consumer was cook "out.bin" { cp build/gen.a $<out> }, in which build/gen.a appears only as shell-body text. A command body is not a declared input surface, so the example never instantiated the rule’s own precondition (“B declares that same canonical path as a literal input entry”) and was not rejected at plan time: it executed and died at runtime on cp: cannot stat — the exact pre-existing failure §16.1.2 exists to replace. The example now declares the path through cook.add_unit’s inputs[], pairing it with a cook-step output literal on the producer so the worked case exercises one surface from each side of the table. It is verified against the reference implementation: the closure is rejected at plan time with the artifact absent (the cold build), no out.bin is produced, the reverse-dep variant is still rejected, cook consumer alone still builds (Note 16.1.2.1), and naming the producer repairs it. A normative section’s example must be executable truth rather than illustration; this one is now checked as such.

Precedence over §16.1.1 — ruled, and stated normatively. §16.1.2 now states that where a closure violates both rules, the §16.1.1 collision is what MUST be reported. The ruling is on the merits: with two unordered writers of one path, the producer §16.1.2 would name is arbitrary, and its advice (“require writer_a”) would silence the read-after-write while leaving the write-write race intact — actively misleading. §16.1.1 is also the prior question; until the path has one owner, “is the write ordered before the read?” is not well-posed. The ruling is recorded in the Standard only, and the reference implementation’s call order is deliberately left alone: check_literal_read_after_write still runs in run_inner ahead of build_dag, where detect_output_collisions lives. The order is unobservable there. §16.1.1’s predicate is undirected over a graph that includes the requested target, so every recipe in a single-target closure is connected to the target and hence to every other member — detect_output_collisions cannot return Some for any closure run_inner constructs, and §16.1.2 masks nothing. Reordering would have bought an inert extra scan. Should §16.1.1’s predicate ever be narrowed so that it can fire on a real closure, the precedence must be pinned at the call site at that point; check_literal_read_after_write’s doc comment carries that warning. (That §16.1.1 is presently unreachable through run_inner — and that its own Example 16.1.1 is consequently not rejected — is a pre-existing §16.1.1 defect, out of scope here, recorded so it is not mistaken for a regression of this entry. It is tracked as a follow-up.)

A second pre-existing defect, recorded for the same reason. This work also surfaced a zero-unit meta-target defect in build_dag, which is likewise pre-existing, out of scope here, and recorded so it is not mistaken for a regression of this entry (a tracked follow-up). A recipe that declares a dep but contributes no work units of its own severs transitive ordering: given middle : producer where middle has no units, consumer : middle yields no edge to producer, because build_dag records an empty leaf set for middle and a dependent’s edges are drawn to its deps’ leaves. The ordering the author wrote is silently dropped rather than diagnosed — the same failure class this entry exists to kill, in a channel this entry does not touch. Neither cook.require_recipe nor the edge routing through requires causes or worsens it: the API’s edge is indistinguishable from a dep-list entry by construction, so it inherits exactly this behaviour and nothing more, and a zero-unit middle would have severed a recipe consumer : middle dep-list edge identically before this entry.

This entry’s DanglingDepEdge pre-walk deliberately leaves zero-unit behaviour unchanged. It distinguishes absent from the slice — the genuine dangling name, which it raises on — from present-with-zero-units, a legitimate Some(empty) leaf set which it passes; the distinction is pinned by dep_edges_entry_naming_in_closure_zero_unit_recipe_does_not_diagnose. Folding the zero-unit case into that check was considered and rejected: a dangling name and a present-but-empty target are different defects with different fixes (the first is a typo or a missing registration; the second is a legitimate meta-target whose edges need forwarding, not a rejection), and conflating them would have made DanglingDepEdge reject meta-target Cookfiles that build correctly today wherever the dep contributes units of its own. A follow-up is left to rule on the forwarding.

Reference implementation (§16.1.2). cook-engine/src/dag_builder.rs’s check_literal_read_after_write, a sibling of §22.1.2’s check_globbed_output_cross_recipe_edges. It reuses §16.1.1’s detect_output_collisions shape — a canonical working_dir.join(entry) path index over cache_meta, BTreeMap/BTreeSet-keyed for deterministic first-error selection — but deliberately not its undirected graph construction: a separate requires_transitively BFS walks a directed recipe -> requires graph built from both name-reference channels (RecipeUnits.deps, the header colon-list into which the analyzer folds cook.require_recipe, and RecipeUnits.dep_edges, the $<sigil> / cook.dep_output channel). connected() is left alone and unshared; the two predicates are not unifiable, and the divergence is load-bearing. Producer-side entries are gated on !cook_fingerprint::is_terminal_output so §22.1.2’s cases never double-report. The call site is in run.rs’s run_inner, over the reachable closure (all_units) and pointedly not the workspace-wide all_workspace_units that §22.1.2’s check receives a few lines above it — that one distinction is the entire closure-scoping rule, and getting it wrong reddens CS-0142’s tripwire. EngineError::LiteralReadAfterWrite carries producer, consumer, and path, and its message offers all three fixes (: A header dep, $<A>, cook.require_recipe("A")). Tests: cli/crates/cook-engine/tests/literal_read_after_write.rs — thirteen integration tests covering the core case, the reverse-direction path (the case an undirected predicate would wrongly pass), the three must-not-fire ordering mechanisms plus a transitive path, the same-recipe case, and the out-of-closure case that mirrors CS-0142’s tripwire. Four of them pin this entry’s corrections: standard_example_16_1_2_is_diagnosed_verbatim holds Example 16.1.2’s Cookfile byte-for-byte and asserts the plan-time rejection on a cold build (the assertion the first draft’s example failed), with _reverse_dep_still_diagnosed and _repaired_by_naming_producer pinning the paragraphs that follow it; ingredients_sourced_literal_is_not_covered_on_cold_build asserts the narrowed scope’s silence on purpose, so that re-widening the rule to ingredients cannot land without Note 16.1.2.2 and this entry changing with it.

name is bare. cook.require_recipe resolves name against the bare declared names of the current registration pass — the namespace the dep-list and the requires field already use — and MUST NOT resolve it against qualified names. Three reasons: (1) the API contributes to requires, so it must resolve the way requires resolves, or one string in one list would mean two things depending on which mechanism placed it there; (2) authors write link references bare ($<idLib>, links = {"idLib"}) and §10.6 name references are bare; (3) an import prefix is assigned by the importer, so a Cookfile’s own body cannot know its prefix, or whether it will be imported at all — a qualified rule would break links = {"idLib"} the moment that Cookfile were imported under a prefix, whereas bare is prefix-invariant. This settles the tension Note 22.7.1 flagged: cook.recipe_name() returns the QUALIFIED name and is for identity, recipe.name (§21.2) holds the BARE declared name and is the name the author wrote, and cook.require_recipe takes a name the author wrote — so it is bare. A module resolving bare link references now has a dependency API that agrees with them, and need neither strip a prefix off a qualified form nor guess one.

Error contract. Each case is a register-phase Lua error naming the API and stating the fix: called outside a recipe body (scoped as §22.7 scopes cook.recipe_name — top-level, a register block body, and a for_each-feeding probe’s produce on the register VM during the pre-pass; an ordinary probe’s produce runs on an execute-phase worker VM where the API is not registered at all, so the MUST does not extend there); name not a string or the empty string (rejected naming the field and accepted form, never coerced — the CS-0127 field-typing discipline applied here); name naming the enclosing recipe itself (the degenerate cycle, with its own diagnostic rather than a one-element cycle path, compared bare-to-bare); name not registered in the current pass; and a cycle (diagnosed with a rendered cycle path — because the forcing is synchronous, A requiring B whose body requires A must be caught as a cycle rather than surfacing as a stack overflow). §22.8 further requires that the forcing never degrade to a silent skip. An implementation that declines to evaluate some bodies (a parametric chore no invocation reaches; a for_each recipe whose data source it has not resolved) decides those skips from information gathered before any body runs, so they cannot account for an edge a body declares while running. Registering the forced recipe with no work units while the edge still pulls it into the build closure is expressly non-conforming — register and execute would then disagree about what is being built, which is the exact silent failure this entry exists to kill. Two consequences: a forced recipe is reachable by definition, so a reachability-predicated skip MUST NOT apply to it (a forced parametric chore is evaluated with no argv supplied, per §7.5.1); and where evaluation is genuinely unavailable, an implementation MAY reject, MUST say why, and SHOULD name the static-dep workaround. The silence is what is forbidden, not the error.

App. C.16 — the rationale. The arguments this entry makes normatively in §22.8 and §16.1.2 are recorded as rationale under a new App. C.16, “On §16 Cross-recipe ordering”, so that the reasoning survives independently of the sections that assert it. C.16.1 carries the name-reference-as-evidence argument in full — why §10.6’s prohibition constrains the evidence from which an edge may be inferred rather than the mechanism by which one is declared, and therefore why this entry’s API is an instance of the name-reference rule rather than an exception to it. It also records the road not taken, which is the substance of the decision this entry resolves: option (a), the authored edge, was chosen over inferring ordering from output↔input path matching. That inference would have reversed §10.6 and §16.1, reinstating by the back door the byte-equality rule CS-0009 removed, and would have forced a re-think of §22.1.2’s glob-terminality rule — a rule that exists because globs cannot be lexically matched, and for which an ordering-by-path-matching design owes an answer. The only answers available are to leave glob-output recipes silently unordered (the footgun §22.1.2 closes) or to resolve globs against the filesystem at plan time (which makes the graph a function of disk state rather than of the source). Declining to infer keeps both from arising. C.16.2 and C.16.3 carry the two scope divergences recorded above — closure-scoped-vs-workspace-wide and directed-vs-undirected — as rationale rather than as normative asides, since each is the kind of apparent inconsistency between sibling rules that a reader will otherwise try to “fix”.

Conformance. Two fixtures, and the rest is pinned by the reference implementation’s own tests, because none of it is observable from a parse dump. Those tests are worth naming: a produce body reading its own key is a named error and not a stack overflow, an undeclared upstream read is refused while a declared one is served, a produce body cannot capture a unit into the recipe that triggered it, the static-input rule reaches a probe a finalizer resolved, and a discovery pass runs no produce body at all. Every one of those was a defect found by review of the first draft rather than a property designed in, which is the honest provenance of this paragraph. positive/require-recipe-register-phase pins the syntactic ruling: a bare cook.require_recipe("producer") line in a recipe body classifies as CS-0134’s register-phase InlineLua step, the same shape CS-0141’s positive/recipe-name-register-phase pins for cook.recipe_name() — the <id>.<id>(...) bare-module-call rule does not special-case the cook prefix, and a string argument does not change the classification. It is deliberately parse-only (no register_ok.txt): producer is not declared in it, and §22.8 requires a register-phase error for a name not registered in the current pass, so a register-phase assertion on this fixture would pin the wrong rule. negative/require-recipe-outside-recipe-body-rejected pins the first arm of the error contract as an executable assertion: a register-block call is rejected at register time with the fix-hint diagnostic (register_error.txt). Its Cookfile names an already-declared recipe (noop) on purpose, so the fixture proves the outside-a-body guard fires first rather than passing by way of the unregistered-name error. That rejection is register-time semantic, not syntactic — the Cookfile parses cleanly — so it is recorded in tree-sitter-cook/scripts/conformance.mjs’s SEMANTIC_ONLY_NEGATIVES naming §22.8 and the enforcement phase, following CS-0141’s and CS-0143’s entries, and is enumerated in App. F §F.2 alongside them. That registration is load-bearing rather than bookkeeping: without it the tree-sitter harness reads the fixture as a syntactic negative, finds that it parses, and fails the gate — verified in both directions (the harness exits 0 with the entry, 1 without it). The remaining arms of the error contract (non-string, empty-string, self-reference, unregistered name, cycle) stay in cook-register/src/tests.rs, which can assert on a message the corpus cannot.

Backward compatibility: Purely additive: a new API name on the cook global table, register-phase only. No existing grammar, surface syntax, or Cookfile that does not call cook.require_recipe changes behaviour, and no existing recipe’s ordering, dispatch, or diagnostics change. The only new rejections are this entry’s two diagnostics — §22.8’s error contract, which can fire only in a Cookfile that calls the new API, and §16.1.2’s undeclared read-after-write, which can fire only on an arrangement that was already a silent stale read or a missing-file failure under --jobs > 1. CACHE_VERSION does not bump: no key derivation, record shape, or determinant set changes, and no cached artifact is invalidated. Ordering is not a cache determinant; the requires edges this API writes are the same edges the dep-list already produced, and a Cookfile that calls the API gains ordering it did not previously have only where it explicitly asks for it.

Reference implementation. Complete. Landed in two commits: the API surface and its guard rails first, then the forcing, the edge, and the skip-arm closures described below (this paragraph was amended in place, as that first commit anticipated). The surface: cook-register/src/context.rs’s register_require_recipe_api, registered on the cook global as a sibling of CS-0141’s register_recipe_name_api and wired into install_remaining_apis in cook-register/src/engine.rs. It reads the shared per-body slot and hard-errors when BodyCaptureState.current_recipe is unstamped — one check covering every outside-a-body caller (top level, a register block, a for_each-feeding probe’s produce on the register VM). It matches on the raw Lua value rather than converting it (CS-0143’s parse_origin_meta precedent), so a numeric name is rejected outright instead of being coerced to its decimal string by the host binding’s implicit number-to-string conversion; the empty string is a separate error. Diagnostics cite this entry (cook.require_recipe: `name` must be a string, got integer (Standard §22.8, CS-0144)) rather than CS-0127, following CS-0143’s precedent for the same reason: CS-0127 covers §22.1/§22.4/§22.5.7 and would send a reader to an entry that does not describe their error; it is the discipline’s precedent, not its locus for this argument. cook-register/src/lib.rs adds two BodyCaptureState fields: dynamic_requires: Vec<String>, the order-preserving, API-de-duplicated accumulator of required names, and current_recipe_bare: Option<String>, stamped in cook-register/src/engine.rs alongside the existing (qualified) current_recipe from the same bare name the topo vec carries. The self-reference check compares against current_recipe_bare and not current_recipe: the argument is bare per this entry’s ruling, so a qualified comparison would make the check silently never fire under an import prefix.

The forcing. cook-register/src/engine.rs’s step-12 body-invocation loop becomes a BodyDriver whose ensure_invoked(lua, name, forced) is re-entrant, keyed on bare names with a four-state visit map. The precomputed topological sort is demoted from authority to seed order: it is built from the static requires graph before any body runs, so it structurally cannot know a dynamic edge; the seed loop now just calls ensure_invoked for each name, which guarantees every recipe is reached while the DFS does the real ordering. A Cookfile that never calls the API therefore invokes bodies in exactly the previous order.

ensure_invoked recurses into both edge kinds: the dynamic edges a body declares as it runs, and — before invoking the body at all — each of the recipe’s own local static requires, skipping refs absent from the local set exactly as local_topological_sort does (those are cross-Cookfile, and the engine’s analyzer owns them). Recursing into the dynamic edges alone was not enough, and the gap was live for two commits: it left the driver leaning on the seed order to have already evaluated a recipe’s static deps, which a force by construction bypasses. With app forcing gen and gen : tools, the static sort [app, tools, gen] puts the forcer at index 0 and tools at index 1 — so gen’s body ran before tools, and gen’s own cook.import("tools") returned nil while app’s import of gen succeeded. A partial mislink, and therefore a silent one: precisely the class §22.8 exists to kill, and a contradiction of Note 22.8.1’s own “registration order within a pass is dependency-driven”. §22.8 now states the transitivity obligation outright, which is the normative content this bug violated. forced propagates down the static chain for the reason the edge exists: the closure is built from requires, so if N is in it then so is everything N requires, and a dep visited un-forced could hit a skip arm and register zero units while still being built.

The payoff is that the seed order stops being load-bearing for correctness — it now only pins a deterministic order among independent recipes — and the Visiting check becomes complete over the merged graph, since every edge that can reach names is now traversed. That makes step 12a’s post-pass local_topological_sort re-check redundant on the current code: disabling it leaves the whole suite green, including the mixed static/dynamic cycle test, which the DFS now catches at the moment it traverses the static edge. It is retained as defence in depth. The redundancy argument holds only while the driver traverses every edge source feeding names — an invariant a future edge source could quietly break, whose failure mode is a silently accepted cycle — and the cost is one sort over an in-memory map, once per pass.

The four states are load-bearing, and three of them exist because a two-state Visiting/Visited map admits silent failures the first implementation shipped. Visited { forced } (the body ran to completion) makes a repeat force a no-op for the body — the at-most-once rule — while its flag records the separate question of whether forcing was pushed down into the recipe’s static requires; a force meeting Visited { forced: false } runs that propagation and upgrades the flag, without re-running the body. The flag is folded into the state rather than kept in a side forced_visited set so that the compiler obliges every reader of Visited to say what it does about forcing: all three bugs in this family were a visit state that answered fewer questions than the code asked of it, and a parallel set would have been a second source of truth about the same node with nothing to keep the two honest. Visiting (on the invocation stack) is a cycle, rendered from a path stack in local_topological_sort’s shape (framework -> idLib -> framework); this is what §22.8’s stack-overflow clause demands, since a purely dynamic cycle recurses during the pass itself. Skipped (a skip arm declined to evaluate the body) must be distinct from Visited: the seed order is a BTreeMap::keys() walk and therefore lexicographic, so a required recipe whose name sorts before its requirer’s is reached — and skipped — before the force that would rescue it, and a driver that marks a skip Visited short-circuits that force to Ok(()) having evaluated nothing. The recipe then registers zero units while the edge still places it in the build closure: the exact state §22.8 calls expressly non-conforming, arrived at silently, and the same short-circuit defeats the third skip arm’s designed hard error. A force on Skipped therefore re-invokes with forced = true, which is what tells the arms to stand down or to raise. Because a re-invoked body drains a second time, invoke_body and register_skipped now replace a name’s entry in the names vec in place rather than pushing, so a rescued skip yields one registered entry and not one per invocation. Failed(mlua::Error) records a body that raised, replaying the original diagnostic on any later visit: a body may swallow a force error with pcall, and an entry left Visiting on the error path is then read as a cycle by the seed loop’s own later visit — fabricating a one-element “cycle” and destroying the real error, which the pcall had already swallowed. Replaying is preferred to clearing the entry because §22.8 evaluates a body at most once per pass. The variant holds the mlua::Error itself rather than a rendered string so the replay is byte-identical to the original: re-wrapping a RegisterError’s to_string() would stutter (lua error: runtime error: lua error: runtime error: …). A body error is always RegisterError::Lua, so the common path round-trips exactly; the rarer structured variants flatten to one runtime error, which is what force already does to every RegisterError on its way back into Lua.

Cycle detection is written twice; one path is live. Deferring cycle detection to the engine’s analyzer does not discharge §22.8’s MUST, as an earlier draft of this entry assumed: GraphError::CycleDetected names a single node, renders no path, never mentions the API, and is scoped to the target’s closure — so cook list would see nothing at all. The MUST is discharged by the driver’s Visiting check. Because ensure_invoked recurses into static requires as well as dynamic edges (see above), that check is complete over the merged graph and catches every cycle in it — including the MIXED shape (recipe A : B plus a cook.require_recipe("A") in B’s body): the seed loop marks B Visiting (the static sort puts a dep before its dependent), B’s body forces A, and A’s static dep on B recurses straight back into the mark, yielding cook.require_recipe: dependency cycle: B -> A -> B with the full path. Step 12a additionally re-runs local_topological_sort over the merged requires once every body has drained, yielding RegisterError::DependencyCycle { recipes }; on the current code that is redundant (the paragraph above records why) and it is retained as a cheap guard, not as the live path. An earlier draft of this entry described the mixed cycle as invisible to the driver and step 12a as the only thing that could catch it. That was true of the first implementation and is no longer true: the deps-first round’s ensure_static_requires made the driver walk static edges too, which moved the mixed cycle onto the driver’s arm. The correction is recorded here rather than silently dropped, since the superseded claim was this entry’s stated reason for step 12a existing at all.

The forcer is a cell, not a value. cook.require_recipe is installed once, at install_remaining_apis time, over an Rc<RefCell<Option<RecipeForcer>>> that register_cookfile fills once the driver exists. An earlier implementation instead re-registered the function with a driver-backed forcer before the seed loop, leaving the first binding forcer-less — reasoning that every call reachable before the rebind is outside a recipe body and hits the guard rail first. That reasoning has a hole: the top-level chunk runs before the rebind, and local rr = cook.require_recipe there is ordinary Lua (local sh = cook.sh is idiomatic). The alias keeps the forcer-less closure, which still passes the guard rail and still records the edge — accumulating the dependency without forcing, so cook.import returns nil silently and the module mislinks. One closure over a late-filled cell makes an alias and a fresh cook. lookup indistinguishable, and retains the rebind’s stated benefit (list_names and the pre-pass still get the outside-a-body guard for free) because the guard fires before the cell is consulted. The cell is empty for good on list_names, which invokes no body; an empty cell reached from inside a body is a hard error naming the API, never a silent no-op — §22.8 forbids the silence, not the error.

Re-entrancy. Forcing runs a body from inside another body, through Lua, so three hazards are handled explicitly and each is a real defect if missed. The body slot is a single shared Option<BodyCaptureState>: ensure_invoked saves the caller’s aside, lets the callee open a fresh one, and restores it — otherwise the callee’s units land in the caller’s recipe. setup_recipe_context rebinds the Lua recipe global, so it is saved and restored around the nested invocation — otherwise the caller reads the callee’s recipe.name after the call returns. And no RefCell borrow (recipes, units_by_recipe, names, the body slot) is held across a func.call(), which can re-enter; the API closure likewise drops its body-slot borrow before forcing. Both save/restores are no-ops at the top level, which is why the no-API path is untouched.

The edge is merged at body drain, before RegisteredRecipePub and RecipeUnits are built, and deliberately routed through the existing requires field rather than a new channel — the implementation half of this entry’s conformance argument. requires is the single source of truth the engine’s analyzer builds the closure from, validates unknown names against, and detects cycles in (cook-engine/src/run.rs overwrites RecipeUnits.deps from those analyzer edges), so the edge is indistinguishable downstream from a recipe A : N dep-list entry and needs no downstream work at all. The merge reads the body’s dynamic_requires rather than the requires clone taken before the body ran (stale by drain time) and de-duplicates across static and dynamic, so a recipe carrying both recipe consumer : producer and cook.require_recipe("producer") yields one entry.

The skip arms. Three body-skip arms existed, each decided from the static graph before any body ran, so each would have silently swallowed a force. Enumerating them by gate misses arms; by hazard there are exactly three. Two are parametric-chore skips — one gated on reachability when a target is requested, one gated on nothing but target_recipe.is_none() (the arm cook list, cook dag, and most tests take, and the easiest to miss) — and both are now rescued the same way: a forced chore is reachable by definition, so it reaches the empty-argv bound call §7.5.1 already specifies for a dep-of-target chore, on both the target and the no-target branch. Collapsing the two arms onto one forced || reachable_from_target.contains(name) predicate is behaviour-preserving when forced is false, since reachable_from_target is empty whenever no target is requested. The third is a probe-sourced for_each recipe whose feeding probe the pre-pass skipped; forcing it raises the §22.8 error with the static-dep hint. That last is a scope decision with a real reason, not an impossibility, and the entry records it as such: a lazy rescue is feasible — run_for_each_prepass is a free function, and calling it at force time with reachable_from_target = {forced_name} and has_target = true would evaluate exactly that driver’s probe (with has_target = false it would evaluate every driver instead), while evaluate_prepass_probe’s call-local done/upstream_fps maps would simply recompute deterministically and the probe cache would absorb the cost. It is declined on re-entrancy risk: that path holds recipes and probe_registry borrowed — shared borrows — across run_prepass_produce’s Lua call, and its signature forces every caller into that shape, so the hazard is inherent to the path rather than incidental. Re-entry alone would not conflict; the BorrowMutError would arrive the moment a forced body touched cook.probe or cook.recipe and reached a borrow_mut of one of those cells.

Thirty-six tests in cook-register/src/tests.rs. The guard rails (eight, from the first commit): the top-level and register-block outside-a-body errors (the latter through the real surface parser and codegen), non-string rejection (asserting the integer is not coerced into the message), empty-string rejection, self-reference rejection, first-seen-order de-duplication, acceptance of a distinct name through the full register_cookfile pipeline, and — the critical regression guard — self-reference detection under a qualified prefix, which a current_recipe/current_recipe_bare mix-up would silently pass.

The forcing (twenty-one). The headline case — a consumer declared before an unrelated producer, whose body forces it and then reads its export, asserting the captured command is link build/libproducer.a and not link NIL (the literal pre-fix output, and the whole finding in one assertion); at-most-once evaluation under two requirers plus the seed loop; the caller’s units and recipe.name surviving a nested invocation (the two save/restore hazards); the edge landing in RegisteredRecipePub.requires de-duplicated against a static dep naming the same recipe; unknown-name rejection with the closest-registered-names hint (reusing env_api’s closest_declared, as CS-0126’s undeclared-$<NAME> diagnostic does); a dynamic cycle rendering its path; and unchanged body order for a Cookfile with no calls.

All three skip arms: a forced parametric chore evaluating with empty argv and registering its units, tested both with a target requested and with target_recipe: None; and a forced non-reachable probe-sourced for_each recipe raising the hard error rather than silently registering zero units.

Each skip arm again with the skipped recipe seeded before its requirer — the chore renamed agen so the lexicographic seed order reaches and skips it first, and the for_each twin asserting the arm’s designed error still fires from the Skipped state rather than being short-circuited to Ok(()). These are the tests the Skipped-vs-Visited distinction exists for; the un-renamed originals pass on a two-state driver by an accident of naming. Alongside them: a rescued skip registering exactly one entry in names (not one per invocation); a cook.require_recipe aliased to a local during the top-level chunk still forcing, which is what makes the forcer a call-time cell read rather than a re-registered table entry; a force error swallowed by pcall surfacing the real diagnostic and not a fabricated cycle from an abandoned Visiting mark; and a mixed static/dynamic cycle (aaa : bbb static plus bbb’s body forcing aaa) rendering a path across both recipes.

The deps-first round (four). A forced body jumping ahead of its own static requires was live until this round: app forces gen, gen : tools, and the static sort [app, tools, gen] puts the forcer before the forced recipe’s own dep — so gen’s body ran at seed index 0 and its cook.import("tools") returned nil, giving app gen-using-NIL where app gen-using-protoc is correct. The force itself worked, which is what made the mislink partial and therefore silent: the caller saw gen’s export and was satisfied. Seed order is the whole mechanism, so the fixture pins it explicitly. A second test strips cook.import out entirely (ccc forces ddd, ddd : eee, asserting eval order eee,ddd and not ddd), proving the defect is the ordering itself and not an import artifact. A third holds the at-most-once rule across a recipe reached three ways at once — forced directly, pulled in as a forced requirer’s static dep, and visited by the seed loop — since the deps recursion adds two new inbound paths to every node. A fourth drives the force through a static dep to a skipped parametric chore (app forces gen, gen : agen), pinning forced propagation down the static chain: a dep visited un-forced hits the skip arm and registers zero units while the gen : agen edge still has it built.

The already-visited round (five). The deps-first round above propagated forced into a recipe’s static requires — but only along the path that evaluates the body. ensure_invoked’s Visited arm returned Ok(()) ahead of the deps recursion, so a force arriving at a recipe the seed loop had already evaluated un-forced never pushed forced down at all, and the round’s own test could not see it: naming the forcer app puts it before gen in the seed order, so gen was still unvisited when the force landed and the propagation ran off the normal body path. Rename the forcer zzz and the same fixture fails. The shape is zzz forcing bbb, bbb : achore: the seed order [achore, bbb, zzz] skips achore (nothing has forced anything yet — correctly), runs bbb un-forced, and then zzz’s force short-circuits on bbb’s Visited mark, leaving achore at zero units while zzz -> bbb -> achore sits in the build closure. Tested with a target requested and with target_recipe: None; a fourth test uses the names an author would actually write (web forces assets, assets : codegen), where web sorts after assets with no contrivance at all. The sharpest of the five routes the force at an unreachable probe-sourced for_each through an already-visited recipe (zzz -> bbb -> afan): both pre-existing arm-3 tests force afan directly, and through bbb the arm’s designed hard error simply never fired — the pass succeeded with afan at zero units, an implementation silently swallowing its own diagnostic because of a name. The fifth chains the propagation through two consecutive already-visited recipes (zzz -> bbb -> ccc -> achore), found by auditing the new arm rather than reported. All five fail before the fix — four on zero captured units, the for_each one on the vanished error — and reverting the arm alone fails exactly those five and nothing else.

The cycle-path round (two, require_recipe_force_through_already_visited_recipe_renders_full_cycle and require_recipe_force_before_visit_renders_full_cycle_matches_already_visited_case). The already-visited round’s rescue arm had the same name-dependent hazard one layer down: it calls ensure_static_requires to push forced into the recipe’s static deps, but — unlike the normal (None-state) body path a few lines below it — never pushes name onto self.path around that walk, and self.path is exactly what the Visiting arm renders a cycle from. A cycle that closes THROUGH this arm therefore renders with name missing off the stack. Fixture: mid : achore (achore a parametric chore) plus achore’s body forcing mid back — invisible to the ordinary seed pass because the chore’s skip arm keeps that body from running unforced at all, so the cycle surfaces only once something forces mid. Naming the forcer zzz (sorting after both achore and mid, so mid is already Visited { forced: false } when the force lands) routes the walk through the buggy arm and collapses the diagnostic to achore -> achore — a fabricated one-element self-cycle on a recipe that requires nothing, the same pathology Failed exists elsewhere in this driver to prevent. The control names the forcer app (sorting before mid, landing on the normal None-state path instead) and — via a rotation-invariant comparison, since a cycle has no privileged starting node and a correct implementation can catch it at either recipe — asserts both fixtures normalize to the identical 2-node cycle, pinning the paragraph above’s “brought to the same result” MUST rather than just re-testing the already-visited round’s zero-units symptom. The fix pushes name onto self.path immediately before the re-walk and pops it immediately after, mirroring the normal path’s own push/pop exactly; both tests fail before the fix, and reverting the push/pop in isolation fails exactly those two and nothing else.

Seed order is a test parameter, not a fixture detail — the lesson of the Skipped state above. The three skip-arm tests as first written all named the requirer app and the required recipe gen / fan, which sort after it, so the force always landed before the seed visit and the arms’ gates were never actually exercised: every one of them passed against a driver that silently skipped a forced body. Each arm therefore has a twin whose required recipe is named agen / afan and so seeds first (the parametric-chore twin in both the target and no-target flavours), plus a test asserting a rescued skip registers exactly one names entry rather than one per invocation. Three more cover the holes the seeded-first cases exposed elsewhere: an alias taken at top level (local rr = cook.require_recipe) forcing exactly as the un-aliased call does — link build/libP.a, not the link NIL the forcer-less closure produced; a pcall-swallowed force error surfacing the real diagnostic (boom: the real failure) and asserting the output contains no “cycle” at all; and a mixed static/dynamic cycle (aaa : bbb plus bbb’s body forcing aaa) rejected at register phase with a rendered path. No conformance fixtures: the API is reachable only from Lua and adds no surface production, so there is no grammar behaviour to fence.

CS-0145 — Implementation conformance: a zero-unit recipe no longer severs transitive cross-recipe ordering [#changes.cs-0145]

Date: 2026-07-16 Version: v0.15 Sections affected: App. E (this entry) only. No normative or informative section is amended, and none needs to be: § 16.1‘s prerequisite mandate already required the behaviour recorded here, and the repair is to the reference implementation alone. An implementation-conformance record in the mould of CS-0100 / CS-0138 / CS-0142, with no amendment attached — no grammar (App. A), surface syntax, Lua API, or diagnostic gains or loses anything, and no conforming Cookfile is spelled differently after it than before.

Summary: Repairs a silent stale-read race: a recipe that materialises no work units severed the cross-recipe ordering chain running through it. Given

recipe producer
    cook "build/gen.a" { … }

recipe middle : producer

recipe consumer : middle
    cook "out.bin" { cp build/gen.a $<out> }

cook consumer ran producer and consumer concurrently, and consumer died on cp: cannot stat 'build/gen.a'. cli/crates/cook-engine/src/dag_builder.rs’s build_dag recorded each recipe’s final sequential barrier as its leaf set, and downstream root units take their cross-recipe deps from recipe_leaves.get(dep_name). A recipe that materialises no units never advances the barrier, so middle recorded an empty leaf set; an empty set contributed no edges, and the chain consumer -> middle -> producer vanished in its entirety — not coarsened, not weakened, absent. The fix, at the leaf-recording site, forwards the recipe’s own cross_deps (the union of its deps’ leaves, already computed earlier in the same loop iteration) whenever the barrier is empty:

let leaves = if barrier.is_empty() { cross_deps } else { barrier };
recipe_leaves.insert(ru.recipe_name.clone(), leaves);

This is correct by induction over the topo-ordered slice: every recipe’s leaves are recorded before any dependent reads them, so a chain of empty-barrier recipes forwards the original producer’s leaves the whole way down. A zero-unit recipe with no deps of its own forwards nothing, which is the correct base case — empty forwards empty.

The conformance argument. The mandate is § 16.1‘s, and it is not subtle. For each name reference from recipe A to recipe B, “a conforming implementation MUST treat B as a prerequisite of A”. Prerequisite is a relation over recipes, not over units — and that is the whole point here. middle materialises zero units, but it is a recipe; it names producer in its dep-list, and consumer names middle in its. So middle MUST NOT be built before producer, and consumer, whose prerequisite is middle, is ordered after producer in consequence. The mandate binds non-vacuously on a unit-less recipe because nothing in it asks how many units any of the three recipes happens to materialise. § 16.1.1 supplies the supporting observation that the relation composes: it speaks of the ordering rules of § 16.1 establishing “a path between them”, and a path is precisely what a chain of prerequisites is. § 10.6‘s first bullet says the same thing from the closure side — a name reference is “observable through execution ordering”, and B “MUST join the build closure of any invocation that reaches A”. The pre-fix implementation severed that path whenever the intermediate recipe materialised no units; it therefore did not treat producer as a prerequisite of consumer, and was non-conforming against text already in force. Nothing in the Standard changes to make it so.

What the argument deliberately does not rest on. Two nearby pieces of § 10.6 look load-bearing and are not. An earlier draft of this repair’s plan rested on the first of them and an independent reviewer caught it; the error is recorded here rather than quietly dropped, because a future reader reaching for the same bullet deserves to know it was already tried.

  • The granularity bullet is NOT the basis. “A dep-list reference (recipe A: B) MUST be interpreted at whole-recipe granularity: every unit of A waits for every unit of B” is vacuous in both directions when B has zero units. Every unit of consumer vacuously waits for each of middle’s zero units; middle’s zero units vacuously wait for producer’s. Quantified over an empty set, the bullet mandates nothing whatsoever about consumer versus producer — an implementation could satisfy it perfectly while running them concurrently, which is exactly what this one did. The bullet may be cited only for the far weaker claim that it does not contradict the fix (“Coarse edges are always conformant”), which is true, and which is not an argument for anything.
  • The “transitive closure of all cross-recipe edges” phrase is NOT a basis for ordering propagation. It appears in § 10.6‘s validation-and-cycles bullet and identically in § 16.1‘s third bullet, and in both places it is scoped to cycle rejection: it says what an implementation MUST reject, not what it MUST order. Citing it for propagation would borrow a rule from the wrong subject and prove nothing about this defect.

The prerequisite relation is the mandate, and it needs no help from either.

Ordering still comes only from name references. The fix forwards leaves along author-declared deps edges and infers nothing from anything else. cross_deps is the union of the leaves of recipes the author named in the dep-list — ru.deps, into which the engine’s analyzer also folds names reached via cook.require_recipe — and no path string is examined, compared, or matched at the leaf-recording site or anywhere near it. (The $<NAME> / cook.dep_output channel lands in the separate dep_edges field, which leaf-forwarding does not read.) CS-0142’s tripwire stays GREEN. cli/crates/cook-engine/tests/raw_path_cross_recipe_edge.rs’s raw_path_input_does_not_order_producer_before_consumer — flagged by CS-0142 as a tripwire whose reddening would signal a normative reversal of § 16.1/§ 10.6 — is unaffected and MUST remain passing (verified: three tests, all green). A consumer that declares a raw path equal to a producer’s output and names nothing still gets no edge, and gains none from this entry: forwarding leaves along an edge the author declared cannot manufacture an edge the author did not declare. Path-string equality is evidence of nothing after this entry, exactly as before it.

Why this mattered: the severed shape is the documented idiom. A unit-less aggregate is not a pathology invented to exercise a corner. recipe game : dhewm3 base d3xp maps — a recipe whose entire content is its dep-list — is exactly this shape, and it is the documented idiom for order-without-consumption in the explicit-recipes milestone, whose “Header deps stay user-written” rule has authors write these aggregates by hand. The bug was therefore reachable from idiomatic Cookfiles: any author who grouped targets behind an aggregate and then depended on that aggregate lost the ordering the aggregate existed to express, and lost it silently. That silence is what makes this worse than a loud failure. cp: cannot stat is the lucky outcome — the unlucky one is a stale build/gen.a left by an earlier run, consumed successfully, yielding wrong bytes with no error at any point; and since the consumer’s key does not fold a path its body merely mentions, that wrong output is then cached as though it were right. CS-0144’s §16.1.2 kills this failure class in the channel where the author declared nothing; this entry kills it in the channel where the author declared the dependency correctly and the implementation dropped it.

The trigger is the empty barrier, not zero units. The shipped rule keys on barrier.is_empty() — a deliberate superset of “the recipe has no units”. A recipe can carry units and still finish its loop with an empty barrier, because probes never advance the barrier: a recipe whose only units are probes is necessarily a recipe whose probes are all demand-pruned (probe consumption is seeded only from non-probe units in the same recipe, so an all-probe recipe seeds nothing), and it forwards cross_deps under the same rule, and rightly so — a recipe’s prerequisite obligations do not depend on what kind of units it happens to hold. The distinction is not decorative. All four of the repair’s original unit tests used units: vec![], so a naive ru.units.is_empty() rule would have passed every one of them and the superset would have been asserted nowhere. A discriminating test now pins it: recipe_with_only_a_pruned_probe_unit_still_forwards_cross_deps gives middle a non-empty units vec holding a single demand-pruned body-scope probe and asserts the actual forwarded edge (that consumer depends on producer’s dag id), not an edge count. It fails under the rejected ru.units.is_empty() rule and passes under the shipped barrier.is_empty() one, with the other four tests unaffected either way.

CS-0144 predicted this entry, and left it the ruling. CS-0144 recorded this defect as pre-existing and out of its own scope, noting that neither cook.require_recipe nor its routing through requires caused or worsened it — the API’s edge is indistinguishable from a dep-list entry by construction, so a zero-unit middle severed a recipe consumer : middle dep-list edge identically before it. CS-0144’s DanglingDepEdge pre-walk deliberately left zero-unit behaviour unchanged, distinguishing absent from the slice (the genuine dangling name, which it raises on) from present-with-zero-units (a legitimate Some(empty) leaf set, which it passes), and expressly left the forwarding to be ruled on here. This entry makes that ruling, and CS-0144’s reasoning holds up: a dangling name and a present-but-empty target are different defects with different fixes, and because CS-0144 declined to conflate them, this repair is a clean forwarding change at one site rather than a rejection rule that would have broken meta-target Cookfiles that build correctly today.

Backward compatibility: No surface, grammar, API, diagnostic, or normative change; CACHE_VERSION does not bump. Ordering is not a cache determinant, and no key derivation, record shape, or determinant set changes, so no cached artifact is invalidated and nothing re-runs on the boundary. The only behaviour change is that a build whose ordering ran through a unit-less recipe now has that ordering: edges appear where the author declared them and the implementation had been dropping them. Nothing that was correct becomes incorrect — every affected build was already racing, so a build that passed did so by lucky scheduling and now passes by construction. The one thing an author could lose is concurrency between two recipes they had explicitly ordered, which was never something the Standard permitted them to have. No new rejections: this entry adds no diagnostic, and a zero-unit recipe with no deps of its own still forwards an empty leaf set exactly as before.

Reference implementation. Four commits, all confined to cli/crates/cook-engine. The first is the repair: the two-line leaf-recording change quoted above in src/dag_builder.rs, plus four unit tests over build_dag — zero_unit_recipe_forwards_producer_leaf_to_downstream_consumer (the headline chain), two_hop_zero_unit_chain_forwards_leaf_transitively (the induction at depth two), diamond_through_zero_unit_recipes_dedups_to_one_dep (two zero-unit paths to one producer yield one edge, not two), and zero_unit_recipe_with_no_deps_forwards_empty_leaf_set (the base case). The second adds tests/zero_unit_meta_target_ordering.rs, a binary-level e2e proof of the producer -> middle -> consumer chain, cache-busted on purpose: isolated from the global CAS via a private cache_dir, carrying a per-run nonce in the producer’s payload, and asserting on log ordering and copied-content equality in addition to exit status, so it cannot pass on a lucky schedule or a stale hit. It was confirmed against a manual revert of the fix — reproducing the exact cp: cannot stat 'build/gen.a' failure, then passing again once restored. The third adds the discriminating pruned-probe test above. A fourth commit corrects the module-level and build_dag doc comments to state plainly what the code already does: the barrier ends up empty in exactly two cases — zero units, or all units are demand-pruned probes (probe consumption is seeded only from non-probe units in the same recipe, so an all-probe recipe’s probes are always pruned). That enumeration is exhaustive, and phrasing the trigger as “empty barrier” in both the code and the comments — not “zero units” — is what keeps the two from drifting apart.

CS-0146 — Single-target makers MUST be step contributors; exports are orthogonal to recipe ownership [#changes.cs-0146]

Date: 2026-07-17 Version: v0.15 Sections affected: §12.7 (§12.7.8 gains the single-target MUST specialization and the CS-0080 orthogonality note), App. E (this entry). No grammar (App. A), surface syntax, Lua API, diagnostic, or cache-key change.

Summary: Strengthens CS-0143’s §12.7.8 “ordinarily … SHOULD NOT mint” framing to a categorical MUST for the single-target shape: a single-target maker — a target maker whose call contributes the units of exactly one target — MUST be a step contributor. It MUST be invoked from within a user-written recipe body, from which it derives that recipe’s identity via cook.recipe_name() (§ 22.8), and it MUST NOT itself call cook.recipe (§ 22.3). CS-0143’s two carve-outs — data-driven fan-out and support recipes — remain MAY-mint, unchanged: their origin-annotation MUST, their documentation MUST, and §22.6’s ruling that an origin annotation MUST NOT gate dispatch all stand exactly as CS-0143 left them, because neither carve-out is a single-target maker (fan-out contributes N targets’ units per call; a support recipe’s call is not a target maker at all). This entry also adds an explicit orthogonality note: these minting rules do not touch cook.export / cook.import semantics (§ 24.5). A target’s exported transitive-link surface — what CS-0080’s PRIVATE/PUBLIC propagation governs — is a property of the target’s own options, independent of which recipe owns the unit; a step contributor exports under the enclosing recipe’s identity exactly as a minted recipe would. Who owns the recipe and what the recipe exports are separate questions, and CS-0080 is untouched by this entry. The rule is the explicit-recipes milestone’s teeth for the ordinary case: recipe identity, invocability, and the Cookfile’s greppable surface belong to the author; a module supplies toolchain probing, flag assembly, and transitive resolution — sugar, not sovereignty.

Backward compatibility: Nil. A normative prose rule binding module authors; no Cookfile, grammar, Lua API, diagnostic, or cache key changes; CACHE_VERSION does not bump. A conforming module already on the step-contributor shape (e.g. cook_cc 0.13) is unaffected; the rule only forbids a shape — a single-target maker that self-mints — that the milestone had already removed in practice.

Conformance: No conformance case is added. This is a purely normative prose rule about module authorship, with no new Cookfile surface: the engine does not, and per CS-0143’s ruling MUST NOT, enforce “a maker was called inside a recipe body” — doing so would invent an invocability tier the language does not have. The enforcement primitives the rule relies on already carry conformance coverage of their own: cook.recipe_name() erroring outside a recipe body (CS-0141, positive/recipe-name-register-phase), the origin annotation (CS-0143), and cook.require_recipe (CS-0144). Per CONTRIBUTING’s spec-first rule item 4 — a conformance case is required only “if the change is observable from a Cookfile” — none applies here.

CS-0147 — Canonical workspace-qualified prefix: shortest alias chain from the root [#changes.cs-0147]

Date: 2026-07-18 Version: v0.15 Sections affected: §11.5 (gains the canonical workspace-qualified prefix rule), App. E (this entry). No grammar (App. A), surface syntax, Lua API, or cache-key change.

Summary: Specifies which alias chain names a diamond-deduplicated directory. §11.5 requires the same directory reached through several import chains to load once, but the Standard never said which chain supplies the directory’s workspace-qualified prefix — the dotted name surfaces like recipe listings and workspace-scoped invocation targets present. The reference implementation resolved the ambiguity by accident: the last edge to insert into a reverse child→parent map won (the “BTreeMap insertion race” CS-0029’s rationale already named), so a directory the root imported directly could be listed and addressable only under another importer’s nested chain (web.contracts.gen for a directory the root imports as contracts), violating § 20.2.2‘s requirement that the entry Cookfile’s imports remain addressable under their locally declared aliases — and any import added or reordered anywhere in the workspace could rename recipes workspace-wide. §11.5 now requires the canonical prefix to be the shortest alias chain from the workspace root, ties broken by declaration order (breadth-first over the import graph, source order per Cookfile). A root-direct import is therefore always named by the root’s own alias, and names are stable under unrelated import edits. The rule is naming-only: per-Cookfile qualified-name resolution (§ 11.3, CS-0029’s alias-to-canonical-prefix mapping) and cache identity (§ 17.1.1) are explicitly untouched.

Backward compatibility: Workspace-qualified names change wherever a diamond previously let a longer chain win: the canonical name of such a directory’s recipes shortens (web.contracts.gen → contracts.gen when the root also imports contracts directly), and the superseded chained spelling is no longer an invocable target — a script invoking it gets the ordinary recipe-not-found diagnostic and should switch to the canonical name. Workspaces without diamonds are unaffected. The prefix never contributes to the cache key, so cached artifacts and shared-store entries survive the rename unchanged; CACHE_VERSION does not bump.

Conformance: No conformance case is added: the suite’s cases are single-Cookfile parse goldens, and this rule is workspace-composition behavior with no parse-level surface. The selection rule is pinned by implementation unit tests over the namespace map (root-direct alias beats a nested chain regardless of declaration order; equal-depth ties fall to declaration order; shortest chain beats deeper chains) and exercised end-to-end against diamond-import workspace fixtures.

CS-0148 — files { … } probe producer: a file set as a first-class, sealable determinant [#changes.cs-0148]

Date: 2026-07-18 Version: v0.15 Sections affected: §22.5.2 (seventh producer form, contextual-keyword list, lowering note), App. A.3.2 (glob_list production, source-list rules), App. E (this entry). No Lua API signature change; no cache-key algorithm change; CACHE_VERSION does not bump.

Summary: Completes the producer-kind symmetry with the probe-input model of §22.5.4: inputs.env, inputs.tools, and inputs.files have always been the three fingerprint contributors, but only the first two had surface producers (envs { … }, tools { … }). The new files { "GLOB" !"GLOB" … } form takes a brace GLOB LIST in ingredients pattern syntax; the glob set is the probe’s file-input fingerprint set, and the value is a JSON object mapping each resolved file’s workspace-relative path to its content hash ("<missing>" when unreadable). Sealing the probe folds per-file identity into consumer unit keys, and cook why’s shared-miss diff names the exact file that changed — replacing the DIY idiom (an ingredients line plus a shell body that re-lists the same globs into sha256sum) whose duplicated glob lists could silently diverge, whose digest was opaque to attribution, and whose value drifted machine-relative if the body used absolute paths. The reference lowering emits inputs.files plus the reserved produce string "@files-manifest" (not executable Lua, so it collides with no hand-written body); the engine intercepts the sentinel and synthesises the value from the same resolved path→hash pairs the fingerprint folds, so trigger and value cannot drift. A files probe MUST NOT also declare an ingredients line (parse-time rejection): the glob set IS its file-input fingerprint set. Granularity note: sealing remains recipe-wide (§8.4.3) — a files probe does not provide per-fan-out-record file inputs, which stay out of scope for this entry.

Backward compatibility: Additive. files was previously an ordinary identifier in probe-producer position and would have been rejected as an invalid producer; no existing Cookfile parses differently. The sentinel "@files-manifest" was never valid Lua, so no existing cook.probe caller’s produce body changes meaning; a caller MAY now pass it deliberately to obtain the synthesised manifest. Existing cache keys are unaffected.

Conformance: positive/probe-files-producer pins the parse shape (glob list with ! exclude, sealed consumer); negative/probe-files-with-ingredients pins the parse-time rejection of a files producer combined with an ingredients line. Engine behavior (manifest value, invalidation on edit/add/remove, exclude inertness, cook why attribution) is covered by implementation tests.

CS-0149 — cook.on_register_complete: a run-once, run-last register-phase finalizer hook [#changes.cs-0149]

Date: 2026-07-18 Version: v0.15 Sections affected: §22.9 (new — cook.on_register_complete signature, phase, argument-type check, ordering and timing, the outside-a-recipe-body scoping, the recipe/probe minting ban, re-queuing, error propagation, the unrestricted remainder of register-phase Lua, the discovery-surface exemption, and Note 22.9.1’s motivating case); §12.3.5 (new — register-completion callbacks, the lifecycle-end counterpart to §12.3.1’s init() lifecycle-start); §12.7.1 (amended — one sentence noting a module MAY queue end-of-register work via cook.on_register_complete); App. C (new — C.23, in two subsections: why a run-last callback rather than an all_recipe_names()-style query, and why minting a recipe from a callback is banned); App. E (this entry); App. F §F.2 (amended — the tree-sitter skip enumeration gains this entry’s register-phase-only negative). No grammar (App. A), surface syntax, parser, codegen, or cache-key change.

Summary: Adds cook.on_register_complete(fn) -> nil, a register-phase-only API that queues fn to run once, on the register-phase Lua VM, after every recipe body of the current registration pass has been evaluated and the pass’s whole-graph validation has completed, and before the implementation commits the pass’s module-held state for the register→execute handoff (§ 12.3.4). It is the finalize counterpart to a module’s init() (§ 12.3.1): init() runs before any recipe body registers; a queued callback runs after every one has. fn MUST be a function, rejected by type — naming the API, the accepted type, and the received type, never coerced — per the CS-0127 discipline if it is not. Queued callbacks run in registration order, exactly once each, outside the dynamic extent of any recipe body, so a body-scoped register API called from one — cook.add_unit, cook.add_test, cook.recipe_name, cook.require_recipe — raises its ordinary outside-a-recipe-body error. A callback MUST NOT register a recipe (cook.recipe) or a probe (cook.probe); a conforming implementation MUST reject the attempt with a register-phase error naming cook.on_register_complete and the API attempted, rather than accepting the registration and leaving an inert, never-evaluated recipe in the discovered set — the body-invocation loop has already completed when callbacks fire, so a mint at this point reproduces exactly the register/execute silent-disagreement failure mode §22.8’s no-silent-skip rule closes on the forcing side. A callback MAY itself call cook.on_register_complete; the newly queued callback is appended and the queue drains until empty, preserving order and the fires-once guarantee throughout. An error a callback raises fails the registration pass with that error. Everything else register-phase Lua can already do — cook.import/cook.export, cook.probes.get, module-held state, fs.*, cook.sh, cook.load_module — remains available to a callback. A no-body-invocation listing surface (§ 22.7) MAY complete without draining the queue, since a callback cannot alter the discovered set such a surface reports. The motivating consumer (Note 22.9.1) is a compile_commands()-style aggregator that must snapshot per-target state accumulated by recipe-body target-maker calls only once every target has registered — a need a name-only query cannot serve (the name set is knowable before any body runs; the state the consumer needs is not) and which today is expressible only via a fake ordering dependency an author must keep in sync by hand.

Backward compatibility: Nil. Purely additive: one new register-phase-only name on the cook global. No existing Cookfile, grammar, surface syntax, or module changes behaviour; the only new rejections are the two this entry defines (a non-function argument to cook.on_register_complete; a recipe or probe registration attempted from inside a queued callback), and both are reachable only from a Cookfile that calls the new API. CACHE_VERSION does not bump: no key derivation, record shape, or determinant set changes.

Reference implementation: cook-register’s on_register_api.rs registers cook.on_register_complete on the cook global alongside this chapter’s other register-phase-only APIs, and enforces the argument-type check and the callback’s own outside-a-recipe-body / no-minting restrictions on its dynamic extent. engine.rs drains the queued callbacks — in registration order, redraining until the queue is empty so a callback that re-queues is itself honoured — at the finalizer point between the pass’s for_each static-input check (§ 22.5.10) and the module-cache flush that precedes the register→execute handoff.

Conformance: Two fixtures, landing in the same change series. positive/on-register-complete-order pins the fires-once, registration-order, after-every-body guarantee across several queued callbacks. The re-queue path — a callback queued from inside another callback, which the drain-until-empty rule appends and runs — is pinned by the reference implementation’s unit tests rather than a fixture: a corpus case observes only register success or failure, and a wrongly-dropped final callback in the queue has no later observation point inside the pass from which to raise. negative/on-register-complete-mints-recipe-rejected pins the minting ban: a callback that calls cook.recipe is rejected with a register-phase error naming cook.on_register_complete and the attempted call, rather than being accepted and left inert. The latter is a register-phase semantic rejection over a syntactically well-formed Cookfile — parallel to CS-0143’s origin-type negative and CS-0144’s outside-a-recipe-body negative — and is accordingly enumerated in App. F §F.2’s tree-sitter skip list.

CS-0150 — Implementation catch-up: fan-out cook steps register every declared output [#changes.cs-0150]

Date: 2026-07-18 Version: v0.15 (implementation-only catch-up; § 8.5‘s multi-output contract already applied to fan-out bodies via §22.5.7’s uniform-application rule) Sections affected: §22.5.10 (explicit per-member multi-output sentence), App. E (this entry). No grammar, surface-syntax, Lua API, or cache-key change.

Summary: An ingredients <probe> fan-out cook step with more than one output template registered only the first output per member unit in the reference implementation; the remaining templates were silently dropped. The body still wrote those paths, so they existed as undeclared side effects — uncached, unshared, invisible to cook why, and swept as orphans by stale-output reconciliation the moment a refactor re-shaped the recipe (observed live: a light/dark SVG pair whose dark half was deleted mid-refactor). The lowering now expands every output template per member and registers the full set on one unit, exactly as the glob-driven multi-output iteration arm always has. §22.5.10 gains one sentence making the per-member application of § 8.5 explicit.

Backward compatibility: Behavioral fix in the author’s favor: previously-dropped outputs become declared, cached, and reconciled. A Cookfile that (unknowingly) relied on the second output being ignored would now have it participate in caching and sweeping — the correct reading of the declaration. Single-output fan-out lowering is byte-identical; CACHE_VERSION does not bump.

Conformance: positive/probe-fanout-multi-output pins the parse shape (two accessor-bearing output templates on one fan-out cook step). The registration behavior (both outputs on each member’s unit, orphan-sweep correctness) is covered by implementation tests.

CS-0151 — cc.compile_commands becomes a top-level finalizer-backed call; the recipe-body form is removed [#changes.cs-0151]

Date: 2026-07-18 Version: v0.15 Sections affected: §28.3.11 (amended — cc.compile_commands() is a top-level register-phase call that queues a run-once finalizer via cook.on_register_complete (§22.9); the completeness requirement over every registered target, the idempotence requirement, and the recipe-body-invocation rejection; the stale worker-VM recipe-body sourcing sentence is removed and replaced with register-phase-VM, module-held-known-target sourcing); App. E (this entry). No grammar (App. A), surface-syntax, parser, codegen, or cache-key change.

Summary: Before 0.14, compile_commands.json was written by a db.write()-style call inside a user recipe body, sequenced by a hand-maintained ordering dependency (recipe compile-commands : <target>). Any registered target outside that dependency’s closure was silently dropped from the database — the dhewm3 base / d3xp disconnected-plugin case, where undispatched plugin targets never appeared in the wrapper recipe’s dep list and so never reached the writer. cc.compile_commands() is now a top-level register-phase call that queues a run-once finalizer through cook.on_register_complete (CS-0149’s motivating consumer, Note 22.9.1 — this entry closes that loop). The finalizer snapshots the COMPLETE known-target set after every recipe body of the registration pass has registered its targets: complete by construction, with no ordering dependency required of the author. Repeat top-level calls are idempotent — at most one finalizer queued per registration pass, one write of the database per pass. A call made from inside a recipe body is a hard register-phase error naming the top-level form. No support recipe is minted: the finalizer regenerates the database on every registration pass, so a dispatchable name would have nothing to do — and CS-0149 bans minting a recipe from a register-completion callback, while minting one at call time (per CS-0143’s annotation model) is deliberately not done.

Backward compatibility: Module hard break, cook_cc 0.13 → 0.14 (pre-1.0; no dual API). Every Cookfile carrying the wrapper-recipe form must delete the wrapper recipe and its ordering dependency and hoist a bare top-level cook_cc.compile_commands() call. The language surface is unchanged — a top-level <id>.<id>(...) module call has been valid since CS-0072 — and CACHE_VERSION does not bump.

Reference implementation: cook_cc 0.14.0. cook_cc/compile_db.lua gains the compile_commands() guard (the top-level entry point), a per-register-VM latch so at most one finalizer is queued per VM, and cook.on_register_complete(M.write); write() remains the renderer that snapshots the module-held target state and emits the database. cook_cc/init.lua wires the surface. Busted specs pin the disconnected-target completeness (both targets’ sources present with no dep between them), idempotence (one write across repeat calls), and the in-body rejection.

Conformance: positive/cc-compile-commands is conformed from the recipe-body form (pre-0.13 two-arg maker syntax plus the recipe compile-commands : <target> wrapper) to the top-level form (cook_cc.compile_commands() at column 0, one recipe carrying a 0.13-style maker call). Parse-only. The finalizer/write behavior itself is blessed-module behavior (§28), not language surface, so it needs no new corpus case; the parse-level top-level module-call shape it relies on is already pinned by positive/top-level-module-call.

CS-0152 — Lua-body probe reads are demanded: static cook.probes.get scan + hard error on unmaterialised reads (COOK-217) [#changes.cs-0152]

Date: 2026-07-18 Version: v0.15 Sections affected: §22.5.7 (the Lua-body static-scan rule), §22.5.8 (unmaterialised-read error replaces nil-on-miss; the null-hit boundary), App. E (this entry). No grammar (App. A), surface-syntax, parser, codegen, or cache-key change; CACHE_VERSION does not bump.

Summary: Closes a launch-blocking silent-nil defect in probe consumption from execute-phase Lua bodies. Demand-driven scheduling (§ 22.5.8) executes a probe only when a scheduled non-probe unit lists its key in probes. A shell body gets that field auto-populated by $<key> sigil scanning (CS-0074), but a Lua body — a native >{ … } cook body, or cook.add_unit’s lua_code — had no equivalent surface: a probe consumed ONLY via a Lua body’s cook.probes.get("key") was never demanded, and §22.5.8’s old sentence (“a call for a key that has not completed as a probe MUST return nil”) turned the un-demand into a silent wrong-value read. The failure was nondeterministically masked — any sibling shell step that happened to demand the same key materialised the value and made the Lua read work — which is exactly why the shape survived in the public README and the dogfood Cookfiles. Two coordinated rules repair it, and they compose: (1) static scan — §22.5.7 now requires the implementation to statically scan a Lua step body’s source for cook.probes.get calls whose first argument is a string literal constituting the entire argument (comments and unrelated string literals excluded; a literal followed by concatenation is not collected) and union each discovered key into the unit’s probes list, from where § 22.5.6‘s end-of-pass machinery applies unchanged — unknown-key register-phase diagnostic, DAG edge, fingerprint fold, demand-driven scheduling. Native >{ … } bodies lower to cook.add_unit({ lua_code = … }), so the one capture-site scan covers native bodies and raw API calls alike. (2) hard error — §22.5.8 now requires cook.probes.get (and scoped scope(label).get, which reports the full prefixed label:key) on a key absent from the per-run probe-value store to raise an execute-phase error naming the key and the remediations (reference the probe in the step, declare it in the probe’s inputs.requires, or seal it), instead of returning nil. Literal keys are therefore demanded and just work; dynamic keys — a variable or expression argument, a concatenation, a cook.probes.scope(...) chain, the paren-less cook.probes.get"key" / long-string call sugar — are deliberately NOT scanned and degrade safely to the loud execute-phase error. The null-hit boundary is preserved: a key that IS materialised whose canonical value is null (§ 22.5.5) still decodes to Lua nil with no error — value-was-null is not a miss. That boundary is load-bearing for module finder produce bodies, which read an upstream probe with cook.probes.get(KEY) or { … } and rely on a legitimately-null value falling through to the default. Probe produce bodies themselves are outside the scan’s scope: they register via cook.probe and are ordered by inputs.requires.

Backward compatibility: Two observable breaks, both in the loud direction. (1) An execute-phase read of an unmaterialised key now errors instead of returning nil: a Cookfile that relied on nil-on-miss as a feature-detection idiom (“probe absent means feature off”) breaks loudly and must switch to demanding the probe and testing its value (a probe whose produce returns null on absence expresses the same idiom correctly, and still reads as nil). (2) A lua_code body’s literal cook.probes.get naming an UNDECLARED probe key — even in dead or rarely-taken branches — is now a register-phase error via § 22.5.6‘s unknown-key diagnostic (previously it registered fine and read nil if reached); and a literal read in a conditionally-executed branch now unconditionally demands and schedules the probe (the scan performs no reachability analysis). Both are accepted deliberately: the alternative in every case was a silent nil flowing into build commands, and the overapproximation matches the shell-sigil precedent exactly — a $<key> sigil has always demanded its probe regardless of whether the line carrying it runs. No key derivation, record shape, or determinant set changes; a Cookfile that was reading real probe values keeps its cache entries, and a newly-demanded probe folds into consumer fingerprints precisely as an author-listed key always has.

Conformance: No conformance case is added. Per CONTRIBUTING’s spec-first rule item 4 — a conformance case is required only “if the change is observable from a Cookfile” at the parse level — none applies: there is no grammar or surface-syntax change, and a conforming Cookfile is spelled identically before and after this entry; what changes is register/execute-phase engine behavior over unchanged spellings. The behavior is pinned by the reference implementation’s tests instead: cli/crates/cook-luaotp/src/pool.rs unit tests (missing-key error names the key; scoped miss names the full label:key; stored-null reads as nil with no error), cli/crates/cook-register/src/unit_api.rs capture tests (scanned key unioned into probes; union with an explicit probes list deduplicates; a shell-command unit is unaffected by the scan), cli/crates/cook-luagen/src/lua_env.rs scanner tests (quoting forms, whitespace, comment/string exclusion, concatenation and scope(...) non-collection, sort/dedupe), and the cli/crates/cook-engine/tests/probe_in_body_e2e.rs end-to-end pair — lua_only_probes_get_demands_and_reads_value (a probe consumed only from a Lua body is demanded, scheduled, and read) and dynamic_probes_get_on_undemanded_key_hard_errors (a dynamic key on an undemanded probe fails the unit with the naming error).

CS-0153 — cook.add_unit rejects step_kind = "test"; §22.4 documents cook.add_test’s inputs field [#changes.cs-0153]

Date: 2026-07-18 Version: v0.15 Sections affected: §22.1 (step_kind accepted set narrowed to "cook" / "chore"; normative rejection sentence), §22.4 (inputs row added to the field table), App. F §F.2 (skip-list enumeration), App. E (this entry). No grammar (App. A), surface-syntax, parser, codegen, or cache-key change.

Summary: Two register-API repairs to the same silent-no-op class. First, cook.add_unit accepted step_kind = "test" but the value was only sandbox/diagnostics metadata (CS-0135): the recorded unit was an ordinary non-test payload, and the test runner discovers tests by payload variant — so the unit was invisible to cook test. Passing step_kind = "test" looked like registering a test, silently was not one, and could turn a test gate green over a failing check. The value is now a hard register-phase rejection whose diagnostic names step_kind and directs the author to cook.add_test (§ 22.4), the API that builds the test payload; "cook" and "chore" remain accepted, and no codegen path has emitted the field since the v1.0 language cut removed the for_each test-body lowering. Second, the reverse direction of the same edge: §22.4’s field table omitted the long-implemented inputs field on cook.add_test — an array of file paths whose content keys the test’s cache entry, unioned with the enclosing step group’s dependency-output paths (deduplicated, order-preserving), mirroring cook.add_unit’s inputs. The field is what lets a module target-maker key a hand-registered test on the file set it scans; it is now documented as implemented (implementation catch-up in the spec’s favor).

Backward compatibility: Any Cookfile or module passing step_kind = "test" to cook.add_unit now errors at register phase instead of silently registering a non-test — strictly author-favoring (the accepted path was always wrong), and no such caller exists in the reference corpus or blessed modules. The "cook" / "chore" values, the absent-field default, and the wrong-type / unknown-string rejections are unchanged. The §22.4 inputs row documents shipped behavior; nothing changes at runtime and CACHE_VERSION does not bump.

Conformance: negative/add-unit-step-kind-test-rejected pins the register-phase rejection (register_error.txt; on the tree-sitter harness’s SEMANTIC_ONLY_NEGATIVES skip list and enumerated in §F.2, since the Cookfile parses cleanly). positive/add-test-inputs-register-ok pins cook.add_test accepting inputs end-to-end at register phase (register_ok.txt). Implementation tests additionally cover the surviving accepted values landing on the recorded payload.

CS-0154 — Block body is the character span between braces; shell quote state carries across lines [#changes.cs-0154]

Date: 2026-07-19 Version: v0.15 Sections affected: §3.9 (body redefined as the character span between the opening brace and its matching close: the opening-line remainder and the closing-line prefix are body segments; segment-based normalisation; non-whitespace after the closing brace is a parse error; new Examples 3.9.4 / 3.9.5), §3.9.2 (shell single- and double-quoted strings become multi-line opaque spans; boundary-segment participation note), App. A.5 (brace-balance and line-normalisation paragraphs restated in span/segment terms), App. E (this entry). No Lua API, execution-model, or cache-key change.

Summary: Two related repairs to brace-balanced block collection, both found live authoring a probe … json { echo '[ … ]' } producer. First, §3.9 defined the block body as the whole lines strictly between the opening and closing lines, so text after the opener on its line (json { cat <<'J', >{ return {) and text before the closer on its line (]' }) was silently discarded — a heredoc opened on the opening line was never seen by the scanner, and a Lua block opened as >{ return { lost its first statement and failed to compile with a baffling downstream syntax error. The body is now the character span between the braces: the opening-line remainder, the interior lines, and the closing-line prefix are all body segments, scanned with the same opaque-span state machine; the single-line inline form becomes the degenerate case of the same span rather than a separate quote-naive code path (fixing the latent { echo '}' } inline miscount). Text after the closing brace on its line is the enclosing production’s trailer: the cook/test step’s modifier tail keeps its meaning — now sourced from the exact close position instead of an rfind('}') heuristic — while stray trailer text in trailer-less contexts (probe producers, chore Lua blocks), previously skipped silently, is now a parse error. Second, §3.9.2 declared shell quote spans single-line, so a POSIX quoted string spanning newlines — legal shell, and natural for inline JSON — had its continuation lines scanned as bare text: the closing ]' } line opened a phantom quote that swallowed the real closing brace, and the block failed as unclosed. Shell single- and double-quote state now carries across body lines exactly as heredoc state has since CS-0035. App. A.5’s per-segment trim is unchanged and applies uniformly, including to lines interior to a multi-line quoted string.

Backward compatibility: Accepts strictly more programs on the span side: every block that parsed before parses identically (a boundary segment that is empty or whitespace-only is dropped, so the canonical {-at-end-of-line / }-alone-on-a-line layout is byte-for-byte unchanged in both body forms), and previously-rejected or previously-truncated layouts now mean what they say. One new rejection, in the loud direction: stray non-whitespace text after a block’s closing brace in a trailer-less context (a probe producer, a chore-body Lua block) was previously discarded without diagnosis and is now a parse error; the cook/test modifier-tail positions are unaffected. Multi-line quote carry can reclassify a pathological Cookfile that relied on an unbalanced ' being treated as line-local (no such Cookfile exists in the reference corpus, the blessed modules, or the dogfoods); the reading that tracks what /bin/sh does with the assembled script wins. CACHE_VERSION does not bump: command strings for previously-valid blocks are unchanged.

Reference implementation: cook-lang’s brace_scan.rs scanners gain a scan-to-close entry point returning the in-line close position, with ShellScanner carrying in_quote state across lines alongside its heredoc queue; shell_block.rs / lua_block.rs collect the opening-line remainder and closing-line prefix as body segments and reject trailing text after the close; the quote-naive try_inline_* scanners are deleted in favour of the unified span walk; collectors return the post-close trailer to the caller, retiring the rfind('}')-based tail_after_body heuristic; the chore-body Token::LuaBlockOpen arm and probe producers recover the opening-line remainder from source. Regression: COOK-267 / COOK-268.

Conformance: positive/shell-block-multiline-quote pins the multi-line single-quoted producer (Example 3.9.4); positive/lua-block-boundary-segments pins the >{ return { … } } boundary-segment chunk (Example 3.9.5); positive/shell-block-heredoc-open-line pins a heredoc opened on the block’s opening line; negative/probe-producer-trailing-after-close pins the new stray-trailer rejection.

CS-0155 — ingredients <probe> fan-out follow-ups: literal-output steps gather; $<out_N> resolves per member [#changes.cs-0155]

Date: 2026-07-19 Version: v0.15 Sections affected: §8.4.1 (probe-driven mode selection now follows the output-pattern rule — accessor-bearing steps iterate members, literal-output steps are the ordinary chained many-to-one gather; new first-step rejection; new Example 8.4.1.2), §22.5.10 (per-member paragraph scoped to fan-out steps), App. E (this entry). No grammar (App. A) or parse-shape change.

Summary: Two repairs to the ingredients <probe> consumption surface, both found writing the v0.6.0 README’s ship demo follow-ups. First (COOK-271), §8.4.1 declared every cook_step of a probe-driven recipe member-iterated “regardless of its output-pattern accessors”. Read literally, a literal-output step registered one unit per member — N units colliding on the same declared output, each with $<in> expanded to the member’s raw record text as shell words (cat {"name":"a"} > out/page.md). That breaks the fan-out-then-aggregate shape the feature exists for, and a tolerant command could cache a wrong artifact silently. The mode now follows the same output-pattern rule as everywhere else: accessor-bearing outputs iterate members; all-literal outputs gather the preceding step’s collected outputs (the ordinary chained many-to-one), so fan out → pack works in one recipe. A literal-output step with no preceding cook_step has nothing path-shaped to gather — data members are records, not files — and is now a register-phase rejection directing the author to fan out first or read the probe from a Lua body. Second (COOK-270, implementation catch-up): CS-0150 made multi-output fan-out steps register every declared output, but the placeholder resolver still validated $<out_N> against a hardcoded single-output shape, rejecting every indexed placeholder in a fan-out body with a nonsense count (“requires multi-output ≥ 1 … step declares 1”). The resolve context now follows the declared template count; $<out_1> / $<out_2> resolve to the member’s declared outputs, and bare $<out> on a multi-output fan-out step is rejected exactly as § 8.5 already requires elsewhere. No normative text needed for this half — §22.5.10 (CS-0150) and § 9.3 already promised it.

Backward compatibility: Author-favoring in both directions. A literal-output step after a fan-out step previously produced colliding per-member units over record text — garbage in every observed case — and now means what the manual’s chaining rule always said; a Cookfile that somehow relied on per-member record text in a literal-output step must switch to an accessor-bearing output or a Lua body. The first-step literal case moves from silent garbage to a loud rejection. $<out_N> acceptance is strictly widening. CACHE_VERSION does not bump; previously-working recipes (accessor-only, or aggregating via cross-recipe refs) are byte-identical.

Reference implementation: cook-luagen’s recipe generator routes a probe-recipe cook_step by output-pattern kind — all-literal with a preceding step goes through the ordinary chained generate_cook_step (input source _cook_outputs_<prev>), all-literal with no preceding step emits the register-phase rejection, accessor-bearing keeps generate_for_each_cook_step; the fan-out resolve context uses count_to_output_shape(outputs.len()) instead of hardcoded Single. Regression: COOK-270 / COOK-271.

Conformance: negative/probe-fanout-literal-first-step pins the first-step rejection (register_error.txt; the Cookfile parses cleanly, so it joins App. F §F.2’s tree-sitter skip list). The gather-after-fan-out and $<out_N> behaviors are register/execute-phase semantics over unchanged parse shapes and are pinned by cook-luagen unit tests and an engine e2e (fan out → gather → member removal resweeps and repacks).

CS-0156 — Candidate-key recovery on input change: edit-then-revert restores instead of re-executing [#changes.cs-0156]

Date: 2026-07-20 Version: v0.15 Sections affected: §17.3.1 (new — candidate-key recovery for discovered-inputs units: multi-set candidate retention under the declared-only key, key recomposition from current working-tree content as the validation, output recovery by the candidate key’s own recorded output list, recovery-equals-execution local recording and stray sweep); §17.1.5 manifest note (the determinant manifest additionally records empty-directory implicit outputs, serde-defaulted for old manifests); App. E (this entry). No grammar (App. A), surface-syntax, parser, or codegen change. CACHE_VERSION does NOT bump — key composition is unchanged; every addition is a new reserved artifact or a defaulted field, safe in both mixed-version directions.

Summary: Reverting an edit (checkout, stash pop, rebase, toggling a change to compare) re-executed discovered-inputs units instead of restoring the artifact cached moments earlier — a full next build each direction on the cap-cook flagship (COOK-278). The audit found the fetch path already recomposed candidate full keys (COOK-177) but failed in four stacked ways: (1) the restore probed the CALLER’s output list, which for glob-output units is the last run’s concrete names — content-dependent filenames (Next.js chunk hashes) therefore never matched the original build’s artifact keys; (2) the discovered-inputs manifest under the declared-only key was single-set last-writer-wins, so an edit that changed the discovered SET erased the older set’s recovery path; (3) after a fetch hit the local entry was recorded THIN (declared inputs only, no depfile output), so every subsequent run silently re-fetched from the store instead of local-skipping; (4) the previous build’s stray concrete outputs were never swept on restore. §17.3.1 now specifies the ccache-style candidate-manifest pattern: retain every distinct discovered set under the declared-only key (newest first, bounded), validate candidates by recomposing the full key from current working-tree content (a set whose files hash differently composes a different key and misses naturally — no stored hash is ever trusted over bytes), restore the output list recorded for the WINNING key via the determinant manifest, then record a fat local entry and sweep unrestored prior outputs so recovery is byte-identical to a fresh build. The same manifest-driven output recovery serves declared-only units with content-dependent glob output names (sole candidate = the empty set), and lifts the old “glob outputs cannot cold-fetch” limitation.

Backward compatibility: Fully compatible in both directions with existing v6 stores — no key change, no store invalidation. A pre-CS-0156 binary reading a store written by a new binary keeps working through the still-written single-set manifest; a new binary reading an old store recovers through the v1 single-set fallback and caller-list output fallback (exactly the old behaviour). Old determinant manifests parse with empty_dir_outputs = [] (serde default) and restore files only, as before.

Reference implementation: cook-fingerprint: fetch_by_key returns Option<FetchOutcome> (restored outputs + winning discovered set) and loops candidates from read_discovered_input_sets (v2 multi-set artifact at reserved index u32::MAX-1, path __cook_discovered_input_sets__, capped at 64; v1 fallback); per-candidate output recovery via get_manifest(full_key) (files, implicit depfile, empty dirs — publish index order) with caller-list fallback. cook-engine: publish maintains both manifest generations and records empty dirs in the determinant manifest; the fetch-hit path sweeps prior-entry outputs not restored (files first, then empty-only remove_dir), reconciles dir/ outputs against the restored set, and records the fat StepEntry; cook why’s read-only shared probe uses the manifest output list, killing a false SharedMiss for glob units. Regression: cook-cache/tests/integration_revert_restore.rs (stale-caller-names restore, older-set restore, v1-format recovery) and e2e-fixtures/cache_revert_restore/verify.sh (12-step edit/revert walkthrough over both unit shapes, asserting restore, sweep, and settled local skips).

CS-0157 — tools { } value carries identity only: resolved path becomes read-view metadata [#changes.cs-0157]

Date: 2026-07-20 Version: v0.15 Sections affected: §22.5.2 tools producer value shape (amended — per-tool entry is { "hash": <content hash> }; the value MUST NOT contain the resolved path or any machine-specific location; the path becomes REQUIRED read-view metadata, resolved afresh every run, merged into cook.probes.get’s returned table and shown by cook why); §17.1.6 (the key explanation surfaces tool paths from the metadata channel at query time, not from the value); App. E (this entry). No grammar (App. A) or parse-shape change; CACHE_VERSION does not bump.

Summary: The engine’s native tools { } producer folded the resolved absolute path into the sealed value, so seal_contribution keyed every sealing unit on WHERE the toolchain lives: identical toolchain bytes at different locations (homebrew vs /usr/bin, nix store paths, ~/toolchains) produced different unit keys and heterogeneous fleets could never share sealed artifacts — the over-keying half of COOK-277, and a direct violation of the portability principle the sibling files producer already documents (“the value contains no machine-specific absolute paths”). The machine-independent identity (the binary’s content hash) was already sitting in the same value; the path poisoned it. The value now carries { hash } only. Path is location, not identity: it rides a per-run read-view metadata channel populated from the same fresh PATH resolution the fingerprint pass already performs, merged into the Lua read view (shape-scoped, so custom-body probes declaring inputs.tools keep their values untouched) and displayed by cook why — which also fixes the pre-existing staleness hazard where a cache-hit value replayed a path recorded on another machine or before the tool moved. The probe FINGERPRINT (name + content hash) always excluded path and is unchanged.

Backward compatibility: No consumer of the native producer’s .path existed in any fixture, example, module, or downstream Cookfile (audited); consumers that do read $<KEY.NAME.path> keep working and now always see the current location. Old cached probe values (path-bearing) are unreachable by construction: the produce body is part of the probe fingerprint, and the lowered body changed. Sealing units re-key once (the sealed value bytes changed); a mixed-version fleet simply does not share tools-sealed artifacts until both sides upgrade — safe degradation, no store invalidation.

Reference implementation: cook-luagen emits _t[NAME] = { hash = _h }; the engine records fresh which-resolved paths per probe at dispatch (ProbeValueStore::set_tool_paths); cook.probes.get (execute VM, plain + scoped) merges path into tool-shaped entries; cook why re-resolves at query time (<not found> when a tool left PATH). Regression: e2e-fixtures/tool_identity_seal/verify.sh (relocated-toolchain shared hit, hash-only value on disk, fresh read-view path, upgraded-bytes re-key) and the luagen shape test asserting path never enters the value.

CS-0158 — cook.tools.id(name): canonical tool identity for module seal values [#changes.cs-0158]

Date: 2026-07-20 Version: v0.15 Sections affected: §24.9 (new — cook.tools.id both-phase API: signature, hash-is-identity / path-is-location semantics, the MUST NOT-seal-the-path rule, Note 24.9.1 install requirement); §12.7.5 cross-reference (the seal policy’s “resolved toolchain identity” now has a named engine-provided source); §28.3.10 (amended — the toolchain probe value MUST fold the chosen drivers’ content-hash identities cxx_id/cc_id and MUST NOT contain a resolved path); App. E (this entry). No grammar or parse-shape change; CACHE_VERSION does not bump.

Summary: The under-keying half of COOK-277: cook_cc’s toolchain probe sealed command NAMES only ({cxx="g++", cc="gcc"}) — a deliberate workaround for CS-0157’s pre-fix path folding — so gcc 13 and gcc 14 produced identical unit keys and a compiler upgrade reused stale objects, including from the shared store across machines with different compilers. §12.7.5 requires sealing on “a resolved toolchain’s identity/content hash”, but no engine API existed to obtain one without Lua hashing 60MB binaries. cook.tools.id(name) returns { hash, path } — the hash to fold into a sealed probe value, the path for invocation only — backed by the fingerprint machinery’s per-run tool-hash memo, so the binary is hashed at most once per run. Module adoption (cook_cc folding the chosen compiler’s hash into its sealed value) ships with the module, not this engine revision.

Backward compatibility: Purely additive API; no existing surface changes.

Reference implementation: cook-lua-stdlib::register_tools_api (shared install, register VM + every worker VM, mirroring the CS-0123 codec pattern) over cook_fingerprint::tool_identity. Pinned by stdlib unit tests (real-tool resolution shape, nil on missing) and the tool_identity_seal fixture’s cook.tools.id scenario.

CS-0159 — seal on test units: the test tail and the baseline that reaches it [#changes.cs-0159]

Date: 2026-07-20 Version: v0.15 Sections affected: §8.6 (amended — a test_step takes an optional test_mods tail; the blanket “no trailing modifiers” rule is replaced by “test_mods, and nothing else”, preserving the should_fail/timeout/as did-you-mean diagnostics; a seal MUST NOT make a source-less test cacheable); §8.4.3 rules 1-4 (amended — the recipe-level baseline applies to every cacheable unit, cook and test alike; a baseline with no cacheable unit to land on is rejected; a trailing unseal is per-unit); §8.4.3.3 (new — test_mods, rules 9-10, Note 8.4.3.3); Example 8.4.3.3 (new); §17.4 rule 1 (amended — names the declaring surface, requires sealed probes to be scheduled ahead of the test, fixes the absent-key encoding, and forbids collision with the env-key fold); §22.4 (new seal field on cook.add_test); App. A (test_step gains test_mods?; new test_mods production); App. E (this entry). CACHE_VERSION does not bump.

Summary: §17.4 rule 1 has required a test unit’s cache key to fold “the values of every probe in the unit’s effective seal set” since CS-0138 — but no surface could produce a non-empty one. TestStep carried no disposition, cook.add_test accepted no seal field, and §8.6 rejected every trailing token, so a test’s effective seal set was unconditionally empty and rule 1 was unsatisfiable as written. This was a spec/implementation divergence, not a missing feature. CS-0159 supplies the surface. A test’s cache key already folds the content of the files it consumes; what it could not see was every determinant that is not a file — the compiler the binary under test was built with, a service version the suite runs against, a feature flag. The only prior remedies were to make the test uncacheable (losing reuse outright) or to thread a sentinel file through the recipe. The seam that decides what a test inherits is the one §8.4.3 already draws: seal/unseal name a determinant INPUT, which every cacheable unit has, so a test takes them; local/pinned/nondet state a fact about an output ARTIFACT, which a pass/fail record is not, so a test rejects them. Hence test_mods is the input half of cook_mods and a distinct production, not cook_mods with a runtime check. The test-only recipe, formerly rejected with “no cook units to apply to”, is now a legitimate seal target.

Backward compatibility: Purely additive at the surface — every previously-valid Cookfile stays valid, and the one previously-invalid construct that becomes valid (a recipe-level seal over a test-only recipe) was a hard parse error, so no program changes meaning. The digest is additive too: the sealed-value fold is emitted only for a non-empty set, so an unsealed test hashes byte-identically to its pre-CS-0159 key. No CACHE_VERSION bump is required, existing test-cache entries stay valid, and a newly-sealed test has no prior entry to collide with. The fold carries a \0seal\0 domain tag before its pairs: without it a sealed probe K with value V would hash identically to an env contribution K=V, since both pair lists share one encoding and are hashed back-to-back — two different determinants colliding on one key, i.e. a false cache hit. NUL cannot occur in an env key or a probe key, so the tag is unambiguous.

Reference implementation: cook_lang::ast::TestStep gains seal; disposition::parse_test_modifiers parses the tail (sharing parse_seal_refs with the cook path, so ref validation cannot drift) and subsumes the former reject_test_trailing; recipe::apply_base_seal folds the baseline over both step kinds keyed by step index, and finalize_base_seal admits a test-only recipe. Codegen emits seal = {...} on all six cook.add_test emission sites (including the ingredients <probe> fan-out arms); cook-register’s test_api reads it and unions the keys into the unit’s probes list — the wiring that guarantees a sealed value is in the ProbeValueStore by ready time. WorkPayload::Test carries seal_keys (payload, not cache_meta, which the executor requires to stay None for test nodes); compute_ready_test_fingerprint resolves them through the shared seal::resolve_sealed_probes and compute_test_fingerprint folds the result. Regression: 15 parser tests (baseline reach, tail additivity, per-unit unseal isolation, order independence, share_mod and bare-seal rejection, migration diagnostics, ref validation) and 6 fingerprint tests (empty-set identity, value sensitivity, order insensitivity, key/value swap, env-fold non-collision).

CS-0160 — derived test unit names [#changes.cs-0160]

Date: 2026-07-21 Version: v0.15 Sections affected: §22.4 (amended — the recorded test unit’s name is derived as <recipe>_test<N>); App. E (this entry). CACHE_VERSION does not bump.

Summary: CS-0135 removed cook.add_test’s name field, and the reference implementation defaulted the engine-facing name to the empty string. That empty string leaked into every consumer that renders a test unit: the progress verb line fell through its command-token fallback and printed a literal $?, cook logs showed a blank unit name, and the test identity collapsed to recipe:. The gap became visible with CS-0159: a sealed, target-less test step is now an ordinary construct, so its unit is on screen in any run. §22.4 now requires the name to be derived at register time as <recipe>_test<N> — the enclosing recipe’s fully-qualified name (the same value the suite default reads) and the 1-based ordinal of the test unit within the recipe body. The ordinal counts test units only, so interleaved cook steps do not perturb numbering, and a OneToOne fan-out numbers its per-input units in iteration order with iteration_item still carried as the identity discriminator.

Backward compatibility: No surface change — no field is added or removed, and no previously-valid Cookfile changes meaning. Test cache keys do not fold the unit name, so existing test-cache entries stay valid and no CACHE_VERSION bump is required. The human-facing verdict label printed by the test reporter is unchanged: it remains recipe@line [item] — the derived name is identity, not prose, and the reporter’s dormant recipe::name arm (dead since CS-0135 removed the as modifier) is deleted rather than resurrected by the now-always-non-empty name. Rendered output changes only where a defect showed: progress lines, cook logs, and test identities show <recipe>_test<N> where they previously showed $?, a blank, or recipe:. Anything parsing those strings was parsing a defect.

Reference implementation: cook-register’s test_api derives the name at cook.add_test time from current_recipe and a count of previously-recorded WorkPayload::Test units in the body; cook-progress’s NodeState::display returns the name bare for NodeKind::Test nodes instead of $-prefixing it as command text; cook-cli’s test_reporter::label drops its name parameter and always renders recipe@line. The cache-hit path needs the same treatment and did not get it initially: a cached test emitted only NodeCompleted, which updates an existing Running node and never creates one, so no event carrying the derived name ever reached the renderer and the node showed ? (JSON: a bare node#N). The engine now emits NodeCacheHit for a cached test as it already does for every other cached node kind, and that event gains a kind field — without it the node was created with the default kind and the name was rendered as command text ($rust_test1). The kind is additive on the JSON node-cache-hit object. Regression: register-phase ordinal/naming tests, a progress display test, a build-state test asserting a cached NodeKind::Test node renders its derived name bare, and the reporter label tests reduced to the @line convention.

CS-0161 — cook.dep_order: forcing without the whole-recipe barrier [#changes.cs-0161]

Date: 2026-07-22 Version: v0.15 Sections affected: §22.8 (qualification — a module wanting the register-order guarantee without the barrier uses cook.dep_order); §22.10 (new — cook.dep_order, its register-order guarantee, its closure membership, and Note 22.10.1’s division of labour); §28.3.6 / §28.3.7 / §28.4 (dep_recipes on cc.archive / cc.link, and the link_dep_recipes closure-walk set).

Summary: The 2026-07-21 dhewm3 perf audit found that wide C++ sweeps executed as sequential per-recipe waves despite every translation unit being an independent node. The cause was a conflation, not a graph defect: cook.require_recipe fuses two separable concerns — forcing the referent’s body so its exports are readable, and establishing a whole-recipe ordering edge. A C/C++ target maker resolving links needs only the first. It paid for the second, so every compile in a target queued behind the linked library’s archive, an artifact no compile reads.

cook.dep_order separates them. It carries cook.require_recipe’s register-order guarantee in full — same transitivity, same at-most-once rule, same prohibition on silent skips — and establishes closure membership on the same terms every other name reference does (CS-0121). What it does not carry is the coarse edge: it records a per-unit reference, attaching to the units registered after it, with no returned paths and no cache-input fold. A maker forces its links inside an empty cook.step_group (references accumulated in a group are discarded at its close, so no compile inherits an edge), then names the referent again immediately before the archive or link unit that genuinely needs the ordering.

Rejected alternative — fine-covered narrowing. The first draft of this change let a fine-grained reference suppress the coarse edge it overlapped: declare both, and the engine cancels one against the other. That was withdrawn. Subtracting an edge the author declared removes ordering nobody asked to have removed, which cost a carve-out in §16.1.2’s read-after-write reachability and a standing note pinning the residual hazard. The additive form has neither, because nothing is removed: an author who wants the barrier declares requires, an author who wants per-unit ordering declares neither and uses cook.dep_order, and the opt-in is the absence of a declaration rather than the cancellation of one. Measured on dhewm3 (32 cores), the additive form matches what narrowing achieved: header sweep 4.74s → 2.50s, cold rebuild 17.5s → 12.4s at 2845% CPU, byte-identical outputs.

Forcing follows the read, not the declaration. A first cut gave cook.dep_order the forcing guarantee and left a maker to call it purely for that effect — inside an empty cook.step_group, so the references it recorded were discarded at the group’s close and no compile inherited an edge. That worked and shipped in cook_cc 0.17.0, but the emptiness of that group was load-bearing, which is a poor thing to depend on. cook.import now forces instead (§ 24.5.2): a module needs the body evaluated at exactly the moment it reads the export, so the force belongs there. The empty group is gone from cook_cc entirely, and resolve_links is the only thing that touches the referent.

cook.dep_order keeps its own forcing guarantee, which is not redundant: a recipe reached ONLY through a fine-grained reference must still have its body evaluated, or the register phase would skip it and the engine would pull a unit-less recipe into the closure — the register/execute disagreement § 22.9 forbids.

Sections affected (addendum): §22.5 (opts.dep_order on cook.step_group — the declarative alternative to a positional bare call); §24.5.2 (cook.import forces inside a recipe body, with the already-exported, outside-a-body, and unknown-recipe boundaries).

Migration: none required — cook.require_recipe is unchanged and remains correct wherever the caller genuinely consumes the referent from every unit. Modules resolving link references SHOULD move to cook.dep_order; cook_cc does so in 0.17.0.

CS-0162 — Implementation catch-up: $<in.FIELD> renders a nested field as canonical JSON [#changes.cs-0162]

Date: 2026-07-22 Version: v0.15 (implementation-only catch-up; § 9.3‘s record-key rule already required the JSON rendering) Sections affected: §9.3 (one paragraph making the field rendering explicit and naming the cache consequence), App. E (this entry). No grammar, surface-syntax, Lua API, or cache-key change.

Summary: In a record fan-out (ingredients <probe>), $<in.FIELD> lowered to Lua’s tostring(item["FIELD"]). For a flat string or number field that is the right rendering, and the accessor worked as specified. For a nested field — an array or an object, i.e. a Lua table — tostring yields the table’s address, which is ASLR-fresh in every process. That address landed in the unit’s command string, so the command hash changed on every run and the unit could never cache: it rebuilt forever, on a Cookfile and a dataset that had not changed.

The failure was silent and self-concealing. Nothing errored, every build was correct, and cook why attributed the invalidation to command_hash — which reads as “your command is nondeterministic” and sends the author auditing their own recipe body. Any fan-out over realistic JSON (anything with a list or object field, whether or not the recipe referenced it as a whole) lost caching with no diagnosable cause. The lowering now routes the field through the same cook.member_to_string renderer the whole-member $<in> already used, so a nested field renders as compact key-sorted JSON and scalars keep their bare string form.

Backward compatibility: A nested-field unit’s command text changes, so its cache key changes and it rebuilds once — which is vacuous, because such a unit could not have had a reusable entry in the first place. Flat string / number / boolean fields render byte-identically (member_to_string returns a string scalar bare and a number in its JSON form, matching tostring for both), so their keys are unchanged and no CACHE_VERSION bump is required. One rendering does change for a scalar: an absent field, formerly nil, is now null, consistent with the JSON rendering of the whole member.

Conformance: No corpus case — the parse shape of $<in.tags> is identical to the already-covered $<in.name>, and the defect was entirely in the lowering. Pinned instead by the surface e2e fixture 21-fanout-nested-field-json (nested array and object fields render as canonical JSON; the second run is a cache hit) and by the luagen lowering tests. Found by COOK-302, baselining data-driven fan-out on cook 0.8.0.

CS-0163 — Config-block sandbox and the host.* read surface [#changes.cs-0163]

Date: 2026-07-23 Version: v0.16 (first surface revision of the config-block redesign; introduces the host.* read surface and the config sandbox) Sections affected: §5.3.1 (composition now runs against the sandbox environment), §5.3.2 (new: the sandbox and the host read surface), App. E (this entry). New conformance corpus cases. No change to $<NAME> resolution, the env.* output form (that is CS-0164 / COOK-309), the grammar, or the cache key.

Summary: A config body previously ran in the full-standard-library register VM: it could call os.execute, read the clock, draw math.random, open arbitrary files, and read the ambient process environment, and any of those values could flow into an env.* output and thus into a $<NAME> a step consults. Nothing enforced that a config block was a pure function of declared inputs, so a config value could be silently nondeterministic while feeding a cache key.

The config body now executes in a sandboxed Lua environment. The os and io libraries, clocks, pseudo-random sources (including math.random / math.randomseed), process spawning, filesystem writes, dynamic code loading, debug, and coroutines are unreachable; reaching one is a load-time error that names the symbol and points the author at host.*. Pure Lua control flow (conditionals, loops, locals, concatenation, tables, arithmetic, string methods) stays available, and a use-imported module handle in scope remains callable. External input enters only through a new host table: host.os, host.arch, host.env(name, default), and host.read(path), each recorded as a determinant for provenance. Because a host-varying config value already re-keys every consuming step through the value it resolves in $<NAME>, the recorded reads are explanatory (a future cook why), not a second cache-key channel.

The sandbox is applied to config bodies only. It swaps the _ENV of the generated config dispatcher for a restricted table, which governs global lookups without touching upvalues or the register/recipe contexts. register blocks and recipe bodies keep the full standard library, since they run commands rather than compute cache-determining values; os.execute in a recipe body remains a supported escape hatch.

Scope boundary: Renaming cook.platform.* to host.* in full-Lua (register and recipe) contexts is deferred. This revision requires host.* inside config blocks only; cook.platform.* stays valid everywhere it was before.

Backward compatibility: Breaking for config bodies that read os / io / cook.platform / cook.env: those now error and MUST migrate to host.env / host.read / host.os / host.arch and the env.* output form (Cook is pre-1.0; breaking config surface is permitted). Config bodies that only assign env.* from literals and pure Lua are unaffected. No CACHE_VERSION bump: the change removes reachable surface and adds a read table; it does not alter how any existing value is keyed.

Conformance: Negative config-sandbox-rejects-os and config-sandbox-rejects-io assert the register-phase rejection of os / io in a config body (baselining the stable diagnostic prefix). Positive config-sandbox-host-reads exercises all four host.* accessors in one base block and asserts clean registration. Found while implementing COOK-308 under the config-block-redesign milestone.

CS-0164 — Config-block var.* output namespace (replaces env.*), $<NAME>-only [#changes.cs-0164]

Date: 2026-07-23 Version: v0.17 (second surface revision of the config-block redesign; the output namespace) Sections affected: §5.3 (the var output namespace and the $<NAME>-only rule), §5.3.1 / §5.3.2 (examples and composition wording move to var.*), §9.2 (placeholder resolution names the config value and its process-env exclusion), §10.2 step 3 + §10.7 (declared-value resolution is exported via var.NAME), App. E (this entry). New conformance corpus cases. This satisfies the CS-0163 forward reference.

Summary: A config body wrote its outputs onto an env.* sink, and those values served two masters: they resolved $<NAME> placeholders and were injected into every step’s process environment. The second channel was unsound. Cook may infer a determinant only from a $<...> a step consults; the process-env injection put a config value into a step’s environment that the cache key could not see. Verified on cook 0.8.0: a step reading a config key as a shell variable ($VIA_SHELL) produced the no-env-consulted sentinel env_contribution regardless of the selected overlay, so cook … @alt served the cached base artifact as a clean HIT with the wrong bytes and no warning, while the sibling $<VIA_PLACEHOLDER> re-keyed correctly. The name env also collided with the ambient process environment, envs { } probe determinants, and CS-0163’s host.env read accessor.

CS-0164 renames the output sink to var and makes a var.* value reachable only through $<NAME>. A config body declares a named value by ordinary assignment (var.MODE = host.env("MODE", "debug")); a local binding stays scratch and is not exported. A conforming implementation MUST NOT place a var.* value into any step’s process environment — the value exists only for $<NAME> substitution, which keeps every consulted config value inside the consuming step’s cache key (consulted_env_keys / env_contribution, unchanged). Reading a step’s config value as a process environment variable is no longer a channel: a shell $NAME for a config-only NAME is simply unset. Because a config value can no longer reach a step except through $<NAME>, the CS-0163 sandbox plus this rule make a config block a pure function whose every output is a keyed determinant of its consumers.

Reading the global env in a config body is a load-time error that names the var sink and reads as a did-you-mean, so the pre-CS-0164 idiom fails loudly rather than silently exporting nothing.

Scope boundary: The explicit placeholder disambiguation form $<env.NAME> (§10.7) keeps its env. spelling for compatibility — the env. there names the resolution namespace, not the config sink; the value it looks up is now exported via var.NAME. The $X→$<NAME> migration of Cookfiles and modules that previously read config values as shell variables is tracked separately (COOK-310).

Backward compatibility: Breaking, and pre-1.0 so permitted. A config body’s env.NAME = ... outputs MUST become var.NAME = ...; a step that read a config value as a shell/process-env variable MUST read $<NAME> instead (the placeholder word-splits and collapses-when-empty exactly as the process-env path did, so the substituted result is byte-identical). No CACHE_VERSION bump: $<NAME> resolution and consulted-value hashing are unchanged; the change removes a channel (process-env injection) that the cache key never covered and renames the surface a body writes.

Conformance: Positive config-var-output exercises a base + overlay writing var.* and a recipe consuming $<NAME>, asserting clean registration. Negative config-env-output-rejected asserts the register-phase did-you-mean rejection of env.NAME = ... in a config body (baselining the stable diagnostic prefix). Found and specified under the config-block-redesign milestone (COOK-309); the R1 no-process-env-injection behavior is proved end-to-end by the reference implementation’s config_env_scope test.

CS-0165 — Config-block composition across Cookfiles: hermetic scope + union-validated @PRESET [#changes.cs-0165]

Date: 2026-07-23 Version: v0.18 (third surface revision of the config-block redesign; cross-Cookfile composition) Sections affected: §11.6 (new — config-block composition across Cookfiles: R2 hermetic scope, R3 union-validated selection, and a non-normative reserved note for config.<alias> routing / diamond resolution), §5.3.1 (a pointer to §11.6 for the multi-Cookfile case), App. E (this entry). New behaviour is proved by the reference implementation’s config_selection integration test.

Summary: §11 (cross-Cookfile composition) was silent on config blocks, so two questions had no specified answer: what a config block’s scope is across an import boundary, and how @PRESET selection resolves when several Cookfiles are loaded. The reference implementation resolved the second by accident of implementation — validate_selected_config checked the entry Cookfile’s named config blocks only — which made cook cli.build @profiling fail (“unknown config ‘profiling’: no named configs defined”) even though cli/Cookfile declared profiling and the selected name already propagated to every member’s register builder. Standing in cli/ (cd cli && cook build @profiling) worked, so the same overlay was reachable or not depending on the invocation directory — in tension with §20.2.3 (invocation directory MUST NOT influence cache identity).

CS-0165 specifies §11.6:

  • R2 — hermetic scope. A config block’s scope is exactly its own Cookfile. Its var.* outputs and host.* reads (§5.3) parameterise only that Cookfile’s own $<NAME> placeholders; neither crosses an import boundary and there is no merged namespace. This promotes the reference implementation’s emergent per-member scoping to specified behaviour: each member’s config runs in its own sandboxed VM and its resolved $<NAME> values are literals by the time registration completes, so there is nothing to merge and no cross-member collision to arbitrate.

  • R3 — union-validated selection. @PRESET is a single per-run global validated against the union of the named config blocks of every loaded Cookfile (root + all transitive imports). A name declared in any member passes; a name declared in no member is a load-time error identifying the requested name and the (deduplicated, ordered) union of available names. A member that does not declare the selected overlay runs its base config — a silent no-op, friendly to third-party imports. Selection remains at most one overlay per Cookfile (the §5.3.1 single-overlay restriction is untouched: a per-run global that some members match and others no-op is one overlay each, not several on one file). With union validation, cook <import>.<recipe> @PRESET and cd <import> && cook <recipe> @PRESET agree.

  • Reserved (non-normative). Cross-Cookfile preset routing — a config.<alias> directive by which an importer steers which overlay each import registers under, and the diamond agree-or-error resolution for a Cookfile reached by several import paths — is reserved as a future direction. It is deliberately not specified in this revision (no consumer has demonstrated the need; the R3 silent-no-op already lets every in-tree import compose), so no conforming implementation is required to implement it and no conformance case exercises it. The full design is recorded in the COOK-307 issue.

Backward compatibility: R3 is strictly more permissive — a selection that previously errored (“unknown config” against the entry Cookfile) now passes when any loaded Cookfile declares it; a selection that exists nowhere still errors, now against the union. R2 codifies existing behaviour. No CACHE_VERSION bump: routing changes nothing about how a value is keyed, and R3 only widens which names validate; a member’s resolved $<NAME> values (and thus every consuming step’s key via consulted_env_keys / env_contribution) are unchanged.

Conformance: Cross-Cookfile validation is a workspace-level behaviour not expressible in the single-Cookfile parse/register conformance corpus; it is proved by the reference implementation’s config_selection integration test (a root importing sub): a preset declared only in sub selects sub’s overlay; an undeclared name errors listing the union of sub’s and root’s names; a member lacking the selected overlay runs its base. Found and specified under the config-block-redesign milestone (COOK-307).

CS-0166 — Recipe index becomes a binary, path-interned .idx file; CACHE_VERSION 6 → 7 [#changes.cs-0166]

Date: 2026-07-25 Version: v0.18 Sections affected: none in the normative chapters (the Cookfile language and execution semantics are unchanged). CS-0048’s recipe-cache clause is superseded as to layout — its read policy and its schema_version / CACHE_VERSION dual role are carried forward unchanged. App. E (this entry). Reference implementation: cli/crates/cook-cache/src/index_bin.rs (new — encoder, decoder, DecodeError), cli/crates/cook-cache/src/store.rs (RecipeCache::load / save switch to .idx; sweep_orphaned_bin_indexes → sweep_superseded_indexes), cli/crates/cook-fingerprint/src/record.rs (CACHE_VERSION 6 → 7).

Summary. The on-disk recipe index moves from the v4..v6 human-readable TOML file (.cook/cache/<recipe>.toml) to a binary file (.cook/cache/<recipe>.idx) with a per-index interned path table. The motivation is measured, on a 1,711-node DuckDB build whose index reached 69,346,159 bytes:

Byte classBytesShare
Repeated [[steps."<key>".inputs]] headers33,835,68848.8%
path = "..." lines (327,951 records, 6,730 distinct)19,093,47127.5%
mtime = ... lines7,214,92210.4%
hash = "..." lines8,526,72612.3%
Everything else675,3521.0%

Nearly half the file was the step key restated once per input record — a toml::to_string array-of-tables artifact rather than a decision anyone made, and one no reader wants — and the path column carried a ~49x redundancy inherent to C++ headers fanning out across translation units. Parsing it cost 0.75s on a settled no-op that executed no commands.

The .idx layout is a 32-byte header (magic COOKIDX\0, schema_version u32, reserved u32, payload length u64, xxh3_64 payload seal u64) followed by an interned path table, a flat record pool that steps address by slice, a step table, and the glob table. It is little-endian throughout and documented in full on cook_cache::index_bin. Two properties are load-bearing rather than incidental:

  • The decoder is total. Every length and offset read out of the payload is bounds-checked before use, so arbitrary bytes produce Err(DecodeError) rather than a panic or an out-of-bounds read. The payload seal catches accidental corruption; the bounds checks cover the case where it does not.
  • The encoding is deterministic. Path ids are assigned in sorted order rather than first-encounter order, so insertion history cannot leak into the bytes. ThreadSafeCacheManager::update_step short-circuits when a rewritten entry equals the stored one, and the dirty set decides whether a recipe is re-serialised at all; both assume an unchanged index produces an unchanged file.

Readability. The v4 move to TOML was made so the index could be read directly. That capability moves to two commands rather than being lost: cook why (§17.1.6, CS-0112) already renders each unit’s attributed key and determinant diff, and cook cache dump <recipe> renders an index as TOML on demand — with input and output records inlined, which is strictly more readable than the array-of-tables shape the file itself used to carry.

The encoding MUST NOT feed the fingerprint beyond CACHE_VERSION. CACHE_VERSION is deliberately both the schema_version on the wire and a fingerprint input, because a fingerprint-side change is by definition an incompatible layout change (CS-0048). That coupling is at the level of the version, never the encoding. If a future revision ever offers a choice of encodings, that choice MUST NOT reach the cache key: two machines configured differently would compute different keys and silently stop sharing, which presents to a user as “shared caching mysteriously stopped working” and is very hard to attribute.

Backward compatibility. Breaking, once, by design and under the CS-0048 evolution policy, which sanctions a CACHE_VERSION bump for an incompatible structural change. There is no migration: a recipe index is regeneratable by definition, so a superseded file is dead weight rather than data. sweep_superseded_indexes deletes index files this cook no longer reads (.bin, .bin.tmp, .toml, .toml.tmp, .idx.tmp) from the cache directory on first touch, and the graph rebuilds once. The sweep is an explicit denylist of superseded index extensions rather than an .idx allowlist, because the cache directory is not exclusively the engine’s — modules keep state beside the indexes (cook_cc.json is present in every cc-built project’s .cook/cache/) and an allowlist sweep would delete it. It remains non-recursive, so the tests/ JSON cache and every other subdirectory are untouched.

The read policy is unchanged from CS-0048: an index whose schema_version does not equal CACHE_VERSION is refused, in both directions. A failed magic check, a payload-length mismatch, a failed checksum, and any structural corruption are refused on the same terms. Every rejection surfaces as a cache miss, never a build error.

Conformance. No grammar change. No Lua API change. No change to any observable in §17.1: the same determinants are folded into the same key, and only the bytes that record the result move. The recipe index is not exercised by the parse/register conformance corpus. New behaviour is pinned by unit tests in cook-cache (index_bin: round-trip identity across empty / populated / no-input / no-output / non-ASCII / env-suffixed-key / empty-glob caches, shared-path interning, encoding determinism under differing insertion order, and refusal of bad magic, a short header, a truncated payload, a flipped payload byte, both wrong schema versions, and out-of-range internal offsets; store: .idx naming, percent-encoded scoped names, truncation and version refusal, and a sweep that removes every superseded index while leaving module state, the live index, and subdirectories intact).

CS-0167 — cc object paths become collision-free (source-relative, not basename-derived) [#changes.cs-0167]

Date: 2026-07-25 Version: v0.18 Sections affected: § 28.1 (surface v0.8 → v0.9), § 28.3.5 (§28.3.5, rewritten), § 28.3.5.1 (§28.3.5.1, new), § 28.3.11 (§28.3.11, single-derivation clause added). App. E (this entry). Reference implementation: cook_cc/src/units/cc.lua, cook_cc/src/codegen/compile_db.lua.

Summary. The cc.compile object path was normatively specified as build/obj/<target_name>/<stem>.o, where <stem> is the source’s filename without its extension. That derivation is not injective, so the Standard required an implementation to alias distinct translation units onto one object path whenever a target contained repeated source filenames — which is the normal condition for any large C or C++ tree, not an edge case. DuckDB has 34 such basename pairs across ~3,000 translation units; a conventional cc.lib / cc.bin graph over it eventually links with hundreds of undefined symbols, because each aliased pair emits one object and silently discards the other.

The derivation becomes:

build/obj/<target_name>/<source-path-relative-to-the-workspace-root>.o

with the relative path carried through unaltered — directory components and file extension retained, .o appended rather than substituted. Injectivity is thereby a property of construction. § 28.3.5.1 explicitly forbids obtaining it by character-folding sanitisation instead (replacing separators or non-alphanumerics with _), because such transforms are themselves non-injective: a/b.o collides with a.b.o, and dictionary_compression.cpp with dictionary/compression.cpp. Retaining the extension keeps foo.c and foo.cpp — legal in one target, and dispatched to different drivers per § 28.1 — distinct.

Two consequences are specified alongside it:

  • Depfile identity follows the object, not the basename. § 28.3.5 now requires the make-format depfile named by discovered_inputs to be derived from the unit’s output. Under the old rule, up to four translation units shared one .d file: each compile overwrote the others’ discovered-header set, so an edit to a header only one of them included could fail to trigger that unit’s rebuild, and the losing units saw their recorded depfile as drifted on every subsequent run — restoring it, recording a fresh mtime, and dirtying the recipe index on a run that changed nothing.
  • The compile database and the compiler must agree by construction. § 28.3.11 now requires the object path in each compile_commands.json entry to come from the same derivation the compile units use, and forbids re-deriving it independently. The reference implementation had two independent copies of the basename derivation, one in cc.lua and one in compile_db.lua; a lockstep-by-discipline requirement is precisely what failed, so the Standard now requires a single shared derivation rather than two agreeing ones.

Migration. This is a breaking surface change: every object moves. The first build after an implementation adopts CS-0167 is a full rebuild, and objects under the old layout become orphans swept by § 17.7 wherever the owning recipe is reached. Call sites that worked around the collision by passing an explicit opts.output to the low-level cc.compile (routing objects source-relative by hand) can drop the override and the surrounding scaffolding; opts.output, when supplied, is still honoured verbatim.

Note. The aliasing was silent: the engine accepted two work units declaring the same output without diagnostic, which turned a specified collision into a build that never settled rather than an error on the first run. That is an independent engine defect with its own remedy; CS-0167 removes the Standard’s requirement to collide, but does not by itself prevent a module or a hand-written Cookfile from reintroducing an ambiguous graph.

CS-0168 — cc source selection, fpic, and a code-generation option vocabulary [#changes.cs-0168]

Date: 2026-07-25 Version: v0.18 Sections affected: § 28.1 (surface v0.9 → v0.10), § 28.3.1 (BinOpts.sources restated, fpic added), § 28.3.1.1 (§28.3.1.1, new), § 28.3.10 (optimize / debug_symbols / gc_sections), § 28.3.10.1 (§28.3.10.1, new). App. E (this entry). Reference implementation: cook_cc/src/units/targets.lua, cook_cc/src/toolchain/init.lua.

Summary. Three gaps that together forced any large real target off the high-level cc.bin / cc.lib surface and into hand-rolled cc.compile fan-outs.

1. Source selection (§ 28.3.1.1). BinOpts.sources was documented as “source paths or globs”, but the resolution rules were never stated and the reference implementation globbed only opts.dir — a single root, no exclusions. A target assembled from several roots minus a handful of files (the normal shape: a src/ tree, some vendored subdirectories, minus the platform-specific allocator and a generated file compiled separately) had to be built by the caller with its own glob helper, which is how call sites ended up holding raw fs.glob results in the first place.

sources entries are now literals, glob patterns, or !-prefixed exclusion patterns, resolved as the set difference (includes) \ (excludes) — the same item grammar and the same order-independent semantics as the ingredients step (§ 8.2), rather than a second, differently-spelled exclusion mechanism.

Two adjacent requirements are stated because both are load-bearing and neither was previously implied:

  • The resolved set MUST reach the compile fan-out in a stable host-independent order. Source order reaches the ar and link command strings, which are cache-key determinants (§ 17.1.1); a directory-order-dependent set would re-key the archive across machines that differ only in filesystem layout.
  • Duplicates MUST collapse. Overlapping include patterns otherwise register two units with the same output — the ambiguous graph § 28.3.5.1 exists to prevent, reintroduced through a different door.

An include pattern matching nothing is now an error. This is the rule with the most practical bite: in a target of several thousand sources, a stale directory in one pattern silently drops an entire subtree, the build succeeds, and the loss surfaces much later as an undefined symbol at link. Exclusions matching nothing stay legal, since they are routinely written to tolerate a file’s absence.

2. fpic. The reference implementation hardcoded -fPIC to “on for cc.shared, off otherwise”, with no way to ask for it. A static archive destined to be linked into a shared library needs it, so such a target could not be expressed on the high-level surface at all. fpic is now an explicit field on BinOpts / LibOpts / SharedOpts, defaulting to the previous behaviour.

3. Code-generation vocabulary (§ 28.3.10.1). optimize, debug_symbols, and gc_sections join standard and warnings as options settable build-wide on cc.toolchain and overridable per target. The module already places the compiler behind a probe so a Cookfile is portable across drivers; requiring call sites to write extra_cflags = "-O3 -DNDEBUG -g -ffunction-sections -fdata-sections" hands that portability straight back. optimize takes a preset or raw flags, mirroring warnings.

gc_sections is specified to emit both halves — the per-section compile flags and the linker’s collection flag — because the failure mode of the raw-string form is to emit only the first. That is silent and strictly costly: larger objects and slower links, with none of the size reduction the flags were added for. It is exactly the state the DuckDB Cookfile that motivated this change was in. An option that owns both halves makes the half-configured build unrepresentable.

Migration. Additive; no existing call site changes meaning. Callers passing the equivalent raw extra_cflags may keep doing so — optimize’s raw-flags escape hatch accepts the same string — but a caller adopting gc_sections = true should drop the corresponding hand-written compile flags rather than pass both.

CS-0169 — a literal output path may be declared by at most one work unit [#changes.cs-0169]

Date: 2026-07-25 Version: v0.18 Sections affected: § 22.1 (§22.1, output-uniqueness clause added). App. E (this entry). Reference implementation: cli/crates/cook-engine/src/pipeline/registers.rs (reject_duplicate_outputs), cli/crates/cook-engine/src/pipeline/error.rs (PipelineError::DuplicateOutput), cli/crates/cook-cli/src/pipeline.rs (exit-code classification).

Summary. Two work units could declare the same output and the engine accepted it silently. The resulting graph has no correct execution: the units race to write one path, whichever loses is seen as drifted on the next run and rebuilt, and it hands the loss straight back. The build reports success and never settles — indefinitely, with no diagnostic at any point. Six lines reproduce it:

recipe dup
    cook "same.txt" {
        echo one > same.txt
    }
    cook "same.txt" {
        echo two > same.txt
    }
run 2: dup/same.txt cached
dup/same.txt rebuild (command changed)
run 3: dup/same.txt cached
dup/same.txt rebuild (command changed)

This is the root pathology behind the cc object-path defect that CS-0167 closes. Because cc derived object paths from the source basename, every tree with repeated source filenames produced exactly this shape — and the observable symptoms were all downstream of it: permanent cache churn, a 128 MB recipe index reserialised on a run that changed nothing, silently wrong header dependency sets, and hundreds of undefined symbols at link on a conventional cc.lib graph. Each cost real debugging time before the shared cause was identified. A register-time refusal naming both units turns the whole investigation into one error message on the first run.

CS-0167 removes the Standard’s requirement to collide, but does not prevent a module or a hand-written Cookfile from reintroducing an ambiguous graph; injectivity by construction in one module is not a substitute for the engine refusing to build one. Hence a rule at the layer where units are declared.

Scope. Run-scoped rather than recipe-scoped: two recipes writing one path is the same defect, and a per-recipe check would miss it. Restricted to literal output paths — an output glob (§ 22.1.2) and a build-owned directory output (§ 17.7) declare a claim over a set, and the semantics of overlapping set-claims belong to output reconciliation. That question is left open rather than settled by side effect; an existing conformance behaviour (two units declaring outputs = { "dist/**" }, each warning once when the glob matches nothing) is preserved unchanged.

Conformance. The parse/register corpus is a parse-gate, and this input parses cleanly — the defect is semantic, at registration. It is pinned instead by e2e tests in cook-cli (duplicate_literal_output_is_rejected, duplicate_literal_output_across_recipes_is_rejected), which assert the refusal, the named path, and that both producing sites appear in the diagnostic. Naming both sites is the load-bearing part: when the collision is between two of several thousand compile units, locating the pair is the entire task.

CS-0170 — DAG waves and the terminal viewer removed; DAG payload flattened (schema 2 → 3) [#changes.cs-0170]

Date: 2026-07-25 Version: v0.18 Sections affected: none in the normative chapters. CS-0047’s “implementation-optional” clause is exercised rather than amended; CS-0048’s wire-format pin for the DAG payload is bumped 2 → 3 per its own evolution policy; CS-0060’s TUI rendering surface is retired. App. E (this entry). Reference implementation: cli/crates/cook-dag-viewer/ (wave_grouper.rs, state.rs, input.rs, tui.rs, theme.rs, frame.rs, render/ deleted), cli/crates/cook-cli/src/pipeline.rs, cli/crates/cook-cli/Cargo.toml (viewer feature removed).

Summary. The reference implementation’s DAG payload was structured as a list of waves — WaveDagData { waves: [WaveData], inter_wave_edges }. The engine stopped scheduling in waves at SHI-222 Phase 4, when it moved to a single unified work-unit DAG; the grouping survived only in the viewer, which computed it purely for display.

Keeping it was not neutral. Inter-wave edges were synthesised from the recipe-level dependency map at whole-recipe granularity — every terminal unit of the referent to every root unit of the consumer. On a large target that is a cross product: on DuckDB, 1685 synthetic edges standing in for a single real one. Those edges describe a barrier the engine does not impose, and they outnumbered the genuine per-unit cook.dep_order edges (§ 22.11, CS-0161) badly enough to mask them entirely under any aggregation. A tool whose purpose is to explain why a build serialises was reporting the opposite of the truth.

The payload is now flat — DagData { recipes, nodes, edges } — and built in two passes rather than in wave order: pass one emits each recipe’s nodes and intra-recipe edges and records its terminal units, pass two wires cross-recipe edges against those terminals. Removing waves is what makes two passes sufficient: the grouping existed to establish an evaluation order that two passes simply do not need. The coarse whole-recipe edge is emitted only where nothing fine-covers the dependency, so a barrier in the output now denotes a real whole-recipe barrier and its absence denotes a fine-covered dependency.

The terminal viewer is removed. Its entire selection model was wave-indexed (Selection { wave, leaf }, WaveRow, recipe(wave, i), unit(wave, r, u)) — a navigation model for a structure that no longer existed. CS-0047 already established that a graphical DAG viewer is implementation-optional and that “the pre-1.0 references to cmd_dag are descriptive of the reference implementation’s behavior, not normative requirements”, so no normative clause changes. The viewer cargo feature is removed with it; cook dag now works in a default build, which it previously did not — a released binary could only report that it needed rebuilding.

cook dag retains and replaces the capability: --level recipe|group|unit collapses the graph, and --format text|mermaid|dot|json prints it. The JSON form is the successor to the CS-0048 payload for external consumers.

Conformance. No grammar change, no Lua API change, no change to any §17.1 cache observable. cook dag’s output is not conformance-checked — it is a tooling affordance — and is pinned by unit tests in cook-dag-viewer and e2e tests in cook-cli (dag_render), including that a dependency fine-covered by cook.dep_order renders as dep_order and a bare dep-list dependency renders as barrier.

CS-0171 — cook dag merged into cook why; one transparency query [#changes.cs-0171]

Date: 2026-07-25 Version: v0.18 Sections affected: § 17.1.6 restructured into § 17.1.6.1, § 17.1.6.3, § 17.1.6.4, § 17.1.6.5, § 17.1.6.6. § 7.5.1 loses a passing reference to cook dag. App. E (this entry). Reference implementation: cli/crates/cook-graph/ (renamed from cook-dag-viewer), cli/crates/cook-engine/src/why.rs, cli/crates/cook-cli/src/{cli.rs,main.rs,pipeline.rs,completion.rs}, cli/crates/cook-progress/src/{event.rs,render/json.rs}.

Summary. The reference implementation shipped two read-only transparency commands that each answered half of one question, and each was weakest exactly where the other was strong.

cook why (§ 17.1.6) had the good cache answer: every determinant behind the key, both tiers reported independently, and on a shared miss the diff against the producer determinant manifest (§ 17.1.5). It had no structure whatsoever. It emitted one flat block per cacheable unit in the reachable closure with no aggregation and no ceiling, which on a real C/C++ tree is thousands of blocks. It also could not attribute a miss whose cause was upstream, because a flat list has no edges to attribute along.

cook dag (CS-0170) had the good structure: edge kinds, three aggregation levels, a node ceiling, four renderers. Its cache answer was a second implementation of the cache check, consulting the local index only, with no shared tier and no reason, surfaced as a bare [cached]/[stale] marker per node.

So the command that could explain a miss could not be read at scale, and the command that scaled explained nothing about the miss. Neither knew what any of it cost.

Resolution. One command, cook why. cook dag is removed outright with no deprecation shim: it had never appeared in a tagged release, so there is no compatibility surface to preserve.

why survives as the name because it is the specced surface and it is the question the user has. The dag implementation survives as the structural spine because aggregation is the thing that makes per-unit fidelity legible; grafting graph output onto the flat renderer would have inherited its unreadability instead. The duplicate cache classification in dag_data.rs is deleted, leaving one cache truth, and it is the one that knows about the shared tier.

The merged query registers through the dispatch path rather than the enumerate path cook dag used. This is now normative in § 17.1.6: a query that observes the graph under different registration conditions than the run path is not an explanation of that run, and for a cache-explanation tool key fidelity with cook run is the whole point.

New capability. Two things neither half could do:

Cascade attribution (§ 17.1.6.4). A unit that will rebuild reports how many downstream units its rebuild forces, and a downstream unit whose own determinants are unchanged attributes its miss to the upstream unit by name instead of restating the consequence as an independent finding. Reporting a miss at every vertex of the invalidated subgraph is accurate and useless; the cause enters the graph at one point and that is the point worth naming.

Observed timing (§ 17.1.6.5). Per-node wall time recovered from the retained .cook/logs/<build-id>/events.jsonl streams. The section is deliberately hedged, because the data is: cache-hit records carry no elapsed time, retention defaults to twenty builds, and the records are machine-local, so a fleet-warm unit that never ran on this machine has no observation. The surface therefore reports “last observed, N builds ago” and reports absence as absence. It does not predict, and § 17.1.6.5 forbids an implementation from dressing it up as a prediction.

The join required a prerequisite. Node progress records identified a unit by rendered display name, which is not unique — CS-0167 was an entire defect class built on basename collisions — so cache_key is added to the node-started and node-completed records. The field is additive with a serde default, so PROGRESS_SCHEMA_VERSION is unchanged and existing readers ignore it.

Surface. cook why [<recipe>] [<config>] [--level recipe|group|unit] [--format text|mermaid|dot|json] [--max-nodes N] [--unit <pattern>]. The former cook why --json folds into --format json: one spelling for one thing. The graph payload and the determinant payload become a single versioned JSON document rather than two, so a consumer reads structure and cache state from one query.

Conformance. No grammar change, no Lua API change, no change to any §17.1 cache observable, and no change to any cache key. The determinant reporting of § 17.1.6.1 is unchanged in substance from the prior §17.1.6 and remains normative; aggregation gains normative floor requirements (a coarse default, tallies rather than booleans at coarse levels, most-constraining-kind preservation when collapsing an edge bundle, and a ceiling on unaggregated unit-level output); cascade attribution is SHOULD; timing is MAY with three MUST-level constraints on how it may be presented. Pinned by unit tests in cook-graph and e2e tests in cook-cli (why_render, successor to dag_render).

CS-0172 — var is its own namespace, resolved before register phase [#changes.cs-0172]

Date: 2026-07-25 Version: v0.18 Sections affected: § 5.3, § 5.3.2, § 9, § 10.7, § 17.1.2, § 21.3, § 24.3 amended; C.21.1 rewritten; App. D.15 marked resolved. App. E (this entry). Reference implementation: cli/crates/cook-register/src/var_api.rs (replacing env_api.rs), cli/crates/cook-luagen/src/lua_var.rs (renamed from lua_env.rs), cli/crates/cook-engine/src/pipeline/env.rs, cli/crates/cook-luaotp/src/pool.rs.

Summary. The declared-variable namespace and the process environment were one table. A config block’s var sink was cook.env, pre-seeded from std::env::vars() and a .env file, and four defects followed from that single fact. They are fixed together because they have one cause.

Every ambient variable was an undeclared build variable. $<HOME> resolved in a Cookfile that declared nothing at all. The config sandbox of § 5.3.2 exists precisely so that a config body’s external inputs are declared and therefore keyable; seeding the same table from the ambient environment made that gate bypassable by simply not declaring anything. The invocation override could likewise invent a variable no config block mentions, which is the trap App. D.15 recorded and could not close by rule.

Config resolved too late to configure anything. Config blocks ran after all register-phase Lua, so a top-level module_call — which is exactly where a toolchain is configured — read nil. cc.toolchain({ optimize = var.optimize }), the shape the surface invites, could not work.

Values were string-only, because an environment holds strings. That left the boolean half of the cc code-generation vocabulary of CS-0168 (debug_symbols, gc_sections) unreachable from config: there was no way to spell a false boolean that did not arrive as the truthy string "false".

envs { } probes and $<env.NAME> read the same conflated table, so a config block could silently redefine what a probe had recorded about the host, corrupting a determinant with a value the host never had.

Resolution. The namespace is exactly what the config blocks export. var is a read-only global everywhere outside a config body (§ 21.3, § 24.3), and its store lives in the Lua registry where Lua cannot reach it, so a determinant cannot be mutated after a unit has been keyed on it. The override applies after the blocks run and only to a name a block declared, which closes App. D.15 by construction rather than by prohibition.

Ordering is fixed in codegen: everything after the config function is wrapped in __cook_main, which the engine calls after dispatch. Config is therefore resolved before any register-phase Lua runs, and a top-level module call reads declared values.

Values keep their Lua type — string, number, or boolean — for var.NAME reads during register phase, and render to a defined string form for $<NAME> interpolation and for cache keys. An execute-phase Lua body receives the string form only; reading a non-string variable there is refused at register phase rather than coerced, so a boolean can never arrive on a worker as truthy "false".

The ambient environment now reaches a build through exactly two declared doors: host.env in a config body, and an envs { } probe, which reads os.getenv — the host environment it always meant.

Retired with no shim. cook.env, cook.require_env, the $<env.NAME> placeholder prefix (now $<var.NAME>, § 10.7), and the .env file layer. The consulted_env_keys determinant field keeps its name: it is named in a published rockspec, and renaming a determinant field is a separate sweep with its own cache-version cost.

Conformance. Breaking. A Cookfile that read an ambient variable it never declared no longer resolves, and the diagnostic names the undeclared placeholder and points at var.NAME in a config block. Pinned by five fixtures: config-var-typed-toplevel-read (typed values, preset overlay, register-phase read) and config-var-output on the positive side; config-var-ambient-env-rejected, config-var-table-value-rejected, and config-var-write-outside-config-rejected on the negative side.

CS-0173 — cook why resolves a determinant from its producer, not the working tree [#changes.cs-0173]

Date: 2026-07-25 Version: v0.18 Sections affected: § 17.1.6.2 (new §17.1.6.2); § 17.1.6.4 amended; § 17.1.6 subsections renumbered .2–.5 → .3–.6. App. E (this entry). Reference implementation: cli/crates/cook-engine/src/why.rs, cli/crates/cook-fingerprint/src/check.rs (hash_reader), cli/crates/cook-cli/src/pipeline.rs.

Summary. CS-0171 gave cook why a graph, but only its reporting used it. Classification stayed per-unit: every declared input was hashed from the working tree, whatever the graph said about who writes that file. The result was a query that answered its own headline question wrongly, in both directions, on the same four-unit workspace.

On a cold tree with a warm shared cache — a fresh checkout, which is the first thing a new user does — a consumer of a not-yet-restored file reported a rebuild. Its producer was a cache hit, so those bytes were already determined and the file’s absence was an artifact of evaluation order. The reference implementation predicted two rebuilds on a graph that then rebuilt nothing.

After an edit, the same blindness inverted. A consumer whose input was about to be rewritten still hashed the stale file on disk, matched its recorded key, and reported a hit. The implementation predicted one rebuild on a graph that then rebuilt three.

Resolution. An input that another unit in the closure produces is resolved from that producer. The working tree is authoritative only for inputs no unit produces. Resolution follows the producer’s own classification: a producer served from cache has determined output bytes, so their content hash is knowable whether or not the artifact has been restored; a producer that will rebuild leaves its consumer with no computable key at all.

That second case gets its own reported state rather than being folded into a miss. A unit waiting on bytes that do not exist yet has not looked anything up: it has no key to hit or miss with. The query reports it as rebuilding on account of the named producer, reports the pending input as undetermined rather than carrying a stale or zero hash, and reports no key. Machine-readable forms distinguish it from a unit that rebuilds on its own changed determinants, because only the latter has a key behind it.

A narrowed prohibition. § 17.1.6.4 previously forbade the query from revising “any reported key, tier classification, or determinant value … to describe a predicted future state”. Read literally that forbade this fix, and the sentence was drawn too widely. The distinction it was reaching for survives: a unit reported as served from cache MUST still have been answered by an actual cache lookup, never inferred from its neighbours. What is not a prediction is reading an input from the unit that writes it — the producer’s classification is an observation of the cache as it stands, and “a cache hit reproduces a recorded artifact” is a present fact. The line the spec now draws is against inventing a key, which is narrower and is the thing that was actually worth prohibiting.

Implementation note. The shared-tier probe already drained every artifact’s bytes to trigger streaming verify-on-restore (CS-0054). It now keeps the digest instead of discarding it, so a producer’s output hashes cost nothing beyond a read that had to happen. This relies on hash_reader and hash_file agreeing byte-for-byte, so they live beside each other in cook-fingerprint with a test pinning their agreement: a drift would not fail to compile, it would silently mispredict every downstream key. Note the tension with COOK-330, which wants coarse levels to stop draining: whichever presence check lands there must either retain a way to learn output hashes or accept a coarser answer and say which.

Conformance. No grammar change, no Lua API change, no change to any cache key, and no change to what a build does — only to what the query says a build will do. cook why’s output is a tooling affordance rather than conformance-checked surface (as CS-0171 established), so this adds no conformance fixtures; it is pinned by e2e tests in cook-cli (why_render) asserting the cold-tree and post-edit tallies against what the build then actually does, and by a unit test in cook-fingerprint pinning the two hash functions together.

CS-0174 — cook why reports the local-miss cause, and why a unit last ran [#changes.cs-0174]

Date: 2026-07-25 Version: v0.18 Sections affected: § 17.1.6.1 gains the local-miss attribution rule; § 17.1.6.5 renamed “Observed timing” → “Observed history” and extended with past attribution. App. E (this entry). Reference implementation: cli/crates/cook-engine/src/{why.rs,timings.rs,lib.rs}, cli/crates/cook-engine/src/executor.rs, cli/crates/cook-progress/src/{event.rs,render/json.rs}, cli/crates/cook-cli/src/pipeline.rs.

Summary. Both halves of this entry are the same finding: the answer already existed and was being discarded on the floor.

The live cause. COOK-276 gave the executor per-unit attribution, so a build prints rebuild (input changed: palette.txt) as it works. cook why called the very same rebuild check and kept one bit of its answer, the boolean. The consequence was an asymmetry with no defence: a shared miss was diffed against the producer manifest and its differing determinant named, while a local miss printed every determinant and identified none. The explain tool was less informative than the log of the build it exists to run ahead of. § 17.1.6.1 now requires the local tier to name its cause too, and requires that the named determinant be the one the implementation’s own rebuild decision actually turned on — a query naming a different determinant than the one that will cause the re-execution would be worse than naming none.

The historical cause. A unit that is a cache hit has no live cause; nothing is going to rebuild. The only causal question left about it is why it ran the last time, and that is a question people genuinely ask, usually phrased as “why did this rebuild overnight when I touched nothing”. The attribution was already being written to the retained build records, so the answer was on disk and unread. § 17.1.6.5 is renamed to “Observed history” and extended: an implementation MAY report the past attribution from the same records it reads timings from, under the same three constraints, which already covered exactly the caveats this data has.

Keeping the two apart. They routinely disagree, and a reader that confused them would draw a conclusion about the wrong build. The live cause is a verdict on the run being explained; the past cause is a record of a different run. The spec requires them to be distinguishable and to be separate values in machine-readable output. The reference implementation renders them as local-miss cause: <what changed> and last ran because: <what changed> (N builds ago), and emits local_cause, last_cause, and last_cause_builds_ago as distinct JSON keys.

Implementation note. cause rides on the node-started record and elapsed_ms on node-completed, so recovering both meant joining them. node is a display string that collides across distinct units, exactly the problem CS-0171 solved for timings by stamping the recipe-local cache key onto node-completed; the same field is now stamped onto node-started for the same reason. It is additive with a serde default, so PROGRESS_SCHEMA_VERSION is unchanged and existing readers ignore it. Causes are buffered per build and joined within that build before being recorded, so a unit can never take its cause from one run and its timing from another — which the amended § 17.1.6.5 now requires rather than merely recommends.

Conformance. No grammar change, no Lua API change, no change to any cache key, and no change to what a build does. cook why’s output remains a tooling affordance rather than conformance-checked surface (CS-0171), so no conformance fixtures; pinned by four e2e tests in cook-cli (why_render) covering the live cause in both renderers, the historical cause on a settled unit, and the two being reported independently when they disagree.

CS-0175 — a test unit may narrow which predecessor outputs it consumes [#changes.cs-0175]

Date: 2026-07-25 Version: v0.18 Sections affected: § 17.4 rule 1 gains the consumed-set narrowing rule and the terminal-output resolution rule; § 22.4 gains the consumes field. App. E (this entry). Reference implementation: cli/crates/cook-fingerprint/src/consumes.rs, cli/crates/cook-engine/src/run.rs, cli/crates/cook-register/src/test_api.rs, cli/crates/cook-contracts/src/lib.rs.

Summary. A test unit’s key folds the content of every immediate-predecessor output, and until now that was the only shape available to it. A cook unit has always had the counterpart control — it declares its own inputs, and a depfile records what it actually read. A test could say what it consumes only by adding to that set; it had no way to say that of a dependency’s dist/, it reads the declarations and not the sourcemaps.

The asymmetry is not academic. A JS dependency’s output directory routinely carries artifact classes no consumer reads, and the churniest of them is one that nothing reads at all: tsup and esbuild inline sourcesContent, so a comment-only edit upstream rewrites index.mjs.map byte-for-byte while index.mjs stays identical. Every downstream check then loses its cached pass over a file it never opened — the exact over-invalidation § 17.4‘s output-content fold exists to prevent, reintroduced one directory up. consumes is the missing half of that surface.

Why the safeguards are mandatory, not advisory. Most narrowing surfaces in this Standard err toward over-invalidation when they are wrong. This one does not: a filter that matches too little produces a key that ignores a real determinant, and a test whose key ignores a determinant replays a pass against inputs that have moved. That failure is silent, it is green, and it is the one outcome a build tool must never produce. So the rule requires an unmatchable filter to be inert rather than authoritative — where the candidate set is non-empty and nothing matches it, the unnarrowed set folds — and requires unparseable patterns to be rejected at register phase, where an author can still see the diagnostic. An implementation treating “matched nothing” as “consumes nothing” would satisfy the letter of the narrowing rule and violate rule 1.

The rule also requires the full consumed-output set to remain the produced-upstream filter. Otherwise an excluded artifact re-enters through the test’s own ingredients — a real shape, since a glob ingredient like dist/**/* covers precisely the files consumes just excluded — and the narrowing would appear to work while doing nothing.

The resolution defect this exposed. Writing the tests surfaced a pre-existing bug in the reference implementation, fixed under the same entry and now pinned normatively. Rule 1’s fold resolved a predecessor’s terminal outputs by passing the declaration to the glob resolver verbatim. The trailing-** normalisation CS-0085 requires when the producing unit captures that same declaration was not applied on the consuming side, and the glob library treats a bare trailing ** as directories-only — which the resolver then drops (CS-0064). A predecessor declaring the canonical dist/** therefore resolved to the empty set: its content contributed nothing to any downstream test’s key. Directory outputs (dist/) were broken identically.

That is under-keying of exactly the kind the paragraph above forbids, and it stayed latent because every existing fixture for the ready-time fold declares a literal output, which needs no normalisation. One output declaration must not resolve to two different file sets depending on which side of the edge is looking at it; the new rule says so normatively.

Implementation note. consumes rides on WorkPayload::Test rather than CacheMeta, for the same reason input_paths and seal_keys do: the executor relies on test nodes carrying no cache_meta. Matching lives in cook-fingerprint rather than in either caller, so the register phase validates patterns with the identical matcher the engine later folds with — a pattern accepted at register time is by construction the pattern that runs. globset rather than glob, because these patterns are matched against paths rather than expanded against the tree, and literal_separator is what makes * stop at a path segment.

Conformance. No grammar change. The Lua API change is additive: consumes defaults to empty and an empty filter folds every predecessor output, so every existing Cookfile and module keys exactly as before. The terminal-output resolution fix does change computed keys — a test downstream of a glob or directory output was previously keyed without that dependency’s content and is now keyed with it, a one-time invalidation of an incorrect key rather than a semantic change. Pinned by nine unit tests in cook-fingerprint (consumes) covering the matching convention and both safeguards, and seven in cook-engine (run_tests) covering the sourcemap case end to end, the narrow-does-not-blind case, the fail-safe, the ingredient re-admission path, and one regression test per broken output spelling.

CS-0176 — cook.chore: a module may register a namespaced verb [#changes.cs-0176]

Date: 2026-07-25 Version: v0.18 Sections affected: § 22.12 (new §22.11); § 12.7.8 gains the recipes-not-chores scope and the carve-out; App. C gains § C.24; App. E (this entry). Reference implementation: cli/crates/cook-register/src/{capture.rs,lib.rs,engine.rs}, cli/crates/cook-engine/src/lib.rs, cli/crates/cook-cli/src/pipeline.rs.

Summary. Registering a chore was possible for the code generator and nobody else. A surface chore NAME block lowered to a codegen-private helper, so the only way a chore came into existence was an author writing the block — which is exactly the work a module offering cc.add / cc.link / cc.fmt exists to remove. cook.chore(name, metadata, body) is the public sibling of cook.recipe, and the three constraints below are what make it safe to have.

The namespace is the mechanism, not a convention. § 12.7.8 says a recipe’s name and its invocability belong to the author of the Cookfile, as a categorical MUST. Read as covering chores it would forbid this feature outright, so the amendment scopes it to recipes and states why: a recipe is a node in the DAG, and minting one changes what the author’s build does; a chore is uncached, outside the DAG, and inert until invoked by name. The author’s namespace is still theirs — a module registers only under a dotted prefix identifying itself (cook_cc may use cc. or cook_cc.), so ownership is preserved by partitioning the namespace rather than by refusing the registration. Note the inversion against § A.2: dots are banned in a declaration header, where the author writes the name, and required here, where a module does.

The registration window follows from the namespace rule. The check needs the identity of the registering module, and that is exact only while the module’s own chunk runs. Afterwards an implementation knows at best which module was loaded most recently — a different question, answered wrongly the moment a second use intervenes, so a prefix validated against it would be a guarantee in name only. cook.chore is therefore rejected outside module evaluation, including from a function the module returned. The ergonomic consequence is the desirable one: use cook_cc is the whole of what an author writes to get the verbs.

Chore semantics do not come for free. A surface chore is no-cache and interactive because generated code wraps its body in enter/exit markers. A cook.chore body is a plain Lua function that no generator touched, so an implementation reusing that mechanism gets a chore whose units are cacheable and non-interactive — passing every test of the registration path while violating § 7.4 silently, caching a command whose entire purpose is to re-run. § 22.12 requires the properties of the chore, not of the syntax that declared it, and forbids relying on the codegen wrapper here. The reference implementation brackets the body invocation in the engine, and pins it with a test asserting cache = true is refused inside a cook.chore body — a test verified to fail with the bracket removed.

Diagnostics must not invent a location. caller_line_in_cookfile walks the Lua stack, so a module-registered chore has a line number: the module’s own, nonzero and meaningless to the author. Keying the message on “line is zero” — the obvious shortcut — is therefore wrong in both directions, since a surface chore whose line failed to resolve is still an author’s declaration. § 22.12 requires collision and parameter diagnostics to identify such a chore by its origin and forbids reporting a Cookfile line for it.

Conformance. Additive: no grammar change, no change to any existing API’s behaviour, no cache-key effect, and no change to what any existing Cookfile does. A conforming implementation that does not expose cook.chore is unaffected. Pinned by eight tests in cook-register (cook_chore_*) covering acceptance under both prefix spellings, rejection of an undotted name, of another module’s namespace, of top-level registration and of post-load registration, parameter metadata, and the § 7.4 bracket.

CS-0177 — a failed test blocks its dependents [#changes.cs-0177]

Date: 2026-07-25 Version: v0.18 Sections affected: § 8.6 gains the failure-propagation rule and its dependents-not-siblings scope; § 17.4 rule 2 widens blocked to any failed dependency. § 19.2 is unchanged. App. E (this entry). CACHE_VERSION does not bump. Reference implementation: cli/crates/cook-engine/src/executor.rs.

Summary. The engine already had the machinery and applied it everywhere except one step kind. A failed cook unit calls cancel_subtree over its dependents, which emits skipped (upstream-failed) and synthesises a Blocked outcome for any test it cancels. A failed test unit did not: it was classified a “soft” failure and its dependents were explicitly allowed to proceed. The comment in the executor said so outright — “Dependents of failed tests are NOT cancelled: a test failing does not block sibling or downstream tests” — conflating two different things, since a sibling is not a dependent and was never at risk.

The consequence is that a dependency on a test ordered work without gating it, which is not what the dep-list spelling reads as, and not what any other edge in the language does. chore ship: checks ran ship’s body with checks red, then reported the failure through the exit status. For a chore whose body pushes a tag or uploads an artifact, “exit 1 afterwards” is not a gate; the side effect has already happened. This is why the reference implementation’s own release chore hand-rolled a gate by shelling out to a nested cook invocation rather than declaring chore release: cli.test.

Scope. The rule follows the dependency edge and nothing else. A sibling unit that merely runs alongside a failed test is untouched, so cook test still runs and reports the whole suite rather than halting at the first failure — the property that makes a test runner useful, and the one the old comment was really protecting. Blocked dependents are reported as blocked with the failed test named as the cause, the outcome § 17.4 rule 2 already defined and already forbade caching; before this entry that outcome was reachable only when a build dependency failed.

Backward compatibility. Behavioural, and deliberately so: a Cookfile whose dependent previously ran with its test dependency red now reports that dependent as blocked. No surface changes, no grammar change, no cache-key effect, and no CACHE_VERSION bump — a blocked unit is not cached, which rule 2 already required. Exit status is unchanged in every case: § 19.2 already required a non-zero exit when a test fails, times out, or is blocked by an upstream failure, so a run that previously exited 1 with the dependent executed now exits 1 with it blocked.

Reference implementation. The is_test_semantic_failure arm in executor.rs now cancels the dependent subtree before completing the node — ordering matters, because cancel_subtree marks entries in cancelled and process_ready consults it, so completing first would let a dependent dispatch in the same drain. The test outcome stays out of the hard failures list, which is reserved for infrastructure errors; the exit code continues to come from the test reporter. One further fix was required and is not cosmetic: blocked_results was merged into the returned rows only on the Err arm, which was sound while cancel_subtree fired exclusively for hard failures (those always take that arm). A cancelling test failure returns Ok, so the blocked rows were being dropped and the suite reported a blocked dependent as simply absent — present in neither the passed nor the failed count. Pinned by surface fixture 23-test-failure-blocks-dependents, which asserts the dependent’s side effect does not occur, that upstream-failed is reported, and that a sibling still runs.

CS-0178 — a probe with no declared inputs has no cache key [#changes.cs-0178]

Date: 2026-07-25 Version: v0.18 Sections affected: § 22.5.8 gains the no-declared-inputs rule, its requires propagation, and the three explicit non-effects. § 22.5.4 and § 22.5.2 unchanged. App. E (this entry). CACHE_VERSION does not bump. Reference implementation: cli/crates/cook-engine/src/executor.rs.

Summary. The Standard specified this staleness rather than merely tolerating it, which is why it survived so long. §22.5.4 builds a probe’s fingerprint from seven sections; the first three — marker, key, produce source string — are always present and constant for a given probe, and sections 4 through 7 are empty unless the author declares env, tools, files, or requires. A probe declaring none therefore had a well-defined, permanently stable fingerprint, and §22.5.8 required a conforming implementation to load the cached bytes and skip execution whenever a fingerprint addressed a stored artifact. The result was that a probe whose entire job is to ask the host a question asked it exactly once, ever.

The failure is quiet and it outlives the obvious remedy: the value lives in the shared store, so rm -rf .cook does not clear it. The only way to make such a probe re-observe was to declare an input it did not otherwise need — which is what an ingredients line inside a probe was actually doing, and why that line reads as inexplicable ceremony rather than as the opt-in it is.

The asymmetry this closes. Test units have obeyed the mirror-image rule since CS-0138 and state it in three places: no declared source, no cache key, runs every invocation (§8.6, the §8.6.1 mode table, §17.4 rule 1). Probes did not, and the two kinds sat on opposite sides of the same question. After this entry the rule is uniform and says one thing: a unit’s cache participation follows from what its author declared, and declaring nothing buys nothing.

Propagation. Keylessness reaches along requires. A probe naming a keyless probe folds section 7 over an upstream fingerprint that is constant while the upstream’s value moves, so it would be served from cache across exactly the change it was written to notice. Naming a keyless probe therefore makes the namer keyless too.

What is deliberately not affected. tools { }, envs { }, and files { } producers lower to inputs.tools / inputs.env / inputs.files (§ 22.5.2), populating sections 5, 4, and 6: they are self-keying, and upgrading a compiler re-fingerprints a tools probe exactly as before. A unit that seals a keyless probe keys on the probe’s value (§ 17.1.1), not its fingerprint, so re-observing an unchanged world leaves every consumer’s key untouched — the sealing unit still hits, and an A→B→A revert still re-serves the original artifact. Demand-driven scheduling is untouched: an unreached probe still MUST NOT execute and its fingerprint still MUST NOT be computed.

Superseded by CS-0244. The probe fingerprint this entry’s keylessness rule was a rule about — which probes may consult the cache — is removed entire, and with it the cache there was to consult. Keylessness has nothing left to be a rule about. One reference in the paragraph above has since gone stale by a different route: the envs { } producer was removed by CS-0226 in favour of an ordinary shell probe over the same variables, sealed by name, but the inputs.env sub-key it lowered to survived underneath it, and CS-0244 is what retires that sub-key.

Backward compatibility. Behavioural for the affected class, and no CACHE_VERSION bump: the fingerprint algorithm is unchanged, and existing stored probe values simply stop being addressed for keyless probes. A keyless probe also stops publishing; skipping only the lookup would leave a stable fingerprint addressing a stored value that a verifier, another machine sharing the store, or a future run could still serve. The blessed modules need no migration — every production probe in cook-modules already declares inputs (cook_cc toolchain/checks/finder/cmake_compat/bare_probe, cook_pnpm toolchain/install/finder); the only inputs = {} occurrences are unit-test stubs.

Reference implementation. The dispatch site computes is_keyless from the declared-input sets plus a keyless_probes set carrying the requires propagation, records it, and short-circuits the cache GET; the completion site skips the backend PUT for the same probes. The fingerprint is still computed and recorded either way, because downstream folding and cook why both read it. Three existing fixtures had quietly depended on the old behaviour and were repaired rather than deleted: surface fixture 04-probe-add-unit-cache and cook-cli’s probe_produce_does_not_re_execute_on_cache_hit both asserted that an input-less probe reuses its value, which is the assertion this entry inverts, and cache_du’s seeded store used an input-less probe purely to place a probe_value artifact in the CAS. Each now declares an input, which is what each was really testing for. Pinned by two engine tests (a keyless probe ignores a seeded entry and re-executes; the keyed counterpart still takes it) and surface fixture 23-probe-no-inputs-reproduces, which also pins the self-keying files control.

CS-0179 — cook.cookfile: structure-preserving Cookfile edits [#changes.cs-0179]

Date: 2026-07-25 Version: v0.18 Sections affected: § 22.13 (new §22.12); App. C gains § C.25; App. E (this entry). Reference implementation: new crate cli/crates/cook-cookfile, cli/crates/cook-lua-stdlib/src/cookfile_api.rs, cli/crates/cook-register/src/engine.rs.

Summary. The editing layer under the cc.* project-management verbs of CS-0176. splice_field inserts an entry into a named list inside the module call of a named recipe; append adds a declaration at end of file; find_call looks without writing.

Preservation is normative, and re-rendering is prohibited by name. The easy implementation — evaluate the Lua to a table, edit, print it back — is specifically forbidden, because it is lossy in ways that do not announce themselves. standard = cxx_std is written back as whatever that variable held; comments have nowhere to live in a table and vanish; field order becomes pairs() order. The result still builds, which is the problem: an author who ran a verb to add one library gets a file that no longer resembles what they wrote and no indication that anything happened. A splice touches only the inserted range, so everything else survives by construction rather than by fidelity effort.

Failure is explicit, total, and the point. Where the edit cannot be located — no such recipe, no module call, no such field, or a field that is not a list — the implementation MUST name what it looked for and leave the file byte-identical. This is what the splice buys. A re-rendering implementation cannot fail this way because it cannot detect that anything was unusual; it writes a plausible file and reports success. Being told to make an edit by hand is worse than the edit working and much better than the edit appearing to work.

Locating splits along what the frame grammar knows. Recipes and module calls are structure and are located structurally — the multi-line, brace-nesting, string-containing-braces case that hand-rolled scanning gets wrong. Fields are not: embedded Lua payloads are opaque leaves by design, so no field node exists. Fields are found by scanning within the located call span, under two rules that are each a way a plain find corrupts a file: a } inside a string or a comment does not close the list, and a field name matches a key, not a substring (links must not match inside "mathlinks.cpp"). A third case surfaced during implementation and is now covered by the same discipline — anchoring the insert at the last non-whitespace byte lands inside a trailing comment, which is the very thing the layer exists to preserve, so the anchor is the last byte of code.

Sandbox. Paths are gated exactly as fs.* paths are (CS-0045): a chore may rewrite the Cookfile that invoked it, never one outside the project root, and the refusal happens before the read.

Conformance. Additive; no grammar change and no effect on any existing Cookfile or cache key. An implementation that does not expose cook.cookfile is unaffected. Pinned by 18 tests in cook-cookfile covering the editing algebra and its four failure modes, and 8 in cook-lua-stdlib covering the binding, the on-disk write, the sandbox refusal, that a failed edit leaves the file untouched, and that one verb’s output is ordinary input to the next.

CS-0180 — fs.remove [#changes.cs-0180]

Date: 2026-07-25 Version: v0.18 Sections affected: § 25.6 (new §25.6); §25.6–§25.10 renumbered to §25.7–§25.11 (fs.mkdir_p, fs.glob, fs.mtime, the project-root sandbox, and the shell escape hatches, with their notes and examples); App. E (this entry). Reference implementation: cli/crates/cook-lua-stdlib/src/fs_api.rs.

Summary. The fs surface could create files and could not delete them. fs.remove(path) closes that, returning true when a file was removed and false when nothing was there.

Why absent-is-not-an-error. A caller removing a path wants it gone, and it is gone. Raising would make every cleanup path guard with fs.exists first — noise, and a race between the check and the call. Returning a boolean keeps the outcome reported rather than swallowed, so a caller that genuinely needs to distinguish “I removed it” from “it wasn’t there” still can. This is the narrow exception the §25.1 no-silent-success rule contemplates, made explicit rather than assumed.

Files only, and no recursion. A directory is refused with an error naming it. There is deliberately no recursive form and this is not a first step toward one: a sandboxed API that can delete a tree is a substantially larger thing to get right than one that can delete a file, and nothing that motivated this needs it. fs.remove also does not follow symlinks — it removes the link and leaves the target alone, which matters because the naive metadata check would refuse a symlink pointing at a directory and the naive removal would delete through it.

What motivated it. CS-0179’s conformance fixture edits a Cookfile and must leave the tree exactly as it found it, including when its own assertions fail. Editing a copy and deleting the copy is the honest way to do that; without a removal primitive the fixture had to edit the tracked file in place and restore it, which is correct only on the happy path. It failed to be, and the residue accumulated across runs — a fixture that is dirty from its first failure onward. That is a small motivation for a language-surface addition, but the gap it exposed is not small: a module that writes a scratch file had no way to clean up after itself.

Conformance. Additive; no grammar change, no effect on existing Cookfiles or cache keys. The renumbering is presentational — every cross-reference in the Standard is by slug, and fs.mkdir_p, fs.glob, fs.mtime and the sandbox section keep their anchors, so no link breaks. Historical App. E entries citing the old numbers are left as written, per the convention CS-0173 followed. Pinned by five tests in cook-lua-stdlib covering removal, the absent-path boolean, directory refusal, sandbox refusal, and symlink handling.

CS-0181 — tools { } admits the tool names that exist [#changes.cs-0181]

Date: 2026-07-26 Version: v0.18 Sections affected: § A.3.2 (producer split into tool_name_list / env_name_list; new TOOL_NAME terminal; source-list-kinds paragraph; new tool-name charset paragraph; CS-0131’s charset note re-pointed), § 22.5.2 (tools producer bullet and the name-list rejection bullet), App. E (this entry). Reference implementation: cli/crates/cook-lang/src/probe.rs.

Summary. A tools { } name list accepted only IDENT, so a hyphenated executable could not be named. tree-sitter, pkg-config, llvm-config and wasm-opt were all unspellable. TOOL_NAME is now PROBE_SEG — the recipe-name character set, internal - and . admitted — which is the class CS-0131 already gave probe keys.

The Standard contradicted itself. § 22.5.4‘s tools row gives "pkg-config" as its worked example of a tool name, and the § 22.5.2 Lua-API example seals on exactly that. Neither could be written in the native probe surface the rest of §22 specifies. CS-0131 is where the two halves parted: it widened BARE_PROBE_KEY to PROBE_SEG and recorded that it was “leaving the narrow IDENT for the tools/envs name_list” — a deliberate choice, but one made about probe keys, with the tool-name consequence unexamined.

envs is deliberately not widened. An envs entry names an environment variable, and a shell cannot address FOO-BAR; admitting one would let an author spell a determinant that nothing could ever set, turning a typo into a silently-always-unset input rather than a parse error. The two kinds share a brace shape and nothing else, so the grammar now names them separately instead of pairing them under one name_list.

What motivated it. tree-sitter-cook commits its generated src/, and the tree-sitter CLI that generates it is therefore part of the source: 0.25.10, 0.26.9 and 0.26.11 each emit different bytes for one unchanged grammar.js. package.json pinned the version and CI installed it, but nothing tied that pin to a cache key, because the determinant could not be named. The failure mode this leaves is the bad one: a developer whose PATH resolved a different CLI regenerated tracked files in place, with no key moving to say why, and the diff surfaced only as an unexplained dirty tree during a release.

Conformance. Strictly widening; every name legal before is legal now, so no existing Cookfile changes meaning and no cache key moves. Both classes keep the [A-Za-z_] head, so tools { cc --version } stays rejected and the probe-as-tools-bad-name negative fixture is unaffected. Pinned by the new positive fixture probe-tools-hyphenated-name and by the existing probe-as-tools-bad-name / probe-as-tools-empty negatives.

CS-0182 — a one-to-one test unit folds its own item, not the whole source set [#changes.cs-0182]

Date: 2026-07-26 Version: v0.18 Sections affected: § 17.4 rule 1 (new ingredients bullet; new per-item-folding paragraph), § 8.6.1 (one-to-one mode sentence), App. E (this entry). Reference implementation: cli/crates/cook-luagen/src/test_step.rs.

Summary. A test_step in one-to-one mode over ingredients emitted one unit per item, and every one of those units folded the whole resolved ingredient set. Editing one file therefore moved the key of every unit in the fan-out. Each unit now folds only its own item.

The two step kinds disagreed on one surface. Given ingredients "src/*.c", a cook step fanning out to cook "build/$<in.stem>.o" keys each object on its own source — that is the reuse the whole cache exists to deliver. A test step over the identical line, in the identical mode, keyed every unit on all of them. The codegen shows the divergence directly: inputs = {_cook_in} against inputs = ingredients, with the item carried alongside in iteration_item and used only for the label and $<in> substitution. Two fan-outs over one ingredients line must not disagree about what a unit depends on.

It is the rule observable 5 already states. § 17.1 observable 5 requires per-member independence for ingredients <probe> data-member fan-out, in as many words: “editing one member re-runs only that unit.” Nothing distinguishes the file-glob fan-out that shares the surface. This entry does not introduce a principle; it applies an existing one to the case that was missed, and promotes the test’s own ingredients fold from the section’s informative note into rule 1’s normative list, where it had never been stated.

What motivated it. The Cook Standard’s own conformance corpus is 284 fixture directories. Driving it from a test step is the natural Cook shape — one cached pass/fail unit per fixture, fanned out in parallel — and it was unusable: a one-character edit to one fixture re-ran all 284. The fan-out delivered parallelism and no reuse, which is the half of the bargain that does not need a build system.

Scope. The narrowing reaches only a unit’s own ingredients. Where the iteration source is a preceding cook step’s outputs, the item is an upstream artifact folded through the consumed-output path, narrowable by consumes (CS-0175); an implementation must not reclassify an upstream output as the test’s own input to narrow it. That case is left as it stands.

Conformance. A key-composition change for one-to-one test units over ingredients, and only those: single-unit (ManyToOne) and source-less (OneShot) tests are untouched, as are all cook units. No CACHE_VERSION bump. Records written under the old composition address a different digest, so they miss and the unit re-runs — a cold miss, never a false hit, which is the safe direction for a narrowing. No corpus fixture accompanies this entry: the corpus pins parse-level AST shape, and a key composition is not parse-visible; the behaviour is pinned by the reference implementation’s codegen tests.

CS-0185 — one registration function; cook.add_test is removed [#changes.cs-0185]

Date: 2026-07-27 Version: v0.18 Sections affected: § 22.1 (step_kind accepts "test"), § 22.4 (rewritten: cook.add_test removed, test-unit fields stated by reference, naming rule restated). Supersedes CS-0153’s refusal and CS-0160’s naming derivation. Reference implementation: cli/crates/cook-register/src/{unit_api,test_api}.rs, cli/crates/cook-luagen/src/test_step.rs.

Summary. A test unit is an ordinary work unit with step_kind = "test" and no declared outputs, recorded by cook.add_unit. The separate cook.add_test is removed — not aliased, not deprecated — and calling it is a register-phase error naming the replacement.

It carried nothing cook.add_unit lacked. command, lua_code, inputs, seal and line were the same fields under the same names, and the specification of two of them said so outright: inputs was documented as “mirroring cook.add_unit’s inputs” and seal as mirroring it “exactly”. iteration_item duplicated member. timeout and should_fail had been inert constants since CS-0135 removed the modifiers that fed them, retained only because the payload had fields for them. The one field that looked genuinely its own was suite, and it is removed too, because it captured nothing. It defaulted to the enclosing recipe’s qualified name; it was excluded from the cache key as display metadata; the test identity was built from the recipe name and not from it; and the JUnit writer, the one consumer for which a “suite” concept means something, derives its <testsuite> grouping per recipe from the test ID prefix and ignores the field entirely. Its only reader emitted it as a JSON key beside "recipe" — two keys that held the same string in every build ever run, because nothing in the language, the corpus, or any published module ever passed a value differing from the default. A field that cannot distinguish two units is not a field. Suites are reserved: a grouping independent of the recipe may be introduced when there is a design for one, and the removed field was not that design.

CS-0153 had made the split self-defending: cook.add_unit was required to REJECT step_kind = "test" on the ground that such a unit would be “invisible to cook test”. That was true only because the registration bookkeeping lived in the other function, so the rule cited as justification for the split was a consequence of it. With one function the bookkeeping happens once, at the point that sets the kind, and the hazard cannot arise.

The entry also fixes the inference this milestone exists to remove. An empty output list and being a test are now stated as independent facts: the empty list is what makes a cache hit replay a recorded outcome instead of restoring artifacts, and step_kind is what selects the reporter. Neither may be derived from the other. A conforming implementation MUST reject outputs on a "test" unit, and MUST NOT treat an output-less unit as a test.

Backward compatibility. Breaking, for Cookfiles and modules that call cook.add_test directly. Authors writing test { … } are unaffected: the step lowers to the new form and no surface syntax changes. cook.add_test is not retained in any form; a call raises rather than warning, on the ground that a shim would preserve exactly the ambiguity the entry removes. step_kind gaining an accepted value is additive for every existing cook.add_unit call.

Test unit names change shape. CS-0160 derived <recipe>_test<N> with <N> the unit’s 1-based ordinal among the test units recorded so far in the recipe body; the derivation is now <recipe>_test<LINE>, the Cookfile line of the originating step. Both are unique within a recipe — two test_steps cannot share a line — but an ordinal must be COUNTED, which forces the registration API to inspect the units already recorded before it can name the one it is recording, and that inspection is the only reason the naming step needs the recipe body at all. The line is already a field of the call. A name enters no fingerprint, so nothing is re-keyed; what changes is displayed identities and therefore any saved --rerun pattern matching on _test<N>.

Machine-readable graph output changes too. A grouped test unit’s dep kind was reported as test_sibling and is now step_group, because the distinct kind was removed: it claimed to be “like a step group but failures don’t cancel siblings” and enforced nothing, group members depending on a shared barrier and never on each other, so a sibling is never a dependent and no cancellation walk can reach one. § 8.6‘s sibling exemption is a property of the graph’s shape rather than of an edge label, and the label encoded a conjunction — the grouping, and that the members were tests — of which the second half is step_kind. Consumers distinguishing test units from other grouped units MUST read the node’s kind.

Removing suite changes machine-readable output: the "suite" key disappears from the JSON test report. Consumers MUST read "recipe", which carried the same string. The Lua chunk name for a Lua-body test becomes @test:<name>, where <name> is the derived <recipe>_test<LINE>. It was @test:<suite>:<name>, whose first segment was the recipe under another name and whose second already embedded it, so the recipe was reported twice; one segment carries it once. A runtime error inside a test body therefore reports a chunk name built from the same identity every other diagnostic uses. This is a change in reported text, not in behaviour.

Conformance. Corpus fixtures naming cook.add_test move to the cook.add_unit form. Three negatives pin the removals: calling cook.add_test raises, declaring an output on a "test" unit raises, and passing suite raises. The positive fixture 046-test-add-test-default-suite is withdrawn rather than ported — its subject was an explicit suite override, which no longer exists. No CACHE_VERSION bump is required by this entry on its own — the lowering changes which function records a unit, not what its key is composed from — though the record unification this milestone continues into will bump it independently.

CS-0187 — $<file:PATH> is removed [#changes.cs-0187]

Date: 2026-07-27 Version: v0.18 Sections affected: § 9.3 and § 9.4 (the placeholder tables lose their file-reference rows; the output-pattern rejection goes with them), §10.8 (the reserved file: placeholder namespace, deleted; §10 now ends at § 10.7), § 10.2 (the position-independence example no longer cites it), § 17.1 (observable 6 withdrawn; observables now number 1–5), § 17.1.1 (the declared-input-contents bullet), § 17.4 (rule 1’s third source), § 22.1 (the file_refs field), § 22.5.7 (the colon-dispatch carve-out), App. A (the file_ref and file_path_char productions), App. F (the corpus skip list). Reference implementation: cli/crates/cook-register/src/file_ref.rs (deleted), cli/crates/cook-luagen/src/{template,sigil,resolver,cook_step,test_step,recipe}.rs, cli/crates/cook-register/src/unit_api.rs, tree-sitter-cook/grammar.js.

Summary. CS-0101’s file-reference placeholder is removed outright, with no deprecation period and no shim. A $<file:PATH> token is no longer recognised: it is ordinary literal shell text under the strict-bail rule of § 9.1, exactly as any other unrecognised sigil is.

Why it goes rather than stays. It was a determinant mechanism of its own, built before the probe substrate could carry one. It had its own accumulator in the code generator, its own hoisting convention, its own register-phase API, its own field on the unit declaration, its own resolution path, and its own observable in § 17.1 — six pieces of private machinery to say “this unit reads this file, fold its content”.

Everything on that list now exists generically. A files producer (§ 22.5.2, CS-0148) resolves a glob list to a per-file manifest and is seal-only by construction; a seal (§ 8.4.3) folds a probe’s canonical value into the unit’s key with a defined cross-machine story and a value cache in front of it. The replacement for a file reference is therefore a named files probe and a seal on the unit that reads it, and that is what the removal leaves behind:

probe assets
    files { "voice/*.wav" }

recipe tts
    ingredients lines
    cook "build/$<in.id>.wav" { synth --ref voice/narrator.wav --out $<out> }
    seal assets

What is lost, and it is worth naming. The sugar was genuinely shorter, and it put the path and its determinant in one token at the point of use. The replacement separates them: the body names the path, the seal names the probe. A reader must connect the two. That cost is accepted here because the alternative was to keep a second determinant mechanism alive while its replacement was designed, and a language that has two answers to one question teaches both.

This is a removal, not a redesign. The redesign — $<file:PATH> as sugar over an anonymous file seal, with the same shape extended to $<tool:NAME> for tool identity — is deliberately not attempted here and is not promised by this entry. It has real open questions: whether every reference should mint a graph node (a probe is demand-scheduled and takes a dependency edge), how an anonymous probe’s key is synthesised, and whether cook why can expand a sealed manifest so a changed file is still reported by name rather than as “seal changed”. Removing the old surface first means the answer to those questions is not constrained by a shape that predates them.

Backward compatibility. Breaking, deliberately and with no migration period. Removing the namespace is not by itself enough to make the retired form fail cleanly, and what it degrades into depends on the path — which is why this entry requires a diagnostic rather than recommending one.

file: was dispatched ahead of the probe-value colon discriminator (§ 22.5.7). With that dispatch gone, a retired token falls to whichever rule now claims it, and two different rules do:

  • $<file:tokens.css> is all bare_ident characters, so it resolves as a probe-value reference and reports an undeclared probe key file:tokens — a diagnostic about a probe the author never wrote;
  • $<file:templates/*.html> contains /, which no ident production admits, so § 9.1‘s strict-bail rule makes the whole sequence literal shell text and the step runs with $<file:…> on its command line.

A conforming implementation MUST therefore reject a placeholder whose IDENT begins with file: with a diagnostic naming this entry and the files-probe replacement, positioned where the removed dispatch was — before the probe discriminator. This is what CS-0172 did for the retired env. prefix, for the same reason: a removed surface that fails as something else teaches the wrong lesson twice.

Conformance. The CS-0101 corpus fixtures are withdrawn: two negatives (file reference in an output pattern; file reference to a missing file), two positives (the cook-body and fan-out placeholder forms), and the tree-sitter grammar corpus case. Nothing replaces the positives — there is no surface left to pin.

One negative is added, cs0187-retired-file-prefix, and it pins the diagnostic rather than the removal: a retired $<file:tokens.css> must be refused by name. It is a codegen-phase rejection over a Cookfile that parses cleanly, so the tree-sitter harness carries it on the skip list with the phase that enforces it — trading its two CS-0101 entries for one. The strict-bail shape ($<file:templates/*.html>, where / leaves no ident production to claim it) is deliberately not pinned as a negative: it parses, it is literal text, and that is § 9.1 working as specified.

No CACHE_VERSION bump. Units that used the surface lose input records, which is a cold miss and a rebuild, never a false hit; the recorded shape is unchanged.

CS-0253 — adjacent target comments provide target descriptions [#changes.cs-0253]

Date: 2026-08-22 Version: v0.18 Sections affected: § 3.6.

Summary. A contiguous Comment block immediately before a surface recipe or chore attaches as that target’s full description. cook menu and cook list show the description’s first line.

Backward compatibility. Previously, these comments were ignored; listings gain a suffix only for this adjacent-comment shape.

Conformance notes. Positive fixture adjacent-target-descriptions pins a multi-line recipe description, a blank-separated ignored comment, and an adjacent chore description. cook-cli process test menu_shows_adjacent_surface_comment_descriptions verifies the menu/list surface.

CS-0252 — cook why resolves closure-produced sealed-glob members from their producers [#changes.cs-0252]

Date: 2026-08-21 Version: v0.18 Sections affected: § 17.1.6.1.

Summary. Fresh determinant reporting and upstream causality used to disagree when a sealed files glob matched an output produced inside the queried closure. Hashing the working tree was wrong both when a cached artifact had not been restored and when a rebuild would overwrite stale bytes. The query now composes a served producer’s predicted artifact identity into the canonical files manifest; when the producer rebuilds, it leaves the sealing consumer’s key uncomputable and names the pending path. The current glob expansion remains authoritative for ordinary files, so a removed member is not retained from a prior probe record.

Conformance. A surface fixture materialises and then removes a closure-produced glob member without executing closure work through cook why: a served producer still yields the consumer’s existing key, while editing the producer’s input makes the consumer rebuild with truthful upstream and pending-path attribution. No grammar or cache-schema change.

CS-0251 — an unrecordable pre-pass probe is not resolved [#changes.cs-0251]

Date: 2026-08-21 Version: v0.18 Sections affected: § 22.5.8, App. E (this entry). Reference implementation: cli/crates/cook-probe/src/eval/mod.rs.

Summary. A register pre-pass probe whose canonical local record cannot be written now fails registration instead of returning a value that the execute phase cannot serve. The record is the same-invocation carrier for that cross-phase single-flight, so a produced value without it is not resolved. Execute-phase record failures remain warnings: the executor already has the completed value in its per-run store.

Conformance. The cook-probe evaluation test blocks creation of .cook/probes and requires the synchronous pre-pass evaluation to fail by key. No surface, record schema, key spelling, or cardinality rule changes.

CS-0250 — probe records use workspace-qualified identity [#changes.cs-0250]

Date: 2026-08-21 Version: v0.18 Sections affected: § 22.5.8, App. E (this entry). Reference implementation: cli/crates/cook-probe/src/{eval,store}/, cli/crates/cook-engine/src/{executor,why}.rs, cli/crates/cook-execute/src/pool.rs.

Summary. A probe’s canonical local record is now named by its workspace-qualified identity, not its Cookfile-local key. The same qualification scopes the shared per-run value store and every execute, seal, cross-phase, and cook why reader. Two imported Cookfiles may therefore declare the same local key without overwriting or reading one another’s value. LocalProbeKey remains the Cookfile surface identity and QualifiedProbeKey remains the workspace identity; neither is restamped or inferred from display text.

Conformance. Surface fixture 47-qualified-probe-records imports two real Cookfiles that each register probe shared, builds both consumers, requires distinct qualified record files and values, and runs cook why over the same workspace. No auto-keying, fingerprint, cache schema, or wire-format change; cache record schema remains 11.

CS-0249 — a dotted quoted probe key keeps its declaring import prefix [#changes.cs-0249]

Date: 2026-08-21 Version: v0.18 Sections affected: § 11.5, App. E (this entry). Reference implementation: cli/crates/cook-contracts/src/probe_key/mod.rs, cli/crates/cook-engine/src/{run,why}.rs.

Summary. A workspace-qualified probe identity now retains the canonical prefix of the Cookfile that declared it, rather than re-splitting its rendered dotted spelling. A quoted local key may contain ., so splitting the spelling assigned the local-key suffix to the import prefix and resolved files values from the wrong directory. Two imported Cookfiles may use the same dotted local key without either identity or declaring directory collapsing. The rendered key remains the existing wire spelling and cache record schema remains 11.

Conformance. Surface fixture 46-imported-dotted-files-key registers two imported members with the same quoted dotted files key, then changes one member’s input. Only that member’s sealed output rebuilds. The contracts value-law test pins the retained prefix directly.

CS-0248 — current-invocation probe observations are not cache hits [#changes.cs-0248]

Date: 2026-08-21 Version: v0.18 Sections affected: § 17.1, App. E (this entry).

Summary. Cache reporting now turns on whether this invocation skipped work by serving a result recorded by an earlier invocation. A declaration-derived probe value synthesised without a VM, and a produce value reused from this invocation’s registration pre-pass, are both current observations: their probe nodes report ordinary completion and do not contribute to cached-node totals. Reusing same-invocation work across phases is single-flight, not caching. The existing NodeCompleted event and Resolved probe rendering remain the vocabulary; no progress event or wire schema changes.

Conformance. Surface fixture 04-probe-add-unit-cache pins the real CLI’s warm second run as (2/4 cached): its producing unit and test are hits, while both the declaration-derived seal probe and the Lua-produced probe observe this invocation. The fixture’s unchanged artifact continues to pin CS-0246 independently. No cache or record schema changes.

CS-0247 — an unmaterialised sealed probe gives cook why no key [#changes.cs-0247]

Date: 2026-08-21 Version: v0.18 Sections affected: § 17.1.6, § 17.1.6.1. Reference implementation: cli/crates/cook-engine/src/why.rs.

Summary. A sealed probe implemented by a produce body can have no materialised record for a read-only query to use. The reference implementation folded that absence as empty bytes, minted a key no run could compute, and reported a false cache miss even when the next build served the real hit after producing the determinant. Such a sealing unit now has no computable key: cook why reports it as rebuilding/unknown, retains the probe’s [unmaterialised] determinant marker, and does not execute the body. The no-execution MUST in § 17.1.6 is explicitly scoped to closure work units; the mandatory registration pass remains the same registration a run performs.

Conformance. Surface fixture 45-why-unmaterialised-probe runs the real binary through build, removal of .cook/probes/, explanation, and build. It requires no key and no false MISS from the explanation, then requires the unchanged output and cached sealing unit on the following build. No cache or record schema changes.

CS-0246 — bare probe consumers are ordered but not keyed [#changes.cs-0246]

Date: 2026-08-21 Version: v0.18 Sections affected: § 22.5.7, App. E (this entry).

Summary. § 22.5.7 now states explicitly that probes = {...} supplies the ordering needed for interpolation but does not fold the observed value into the consumer’s cache key. A cached consumer may therefore retain output made from an older observation; authors who require freshness must seal the probe.

Backward compatibility. Clarification only: no behaviour changes and no CACHE_VERSION bump.

CS-0245 — cook why resolves a sealed declaration fresh, and names the probe delta behind a miss [#changes.cs-0245]

Date: 2026-08-17 Version: v0.18 Sections affected: § 17.1.6.1 (17.1.6.1 — the sentence requiring sealed probe values to be “read from their already-materialised canonical-JSON values” is replaced by a freshness rule scoped to a probe the query can resolve without dispatching a produce body of its own: a top-level files/tools declaration or a cook.probe() registration carrying the synthesised-manifest sentinel (both examples of one non-exhaustive class), and, separately, a probe the query’s own registration pass must itself resolve to register the closure — a gather <probe> source or a register-phase read — whose freshly-produced value the report reuses rather than re-deriving; the key such a probe’s report carries is restated as the key a unit sealing it would compute, not “the key for that probe”; a new paragraph requires a failed fresh resolution (the CS-0214 tools-PATH failure, the CS-0241 unreadable-files failure) to be reported against the probe with a fallback to the last materialised value rather than failing the query; a new paragraph states, as a scope limit and not an obligation, that fresh resolution reads the working tree as it stands and does not resolve the case where a matched files path is a closure-internal producer’s output (COOK-534, open); the CS-0157 tools-path sentence is kept intact; the informative Note’s premise is restated as a read-only observation of the tree and PATH rather than as arithmetic over already-computed data, which is false for tools since §22.5.4’s PATH resolution and binary hash happen at query time, not at an earlier register pass; the miss-attribution paragraph now states explicitly that it constrains the attribution alone and does not narrow the full determinant report above it, and qualifies the added/removed entry diff for the case where one side does not exist; the local-forensics paragraph now bounds the case where the local per-probe record — written when a probe is last reached — cannot account for a seal-changed miss whose index record was written when the sealing unit last executed, admitting an entry-less seal-set attribution rather than requiring a diff neither side can supply), § 22.5.8 (22.5.8:474 — one clause appended stating that a declaration-derived probe is the one case this section’s post-hoc explanation account does not cover, since its value is resolved afresh rather than read from the record), App. E (this entry). Reference implementation. cli/crates/cook-contracts/src/probe/value.rs gains the pure probe_delta function both tiers render through; cli/crates/cook-cli/src/pipeline.rs (cmd_why) snapshots .cook/probes/ before registration runs; cli/crates/cook-engine/src/why.rs (explain) re-resolves a declaration-only sealed probe’s value ahead of classification, serves the value a register-pass production of a gather-feeding or register-read probe already computed, falls back to the local per-probe record on a fresh-resolution failure, and carries the pre-invocation snapshot for local-miss attribution; cli/crates/cook-cli/src/why_render.rs renders the delta on both tiers in text and JSON, additively (see Wire format, below). COOK-529.

Summary. §17.1.6.1 (§ 17.1.6.1) contradicted the section it belongs to. §17.1.6 (§ 17.1.6) requires the query to “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.” §17.1.6.1 required the opposite for a sealed probe’s value: “Sealed probe values are read from their already-materialised canonical-JSON values … a sealed probe whose value has not been materialised is reported as unmaterialised rather than produced” — the value recorded on the LAST invocation, not the value a run of the target would compute now. The first sentence states the contract this query exists to keep; the second describes a shortcut the reference implementation took. The contract wins. A blanket “the query MUST NOT produce a probe that needs a produce body” would not have satisfied it: § 17.1.6‘s registration MUST and § 22.5.10‘s pre-pass MUST (“A conforming implementation MUST resolve every such probe of a reachable recipe (via produce execution, per § 22.5.8) before it registers the recipe’s units statically”) together require the query’s identical-to-a-run registration to run a produce body for any probe feeding a gather <probe> source or read by register-phase Lua. So this entry states two independent freshness routes rather than one — a probe the query resolves directly with no VM at all (§ 22.5.8: “Exactly two cases resolve without dispatching a VM, and neither is a store”, which is the normative ground this change stands on), and a probe registration must itself produce in order to register the closure — and scopes the “no produce body of its own” prohibition to reporting rather than to registration. A probe that needs a produce body and that neither route reaches keeps reporting its last materialised value, now stated with a MUST that it be identified as such rather than left to imply the retired sentence was universal. This also answers the open question CS-0187 left standing when it removed $<file:PATH>: “whether cook why can expand a sealed manifest so a changed file is still reported by name rather than as ‘seal changed’” — yes, by naming the manifest’s moved entries instead of reprinting the whole value.

Which sentence was wrong, and why the query could not see what it was about to attribute. The reference implementation’s why::explain builds a ProbeValueStore and attaches .cook/probes, so every sealed value it folds is whatever an EARLIER invocation recorded there; nothing re-synthesises a files/tools declaration’s value at query time even though cook_probe::eval::lookup can produce exactly that value with no VM and no execution, and nothing reused the value a gather <probe>-feeding probe’s own register pre-pass had just produced. §17.1.6.1’s sentence did not just fail to require freshness, it forbade it by naming “already-materialised” values as the source. §17.1.6’s MUST was never in question; §17.1.6.1 is the section that had drifted. cmd_why snapshotting .cook/probes/ before registration runs is the machinery that exists because of the fix’s other half: a register-reached probe overwrites its own record file during the very registration cook why performs, so the pre-invocation snapshot is what preserves the local-miss diff’s “old” side against a record the query’s own run is about to update.

This changes observable behaviour; it is not a clarification. Reproduced by hand: a probe srcs declared as files { "src/*.ts" }, a recipe that seal srcs and writes an artifact, cook build (writes the artifact, records .cook/probes/srcs.json), an edit to one matched file, then cook why build --level unit reports [HIT (local), HIT (shared)] and prints the sealed probe’s OLD hash — while the very next cook build prints rebuild (seal changed). The query answered a question about a build that will not happen, for a unit whose next run misses. After this entry the same sequence reports the local miss cook why exists to predict, and names the probe key and the changed manifest entry rather than the whole stale value.

“Old” is local forensics; the shared tier is unaffected; the two records can diverge. The value a local-miss attribution diffs against is the implementation’s own local per-probe record — .cook/probes/<key>.json (§ 22.5.8) — read as data left over from a prior invocation of this machine, and it is not the index’s own record of the values behind a unit’s previous key. The two are written on different events: the per-probe record moves whenever the probe is reached by any recipe, while a unit’s index record reflects whatever the probe’s value was when THAT unit last executed. Sealing the same probe in two recipes and running only one of them moves the per-probe record without moving the other recipe’s unit past its own last-executed seal digest, so a later cook why on the second recipe is a genuine seal-changed local miss whose record-vs-current diff can be empty. This entry’s attribution obligation admits that case rather than requiring an entry diff neither side can supply: the query names the seal set as the differing determinant and reports no entry, instead of fabricating one. The query performs no store lookup to obtain the local record either way: there is no probe-value store left to look up (CS-0243), and this entry does not reintroduce one. The shared tier continues to answer exactly as before, from the producer determinant manifest’s recorded probe values (§ 17.1.5), which a different machine’s publish populated and which this entry does not touch.

No CACHE_VERSION bump. No key computation changes. A unit’s cache key has always folded a probe’s resolved value at the point execution folds it (§ 17.1.1); this entry changes only what cook why reads when it reports that key ahead of a run. cook why starts computing the key a run would compute; the key a RUN computes is unchanged, and no cached artifact — local or shared — is invalidated by this entry.

Wire format: additive, no bump. § 17.1.6.6 bumps the query’s JSON schema version on a structurally incompatible change (CS-0217’s convention). The reference implementation’s delta rendering adds keys to the existing per-unit determinant object — the moved probe key(s) and, where applicable, the added/removed/changed manifest entries — and removes none, changes no existing key’s type, and changes no existing key’s meaning; a consumer that ignores unknown keys sees no difference. This is additive by the same test CS-0217 applied to schema_version itself, so no bump is warranted.

Conformance. No conformance fixture is added. Per CS-0241’s precedent (and CS-0214’s before it — “No grammar change and no new fixture”), the standard/conformance/{positive,negative} corpus pins single-Cookfile parse/register accept-reject shape, not the runtime content of a cook why report, and there is no surface here for it to express: the Cookfile in the repro above parses and registers exactly as it did before this entry. The repository’s pre-commit conformance-growth check only WARNS on a CS entry that does not grow the corpus, and is not chased into manufacturing a fixture the corpus cannot carry.

CS-0244 — the probe fingerprint is deleted [#changes.cs-0244]

Date: 2026-08-17 Version: v0.18 Sections affected: § 22.5.4 (the seven-section fingerprint algorithm, the COOK_PROBE_FP_V2 marker, and the env sub-key row all deleted; the tools and files rows restated in terms of the probe’s value rather than a fingerprint contribution, each defining its own missing-input / missing-file fold rather than deferring to the other section that deferred back; the requires row restated as an ordering and failure-propagation edge and stated NOT to be a keying mechanism; a new paragraph stating that opts.inputs.env is retired and MUST be rejected naming this entry; a new paragraph stating what each sub-key does on a hand-written produce body — requires alone is a DAG edge, files and tools are resolved rather than scheduled, and none of the three keys anything), §22.5.4.1 “Module source” (cat.probes.module-source, deleted entire — the probe-fingerprint half of CS-0204’s module-source rule), § 22.5.2 (22.5.2’s seal-declares-fingerprint-inputs sentence and 22.5.2.1’s parallel sentence restated without “fingerprint”; the tools NAME and files NAME value-composition paragraphs restated over the value alone; the Lowering subsection’s fingerprint-invalidation sentence restated over the produce source string it actually is), § 22.5.1 (the consumer-probes sentence in 22.5.1 restated: a DAG edge that orders and enables substitution, not a fingerprint fold), § 22.5.6 (rule 3, which folded a resolved probe’s fingerprint into the consumer’s own fingerprint, deleted outright — it kept no promise a probes reference makes; § 22.5.7‘s “does not re-key its consumer” paragraph restated over the two surviving rules), § 22.5.8 (the CS-0178 keylessness block deleted entire — the no-declared-inputs paragraph, the requires propagation paragraph, and the declares-inputs-by-construction paragraph, whose surviving content is folded into the Always observe paragraph, already present from CS-0243; the Demand-driven paragraph’s fingerprint clause restated over the implementation as “MUST NOT observe an unreached probe at all”; Example 22.5.8.1’s fingerprint sentence restated; the comparison to § 17.4 rule 1 kept and its “before CS-0244” narrative half dropped as ledger material), § 22.5.10 (the register-phase-reads paragraph, its Demand-driven bullet, and its produce-body-confined-to-requires bullet each lose a fingerprint clause; the Per-member-units paragraph restated over the observed array rather than a “probe-value fingerprint”), § 22.5.11 (informative build-log annotation naming the deleted fingerprint call sites, alongside CS-0243’s existing CacheBackend annotation), § 12.3.6 (12.3.6 — the probe cross-reference to the now-deleted §22.5.4.1 dropped, and the MUST narrowed to a work unit body, which is the whole of the scope it has left; the probe is scoped out of the MUST rather than carved out of it in following prose, in the shape § 17.1.1.3 already uses), § 12.7.3 (12.7.3 — env dropped from the determinant-modelling guidance; the “fingerprint folds into the probe’s value” sentence restated over the probe’s value, which is what seal carries into a consuming unit’s key, with each of the three sub-keys stated in the role it actually has and requires stated explicitly as ordering only), § 12.7.5 (12.7.5 — the sentence directing a module author to “the lower-level inputs.env API” replaced: a module registering its own probe declares it with cook.probe and reads what it needs in the produce body, since inputs.env is now a MUST-reject and this entry retires the probe keying CS-0178 was a rule about), § 17.1.1.3 (17.1.1.3 — one stranded cross-reference to the deleted §22.5.4.1 repointed; the unit-level rule itself is untouched), § 24.9 (Note 24.9.1 — “the probe-fingerprint machinery” replaced by a reference to the surviving tools-declaration value resolution, which is what the shared per-run tool-hash memo actually serves now), § 22.1 (22-register-phase.mdx’s probes field row restated: the DAG edge orders and substitutes, seal keys), § 24.4.2 and § 24.4.3 (the deprecation banners’ “for memoised values” corrected to “for observed values”, which is what § 22.5.8 now requires; the same correction in § 12.7.4), § 28.3.14 (28.3.14 — “different opts produce different fingerprints” restated as different opts hashing differently, since a probe has no fingerprint to differ in; the claim itself, that opts derives the probe key name, is unchanged), App. C (C.22.1 — env dropped from the still-Lua-API-only determinant list, and cook_cc’s probe hoisting restated over the one observation per invocation it now buys rather than a “single cached probe value”; C.22.2 — “its fingerprint folds what was declared” restated as “what could be keyed on was only what was declared”, since nothing folds a fingerprint any more), App. E (CS-0178 marked Superseded by CS-0244, its stray envs { } reference corrected in that appended paragraph rather than in its historical body; CS-0204 marked Superseded in part by CS-0244, naming paragraph by paragraph which half went and which stands; this entry). Reference implementation. cli/crates/cook-contracts/src/context/ is deleted entire — compute_probe_fingerprint, its ProbeFingerprintInputs shape, the COOK_PROBE_FP_V2 canonicalisation, CS-0204’s fold_module_sources, the orphaned probe_module_manifest_key, and the thirteen byte-exact pins in its tests/context_tests.rs — along with the pub mod context declaration and cook-cache’s re-export of it; the module was the shared-kernel home of a law with no remaining subject. cli/crates/cook-cache/src/probe.rs loses resolve_probe_inputs and gains resolve_probe_input_digests, narrowed to what four live callers in cook-probe still need: the (name, digest) and (path, digest) pairs behind the synthesised @tools-identity and @files-manifest values and behind the CS-0214 and COOK-510 guards. It is named for the input digests it returns rather than for a fingerprint, and it is infallible, because the env and upstream resolutions are gone and neither surviving half can fail. hash_file_sha256, resolve_tool_path and tool_identity stay in that file: they have production consumers outside the probe path (cook why, cook.tools.id, the statmemo tool-hash memo). cli/crates/cook-probe/src/eval/mod.rs loses Lookup.fingerprint, Lookup.keyless, Recorded.fingerprint, Evaluated.fingerprint, Evaluated.keyless, fold_candidate, Produced.module_paths, and the upstream_fps / keyless_upstreams parameters of lookup and evaluate; record drops every parameter but the key, the context and the bytes, keeping its unconditional materialize_value write, which CS-0242’s cross-phase single-flight reads back. ProbeError::ResolveInputs goes with them: its one construction site was the deleted upstream resolution, and with requires resolving nothing there is no failure left for the variant to name. With one variant left the enum is a struct carrying the key and the message, since a one-arm match is not a discrimination. cli/crates/cook-engine/src/executor.rs loses upstream_probe_fingerprints, probe_fingerprint_by_node, keyless_probes, the roughly twenty parameter threadings that carried them through process_ready’s recursion, and the fp= debug log; the worker-completion handler’s None arm — the all-zero “unfingerprinted” sentinel for a node with no probe metadata — goes with the map it guarded. cli/crates/cook-register/src/engine.rs loses the resolver state’s upstream_fps and keyless fields and their propagation, and its RegisterVmRunner stops snapshotting the module observer around a produce run. That snapshot was the register VM’s only observer reader, so register_module_loader stops taking one from its caller and stops installing the require wrapper that filled it; the execute phase installs and drains its own, unaffected. cli/crates/cook-register/src/probe_api.rs carries the removed-surface diagnostic: opts.inputs.env is refused by name, naming the offending probe key, citing this entry and directing the author to a shell probe over the same variables. cli/crates/cook-contracts/src/probe/mod.rs drops ProbeInputs.env, which is what makes that refusal the only way the sub-key can be spelled. Deliberately untouched: cli/crates/cook-contracts/src/cache/cas/mod.rs’s MODULE_INPUT_SETS / MODULE_SET_CAP / path_set_candidates / merge_path_set, which serve § 17.1.1.3 — CS-0204’s surviving unit-level half — and never had a probe caller after CS-0243; and cook-lang / tree-sitter-cook, which never see inputs.env, a Lua table key no parser reads. COOK-528.

Summary. This is the second half of CS-0243. That entry removed the probe-value cache behaviourally and said so in terms: “This entry changes behaviour; the carcass follows.” This entry is the carcass’s removal. Almost every clause deletes a computation whose result CS-0243 already made unreachable, or corrects a sentence that described that computation. One clause is behavioural, and it tightens rather than loosens: a probe whose node carried no probe metadata at dispatch time used to complete without reaching record, because the worker-completion handler’s None arm — the all-zero “unfingerprinted” sentinel — short-circuited it, so its observed value was never materialised to .cook/probes/<key>.json. That arm went with the map it guarded, and every probe completion now materialises unconditionally, which is what § 22.5.8‘s on-disk-layout rule required all along. The bytes written are the same canonical bytes the probe observed; the change is that a case which silently wrote none now writes them. It also strengthens CS-0242’s cross-phase single-flight, which reads that file back to serve a key the register pre-pass already produced.

Why the machinery could go. The seven-section fingerprint §22.5.4 specified was, at the end of CS-0243, a value with exactly one consumer: the UPSTREAM section of a downstream probe’s own fingerprint (§22.5.4.1 “Module source“‘s Propagation clause, and § 22.5.4‘s requires row). No cache read it, because CS-0243 deleted the cache. No unit key read it: a unit that seals a probe keys on the probe’s value (§ 17.1.1), and a unit that merely lists a probe in probes gets a DAG edge, never a key, from the fold CS-0243’s own text already named as inert. No cook why output read it — the query reports resolved values, not fingerprints. No on-disk record read it — .cook/probes/<key>.json holds the canonical value, not the fingerprint that used to address it. A computation whose only reader is another instance of itself, chained arbitrarily deep and terminating nowhere observable, is not a determinant; it is a loop with no exit. Deleting it changes no cache decision, no substitution, and no diagnostic, because nothing outside the loop was ever looking. It changes on-disk bytes in one direction only, and only by omission: the metadata-less dispatch arm the fingerprint map guarded is gone, so a probe that used to complete without materialising its value now records it like every other, as the Summary states.

inputs.env is removed as dead surface. Unlike tools and files, which each feed a probe’s value as well as its now-deleted fingerprint, env fed the fingerprint alone — no probe’s value was ever built from inputs.env, because nothing wired an env-var’s current value into anything a produce body could read except by calling os.getenv itself, unmediated by this sub-key. The Cookfile-level envs { } spelling was already removed by CS-0226, in favour of an ordinary shell probe over the same variables, sealed by name; what this entry removes is what remained underneath — the Lua opts.inputs.env sub-key of the retained cook.probe() API. A module that still spells it MUST get a register-phase removed-surface diagnostic naming this entry and directing the author to the same shell-probe replacement CS-0226 already established.

CS-0178 is retired outright, not narrowed. Probe keylessness was a rule about which probes may consult the cache: a probe declaring no env, tools, files, or requires had a fingerprint constant for the life of the store, and CS-0178 forbade an implementation from treating that constant fingerprint as a standing cache hit. There is no cache left to consult, so the rule has no subject. It is not weakened, narrowed, or made vacuous-but-present; it is deleted, along with the requires-propagation clause that extended it and the self-keying language that distinguished a tools/files probe from a keyless one — a distinction that, like keylessness itself, described two positions on an axis (does the cache serve this probe from a prior run?) that CS-0243 collapsed to one answer (never) for every probe alike. The one clause worth keeping from CS-0178’s third paragraph — that a unit sealing a probe keys on the probe’s value, and that the build’s early cutoff survives re-observation regardless — was already restated as CS-0243’s own Always observe rule and needs no second home.

CS-0204 is split, and only one half moves here. CS-0204 minted two rules under one entry: a work unit is keyed on the module source its body loads (§ 17.1.1.3), and a probe’s fingerprint composes with the module source its produce body loads, propagating the FULL composed fingerprint along requires (§22.5.4.1 “Module source”). The first is a rule about a cache key and is fully implemented and untouched by this entry — it does not mention a probe fingerprint anywhere in its own text, and this entry changes not one byte of it beyond a single stranded cross-reference that pointed at the subsection this entry deletes. The second is a fingerprint rule through and through: it existed only to keep a stored probe value correctly invalidated across a module edit, and CS-0243 already observed that there is no stored probe value left to invalidate, which is why that entry’s own “Deliberately not in this entry” paragraph left the composition standing as “a fingerprint rule, not a cache rule, and it survives independent of one” — survives, that is, until the fingerprint it composes is itself deleted. It is, here. A probe’s produce body still loads modules through the same doors CS-0204 named, and still runs their current code on every invocation it is reached, because Always Observe already guarantees that without help from any determinant-tracking machinery: the next observation simply reads whatever the module says now.

No CACHE_VERSION bump. A probe fingerprint was never written to a cache record — CS-0243 already established that no store outlives the invocation — so no persisted artifact’s identity depends on the bytes this entry stops computing. .cook/probes/<key>.json continues to hold exactly what it held before this entry: the probe’s canonical value, keyed by probe key, never by fingerprint. What changes is how often it is written, not what it holds — the metadata-less completion arm described in the Summary is gone, so the file now appears for every completed probe rather than for every probe but that one — and neither the file’s contents nor its name is a cache identity. No step artifact anywhere changes identity, and no rebuild is forced on any unit by this entry.

Conformance. No corpus case is added or removed, but two existing ones move, which the predictive text did not foresee: positive/probe-register-simple and the vendored cook_cc stubs under negative/cc-find-conflicting-opts both spelled opts.inputs.env, so both are re-spelled over tools alone. That is the corpus behaving exactly as this entry says it should — the only Cookfile this change breaks is one that names the removed sub-key — and the negative case still fails on the cc.find conflicting-opts diagnostic it was written for rather than on the new one. The rule itself is pinned in the reference implementation: cs0244_cook_probe_rejects_inputs_env_by_name in cli/crates/cook-register/src/tests/probe_api_tests.rs asserts that cook.probe("k", { inputs = { env = {"HOME"} } }) fails with a diagnostic naming the sub-key, this entry, and the replacement. The tests that pinned the deleted machinery are deleted rather than ported, because there is no code path left for them to exercise: all thirteen in cook-contracts’ context_tests (the fingerprint’s determinism, order-invariance, per-section sensitivity, its COOK_PROBE_FP_V2 marker, two byte-exact golden vectors, and the whole module-source fold with its manifest key); cook-cache’s two env-resolution tests and its missing-upstream-fingerprint test; and cook-probe’s cs0178_keylessness_propagates_along_requires and a_missing_upstream_fingerprint_is_a_resolve_error. In their place, cook-cache and cook-probe each gain a test that inputs.requires resolves nothing and cannot fail, which is the positive statement of what the deleted upstream fold used to be. cli/crates/cook-engine/tests/probe_path_agreement.rs is rewritten rather than dropped: its two cases stop being about keyedness and become a probe that declares an input against one that declares nothing. The two take the same route through the resolver, which iterates an empty declaration zero times rather than branching on it; the bare case is kept as a cheap regression guard on the one CS-0178 used to single out, and the first case also pins byte-identical values across the two consumption paths. The surface fixtures carry over untouched from CS-0242/CS-0243 (41-probe-single-flight, 42-probe-cross-phase-single-flight, and CS-0243’s own probe_produce_re_executes_on_every_invocation / probes_get_does_not_read_a_prior_invocations_materialised_file), byte-identical and passing: what this entry deletes is computation, not the behaviour these four pin, and any of them failing would mean it deleted more than it claimed to.

CS-0243 — a reached probe always observes; the probe-value cache is removed [#changes.cs-0243]

Date: 2026-08-16 Version: v0.18 Sections affected: § 22.5.8 (the Cache hit / Cache miss pair replaced by Always observe; the escape-hatch rule added; On-disk layout rewritten; §22.5.11’s build-log preamble annotated), § 22.5.5 (the CacheBackend artifact body withdrawn from the canonical-bytes site list), § 17.1 observable 5 (the probe-value-cache parenthetical), § 17.5 (the probe paragraph), § 22.5.1 (the cached-outputs MUST replaced by the always-observe law), §22.5.4.1 “Module source” (CS-0204’s retention obligation and on-lookup candidate probing cut; the fingerprint-composition rule kept and restated as surviving independently), § 22.5.2.1 (CS-0157’s “persisted probe artifact” / “available from a cache” / “cache misses” prose), § 22.5.3 (the produce field’s “on a cache miss” prose), § 22.5.7 (the “cache-read path” renamed to “substitution path”; a new paragraph stating that a bare probes reference orders and substitutes but does not re-key its consumer), § 22.5.6 (the value-keying claim this entry falsifies is now stated and corrected at § 22.5.7 and § 12.7.2 rather than left implicit), § 22.5.10 (the register-phase-read and pre-pass-evaluation cache vocabulary), § 12.7.2 (12.7.2’s “via seal or probes” clause narrowed to seal, with the probes-does-not-key consequence stated inline), § 9.1 (09-placeholders.mdx’s “cache-read path” prose), § 10.2 (10-cross-recipe-references.mdx’s “probe cache” prose), App. C (C.22.2, new) and App. E (this entry). Reference implementation: cli/crates/cook-probe/src/eval/mod.rs (the GET loop, the publish block and CacheAccess all deleted), cli/crates/cook-engine/src/executor.rs (the probe dispatch arm; the per-run store no longer read-through), cli/crates/cook-register/src/engine.rs, cli/crates/cook-contracts/src/evict/mod.rs and cli/crates/cook-contracts/src/cache/cas/mod.rs. COOK-527.

Summary. A probe value is never served from a store that outlives the invocation. A reached probe runs its produce body on every invocation in which it is reached — exactly once, by CS-0242 — and cross-run staleness of a probe value becomes impossible by construction rather than by a freshness rule that has to be got right.

Prior behaviour. A probe’s fingerprint (§ 22.5.4) addressed its canonical value in the content-addressed store, and § 22.5.8 required the implementation to serve those bytes and skip produce whenever the fingerprint hit. The rule was sound only to the extent that the fingerprint folded everything the produce body observed, and a probe exists precisely to observe what no declaration names: the host, the toolchain, a file the author did not think to declare. Every defect in this area was one instance of that gap — a sealed files manifest frozen at a value that could no longer see an edit (CS-0241), a tools identity served across an uninstall (CS-0214), a body reading a module the fingerprint did not fold (CS-0204) — and each was repaired by widening what the fingerprint folded, which is a strategy with no terminating condition. The store’s persistence made every such gap outlive the workspace: rm -rf .cook did not clear it, because the value lived in the shared store.

What changes. The cache is removed rather than made stricter. There is no probe-value GET, no PUT, no publish, and no probe-value artifact; .cook/probes/<key>.json remains, written on every completion, as a record read by cook why and by CS-0242’s within-invocation cross-phase serve, and it is normatively NOT a source across invocations. The two no-VM resolutions that remain are not caching and are stated as such: a files/tools declaration’s synthesised value is computed from the current tree, and a pre-pass-resolved key is served from this same invocation’s own production.

Why this is not a regression in build times. Early cutoff never depended on the probe cache. A unit that seals a probe keys on the probe’s value (§ 17.1.1), not on its fingerprint and not on whether it ran, so re-observing an unchanged world leaves the sealing unit’s key untouched and its artifact served from cache; a value that moves and moves back returns the unit’s key to where it was and re-serves the original artifact. What the cache saved was the observation itself, and the measured cost of re-observing everything is ~70 ms added to dhewm3’s 60 ms warm no-op and ~10 ms on duckdb’s 210 ms, with CS-0242’s single-flight landed first — the dominant multiplier was never the cache but the missing single-flight (47 executions where 8 were correct).

Sharing is withdrawn because it was worthless or unsound. § 17.5‘s mandate that probe artifacts ride the shared store is deleted, not merely made optional. For the declaration producers it buys nothing: a reader of a files manifest must hash its own tree to compute the key it would fetch under, so it has derived the value before it can ask for it. For a host query it is worse than nothing: what the probe observed is a property of the machine that observed it, and serving it to a second machine is the mechanism by which one host’s answer becomes another host’s build. The sharing that matters survives untouched one level up, where two machines observing the same world compute the same unit key and share the same artifact.

The escape hatch is stated normatively. An expensive observation belongs in a recipe with a declared output consumed downstream, which the cache does serve, and not in a probe. This is written into § 22.5.8 rather than left to a rationale annex, because “probe values are cached” was the answer an author with an expensive probe had been given, and removing it without naming the replacement leaves that author with a construct whose cost model changed silently.

No CACHE_VERSION bump. Unit key computation is unchanged: probe values enter a key exactly as before, through the effective seal set’s seal_contribution (§ 17.1.1). No recorded step artifact changes identity, so every existing artifact stays valid and no rebuild is forced on the upgrade boundary. Probe-value artifacts written by earlier revisions are the only stranded entries; they lose the size-sweep exemption they held as reachable cache state and are reclaimed by ordinary eviction, which is what the On-disk layout paragraph’s SHOULD now says.

A probes reference no longer implies a stable value (COOK-530). § 22.5.6 rule 3 folds a consumed probe’s fingerprint, not its value, into the consumer’s fingerprint. Before this entry a stable fingerprint implied a stable served value, because the fingerprint addressed a stored one — so § 12.7.2‘s prior text, that a module carries a probe’s determinant into a unit’s key “via seal or probes”, was effectively true. Under Always observe a probe’s fingerprint no longer addresses anything stored, so folding it establishes only the DAG edge and the substitution of § 22.5.7; it does not re-key the consumer when the observed value moves. seal is the only mechanism that does that (§ 17.1.1). This entry corrects § 12.7.2 and § 22.5.7 to say so and leaves open, as COOK-530, whether a bare probes reference should be made to key its consumer the way seal does.

Deliberately not in this entry. § 22.5.8‘s CS-0178 keylessness paragraph, and the probe-fingerprint machinery it governs, are left standing: with no cache to consult, “MUST NOT consult the cache” is satisfied vacuously rather than contradicted, and cutting the fingerprint out of the Standard and the implementation together is the next entry’s work. This entry changes behaviour; the carcass follows. §22.5.4.1 “Module source“‘s CS-0204 rule is split by the same line: the full-fingerprint composition is a fingerprint rule and survives untouched, folding into requires exactly as before; only the retention of prior loaded-path sets and the on-lookup re-hash against them were a cache rule, and those are cut here. A reader of CS-0204 should not conclude the whole of it went.

Conformance. No corpus fixture. The conformance corpus pins parse and register accept/reject shape, and this entry changes neither — nothing an author writes changes, which is the point. The pins are the reference implementation’s: probe_produce_re_executes_on_every_invocation (the law itself), the unchanged-world unit hit and the A → B → A revert re-serving the original artifact (early cutoff survives), probes_get_does_not_read_a_prior_invocations_materialised_file (the only pin for this entry’s “MUST NOT read a value recorded by an earlier invocation” clause: a key an earlier invocation materialised at .cook/probes/<key>.json MUST still raise the not-materialised error when a later invocation never demands it), and surface fixtures 41-probe-single-flight / 42-probe-cross-phase-single-flight carried over untouched from CS-0242, which fail loudly if this removal takes the cross-phase serve with it.

CS-0242 — a probe’s produce body runs at most once per build invocation, not once per demander [#changes.cs-0242]

Date: 2026-08-16 Version: v0.18 Sections affected: § 22.5.8 (new normative paragraphs: the once-per-key rule, the cross-phase rule, the shared probe node’s ordering semantics, and a carve-out naming the pre-pass-resolved case in the Always observe rule). Reference implementation: cli/crates/cook-contracts/src/unit_graph/mod.rs (probe-node synthesis), cli/crates/cook-contracts/src/probe_key/mod.rs (the import-qualified probe-key join the planner, the engine and cook-plan now share, over the prefix decomposition in cook-contracts/src/naming/mod.rs that the CLI shares with them), cli/crates/cook-engine/src/run.rs and cli/crates/cook-engine/src/executor.rs (the register-pre-pass-to-executor channel), cli/crates/cook-plan/src/registers.rs, cli/crates/cook-register/src/engine.rs and cli/crates/cook-register/src/lib.rs (threading resolved_probe_keys out of the register pass), cli/crates/cook-probe/src/eval/mod.rs (ValueSource::Prepass, served inside lookup). COOK-526.

Summary. A probe’s produce body MUST run at most once per build invocation: one execution per key, shared by every recipe that demands it, and one evaluation, shared across the register pre-pass and the execute phase, rather than one of each.

Prior behaviour. unit_graph::plan() synthesised one probe node PER DEMANDING RECIPE rather than one per probe key: the loop that collects a recipe’s synthesised probes reset its dedup map on every iteration over recipe_units, so N recipes sealing, sigil-referencing, or otherwise demanding the same top-level probe key minted N distinct SynthProbe nodes — N executor dispatches, N probe:<key> progress rows, and N produce executions for a probe whose whole cost model assumes one. Measured on dhewm3: 47 executions where 8 were correct. Separately, and independently of node count, the register pre-pass (§ 22.5.10, resolving a gather <probe> fan-out or a register-phase cook.probes.get read) and the executor’s own DAG dispatch (§ 22.5.8) each evaluated a probe key on its own account, with no channel between them — so a key resolved once at register time and reached again by a seal or probes = {...} reference at execute time paid for produce a second time, doubling the cost of every register-resolved key a recipe also consumes at execute time.

What changes. Two independent mechanisms close the two paths above. First, unit_graph::plan()’s probe-node synthesis is keyed by the probe’s declaring-Cookfile-qualified identity for the whole call, not reset per recipe: the first demanding recipe (in topological order) mints the SynthProbe node, and every later demander reuses that same node id, unioning in only its own requires/consumer edge rather than its Barrier deps — the minting demander’s ordering deps stand for the node’s whole lifetime, which keeps the topo-order invariant dag_builder::build_dag asserts intact (a later demander’s leaves can carry a higher node index than the reused node, so unioning them is not merely undesirable but structurally illegal). That consequence is stated normatively in § 22.5.8 rather than left here: what a shared probe node is guaranteed to run after is now part of the language, not an artefact of a changelog entry. Second, the register pass now records, per invocation, the qualified keys of every probe it actually resolved (RegisteredCookfile.resolved_probe_keys, folded workspace-wide into RegisteredWorkspace.resolved_probe_keys), and cook-engine::run threads a prepass_resolved bit alongside each DAG probe node’s metadata. When the executor’s G4 dispatch (§ 22.5.8) finds nothing to serve on its own account — no cache hit, no synthesised no-VM value — but the bit is set, it reads the value the register pass already wrote to .cook/probes/<key>.json (the canonical local copy § 22.5.8‘s On-disk layout paragraph already requires on every completion) instead of dispatching a worker, and reports it as its own new ValueSource::Prepass, handled identically to a cache hit for publish, for reporting, and for cook.probes.get. The serve is a step of the shared evaluation sequence (cook_probe::eval::lookup), alongside the cache consult it follows, and not an amendment the scheduler makes to that sequence’s result; the register phase passes false for the bit, since it is the pre-pass and a key consulting the channel it is filling would find itself already resolved.

Why now, and why cache-independent. This is a cost-control prerequisite for the milestone’s later removal of probe-value CACHING (the next ticket in this milestone) — a build that no longer caches a probe’s value across invocations must not silently regress to re-producing it many times WITHIN one invocation, which is what the prior per-recipe node count and the ungated cross-phase re-evaluation both did. The register-to-executor channel is deliberately its own thing, independent of the content-addressed cache: it answers “did THIS invocation already produce this key,” never “was this key produced before,” and it has to keep answering that after the CAS lookup it currently sits beside is deleted.

Conformance. cli/e2e-fixtures/surface/41-probe-single-flight/ pins the demander-count case end to end: three recipes seal one probe key whose produce body appends a line to a counter file on every execution, and the run asserts the counter holds exactly one line. cli/e2e-fixtures/surface/42-probe-cross-phase-single-flight/ pins the cross-phase case the same way: one recipe’s gather <probe> forces register-pre-pass resolution, a sibling recipe seals the same key at execute time, and the counter again asserts exactly one line. cook-contracts’s unit_graph suite pins the node-collapse mechanism directly — plan_collapses_synth_probe_nodes_shared_across_recipes (three recipes, one key, one SynthProbe node, all three consumer edges present) and, as the accompanying negative, plan_does_not_collapse_same_named_probes_from_different_members (two Cookfiles’ own same-named local probe keys stay two distinct nodes, since the collapse identity is scoped by declaring prefix and not by the bare key alone).

CS-0241 — a files NAME declaration’s unreadable match fails loudly, and its value hashes against the declaring member [#changes.cs-0241]

Date: 2026-08-16 Version: v0.18 Sections affected: § 22.5.2 (the files NAME bullet: the unreadable-match MUST-fail rule and the declaring-member hashing base). Reference implementation: cli/crates/cook-probe/src/eval/mod.rs (the guard, alongside CS-0214’s), cli/crates/cook-engine/src/run.rs (the declaring-member base resolution). COOK-510.

Summary. Two compounding defects in the files NAME producer, found while COOK-491 was writing a test it then had to replace, and reproducing identically with a colon-qualified key (it predates CS-0240).

First, hashing a declared file that cannot be read silently yields the all-zero digest, and encode_files_manifest folds that into the literal "<missing>" — the SAME string a genuinely absent match folds to. § 22.5.2 already required this fold for a path a glob never matched; it did not distinguish that from a path that matched and then could not be read. The tools NAME declaration had exactly this guard already (CS-0214): a name that resolves but whose bytes cannot be read fails the probe by name rather than recording a placeholder, because a placeholder that means “unreadable” composes identically on every host that hits it, and two hosts each failing to read a DIFFERENT file would produce identical value bytes — one machine could then be served a sealed artifact built from the other’s actual, never-observed content. The files side had no such guard, so the fingerprint’s FILES section and the synthesised manifest froze at a constant the moment a matched file’s permissions changed, and nothing short of deleting the probe’s stored value could unfreeze it.

Second, the register pre-pass and the executor hashed a files declaration’s paths against two different bases. The pre-pass resolves against the declaring Cookfile’s own working directory, by construction (RegisterProbeResolver.working_dir, set once per Cookfile at member_base_builder time). The executor resolved against work_node.working_dir — the CONSUMING recipe’s own directory, which is the declaring Cookfile’s only when a probe is consumed by a recipe in the same Cookfile that declares it. For a probe consumed by a recipe elsewhere in the workspace the joined paths do not exist under the consumer’s directory, every declared path hashes to the all-zero sentinel, and the first defect above then freezes the value at "<missing>" — silently, with no diagnostic pointing at the mismatch, because a hash failure and an absent file are indistinguishable without the fix above.

No current surface reaches the second defect. Every register-phase probe reference — seal NAME, cook.probes.get, the $<NAME> sigil cascade — resolves a probe key against the current Cookfile’s OWN declarations only (CS-0240), and cook-engine’s DAG builder synthesises a probe’s work node from the CONSUMING recipe’s own RecipeUnits.probes list, which cook-register stamps with the declaring Cookfile’s whole probe set and no other Cookfile’s. A Cookfile therefore cannot, today, write a seal or a sigil that reaches another Cookfile’s files declaration; the mismatch is latent in the engine’s internal data flow (cook-engine/src/run.rs’s probe_units_by_node construction, built from the workspace-wide RegisteredWorkspace.probes map, independent of the DAG builder’s same-Cookfile-only synthesis path) rather than reachable from any shipped Cookfile. This entry fixes it regardless: the milestone’s later tickets (COOK-526 onward) touch this exact seam, and a base that is already wrong is not a foundation to build cross-member consumption on top of.

What changes. cook_probe::eval::lookup (the one function both phases call — COOK-359) gains a guard beside CS-0214’s, scoped identically: only the synthesised @files-manifest producer is checked, in both phases, ahead of the cache GET, so it holds on hit and miss alike. A hand-written produce body that happens to declare inputs.files is unaffected — that probe’s value is the author’s to compute, exactly as CS-0214 left the tools side. cook-engine/src/run.rs derives each probe node’s hashing base from the probe’s OWN matched, qualified key (registers.rs’s merge_into inserts every probe under "{its own declaring prefix}.{key}") rather than from the consuming recipe’s qualified name, and pairs it through probe_units_by_node alongside the ProbeUnit so the executor’s EvalCtx.working_dir is the declaring member’s directory rather than work_node.working_dir.

Released-version scope. The files NAME producer (CS-0148) has shipped in every tagged release from v0.5.0 (the first public tag) through v0.14.0 (current). The unreadable-match false-freeze is live in all of them: any project with a files declaration matching a path whose permissions later change is affected, in a single Cookfile, no import required. The base-directory mismatch is present in the same range but has never had a reachable trigger in a released Cookfile, per the surface-reachability note above.

Backward compatibility. No CACHE_VERSION bump. A files declaration whose matched paths are all either present-and-readable or genuinely absent is byte-identical before and after this entry. A declaration with a present-but-unreadable matched path now fails the probe instead of silently producing "<missing>" — a loud break, and the only one: a build that was silently wrong now stops.

Conformance. cook_probe::eval’s test suite pins the guard directly: a present-but-unreadable matched path fails by name (mirroring CS-0214’s own cs0214_a_tools_probe_whose_binary_cannot_be_read_fails_rather_than_recording_zeros test), and a genuinely absent matched path still folds as "<missing>" with no error — the regression guard for the first. The declaring-member hashing base is pinned in cook-engine’s own suite: no current Cookfile surface reaches a cross-Cookfile probe consumer (see above), so the test hand-builds the two-prefix RegisteredWorkspace shape run()’s own doc comment sanctions test helpers for, proving the engine-level fix ahead of the surface work that will reach it.

CS-0240 — a probe sigil resolves by name, not by colon [#changes.cs-0240]

Date: 2026-08-15 Version: v0.18 Sections affected: § 9.1, § 9.2, § 9.3, § 10.2, § 10.2.1, § 10.2.3, § 10.7, § 10.6, § 11.3, § 22.5.2, § 22.5.7, App. A.2, App. A.5, App. C, and App. E (this entry). Reference implementation: cli/crates/cook-contracts/src/sigil/mod.rs, cli/crates/cook-lang/src/lib.rs, cli/crates/cook-luagen/src/resolver.rs, cli/crates/cook-probe/src/sigil.rs.

Summary. A $<TOKEN> placeholder is a probe-value reference when its base name is a probe key declared in the current Cookfile — a probe, files, or tools declaration. Probe key joins § 10.2‘s cascade as step 3; the declared variable becomes step 4 and the hard error step 5. CS-0131’s colon rule is withdrawn as a dispatch rule and kept as a lexing fact: a colon can appear in no builtin, recipe or declared-variable name, so a colon-carrying token is a probe reference on sight and needs no keyset to recognise. Because a probe key now resolves by name, one name may not be two kinds: a Cookfile declaring the same name as a probe key and as a recipe, chore, or import alias is rejected at parse time naming both declaration sites (App. A.2).

Rationale. The colon made the sigil the one bare-name position in the language resolving lexically rather than by lookup. Every other position that names a probe key — a seal operand, a gather source, a probes = {…} entry, a cook.probes.get argument — already resolved a colon-free key by name, and the native probe declaration mints colon-free keys. The result was that probe keyed_obs declared a probe reachable from every surface except $<>, where $<keyed_obs> fell through to the declared-variable step and reported no config block declares 'keyed_obs' — a diagnostic that never mentions probes, for a probe declared four lines above it. The convention of prefixing module keys was load-bearing on reachability rather than being what it reads as, optional namespacing.

Two things the colon was carrying had to be taken over rather than dropped.

The first is phase classification without register state. Probe substitution is execute-phase, and everything else in § 10.2 resolves at register time, so the colon let a renderer classify a token from the text alone. That is now redundant: § 22.5.7‘s detection already unions each discovered key into the unit’s probes list at end-of-register, and the execute-phase renderer classifies by membership in what that list materialised rather than by punctuation. Where no such list exists — the raw cook.add_unit capture, whose pass has not finished registering probes, since § 22.5.6 leaves cook.probe and cook.add_unit ordering unconstrained — the colon still answers, which is exactly the case it is kept for.

That same ordering freedom is why step 3’s lookup set is the current Cookfile’s own declarations and not, as step 2 requires for recipes, the full register-phase set. A name-based lookup over a set that modules mutate could resolve one way or the other depending on load order, so the same $<x> would name a probe in one build and a variable in the next — an unresolved reference is a loud failure, but a reference that resolves to the wrong KIND depending on evaluation order is the silent class this entry exists to remove. A module-registered key stays reachable through the colon form, which is the spelling every module already mints, and needs no lookup at all. The consequence, stated plainly: a module that registers a colon-FREE key cannot be referenced by a bare $<key> sigil, and the author sees § 10.2 step 5’s undeclared-variable diagnostic rather than one mentioning probes. That is the motivating defect of this entry surviving in a corner it does not reach, and it is left open deliberately rather than closed with an order-dependent lookup.

The second is the dotted-token jungle. § 10.2.1 already carried a two-way heuristic (rightmost-dot accessor split, else leftmost-dot import alias), and a probe access path would be a third reading of $<A.B>. Rejecting the collisions is what stops it from being one: with the name space checked, the base name’s KIND selects the reading and the selection is total, which is simpler than the pre-existing split rather than an addition to it. The reach is small — a colon-qualified or quoted key cannot equal a bare declaration name, so only bare colon-free probe keys are constrained at all.

The declared variable is the one member of the cascade the collision rule cannot reach, because a variable is produced by evaluating a config block and has no declaration site a parser can name. That case keeps § 10.2.3‘s existing remedy — a warning naming both, the same one a recipe shadowing a variable has always had — rather than a hard error the parse phase could not raise. $<var.NAME> remains the explicit spelling that reaches the variable.

Compatibility. A Cookfile that never declared a probe is unaffected: a colon-free token with no matching probe key resolves exactly as before. A Cookfile that declared a bare probe key AND a same-named recipe, chore or import alias is now rejected at load time; so is one whose import alias is var, per § 10.7‘s extended reservation. Those are the breaks, and both are loud. A Cookfile that declared a bare probe key and a same-named variable now substitutes the probe where it previously substituted the variable, with the § 10.2.3 warning naming both. No cache-key change: a token that resolved to a variable and now resolves to a probe changes what the unit’s key folds, which is a miss and a rebuild, never a false hit.

A prefix-ordering invariant this entry had to establish. Inserting a by-name step into the cascade forced the question of where the two reserved prefixes sit relative to it, and the answer is not the same for both — see § 10.7. var. moves ahead of every lookup step, which is necessary because a probe keyed var would otherwise capture $<var.X>. It is not quite free: the pre-existing reservation covered var only as a segment of a recipe NAME, so import var … was legal and its recipes were addressed as $<var.NAME>, which the hoist now resolves to a variable. The reservation is extended to the alias and the declaration is refused at parse time (§ 10.7), which turns a reference that would fail confusingly at each use into one rename at the declaration. env. stays behind them, because env is reserved as a placeholder segment and not as an import alias: a qualified $<env.build> naming an imported recipe resolves at step 2 and carries a dependency edge, and hoisting its refusal would delete that edge silently. The asymmetry was implicit in the reference implementation and is now stated.

Conformance. positive/probe-sigil-bare-key pins the bare-key sigil substituting end to end; negative/probe-name-collides-with-recipe and negative/probe-name-collides-with-import-alias pin the load-time rejection naming both sites. Both negatives are semantic rather than syntactic — the colliding Cookfiles parse cleanly, and no context-free tree can hold a uniqueness rule — so each carries its SEMANTIC_ONLY_NEGATIVES entry naming the enforcing phase. Unit tests pin the split/classify seam all three phases share, in both directions: a declared bare key resolves, an undeclared one does not, and a colon key resolves with no keyset at all. Further tests pin what the prose above claims and an implementation could otherwise quietly drop: the § 10.2.3 warning fires for a probe shadowing a declared variable (a config block exporting var.tag beside a probe tag must not flip $<tag> silently); the quoted key form collides verbatim; a files or tools collision names its own keyword rather than probe; and an import alias spelled var is refused.

CS-0239 — gather $<recipe>: fan out over another recipe’s outputs [#changes.cs-0239]

Date: 2026-08-15 Version: v0.18 Sections affected: § 8.2 (Form 3, Example 8.2.2, Note 8.2.1), § 8.4.1, § 10.4, § 22.5.10, App. A.4 (gather_step, recipe_ref), App. E (this entry). Reference implementation: cli/crates/cook-lang/src/{ast,cook_line,recipe}.rs, cli/crates/cook-luagen/src/{recipe,cook_step,test_step,dep_ref}.rs, tree-sitter-cook/grammar.js.

Summary. gather gains a third form: gather $<gen> supplies, as the recipe’s members, the declared output paths of recipe gen (§ 10.4.1). Members are path members. The source stands alone — no trailing globs, no second source — the name MUST resolve to a recipe, and a recipe MUST NOT gather its own outputs. Everything else about the form is the bare-source behaviour already specified: at most one gather per recipe, no mixing with quoted items, output-pattern-selected cardinality, per-member fan-out, stale-output reconciliation when the member set shrinks.

The gap it closes. A per-artifact test step had no spelling. Dep-driven iteration (§ 10.4) declares its driver through an accessor in an output pattern, and a test step has no output pattern, so “validate each artifact gen produced” could not be written. The workaround authors reach for, gather "build/*.conf", is wrong twice over: a path literal establishes no edge (§ 10.6, “Only name references create edges”), so it races the producer, and it globs whatever is on disk rather than what a recipe declared. Declaring the driver on the gather line instead of in an output pattern is what makes it reach a step kind that has no output pattern.

Why the sigil, and not a bare name. A bare gather source resolves in the probe namespace, so gather gen reports a probe gen that was never declared no matter what recipes are in scope — the two namespaces cannot be merged without breaking one of them. $<NAME> is the language’s existing spelling for “that recipe’s outputs” in a command body and in an output pattern, so the third form is the same vocabulary in a third position rather than a new one, and § 10.2.4 already requires it to mean there what it means elsewhere.

Three design points, settled.

Member identity. A member is one declared output path, recorded at register time — not a file found on disk, and not a fact about how the path was produced. This resolves the awkward cases uniformly instead of case by case: a Lua-expression output (§ 8.4.2) contributes its evaluated string, which IS the unit’s outputs[1]; a nondet tail states a fact about an output’s content and leaves its path alone; a many-to-one last step contributes its single output. Members are therefore projectable before execution, which is what keeps § 8.2‘s static-legality rule (“Both predicates are static and MUST NOT consult the filesystem”) true of this form.

A third arm, not a variant of Form 2. App. A.4 mirrors §8.2’s forms one-for-one, so a third form earns a third alternative. The discriminator is lexical and total — quote or ! opens Form 1, $< opens Form 3, anything else is Form 2’s bare name — and the two named forms resolve in different namespaces, which is the distinction the surface exists to make. Form 2’s trailing STRING* is deliberately NOT carried over: it exists because a data member has no path of its own and CS-0197 needed some way to declare what each member unit reads, and a recipe member has no such gap. Adding it later would be compatible; removing it would not.

Members are declared file inputs. Each member path is a declared input of the unit it drives, content-hashed into that unit’s key (§ 17.1.1). This is not an optimisation — a member unit keyed on a path string and not on the file behind it will serve a cached pass over an artifact that has since changed, which is a wrong answer rather than a slow one. It follows the rule § 22.5.10 already states for a member unit’s iteration source, and it is why a test under this form keys correctly even when it is the recipe’s only step.

Keying is not reuse, and this entry claims only the first. § 8.2 states the second as permitted-to-be-coarse: an implementation MAY record the whole upstream output list against every member unit, costing reuse but never correctness. The reference implementation does exactly that, and does it in the dep-driven form of § 10.4 too — one upstream artifact changing re-runs every member of the fan-out, attributed to an artifact most of them never read. The cause is shared: cook.dep_output_list folds the paths it returns into a step-group-wide accumulator, and a fan-out packs its whole member set into one step group; cook.dep_output_member already routes per-member paths around that accumulator for precisely this reason (COOK-96). It was found by running this form end to end rather than by reading the emitted Lua, it is pre-existing rather than introduced here, and it is tracked as COOK-505 because fixing it changes the recorded input set of already-cached units.

Cardinality is still the output pattern’s business. Per CS-0224 and CS-0232, having an input source and iterating one are separate questions, and this entry does not merge them: gather $<gen> supplies members, and § 8.4.1 still decides from the output patterns whether a step fans out. An all-literal first cook step remains rejected here as it is for a probe source (CS-0155), but for a different reason — the objection is not that the members are unpathlike, it is that the aggregate already has a spelling that needs no gather at all (cook "out" { … $<gen> … }) — so the diagnostic must name that alternative rather than the data-member one.

Conformance. One positive, gather-recipe-fan-out, pins the motivating shape: a producer plus a per-output test. Three negatives: gather-recipe-undeclared and gather-recipe-self are codegen-phase rejections over Cookfiles that parse cleanly, and gather-recipe-cycle is a register-phase one pinning that a longer cycle falls to § 10.6‘s general rule rather than to a channel of this form’s own. All three join the tree-sitter harness’s semantic-only list. The reference parser’s own suite pins the parse-time half (trailing content, second source, malformed sigil, reserved name, malformed dotted shape), and cli/crates/cook-luagen/tests/gather_recipe_ref.rs pins the emitted shape — the dep_output_list member source, the per-member declared input, the requires edge, and the qualified-reference refusal.

One limit of that corpus is worth recording, because it bit this entry’s own implementation. Every diagnostic assertion in it is a contains over a substring, and the qualified-reference message shipped for a while with a twenty-space gap in the middle: the reference implementation built it from a line-continued string literal that kept the next source line’s indentation. Each asserted substring sat wholly on one side of the gap, so the whole suite passed over a message no author could read, and only running the binary showed it. This is why § 8.2 now states that rejection’s required content as three separate parts rather than as “names the position” — and it generalises past this rule: a diagnostic’s rendered text is the artifact a conformance check is meant to bind, and substring assertions bind everything about it except how the pieces are joined.

No CACHE_VERSION bump: this adds a surface. A Cookfile that registered before it existed registers identically, and a unit under the new form has no prior key to disagree with.

CS-0238 — seal admits multi-line continuation [#changes.cs-0238]

Date: 2026-08-15 Version: v0.18 Sections affected: § A.4 (the seal_step production’s comment, and the “Multi-line patterns” rule extended from two step kinds to three), § 8.4.3.1 (rule 1 restated over the step rather than the physical line), § 8.4.3 (the same correction to the seal_operand prose), and App. E (this entry). Reference implementation: cli/crates/cook-lang/src/cook_line.rs, recipe.rs, probe.rs, disposition.rs; tree-sitter-cook/grammar.js.

Summary. seal was the only input-declaring form with no continuation. gather_step and cook_step have had one since CS-0078, and CS-0222 gave files and tools indented multi-line bodies; seal_step ::= "seal" seal_operand+ NEWLINE had none, so a seal set had to fit on one physical line. CS-0238 adds seal_step to the existing CS-0078 rule. It introduces no mechanism: the same continuation, spelled the same way, now names a third step kind.

Rationale. The v2 cut moved roughly a third of all migrated ingredients lines onto seal, and the grammar then mandated the long lines that followed. This repository’s own standard/Cookfile carried a 124-column seal; the same shape in a downstream consumer reached 158. Worse than the length was the diagnostic: an author who wrapped the line anyway got “loose shell commands are not allowed in a recipe body (CS-0134)” pointed at a quoted glob, because the orphaned continuation fell through to the recipe body’s catch-all. The message named a rule the author had not broken and a construct they had not written.

The continuation trigger is the quote, not the operand kind. A continuation line may not begin with a seal_operand in BARE_PROBE_KEY form. This is not an oversight and not a second rule: _step_continuation_newline fires on a " or !" lookahead, and a bare key opening a continuation line is indistinguishable from a new step or a module call. Under the CS-0078 rule such a line terminates the declaration silently and dispatches per §A.4, so seal a followed by an indented b continues to mean what it means today. The restriction binds the first token only: a line already admitted by a leading quote is read by the rules that apply on the keyword’s own line, so the bare toolchain in seal "a.ts" continued by "b.ts" toolchain joins the same step. Both parsers agree on this, and an earlier draft of this entry overstated the rule as “bare operands only on the keyword’s line”. Stacked seal steps union by § 8.4.3.1 rule 2 and remain the spelling for a long list of bare refs — the idiom the migration already reached for, now the deliberate one rather than the only one.

Both seal_step positions, because there is one production. seal_step appears twice in the grammar: as a recipe-body step and as the optional first item of a probe_body (§ A.3.2). The reference implementation parsed them at two call sites that had independently arrived at the same three lines, and a fix applied to the recipe site alone would have left the probe site rejecting a wrapped seal while the one production it names admits it. Both now route through a single continuation collector.

No token spans a line, and that is now stated. Extending continuation to a determinant list made an assumption load-bearing that had never been written down: an operand is bounded by the line break, never carried across it. The rule is now normative for all three step kinds in § A.4 — collection resumes only from outside a quoted STRING, and a STRING left open at end of line is rejected. gather has enforced exactly this since CS-0078 (its collector reports an unterminated string); seal reports it as an unterminated quoted file glob. The consequence is what makes it worth stating, and it is the reject-an-open-STRING half that carries it: because a STRING left open at end of line is refused outright, no STRING value can contain a line break, so a fold of these tokens into a record or a cache key cannot have two distinct operand sets collide on one key through an embedded separator. CS-0236’s anonymous_seal_key is exactly such a fold — it joins +glob / -exclude records with \n — and it is that clause, not the agreement clause below, that a citation for cache identity should name. A first draft of this entry’s reference implementation did not hold the rule — it accepted seal "a continued by a lone " as the single glob a — which is how the omission was found.

Known gap. The “one notion of inside a STRING” clause names the backslash explicitly because an adversarial pass over the new guarantee found the two halves already disagreeing in the reference implementation. parse_seal_operands runs a backslash-escape state machine, so it reads seal "a\"b.ts" as the single operand a\"b.ts, while the STRING production and the tree-sitter grammar both end the item at the first " and reject the line. The implementation is the non-conforming side, which is the unusual direction. This predates this entry — the escape machine arrived with the seal surface — and the Standard is not weakened to match it: the clause stands as written, and COOK-503 tracks bringing parse_seal_operands, the production and the tree-sitter grammar into agreement. Recorded here so the gap is not mistaken for a rule, and to be deleted by whichever change closes it.

The gap does not reach this entry’s guarantee. No seal operand can span a line under either escape rule, because the continuation test is not an independent scan: collect_seal_continuation decides quote balance with the same state machine parse_seal_operands splits with, so the two move together whichever way COOK-503 resolves. A reader who finds the note above should not have to re-derive whether CS-0236’s fold is safe — it is, and for a structural reason rather than by coincidence of the current escape rule. Note the division of labour: agreement between the two halves is necessary but not sufficient, since a single scan agrees with itself trivially and could still admit a line-spanning STRING. What makes the fold safe is the reject-an-open-STRING MUST above; the agreement clause only stops the two halves from opening a hole in it.

Backward compatibility. Strictly widening. Every Cookfile that parsed before parses identically: the only lines whose classification changes are quoted lines following a seal, and those were already a hard error under CS-0134. Wrapping a seal does not change that step’s key: it produces the same operand list, and CS-0236 keys the anonymous determinant on that list’s content rather than on the owner and line, so neither the wrap nor the lines it shifts below it move a key. Under the pre-CS-0236 line-anchored name the second half of that would not have held — rewrapping a seal shifted every inline-glob seal beneath it in the file, a cold miss rather than a false hit. No CACHE_VERSION bump is warranted.

Conformance. One positive is added, standard/conformance/positive/seal-multi-line-continuation, pinning an include, a continuation include, and a continuation exclusion collecting into one anonymous determinant, and a following bare-key line terminating the step. The tree-sitter corpus gains the matching case. The fixture’s parse.txt pins the anonymous determinant’s CS-0236 content key, which folds the operand list and no longer carries the owner or the line — so a wrapped seal and its one-line spelling name the same probe, and the question of which line the name anchors on has stopped existing. The no-token-spans-a-line rule is pinned by the parser tests cs0238_quoted_operand_may_not_span_a_line, cs0238_odd_trailing_backslash_does_not_reach_across_the_break, cs0238_even_trailing_backslash_still_continues, and cs0238_crlf_leaves_no_carriage_return_in_an_operand rather than by fixtures: they are rejections and value assertions the tree-sitter harness would classify semantic-only, and the diagnostic is what carries the information.

CS-0237 — §8.2’s converse names every input source, not two of three [#changes.cs-0237]

Date: 2026-08-15 Version: v0.18 Sections affected: § 8.2 (the CS-0224 converse sentence, and four RFC 2119 keywords left lowercase by CS-0231’s restructure) and App. E (this entry, and the removal of CS-0232’s “Known gap” paragraph). Reference implementation: unchanged — cli/crates/cook-luagen/src/recipe.rs, validate_gather_usage.

Summary. CS-0224 gave § 8.2 a two-sided rule: a recipe that gathers MUST name its gathered set, and a recipe with no source MUST reject an own-input reference. The converse side enumerated the sources as “gather or dependency-output driver” — two of the three the language defines and the implementation accepts. The third, a preceding cook_step whose collected outputs feed the next step under § 8.4.1‘s chained-step rule, was simply missing. The sentence is restated over the whole set.

What contradicted what. CS-0232 restated §9’s twin of this rule over all three sources and added positive/071-chained-cook-input-no-gather to pin the one no other fixture pinned: two chained cook steps, the second naming $<in>, no gather anywhere. codegen_positive_conformance_corpus sweeps every positive fixture through generate_checked, so the corpus requires that Cookfile be accepted — while § 8.2, the clause CS-0232 defers to for the mandated diagnostic wording, went on requiring it be rejected. Two normative statements about one Cookfile, disagreeing, with a blessed fixture on one side. App. E is informative, so CS-0232’s own “Known gap” note recording the omission could not stand in for the correction; that paragraph is removed here, its subject having been fixed.

Source and cardinality stay separate. § 8.4.1 decides cardinality from the output patterns alone. Fixture 071’s second step has an all-literal output list, so it is many-to-one with no driver, and its $<in> is the whole collected set rather than an iteration item. It has a source and no iteration, which is why the converse must turn on whether a source exists and not on whether one is iterated — CS-0224’s “gather does not select cardinality”, read from the rejection’s side. §8.2 now says so outright, and the contract suite rejects a re-conflation as well as a re-narrowing.

RFC 2119 casing. CS-0231’s restructure of §8.2 left four lowercase keywords, which turned this repository’s standard.lint gate red on the tree that shipped it. Two were genuine permissions and are capitalised. Two were the negative “may not”, which RFC 2119 does not define: Note 8.2.1’s “it may not be preceded by a quoted item, repeated, or followed by an exclusion” restates Form 2’s constraints 1–3 verbatim, each already a parse-time MUST-reject, and becomes MUST NOT; the other is descriptive recap of the same constraints and is reworded to drop the keyword rather than promoted, since a second normative statement of one rule is the defect class this entry exists to close.

No behaviour changes. Nothing in the implementation moves. validate_gather_usage has admitted all three sources since CS-0224 — its preceding_cook flag is the third one — and emits “nothing gathers what this command references”, the diagnostic §8.2 already mandates. No grammar change, no CACHE_VERSION bump, no diagnostic rewording.

Conformance. No new fixture. positive/071-chained-cook-input-no-gather (CS-0232) is this rule’s pin and already goes red under any narrowing back to the two-source reading; a second fixture of the same shape would pin nothing new. negative/command-input-without-gather holds the floor, where no source is established at all.

CS-0236 — an anonymous determinant’s key is its operands [#changes.cs-0236]

Date: 2026-08-15 Version: v0.18 Sections affected: § 17.4, and App. E (this entry). Reference implementation: cli/crates/cook-lang/src/disposition.rs, cli/crates/cook-lang/src/lib.rs.

Summary. The key an implementation synthesises for an anonymous file determinant (CS-0227) MUST be a function of that declaration’s operands alone — never the owning recipe or probe name, the source position, or a mint counter. Two anonymous declarations with equal operand sets are one determinant, not a duplicate key; operand order is not part of the declaration.

Rationale. The reference implementation minted @seal:{owner}:{line}. That key is a member of the effective seal set of every cacheable unit in the recipe, and § 17.1.1‘s declared-probe-values term is computed over the set’s keys as well as its values. So renaming a recipe, or inserting a line above its seal, changed the cache key of every unit in it — precisely what § 17.4 forbids, reached through the synthesised name rather than through the unit’s own identity. The prohibition was written about author-visible identity and did not say that it binds a key the implementation invents; it does, and for the stronger reason: an invented positional key spreads position to units that declared none.

The Standard never specified the synthesis, so this entry states the rule rather than changing one. Keying by the operands is total: an anonymous determinant’s producer is the files-manifest sentinel (CS-0148) and its inputs are the resolved globs, so nothing but the operands distinguishes two of them. Equal keys therefore mean equal declarations, which makes the de-duplication a consequence rather than a special case — the collision an owner-and-line key existed to avoid was never a collision.

The reference implementation spells it @seal: followed by the 16-character lowercase hex xxh3_64 fold of the sorted, de-duplicated +<glob> and -<exclude> records. A fold rather than the globs verbatim because a probe key is :-segmented (§ 22.5.2) and wears a .-joined workspace prefix when imported, while a glob carries / * . :; the spelling is implementation detail, the operands-only derivation is the rule.

That fold concatenates records, so it is well-defined only if no operand can contain the separator, and it rests on a guarantee rather than on an accident of one parser: CS-0238 states normatively in App. A.4 that a quoted token in an input-declaring step MUST NOT span a physical line. Each record additionally carries exactly one leading marker character with the operand verbatim after it, so the encoding is prefix-unambiguous — an included "-a" and an excluded "a" are +-a and -a, distinct. An implementation whose operand grammar admits a token containing its own record separator MUST escape or length-delimit the records instead; what is normative is that two distinct operand sets never fold to one key.

The width of the fold is deliberate and is the one place this entry chooses convenience. Sixty-four bits is the workspace’s ordinary text fold, not the SHA-256 the shared-store key uses, and it is doing identity work here rather than local-cache work: two anonymous determinants colliding would merge two seal sets. The birthday bound is around 2³² anonymous declarations in one Cookfile, which is not a quantity a Cookfile reaches, so the collision is a theoretical rather than an operational risk — but an implementation is free to widen it, because the operands-only derivation is what this entry requires and the width is not.

No CACHE_VERSION bump. The synthesised-key format is CS-0227’s, dated one day before this entry and unreleased, so no stored artifact carries an old-form key. A key that moves is a cold miss and a rebuild, never a false hit; the recorded shape is unchanged.

Conformance. seal-inline-position-independent pins the rule where it is visible: two recipes differing in name, in the line their seal sits on, and in operand order mint one key and one probe. Thirteen existing positives re-blessed to the content-derived spelling. Parser tests pin the recipe-rename, line-move, probe-body, and operand-order equalities, the glob-set and include-versus-exclude inequalities, and the shape of the key itself.

CS-0235 — a diagnostic speaks the surface the author wrote [#changes.cs-0235]

Date: 2026-08-15 Version: v0.18 Sections affected: § 8.1 cascade rule 3, § 8.4.3.1 rule 4 (new), § 22.5.6, App. E (this entry). Reference implementation: cli/crates/cook-lang/src/recipe.rs, cli/crates/cook-lang/src/disposition.rs, cli/crates/cook-register/src/engine.rs, cli/crates/cook-register/src/module_loader.rs.

Summary. Three post-v2 rejections named something other than what the author wrote, and are corrected together. (1) The recipe-body step-dispatch cascade gains a removed-keyword rule: envs (CS-0226) joins unseal (CS-0225) there, and a removed keyword MUST NOT be answered by the loose-shell rejection of rule 7. (2) A seal ref that resolves to no declaration MUST be rejected in seal’s own vocabulary, naming the ref and the declaration kinds it may name; § 22.5.6 rule 1’s mandated sentence is correspondingly scoped to the probes field of cook.add_unit and MUST NOT be inherited by refs routed through that resolution from elsewhere. (3) The register-time refusal of a probe-value reference in an output pattern (§ 10.2.4) is restated in terms of the reference and the position, with a did-you-mean for the accessor-typo case; its exact wording remains implementation-defined per § 19.1.

Rationale. All three diagnostics were right about the failure and wrong about the audience. envs CC CFLAGS in a recipe body matched no cascade rule, so it fell through to the loose-shell rejection and was told to move into a cook body or a chore: advice that cannot work, because envs is removed from every position, and which named neither the removal nor its replacement. Every other removed keyword already had a migration diagnostic. A seal ref is unioned into the unit’s consumer probes list so the sealed probe is scheduled ahead of the unit, and the resolution failure came back wearing that list’s clothes; “unit ‘main.o’ lists probe key ‘no-such-decl’ in probes but no such probe was declared” is four nouns of cook.add_unit API for an author who wrote three words of Cookfile and no Lua at all. And $<in:stem>, one wrong character in $<in.stem>, reported that the probe was “not materialised in the register pre-pass” — naming an implementation phase in order to decline a typo it had all the information to recognise, the four path accessors being a closed set (§ 10.3).

The through-line is that a diagnostic’s job is to be actionable by whoever triggered it, which makes the surface the author wrote part of the diagnostic’s content rather than a presentation detail. Where an implementation funnels several surfaces into one internal check, as it must here since a sealed probe and a consumed probe are scheduled identically, the funnel is where provenance gets dropped and where it has to be kept instead. Nothing about the failures themselves changed: each is rejected at the same point, for the same reason, as before.

Conformance. recipe-envs-removed pins the recipe-body removal diagnostic and its rendered replacement, twinned with probe-producer-envs-removed for the probe-producer position; both render through one shared function, so the two positions cannot drift. seal-ref-undeclared pins the seal-ref rejection at register phase, and the reference implementation’s unresolved_probes_key_errors remains the guard that § 22.5.6 rule 1’s sentence is unchanged for the probes field. The output-pattern refusal gets register-phase tests and no fixture, since this Standard mandates the refusal and not its wording.

CS-0234 — the data-member quoting law reaches data members only [#changes.cs-0234]

Date: 2026-08-15 Version: v0.18 Sections affected: § 22.5.10, App. E (this entry). Reference implementation: cli/crates/cook-luagen/src/recipe.rs.

Summary. § 22.5.10‘s requirement that a whole-member $<in> be enclosed in single quotes in a shell body is scoped to data members — the array elements of an ordinary probe. It does not reach a bare gather source naming a files declaration, whose members are path members. An implementation MUST NOT reject an unquoted $<in> under a named-files gather. Where an implementation cannot distinguish the two source categories at the point it enforces the rule, it MUST resolve the doubt toward the data-member reading and reject.

Rationale. The rule was written for the hazard it names: a record member renders as canonical JSON, and bare JSON on a command line is mangled by brace expansion while double quotes fail to protect the JSON’s own quote characters. A files declaration’s manifest keys are paths. A path is a string, never composite, and it is the same kind of member a quoted glob gather produces — § 9.3 already gives the two one taxonomy, path member and record member, and gives them the same accessor semantics accordingly.

Enforced without that scope, the rule made the refactor the language itself encourages fail. gather "src/*.test.ts" with test { node --check $<in> } is accepted; lifting the same glob into files srcs and writing gather srcs — same file set, same body — was a hard error demanding single quotes for a hazard that cannot arise. The reference implementation gated the check on the presence of any member-source step, which is the one distinction the surface does not make: bare gather covers both categories, and CS-0230 had already reconciled that a named files declaration is a valid gather source whose manifest keys become the members. This entry states the scope the check should have carried from the start, on the same ground the section’s Non-array value paragraph already states it — that rejection, too, “does not apply to a named files declaration”.

The tie-break clause is deliberate and matches what the reference implementation can see. The check runs before registration, so it decides the category from the Cookfile’s own declarations: a bare gather key naming a locally declared files probe is exempt, and every other key — an array probe, a key this Cookfile does not declare — keeps the demand. That is conservative in the safe direction. A false rejection names a fix (add single quotes) that is correct for a path member too; a false acceptance ships a command whose JSON argument the shell has already rewritten.

Conformance. The negative unquoted-data-member stands unchanged: it is an array probe, and the law still binds it. A positive is added, 072-gather-named-files-unquoted-member, pinning files + gather NAME + an unquoted $<in> through codegen. The reference implementation’s cook-luagen gather suite pins both halves side by side so the pair reads as one taxonomy.

No CACHE_VERSION bump: this widens what registers, and a Cookfile that already registered is unaffected.

CS-0233 — a chore body bans step kinds, not keywords [#changes.cs-0233]

Date: 2026-08-15 Version: v0.18 Sections affected: § 7.2 (new § 7.2.1), App. A.3.1, App. E (this entry). Reference implementation: cli/crates/cook-lang/src/recipe.rs, cli/crates/cook-luagen/src/long_bracket.rs, tree-sitter-cook/src/scanner.c.

Summary. A chore-body line whose first word is gather, cook or test is that banned step kind only when the rest of the line matches that step kind’s own syntax — a quoted operand for gather / cook, ( for cook’s Lua-expression output form, a bare identifier alone for gather’s named member source, { or >{ for a test body. Everything else is a shell_command, so test -f src/canvas.ts, test "$X" = y and cook build parse as the shell commands they are. The ban itself is unchanged: test { … }, cook "out" { … } and gather "src/*.c" in a chore body are still rejected, with the same diagnostic. Separately, a lowering defect that made the second half of this unreachable is fixed: the emitter’s Lua long-bracket literal now escalates its level for content that ends in ], so a chore step or probe producer containing [ -f x ] no longer emits [[[ -f x ]]].

Rationale. The chore body is the language’s shell-first form, and the two lines this made impossible — test -f FILE and [ -f FILE ] — are the two most common conditional spellings in shell. Neither was ever a step: § 7.2 bans three step kinds, and matching on the keyword prefix alone conflated the kind with the word. The recipe-directing hint made it worse by sending the author toward a form that cannot express what they wrote. cook build, which a chore is the natural home for, was rejected by the same rule. The classification this entry writes down is not new invention: tree-sitter-cook’s external scanner has always used it, with a comment noting that “chore shell commands may still begin with these ordinary words (cook dinner)” — the two implementations simply disagreed, and the scanner’s reading is the one that holds. The one place both were wrong is the quoted operand, which the scanner also accepted for test; a test step’s only operand is its body, so test "$X" = y is shell and both sides now say so.

The [ half was a defect in the lowering, not in the language. A long-bracket wrapper closes at the first ]=*] of its own level, and the level was picked by scanning only inside the content — never the boundary where the content’s trailing ] meets the closer’s leading ]. The failure surfaced as a Lua syntax error carrying a generated-chunk line number attributed to the Cookfile (a 24-line Cookfile reporting line 38), which is why it read as a diagnostic problem; it was not one, and the bogus attribution goes away with the malformed literal. It was never chore-specific either: a probe producer containing [ -f a ] failed the same way, with no chore involved. Recipe cook and test bodies were unaffected because they lower through short "…" literals instead.

Conformance. chore-keyword-initial-shell pins keyword-initial test, cook and gather lines, plus both conditional idioms, as shell_command steps. chore-gather-exclude-glob pins the one spelling that must stay rejected on the strength of its operand alone: gather !"build/*", where !"…" is an exclude input (App. A.4) and misclassifying it would replace a parse error with a chore that fails at run time with gather: command not found. The existing chore-ban negatives continue to pin the other rejections, and parser tests pin each surviving banned shape. A surface e2e fixture runs the chore, and a probe producer containing [ … ] pins the lowering fix on the second caller. Unit tests pin both long-bracket boundary classes.

Compatibility. Correcting the boundary also raises the bracket level for content that was already wrapped correctly — content ending in ] followed by a run of = at least as long as the previously chosen level. foo]= moves from level 0 to level 2 and echo a]== from level 0 to level 3. The literal’s text changes; the string it denotes does not, and that invariance is the property to hold onto, because a regression in it raises no error at all: the literal stays well-formed and simply denotes a different command.

That distinction — text versus denoted value — decides what re-runs, and the two unit kinds answer it differently. A cook, test or chore step is identified by the command text its literal denotes, so a level change moves no key and nothing re-runs. A probe is not: § 22.5.4 item 3 folds the produce source string verbatim into the fingerprint, and that string is the outer literal, whose bytes carry the inner cook.sh(…) literal’s encoding. A level change on the inner literal is therefore a genuine byte change in a probe’s fingerprint input. Every probe whose script ends in ] followed by a run of = re-produces exactly once; its value is unchanged when it does.

No CACHE_VERSION bump. The probe case is a one-time re-produce of a vanishingly rare shape, not a change to how any value is encoded or compared, and a global epoch bump would invalidate every artifact in every cache to buy it. Otherwise only Cookfiles that previously failed to parse or to lower are affected.

CS-0232 — $<in> is rejected for want of an input source, not for want of gather [#changes.cs-0232]

Date: 2026-08-15 Version: v0.18 Sections affected: § 9.3 (the $<in>-without-a-source rejection), § 9.4 (the same rejection for test bodies), and App. E (this entry). Reference implementation: unchanged — cli/crates/cook-luagen/src/recipe.rs, validate_gather_usage.

Summary. §9’s two rejection bullets said $<in> requires a gather_step and mandated a diagnostic naming gather as the missing thing. Neither was true of the language § 8.2 and § 8.4.1 define, or of the shipped implementation: a gather is one of three ways a recipe establishes an input source for $<in>, and the diagnostic every implementation actually emits names the absence of a source, not the absence of a keyword. Both bullets are restated over the whole set.

What contradicted what. CS-0224 gave § 8.2 a two-sided rule: a recipe that gathers MUST name its gathered set, and a recipe with no source MUST reject an own-input reference “with a diagnostic stating that nothing gathers what the command references”. §9 was not updated with it and kept an older, narrower sentence. The narrowness is not academic — positive/013-cross-recipe-gather has a recipe whose $<in> is driven by a dep-driven output pattern ($<headers.stem>) with no gather of its own, and it passes every harness. §9 as written rejected a fixture the corpus requires be accepted, which is the sharpest form a spec defect takes: two normative statements about one Cookfile, disagreeing.

The source set. A gather_step in either form, quoted globs or a bare source name (§ 8.2); a dep-driven output pattern, where $<lib.ACCESSOR> in the step’s own output list makes lib’s output list the source (§ 10.4); or a preceding cook_step in the same recipe, whose collected outputs are the source under the chained-step rule (§ 8.4.1). A test_step additionally inherits the preceding cook_step’s output list per § 8.6.1, which is why §9.4’s bullet was wrong in exactly the same way §9.3’s was. The predicate stays static and consults no filesystem, as CS-0224 required. Establishing a source is deliberately not the same question as iterating one: § 8.4.1 decides cardinality from the output patterns alone, so a many-to-one step whose $<in> is the whole collected set has a source and no iteration item, and CS-0224’s “gather does not select cardinality” is what keeps the two apart.

No behaviour changes. Nothing in the implementation moves; the rule §9 now states is the rule validate_gather_usage has enforced since CS-0224. No grammar change, no CACHE_VERSION bump, no diagnostic rewording — the mandated wording is amended to match what ships, which is the direction that keeps a conforming implementation conforming.

Conformance. positive/071-chained-cook-input-no-gather is added: two chained cook steps, the second naming $<in>, no gather anywhere. It pins the one source in the restated rule that no fixture pinned, and it goes red under any narrowing back to the pre-CS-0232 reading. The existing bracket stands on either side of it — positive/013-cross-recipe-gather for the dep-driven pattern, negative/command-input-without-gather for the floor, where no source is established and the diagnostic is the one this entry now mandates in §9.

CS-0231 — the §8.2 item table admits the bare gather source [#changes.cs-0231]

Date: 2026-08-15 Version: v0.18 Sections affected: § 8.2 (restructured into App. A.4’s two arms, with the named-source form’s ordering and arity constraints stated and its trailing globs identified as per-member file inputs rather than members, and Note 8.2.1 corrected on both points), § 8.4.3.3 (Example 8.4.3.1 repaired), § 17.1.1.4 (new §17.1.1.4 — the seal-only-key rule promoted out of §17.1.1’s body prose so it can be referenced, and re-anchored so it can be cited as a clause, below), § 22.5.2 (the TOOL_NAME citation), § D.9, and App. E (this entry). No reference-implementation change.

Summary. §8.2’s normative item table listed only STRING and ! STRING, so a reader who stopped at the table concluded that a bare gather NAME source was ungrammatical. The bare form has been normative since CS-0224 in Note 8.2.1, in App. A’s gather_step production, and in § 22.5.10; only §8.2 disagreed.

§8.2 is restructured to match that production — gather_step ::= "gather" ( input+ | probe_ref STRING* ) — rather than given a third table row. The two arms are not two kinds of item: the glob form takes one or more input items in any order, while the named-source form takes exactly one bare source, first, optionally trailed by plain include patterns. Stating it as a third item form would have admitted gather "include/*.h" sources and gather a b, both of which a conforming implementation rejects. Three constraints on the named-source arm are now explicit and each is a parse-time rejection: at most one bare source, it comes first, and only plain STRING includes may trail it (an exclusion may not).

Note 8.2.1’s claim that “the quoted and bare forms are mutually exclusive within a recipe” is corrected in the same pass. It was false in one direction: gather sources "include/*.h" combines them and is well-formed, which is exactly the shape positive/068-gather-named-files has pinned since CS-0230. The real rule is ordering and arity, not exclusion.

Re-anchoring, not a new rule. §17.1.1.4 restates where a reader needs it, and gives its own address to, the rule for when a cacheable unit has something to key on: it is keyable if it declares at least one of a declared output, a declared input, a materialised data member, or a non-empty effective seal set. The reference implementation has enforced exactly this four-term disjunction since CS-0186 (cook_contracts::cache::record::has_something_to_key_on), and CS-0223 already stated it over all four determinants as one rule for every unit rather than a rule about tests. What it lacked was an address: it was stated inside §17.4’s test-unit section, whose only slug is exec.cache.test-unit for the section as a whole, so the rule could not be cited as a clause of its own. §17.1.1.4 gives it one and states it where the seal-only case needs it. Keyability is not an obligation to store: recording remains the MAY of § 17.1. The clause is declaration-scoped and says so: the rule is asked once over the declared list at registration and again over the resolved list at ready time, because a declaration can be non-empty and resolve to nothing, and § 17.4 rule 1 already states that the second answer binds. Without that scoping an implementer working from the general clause alone would serve a unit whose declared dist/** matched no file — the false green §17.4 exists to refuse. The reference implementation names the same split at cook-register’s unit_api.rs (“This is the DECLARED half of the rule”). Units whose record disables caching outright (a bare shell step, an @ step, any chore) are outside the rule and run every time for a different reason.

The rest of this entry is editorial and changes no conformance requirement. Sixteen §{…} clause references resolved in none of the three places the renderer consults and would have thrown at build time; each now resolves. Five of them named a rule — the seal-only key of CS-0223 — that was real but stated in body prose, and only a heading mints an anchor, so that rule is promoted to §17.1.1.4 verbatim in force. Two v0.18 entries asserted as current a surface a later same-version entry removed (CS-0222 on envs { … }, superseded by CS-0226; CS-0228 on the probe-body ingredients spelling, superseded by CS-0229) and now say so. §22.5.2’s tools NAME bullet cited CS-0181’s PROBE_SEG against its own §A.5 production. App. D.9 asserted that gather admits no multi-line continuation, which CS-0078 shipped and positive/058-multi-line-gather pins. And Example 8.4.3.1, the chapter’s own whole-surface showcase, did not parse: it annotated its gather, seal and cook lines with trailing # comments, which § 3.6 has always forbidden outside the head of a line, so each of the four declarative line kinds rejected it with its own diagnostic; underneath that, its cc … bodies named neither $<in> nor $<out> and so tripped the CS-0224 rule stated 280 lines above them. Repaired editorially — the annotations move into the surrounding prose and each body names its input.

Backward compatibility. No Cookfile changes meaning. Every rule this entry states was already normative somewhere in the Standard; what changes is where a reader finds it. No cache key is affected and no CACHE_VERSION bump is warranted.

Conformance. The bare gather source is already pinned by 068-gather-named-files, and multi-line continuation by 058-multi-line-gather. The fixture cook-disposition-seal-envs-probe is renamed to cook-disposition-seal-shell-probe — it contains no envs, and named the keyword CS-0226 removed rather than the replacement it actually pins. One positive case IS added, cook-disposition-whole-surface, pinning Example 8.4.3.1 verbatim: nothing reads an MDX fence, which is how the chapter’s flagship example came to violate two rules of its own chapter. A new source lint, no-dangling-xrefs.test.ts, makes the renderer’s unknown-slug throw runnable off the same three sources it consults, so the next §{slug} that resolves nowhere fails a test rather than waiting for a build that cannot run. That is the addressed half of the problem only: a rule referred to by name in prose, rather than through a §{…} reference, carries no slug for the renderer or the lint to resolve and is findable by grep alone.

CS-0230 — reconcile named-files gather and segmented source refs [#changes.cs-0230]

Date: 2026-08-15 Version: v0.18 Sections affected: § 22.5.2, § 22.5.10, and App. E (this entry). Reference implementation: cli/crates/cook-register/src/engine.rs, cli/crates/cook-lang/src/cook_line.rs.

Summary. Named files declarations are both sealable determinants and valid gather sources. In gather position their manifest keys drive members, matching the existing register behavior. Probe source refs retain exact-key-first resolution with an optional final field selector and admit unlimited key segments; the obsolete two-segment key and four-segment source-ref limits are removed.

Rationale. The assembled Standard described named files as seal-only even though the registered manifest has long supported deterministic path fan-out. It also retained limits already removed from the shared probe-key grammar. This entry reconciles the contract with those accepted behaviors and updates active parser diagnostics from the retired inputs <probe> spelling to gather <probe>.

Conformance. 068-gather-named-files pins manifest-key fan-out. Parser tests pin multi-segment gather refs and current diagnostics; the Language v2 structural guard pins this entry and the two normative §22 rules.

CS-0229 — remove ingredients [#changes.cs-0229]

Date: 2026-08-14 Version: v0.18 Sections affected: § 8.2, § 22.5.2, App. A, App. D, App. E (this entry). Reference implementation: cli/crates/cook-lang, tree-sitter-cook.

Summary. The ingredients keyword is removed. gather is the sole iteration declaration; determinants are declared with files and applied with seal. The parser retains a migration diagnostic showing both accepted shapes: gather "tests/*.txt" when the command consumes each file, or files core:src with an indented "src/**/*.rs" "Cargo.toml" body followed by seal core:src when it does not. Tree-sitter and the normative Standard no longer recognize or document the old spelling, and the obsolete pre-v1 multi-line checklist item closes with the removed surface.

CS-0228 — seal declares a probe’s fingerprint inputs [#changes.cs-0228]

Date: 2026-08-14 Version: v0.18 Sections affected: § 22.5.2, App. A probe_body, App. E (this entry). Reference implementation: cli/crates/cook-lang/src/probe.rs, tree-sitter-cook/grammar.js.

Summary. A native probe body admits one seal line before its producer. Its refs join the header dependency list in inputs.requires, so each named declaration’s fingerprint contributes to the probe fingerprint through the existing upstream-probe path. A second seal or a seal after the producer is rejected. The legacy probe-body ingredients spelling remains parseable for migration but is no longer the normative production.

Superseded in part by CS-0229 (same version). The probe-body ingredients spelling this entry kept parseable for migration was removed later in v0.18. It is a hard error, not a tolerated legacy form: a conforming implementation rejects it with a migration diagnostic naming both replacements (gather for iteration, files plus seal for determinants).

Conformance. probe-seal pins the positive AST and register shape; probe-two-seals and probe-seal-after-producer pin the cardinality and ordering diagnostics. The CLI probe test edits a sealed file declaration and proves the dependent probe re-evaluates.

CS-0227 — quoted seal operands are inline file determinants [#changes.cs-0227]

Date: 2026-08-14 Version: v0.18 Sections affected: § 8.4.3, § 22.5.2, App. A seal_step, App. E (this entry). Reference implementation: cli/crates/cook-lang/src/disposition.rs, tree-sitter-cook/grammar.js.

Summary. A quoted operand in either recipe-level or probe-body seal desugars to an anonymous files probe. Its per-file manifest enters the existing seal path, so file contents invalidate consumers without binding $<in> or creating fan-out. Multiple quoted operands on one line form one set; the adjacent !"…" spelling excludes matches. Bare operands retain their named-probe meaning and may be mixed with quoted operands in any order. The generated @seal: key prefix is reserved from author declarations so an anonymous determinant cannot collide with a quoted probe key.

Boundary. The blanket seal remains unavailable. An explicit file glob is not a universe: it names the determinant set directly. Inline tool identity is also unnecessary; authors seal a top-level tools declaration by name.

Conformance. 070-seal-inline-files pins the recipe baseline reaching both a producing unit and a test unit; probe-seal pins the probe-body position. The CLI probe test edits the inline-sealed file and proves the sourceless recipe unit misses without gaining an iteration source.

CS-0226 — remove the envs probe producer [#changes.cs-0226]

Date: 2026-08-14 Version: v0.18 Sections affected: § 5.3, § 8.4.3, § 10.7, § 12.7.3, § 12.7.5, § 17.1.2, § 22.5.2, § 22.5.8, and § A.3.2.

Summary. Removes envs { NAME, … } from the probe-body grammar. An unconsumed ambient variable is now observed by an ordinary named shell probe, for example lines { echo "$NAME" }, and that probe is sealed by name. The replacement preserves grouping, naming, and shareability without a dedicated producer kind. A source-less observation probe re-runs whenever reached under CS-0178, while an unchanged value preserves consumer cache hits; changing the environment changes the sealed consumer key. The removed spelling receives a CS-0226 diagnostic naming the shell-probe replacement. No cache-version bump: existing Cookfiles fail at parse time and migrated probes produce a new source string and therefore new keys.

CS-0225 — collapse the seal surface to one tier [#changes.cs-0225]

Date: 2026-08-14 Version: v0.18 Sections affected: § 8.4.3, § 8.6, and § A.4.

Summary. The recipe-level seal step is now the complete seal set for every cacheable unit in its recipe. Trailing seal and every unseal position are removed. cook_mods narrows to share_mod?; a test step admits no tail. Removed-form diagnostics direct trailing seal to the recipe-level step and direct unseal users to omit that ref from the recipe seal. The cook.add_unit per-unit seal field is unchanged for modules.

Why. No production Cookfile used either removed form. unseal was the only subtractive cache-key operator and typoed refs failed open; trailing seal duplicated a recipe-level surface without supporting per-member control. One additive tier is smaller and cannot weaken a key accidentally. The safe fallback is a recipe-level seal, which may rebuild siblings unnecessarily, or a split recipe when exact scoping matters.

Backward compatibility. Breaking at parse time with migration diagnostics. No cache schema change: the stored per-unit seal field and its key effect are unchanged.

CS-0222 — named file and tool sets are top-level declarations [#changes.cs-0222]

Date: 2026-08-14 Version: v0.18 Sections affected: § 3, § 4, § 22.5.2, § A.1, § A.3.2, and § D.9.

Replaces the probe-body files { … } and tools { … } producers with keyword-led files NAME and tools NAME declarations whose indented bodies admit multiple physical lines. Both lower to the existing probe semantics and remain consumable by seal; envs { … } remains a probe producer. Amends §3, §4, §22.5, App. A, and App. D.9.

Superseded in part by CS-0226 (same version). The envs { … } producer this entry left standing was removed later in v0.18. A conforming implementation now rejects it with a removed-keyword diagnostic naming an ordinary shell probe over the same variables, sealed by name, as the replacement. The files NAME and tools NAME declarations this entry adds are unaffected.

CS-0224 — gather names what the command consumes [#changes.cs-0224]

Date: 2026-08-14 Version: v0.18 Sections affected: § 8.2, § 8.4.1, App. A. Reference implementation: cli/crates/cook-lang, cli/crates/cook-luagen, and tree-sitter-cook.

Summary. gather "glob" is an additive register-time driver spelling for quoted ingredients, including !"exclude" set difference. It adds no work unit and does not select fan-out: output patterns still decide between one-to-one and many-to-one. Two static hard errors enforce the vocabulary — gathered paths must be named by a command, while an own-input reference must have a gather or another established driver. Diagnostics direct invisible determinants to seal and missing drivers to gather.

CS-0223 — a seal mints a cache key [#changes.cs-0223]

Date: 2026-08-14 Version: v0.18 Sections affected: § 17.1.1, § 17.4. Reference implementation: cli/crates/cook-contracts/src/cache/record.rs, cli/crates/cook-register/src/unit_api.rs, and cli/crates/cook-engine/src/executor.rs.

Summary. A non-empty effective seal set now gives an otherwise sourceless unit a cache key. The prior rule treated a seal only as a restriction on a key that some file, output, or data member had already minted, so a test whose complete declared determinant was a probe re-ran forever. Sealed probe values are already part of the single key and already materialise before lookup; counting their non-empty set at both registration and ready time removes that contradiction. An unsealed sourceless unit and every chore remain uncached. The reference implementation bumps CACHE_VERSION 10 → 11 so no entry addressed under the old keyability rule can replay across the change.

CS-0221 — a Cookfile edit may create the field it was asked to add to [#changes.cs-0221]

Date: 2026-08-09 Version: v0.18 Sections affected: § 22.13 (an options argument on splice_field and a field_entries signature; six new paragraphs — the opt-in, what creating does not relax, the checked field name, layout matching, the read, and the empty-braces clause folded into “the insertion point is the end of the code”), § C.25 (the “only correct thing to do” absolute acquires the qualification this entry introduces). Reference implementation: cli/crates/cook-cookfile/src/lib.rs (AbsentField, locate_argument_table, create_field_edits, line_end_after, is_writable_key, bracketed_key_present, trailing_separator, first_code_start, split_entries, field_entries, and EditError’s three new variants), cli/crates/cook-lua-stdlib/src/cookfile_api.rs (absent_field_policy and the field_entries binding), and cli/crates/cook-contracts/src/lua_scan/mod.rs (RESERVED_WORDS, which descended when the key check became its second consumer alongside cook-lang’s config-shape detector). COOK-287.

Summary. § 22.13 could add "math" to a links = { … } an author had already written, and could do nothing at all when they had not. That is the common case: a hand-written Cookfile has no links field until something needs one, and neither does a recipe a scaffolding verb generated. splice_field gains an opt-in options.create_if_absent, which answers the one case “no such field” by writing field = { entry } into the call’s argument table, and the subsection gains field_entries, a read that reports what is in a field’s list without editing it.

Why the field is created by the verb that adds to it, not by the verb that made the recipe. The alternative on the table was to have the scaffolding verb emit an empty links = { } on every target it wrote, leaving the edit primitive untouched. It is the smaller change and it solves almost nothing. An author’s own Cookfile has no such field either, so a create-nothing editor works only against recipes some tool generated, and the first thing anyone does to a generated recipe is hand-edit it. Worse, an empty field on every target is a line of noise in the file whose readability the surrounding rules exist to protect, written in the hope that a later command will need it. The field belongs to the command that needs it.

Why it is opt-in, and why that is not merely cautious. “Failure is explicit and total” is the property that pays for the preservation rule, and the previous paragraph of this subsection says so: an implementation that locates and splices knows when it has not found what it was asked for, and a re-rendering one cannot know. A caller that names a field it expects the author to have written is asking a real question, and the honest answer to “there is no such field” is still to say so and change nothing. Making creation the default would convert every existing such call’s diagnostic into a silent write, which is exactly the failure mode the subsection is built to exclude. So the mode is a request, spelled at the call site, and a caller that says nothing gets the behaviour it has always had.

The same reasoning makes an unrecognised options key an error rather than an ignored one. The two policies differ in whether a file is written; a misspelled create_if_missing that quietly meant “refuse” would reach the author as the engine declining an edit their verb was certain it had asked for, and nothing in the message would point at the typo.

What creating deliberately does not relax, and the one case that is easy to miss. A missing recipe, a recipe with no module call, and a file that does not parse are refused under either policy: none of them is a question about a field. A field that IS present and whose value is not a { ... } list is refused too, and that one is worth stating as a rule rather than leaving to an implementation’s judgement, because the permissive answer looks like it worked. Creating links = { "math" } beside the author’s links = shared_links leaves one table binding one key twice; Lua takes the later, the author’s value is gone, and the file still parses and still builds.

The case that only became reachable here is the bracketed key. This subsection has always permitted an implementation not to match ["links"] = { … }, requiring it to report the field as not found instead. That was a sound trade when “not found” meant a refusal. It stops being sound when “not found” means writing links into the same table, since an evaluator reads the two spellings as one key and the created one wins — the same silent discard, arrived at through a rule that was correct when it was written. An implementation that does not match the bracketed form must therefore decline to create over it, which it can do by looking for the spelling it has chosen not to edit. The refusing policy’s diagnostic is unchanged.

Why the field name is checked when the entry is not. “Entries are rendered by the caller” is one of this subsection’s older rules and it is right: only the caller knows whether it is adding a string, an identifier or a table, and an implementation guessing would be wrong for two of the three. It does not transfer to field, which the create path also writes into the file, because a field name admits no such variation — it is a key, in one spelling. An implementation that writes end = { … } or = { … } hands the author a file that no longer loads and a message that does not say which command wrote it. The empty name settles it: “a field is a key, not a substring” already requires it to match nothing, so delegating would leave one input refused cleanly under the default policy and silently corrupting the file under the other, which is the kind of asymmetry a policy flag must not introduce. This is also the posture CS-0220 took one entry earlier, for the same reason, over a use name a rock name could not be spelled as.

Why the layout rule is normative at all. Everything else in this subsection is about which bytes may move. A created field is new bytes, so nothing constrains where they go, and the two obvious placements are both damaging in the way the subsection cares about. Appending , links = { "math" } after the last entry of a call whose entries sit one per line puts two fields on a line in a file that has none — a restyle, performed one field at a time. Anchoring at the last byte of code when that entry’s line ends in a comment inserts the new field between the entry and the comment written about it, so the comment now reads as being about the new field. The requirement is a SHOULD where it concerns indentation and the trailing comma, which are taste, and a MUST where it concerns the comment, which is the same class of damage as splicing into one.

Why a read joins the subsection. A verb that adds a dependency edge must not add it twice, and to know that it needs the current contents of the field. Every module that needs this has the same wrong answer available to it — search the call’s text for the entry — and that answer matches "math" inside sources = { "src/math/main.cpp" }. The right answer is to locate a top-level key and split its list without mistaking a comma inside a nested table, a call, a string or a comment for a separator, which is the work the rest of this subsection already specifies for the write side. Two modules writing it again in Lua is precisely the second implementation of the hard part that § 22.13 exists to prevent, and the first of them was about to be written.

field_entries returns nil for a field that is not there, matching find_call’s rule that nil means “nothing here to report on” — which also makes a verb’s create-or-append decision a single comparison. It raises rather than returning nil for a field that is there and is not a list, because that is the one case where “nothing here” would lead a caller to write.

What changes for authors. Nothing an author writes is interpreted differently and no existing Cookfile means anything new. What changes is what a module’s verbs can offer: a command that adds a link, a dependency or any other list entry now works against a Cookfile the author wrote by hand, instead of only against one a generator produced. One diagnostic is reworded — the not-a-list refusal previously ended “so "x" cannot be added to it automatically”, and no longer names the entry, because the same condition is now reachable from a read that is not adding anything.

Two fixes ride along, both of them in the shared machinery this entry had to touch and both of them corruptions rather than infelicities. A table written with the ; separator the embedded language equally admits received a comma after it — { a = 1;, b }, which does not load — on the append path that shipped with CS-0179, and would have received one on the create path too; the separator the author used is now the separator that is repeated, and the read splits on it as well, so an entry does not come back with a stray ; glued to it and fail every comparison. And a file written with CRLF line endings received a bare \n line, which “preservation is normative” does not permit an implementation to introduce.

Backward compatibility. No CACHE_VERSION bump; nothing here participates in a cache key. A call written before this entry behaves identically after it: the options argument is optional, its absence is the old policy, and the edit performed when a field IS found is byte-for-byte the edit that was performed before. The Cookfile grammar is untouched — options is a Lua argument, and a created field is ordinary Lua inside a payload the frame grammar treats as opaque — so the tree-sitter grammar and its corpus need nothing.

Conformance. One positive fixture, positive/cookfile-creates-an-absent-field, alongside the CS-0179 fixture it is shaped after. A parse dump cannot observe any of this, so the assertions live in the fixture’s own Lua and fail it by error(msg, 0): that the same call without the options table still refuses and still leaves the file byte-identical, that field_entries answers nil for the field that refusal is about, that the created field lands on its own line at the author’s indentation with the author’s trailing comma, that the file grew by exactly the inserted bytes, that a second entry appends into the field the first one created, and that a non-list field is refused even under the create policy. The fixture edits a copy and removes it unconditionally (CS-0180). The rest is pinned by the reference implementation’s own tests, and two of those are worth naming because both were defects the first draft had: two insertions at one offset were applied in the order that put the separator after the line it separates, and creating over a bracketed key of the same name was silently permitted.

CS-0220 — installing a module by name declares it [#changes.cs-0220]

Date: 2026-08-09 Version: v0.18 Sections affected: § 27.1.1 (the wiring rule, its three cases, the unspellable-name refusal, the unparseable-file refusal, the no-argument form, and the deliberate asymmetry of removal). Reference implementation: cli/crates/cook-cli/src/wire_use.rs (new — the decision and the writing), cli/crates/cook-cli/src/main.rs (the cook modules dispatch arm), cli/crates/cook-modules/src/cli.rs (installed_names — which rocks an invocation named), cli/crates/cook-cookfile/src/lib.rs (ensure_use, the § 22.13 core extended with a top-of-file insert), cli/crates/cook-contracts/src/module_binding/mod.rs (is_use_name, § 5.1‘s LUA_IDENT rule, which descended when the installer became its second consumer) and cli/crates/cook-lang/src/lexer.rs (check_use_name, which now asks it). COOK-293.

Summary. cook modules install cook_cc pinned the module, fetched it, wrote cook.toml and cook.lock, and left the Cookfile alone, so the module was on disk and unreachable until the author hand-added use cook_cc. § 27.1.1 now requires a named install to write that declaration: creating a Cookfile that holds only the use lines where none exists, inserting the line where one does, and doing nothing where the declaration is already there.

Why the install is the right verb. A module the Cookfile does not use is inert in a way that is not obvious from anything the author can see: the rock is installed, the lockfile records it, cook modules list shows it, and none of its makers or its § 22.12 chores exist. That is the whole of the gap — the author asked for the module by name, and the request that would have made it do something is a second request carrying no information the first did not. It is the same reasoning cargo add runs on, and it matters most at minute zero: a module offering project-scaffolding verbs cannot be the thing that bootstraps a project, because those verbs do not exist until the declaration does. Requiring a hand-edit before the first command works puts the hand-edit at the front of the flow the module exists to remove.

Why creating the file writes nothing but the declarations. An implementation’s init verb writes a starter Cookfile with a working recipe in it, and that is the right shape for init and the wrong one here. The next thing to run in this flow is a scaffolding verb that writes recipes of its own, and a starter build target is then a name it must either collide with or route around. So the created file is exactly the declarations: minimal, and owing nothing to whatever writes the second line.

Why a name that cannot be spelled is refused rather than rewritten. Rock names admit - and .; a use_name is a LUA_IDENT (§ 5.1) and admits neither. § 12.1‘s alias derivation would map foo-bar to foo_bar, and an implementation that quietly wrote the rewritten spelling would be inventing a declaration that resolves a different module name than the one installed. The install stands and the declaration is refused by name, which is the one outcome that leaves the author holding accurate information.

Why presence is a question about the name. The already-present case is decided on the name a declaration BINDS, in either form, and that is the one rule here that was not obvious. § 27.1.2‘s replacement tells an author patching a blessed module to write use cook_cc ./vendor/cc.lua; an install reading that as “no declaration for the module cook_cc” would add one, and the file would then bind one Lua local twice. The reference implementation’s two phases resolve a repeated binding differently — the register chunk emits one local per declaration, so the last wins, while the execute prelude skips an alias it has already bound, so the first does — which is a build loading the patched module in one phase and the installed rock in the other, with nothing wrong in the Cookfile to see. Requiring the check to be about the binding makes the sanctioned patching workflow survive an install rather than be silently undone by one.

What changes for authors. Nothing an author writes is interpreted differently, and no existing Cookfile means anything new. cook modules install <name> now reports one more line when it edits (Cookfile: added 'use cook_cc') and creates two files in an empty directory that it previously created none of. An author who wants the old behaviour has it by not naming the module on the command line: the argument-free form realises cook.toml and touches nothing.

One unrelated byte changes, and it is in the other verb. The managed .gitignore section’s marker line read # Cook artifacts (added by cook init); two verbs write that section now, so it names neither and reads # Cook artifacts (added by cook). Recognition is by prefix, so a .gitignore written by an earlier cook init is still the same section and never collects a second copy of the block; a project that re-runs cook init after upgrading sees no diff at all, because an existing section is left exactly as it was found.

Backward compatibility. No CACHE_VERSION bump; nothing here reaches a determinant, a key, or a recorded shape. A project whose Cookfile already carries the declaration sees a byte-identical file, which is the case every re-run after the first one is.

Conformance. No corpus fixture. Nothing about the Cookfile language changes — the declaration written is the one § 5.1 already specifies — and a parse-only fixture cannot observe which command wrote it. The rules are pinned by the reference implementation’s own tests: the editing algebra by string-in/string-out tests over ensure_use in cli/crates/cook-cookfile/tests/edit_tests.rs, including the preservation claim asserted as byte equality outside the inserted run, and the three cases plus the two refusals in cli/crates/cook-cli/src/tests/wire_use_tests.rs, half of them pure and half of them over a real directory.

CS-0219 — a register-phase body can read a probe and draw the graph it describes [#changes.cs-0219]

Date: 2026-08-09 Version: v0.18 Sections affected: § 22.1 (an after row in the field table, and a new §22.1.3 defining it, with Example 22.1.4), § 22.5.10 (a new “Register-phase reads” paragraph, and the static-input rule restated to bind every probe the register phase resolves rather than fan-out sources alone), § 21.1 (the cross-phase cook.probes.get note becomes the three-step register-phase lookup). Reference implementation: cli/crates/cook-contracts/src/unit_graph/mod.rs (resolve_after, AfterError, EdgeProvenance::UnitOrder, and plan’s wiring of it), cli/crates/cook-contracts/src/unit/mod.rs (CapturedUnit::after), cli/crates/cook-register/src/unit_api.rs (the field and the deps rejection), cli/crates/cook-register/src/engine.rs (RegisterProbeResolver, and the static-input check generalised to its resolved set), cli/crates/cook-register/src/module_loader.rs (probes_get), cli/crates/cook-graph/src/dag_data.rs (EdgeKind::UnitOrder). COOK-319.

Summary. Cook could already run a probe before registration finished — § 22.5.10‘s pre-pass does exactly that, so a fan-out’s cardinality can be a function of scanned data — but nothing could be built on top of the value. A register-phase body could not read it unless the probe happened to feed an ingredients <probe> source, and even holding the value it had no way to say that one unit it was registering must run after another. Two gaps, and either alone was enough to stop a module from turning a scan into a graph. This entry closes both: cook.probes.get resolves a declared probe on the register VM, and cook.add_unit gains after, a per-unit ordering edge naming an earlier sibling’s declared output.

Why the read is spelled cook.probes.get and not a new function. § 21.1 already said where this name is going: the module-scoped kv store the register-phase get used to mean is “slated for removal alongside cook.probes.set in a later 1.x revision”. A second function would have to be deprecated on the same day the store finally went, so the surface would have carried three meanings to arrive at one. The read is inserted as a step before the kv store instead, and a key naming no declared probe still reads through to it, nil on a miss — module code depends on that and this entry does not disturb it.

Why the read resolves lazily, at the point of the call. The obvious alternative is to resolve every declared probe before the bodies run, alongside the fan-out drivers. It is simpler and it is wrong: it produces probes nothing reads, on every invocation, and registration is already the dominant cost of a settled run. Resolving at the read costs nothing to preserve and gives the rule its teeth, so the rule is stated as a prohibition on the eager strategy rather than as a property the lazy one happens to have.

It is stated carefully, because the tempting stronger claim is false. Registration runs the bodies of every recipe the pass registers, in order to discover their units, so “the body is running, therefore its recipe is reachable from the target” does not hold and the bound this gives is over reads, not over reachability. A read in a body the target does not reach still demands its probe. That is the same overapproximation § 22.5.6‘s static Lua-body scan already makes on the execute side, for the same reason — narrowing it means deciding reachability before knowing what the bodies register — and saying so is better than resting the feature on a guarantee it does not have. The one place the cost is genuinely avoidable is a discovery pass, which registers no units and executes nothing, and which this entry explicitly permits to skip resolution.

Two things a produce body may not do, both of which only became expressible here. Before this entry a cook.probes.get inside a produce source resolved nothing, so neither rule had anything to bind. Now it does, and both gaps are real. A read naming a key outside the probe’s declared inputs.requires is refused: § 22.5.4 folds an upstream’s fingerprint into a probe’s own only for its declared requires, so an undeclared read is real data keyed on nothing and the reader keeps serving a value computed against an upstream that has since moved. The same rule makes a body naming its own key — or two naming each other — a diagnostic rather than an unbounded recursion, since those are not requires edges and § 22.5.9‘s check cannot see them. And a produce body may not register: resolution can now fire in the middle of a recipe body, where the capture state is live, so a cook.add_unit or cook.exec inside produce would attach a unit to whichever recipe happened to be registering — making the recipe’s unit set depend on whether the probe was a cache hit. Both raise the ordinary errors those calls raise when a probe is resolved before any body runs, which is the behaviour they always had.

One narrowing, recorded rather than buried. The old check walked every recipe’s member-source descriptor whether or not the pre-pass had evaluated it, so an unreachable fan-out driver’s artifact dependence was diagnosed on a build that never resolved it. Keying the check to the resolved set drops that: the same Cookfile now builds a target that does not reach the offending driver, and is still rejected on one that does. This is the demand-driven principle applied consistently rather than an oversight — a rule about a value that was never computed is a rule about nothing — but it is a relaxation and it belongs on the record.

Why the static-input rule had to be widened in the same breath. § 22.5.10 forbids a probe resolved before execution from depending on a build artifact, and that prohibition is the whole reason cook can learn a scanned graph without mutating the build graph mid-run the way a dyndep-style mechanism must. It was written against fan-out sources because they were the only probes the register phase resolved. A register-phase read is a second route to the same instant, so leaving the rule scoped to drivers would have left a hole in exactly the guarantee this feature is built on. The rule now binds every probe the register phase resolves, by whichever route, and the reference implementation checks the resolver’s resolved set rather than the drivers — twice, because a cook.on_register_complete callback is register-phase Lua like any other and can resolve a probe after a mid-pass check has already run. Its diagnostic is reworded to match: it named an ingredients <probe> source the Cookfile need not contain.

Why after names an output path. A work unit has no name, and giving it one means inventing a namespace and then a uniqueness rule to police it. § 22.1‘s CS-0169 output-uniqueness rule already guarantees at most one unit per run declares any given literal output, so the declared output is an identifier the model keeps unique for reasons of its own. That rule is scoped to literal paths, which leaves a real gap the surface has to answer rather than assume away: two units may legitimately declare one glob or directory output, and a reference to it names no single producer. Such a reference is a third rejection, not a first-declarer guess, because guessing would leave the declaring unit racing the producer that was not chosen and say nothing about it. The consequence in the other direction is that a unit declaring no output — a cook.exec unit, an interactive unit, a test unit — is unnameable, which follows from the identifier rather than limiting it: within one recipe those are already ordered by the barrier rules. It is also the string the consuming author is already holding, since the unit that must wait is normally the one that reads the file. § 10.6‘s prohibition on inferring an edge from path equality is untouched and unweakened: it constrains the evidence an implementation may reason from, and an after entry is not evidence to be weighed but a reference whose entire content is the reference — the same distinction § 22.9 draws for cook.require_recipe. An inputs[] entry equal to some other unit’s outputs[] entry still establishes nothing.

Why after folds into no cache key. The tempting reading of “units generated from a probe value must fold that value into their fingerprints” is that the value goes into the key. It should not. A unit’s key already folds everything the value can shape and the unit can observe: the command text, the declared inputs, the declared outputs, and member. What is left is the ordering, and ordering is not an input — a unit whose edges moved while all four of those held still reads the same bytes and writes the same bytes. Folding it would re-run work that cannot have changed, which is the opposite of the invalidation precision the surface exists to make possible: change one entry of a scan and exactly the units whose commands or inputs moved lose their keys. An author who does want the whole value in a key has seal, which is what that field is for.

Why backward references only. An after entry may name only a unit registered strictly earlier, so a cyclic edge set cannot be written down at all and acyclicity is structural rather than checked. Admitting a forward reference would mean either permuting a recipe’s units before the graph is materialised or restoring a unit-level cycle search — and the reference implementation deleted its cycle checker (COOK-400) precisely because every edge already pointed backwards. The cost is that a module emits its units in dependency order, which is work it does anyway to know the order; the benefit is that the module reports a cycle in the author’s vocabulary (“circular import foo -> bar -> foo”) where an implementation could only report one in unit positions. The forward-reference diagnostic is therefore not a lesser substitute for cycle detection, it is the enforcement of the invariant that makes detection unnecessary — which is why § 22.1.3 requires it to be worded apart from the unknown-path case, and why validation is deferred to the close of the recipe body, the first moment the two are distinguishable.

What changes for authors. Two additions, both opt-in. A register-phase body may read any probe declared in the pass with cook.probes.get(key). A cook.add_unit call may declare after = { … } to order itself behind earlier units of the same recipe. Nothing existing changes meaning: a body that reads no probe sees the register phase it always saw, and a unit declaring no after is wired exactly as before. One new refusal: deps on a cook.add_unit table is now a named error rather than an ignored key, because it is the word an author reaches for and it is ambiguous between the two ordering channels — the diagnostic names both.

Backward compatibility. No CACHE_VERSION bump. Nothing here reaches a cache key: after folds into nothing by rule, and a register-phase read publishes a probe value through the same path § 22.5.8 already defines, under the fingerprint that section already specifies. No recorded shape moves and no key is composed from a different set of terms. cook why’s machine-readable document gains one possible value, unit_order, on the existing edge kind field; that is additive in the sense CS-0217 used when it added a key without a bump, so DAG_SCHEMA_VERSION is unchanged. No Cookfile grammar changes — after is a Lua field, like discovered_inputs — so the tree-sitter grammar and its corpus are untouched.

Conformance. Two fixtures, and the rest is pinned by the reference implementation’s own tests, because none of it is observable from a parse dump. Those tests are worth naming: a produce body reading its own key is a named error and not a stack overflow, an undeclared upstream read is refused while a declared one is served, a produce body cannot capture a unit into the recipe that triggered it, the static-input rule reaches a probe a finalizer resolved, and a discovery pass runs no produce body at all. Every one of those was a defect found by review of the first draft rather than a property designed in, which is the honest provenance of this paragraph. positive/register-phase-probe-derived-graph carries a register_ok.txt and a Lua module that declares a probe describing a three-node import graph, reads it from a register-phase body, registers one unit per node with after edges derived from the data, and asserts the resulting wiring through cook_contracts::unit_graph::plan — a parse-only fixture cannot observe any of this, so the assertions live in the fixture’s Lua and fail it by error(msg, 0). negative/after-unit-registered-later pins the forward-reference diagnostic with a register_error.txt; it is a register-phase rejection over a Cookfile that parses and generates cleanly, so the tree-sitter harness carries it on the semantic-only skip list.

CS-0218 — a chore body runs when it is asked for, and never speculatively [#changes.cs-0218]

Date: 2026-08-09 Version: v0.18 Sections affected: § 7.6 (new §7.6 — the invocation rule, its independence from parameter declarations, the workspace-graph requirement, the registered-but-not-run guarantee, and the force-after-skip requirement; plus informative Note 7.6.1); § 7.5.1 Note 7.5.1.1 (its “its body is not invoked” clause now cites the general rule rather than stating a parametric-only one); § 22.12 (a new normative paragraph telling a module author what body may assume, and forbidding an empty-__params relaxation); App. C § C.26 (new C.26). Reference implementation: cli/crates/cook-register/src/engine.rs (the chore arm of the body-invocation driver; RegisterSessionBuilder::with_reachable_names), cli/crates/cook-plan/src/registers.rs (the workspace requires graph and the per-mode seeds). COOK-344.

Summary. A chore’s body is now invoked if and only if the chore is the dispatch target of the register pass or is reachable from it, whether the edge is static or established by cook.require_recipe. Previously a chore that declared no parameters had its body invoked on every register pass, whatever was being built and whether or not anything was being built at all.

What the old behaviour actually was. The reference implementation’s chore branch had two invocation arms and a skip. A parametric chore was invoked when targeted or reachable, and skipped otherwise — the rule Note 7.5.1.1 already describes. A paramless chore was invoked unconditionally, ahead of the reachability test, on the reasoning that it was cheap to call and that its captured units were wanted “for dep linkage when reachable and for enumeration tools when not”. Neither half held. Dep linkage when reachable is the reachable arm, which the paramless case never got as far as; and no enumeration surface goes through body invocation, because a chore is registered by the call that declares it and a listing needs the registration, not the units. The units a speculative pass captured were built and then dropped by the planner, for every non-target chore of every invocation.

Why it stopped being harmless. CS-0179 gave a chore body cook.cookfile.*, and CS-0176 exists so a module can offer project-management verbs — add a source file, add a link, scaffold a target, reformat a tree. Such a verb takes arguments only sometimes; cc.new and a formatter take none. A paramless one of those spliced, scaffolded or reformatted on every cook build of an unrelated recipe, on every cook test, and on every pass an editor or watcher runs. The parametric verbs were not safer by design, only by the accident that a body which nil-indexes __cook_params cannot be called without argv.

Why not refuse the writing surfaces instead. The narrower repair — run the body, raise if it reaches cook.cookfile.* or a filesystem write while the pass is speculative — was the option this Standard’s editors considered first and rejected. It cannot be a guarantee: a chore body is Lua, and an implementation hosting it on a general-purpose interpreter has handed it that interpreter’s own file and process facilities, so a rule that fences cook.cookfile.append while os.execute stands open governs which door was locked rather than whether the room can be entered — and the reformatting verb is exactly the one that shells out. Its failure mode is also worse than the defect: raised as an error it breaks every unrelated build in a project until someone other than the sufferer fixes the module, and caught-and-discarded it is this entry’s skip reached by a longer route, after half the body has already run. Neither objection would decide the question if skipping cost anything; it does not, because the units were discarded regardless. § C.26 records the full argument.

What changes for authors. Nothing an author writes, and nothing about which commands exist. A chore skipped this way is still registered, still listed by cook list / cook menu, still invocable by name, and its requires still form edges. What changes is that chore clean no longer has its body evaluated while cook build runs, and a module verb no longer acts unless someone named it or something reached it.

Why the graph requirement is normative and not an implementation note. Restating the rule over reachability moves the whole question onto the graph, and the reference implementation registers a workspace one Cookfile at a time — so its per-pass graph stops at the import boundary. The first implementation of this amendment decided locally and was wrong in the ordinary case: chore a: sub.b built a and skipped b, reporting one node and exit 0. The importee’s pass could not have known; the edge is declared by the importer, and the importer registers last. So §7.6 states the graph as the workspace’s and requires reachability to be resolved before the per-Cookfile decision is taken. The reference implementation derives it from the body-free discovery pass it already runs (§ 22.7) and hands each Cookfile the local names that pass says are reachable; each pass then closes over its own requires from those seeds, so the boundary-crossing entry points are all the workspace layer has to supply, and both layers close over the graph with one shared function rather than two agreeing ones. The derivation assumes the discovery pass registers the same names a build does — the assumption a listing surface already makes when it reports what a build would register, and the one § 22.10 keeps true from the finalizer direction by rejecting registration from a callback.

Enumeration passes seed with every recipe. A pass with no dispatch target and no supplied set invokes no chore body, which is right for introspection and wrong for the whole-workspace commands: cook test builds each test-bearing recipe’s closure and cook serve builds a recipe’s, so a chore either reaches through requires must register its units. Those passes therefore seed reachability with every non-chore name — every recipe is a potential target — while a chore no recipe reaches, which is exactly the shape of a module’s project-management verb, is reachable from nothing and is not invoked. This is the same rule with the honest seed rather than an exemption from it.

The force-after-skip requirement is normative for a reason. The skip must not be permanent within its pass. A recipe body may call cook.require_recipe on a chore the seed order already reached and skipped, and the implementation MUST then invoke it: a chore registering zero units while it sits in the build closure is the register/execute disagreement § 22.9 already calls non-conforming, and it is silent. The reference implementation shares one rescue path between the parametric and paramless cases precisely so the two cannot drift.

Backward compatibility. No CACHE_VERSION bump and no cache identity is touched. A chore is never cached (§ 7.4), so no key, no record and no shared artifact can depend on whether its body ran. A Cookfile whose chore bodies were relied upon to run as a side effect of building something else was relying on behaviour no section of this Standard ever granted.

Conformance. One positive fixture is added, chore-body-not-invoked-when-unreachable: a module registering a paramless chore and a parametric one, both of whose bodies raise unconditionally, beside a recipe that reaches neither. The register harness registers a fixture with no dispatch target, so the rule is what makes the pass succeed — before this amendment the same fixture fails, which is the property a register_ok marker can pin without any new harness machinery. The chores are registered through § 22.12 rather than written as surface chore blocks, and that is load-bearing: a surface chore body admits no register-phase step (§ 7.2), so an error(...) written as a > step raises at execute phase in a unit that a chore nobody invoked never runs, and a fixture shaped that way passes whatever the register phase does. The first draft of this fixture was exactly that.

Six tests in cook-register cover the arms at the single-Cookfile seam (skipped when the target is elsewhere; skipped with no target; invoked when targeted; invoked when reachable; invoked when forced; invoked when forced after being seeded and skipped first), and four in cook-plan cover the workspace seam (a chore reachable across an import registers its units; one that is not stays speculative; an enumeration pass invokes the chores recipes require and no others; a skipped chore keeps its name, kind and requires). Four pre-existing chore tests were given an explicit dispatch target: each was asserting what a chore body’s units look like, and each had been getting its body run by the very arm this entry removes.

CS-0217 — every document the explanation emits carries the version [#changes.cs-0217]

Date: 2026-08-09 Version: v0.18 Sections affected: § 17.1.6.6 (one new normative paragraph — the schema-version requirement is per document, and a selector-scoped document reports the same version as the whole-closure one — plus an informative note recording what the singular reading concealed). Reference implementation: cli/crates/cook-cli/src/why_render.rs (render_why_json, which now emits schema_version), cli/crates/cook-graph/src/lib.rs (DAG_SCHEMA_VERSION, whose doc now says what it versions). COOK-420.

Summary. § 17.1.6.6 required the machine-readable rendering to carry a wire-format schema version. It said “the machine-readable rendering”, singular, and the reference implementation emits two documents: the whole closure, and the subset a --unit <pattern> selector names. The first has carried schema_version since CS-0171. The second has never carried one.

Why the singular reading is the whole defect. A consumer of the selector document reads the same per-unit objects as a consumer of the closure document — the same determinants, the same status, the same local_cause — and every structural change either makes has been made to both. Three amendments did so: CS-0173 added pending_inputs and made key nullable, CS-0174 added local_cause and last_cause, and CS-0189 added the recorded-log size. Each of them bumped nothing, because the number they would have bumped is not in the document a selector-using consumer parses. Such a consumer had no way to distinguish a payload that had changed from a payload it had misparsed, which is the one thing a version exists to tell it. That it took three amendments and a crate-charter audit to notice is the argument for stating the rule per document rather than per rendering.

What the rule now says. The requirement is per document. Every machine-readable document the query emits carries the version, and a document scoped to a subset of the units reports the SAME version as the document covering the whole closure — it is that wire format at reduced scope, not a format of its own. Two numbers could only differ by being wrong: the payload is one payload, so a change that breaks one consumer breaks the other. An implementation may not leave a document unversioned on the grounds that a versioned one is reachable from some other invocation.

What changes for authors. Nothing an author writes. cook why --unit <pattern> --format json gains one top-level key, schema_version, whose value is the number the closure document already reported. Strictly additive for consumers: no existing key moves, changes type, or changes meaning, and a reader that ignores unknown keys sees no difference.

Backward compatibility. No CACHE_VERSION bump and no key is affected. This is a read-only query’s presentation; § 17.1.6 forbids it from executing, publishing or writing anything, so nothing here can reach a determinant.

Conformance. No corpus fixture: nothing about the Cookfile language changes, and a parse-only fixture cannot observe a CLI document’s shape — § 17.1.6‘s surface is pinned by the reference implementation’s own tests. cli/crates/cook-cli/tests/why_render.rs gains both_machine_readable_documents_carry_the_same_schema_version, which drives the real binary, reads both documents, and asserts the versions are EQUAL rather than merely present; asserting presence would pass on two independent constants, which is the state the rule exists to forbid.

CS-0216 — a permanently-zero field leaves the key-explanation payload [#changes.cs-0216]

Date: 2026-08-09 Version: v0.18 Sections affected: § 17.1.6.5 (the informative note loses the clause promising the age of the oldest contributing observation). Reference implementation: cli/crates/cook-graph/src/emit.rs (Node::observed_max_age, the fold that filled it, the age clause in the text renderer, the JSON key), cli/crates/cook-graph/src/annotate.rs (UnitFacts::observed_builds_ago), cli/crates/cook-graph/src/lib.rs (DAG_SCHEMA_VERSION 4 → 5), cli/crates/cook-cli/src/pipeline.rs (the sole producer, which supplied the constant 0). COOK-423.

Summary. § 17.1.6.5 permits an implementation to report an observation, and constrains that reporting: “the absence of an observation MUST be distinguishable from an observation of zero”. An observation’s age was never in the normative text at all; it was promised only by the section’s informative note, which is why removing it needs no normative edit. The reference implementation’s cook why --format json carried an observed_max_age on every node that was always exactly 0, not because every observation was current but because nothing measured the quantity at all. It is removed, and § 17.1.6.6‘s bump requirement is honoured.

Why it was permanently zero. CS-0189 replaced position-in-a-local-history with recorded_at, on the reasoning § 17.1.6.5 already states: an observation may be served to a machine with no history of its own, where “three builds ago” names nothing. The field survived the model it described. Its sole producer wrote the literal 0 into every UnitFacts it built, so the max fold over contributors could only ever return 0, and the text renderer’s , up to N builds ago branch was unreachable in every run the implementation can have.

Why removal rather than retention. A key that is always 0 is not a harmless leftover, because the payload documents what 0 means: every contributing unit was timed in the most recent build. A consumer reading it is not told nothing; it is told something false about every node of every run. That is absence rendered as zero, the precise shape § 17.1.6.5‘s second constraint on this reporting forbids, so the field was in breach of the rule governing the surface it sat on, and deleting it is what compliance looks like rather than a tidying.

Wire-format effect. § 17.1.6.6 requires the machine-readable rendering to carry a schema version and requires a structurally incompatible change to bump it. Dropping a key a consumer may be indexing is one, so schema_version goes from 4 to 5. Nothing in the reference implementation reads the payload back: it is a query result written to stdout, with no deserialiser outside the test suite (which reads only schema_version and the units array), no on-disk persistence, and no presence in the cache, so the bump is a courtesy to external consumers rather than a migration for this implementation. The remaining § 17.1.6.5 hedge is unaffected and is the one derived from real data: how many of a node’s units the total actually covers, which is what tells a reader not to trust a sum over three of forty.

No CACHE_VERSION bump. This is a reporting surface. No recorded shape, no determinant, and no cache identity is touched.

Conformance. No corpus case. § 17.1.6.5‘s permission is a MAY and the removed field was one implementation’s optional extra; nothing in the corpus pins the payload’s key set.

CS-0215 — a failure is reported as the author wrote it, and never as its transport [#changes.cs-0215]

Date: 2026-08-09 Version: v0.18 Sections affected: § 24.1 (two new normative paragraphs — the quoted command is the author’s text and is quoted identically by every report; the transport encoding MUST be decoded before display — plus Note 24.1.1 and a correction to CS-0211’s account of which phase supplied the constant). Reference implementation: cli/crates/cook-contracts/src/command_failure/mod.rs (displayed_command() — the one place that says what a reader is shown), cli/crates/cook-engine/src/executor.rs and cli/crates/cook-cli/src/pipeline.rs (the two renderers holding a failure, which now ask it), cli/crates/cook-cli/src/test_reporter/failures.rs and cli/crates/cook-plan/src/registers.rs (the two holding a bare shell payload, which now call shell_block::strip_set_e instead of each keeping a stripper), cli/crates/cook-register/src/capture.rs (register-phase cook.sh walks the stack). COOK-426.

Summary. § 24.1 required a non-zero exit to be reported as a Lua runtime error and, after CS-0211, required the report to be located. It said nothing about the two other things a report is made of: what the quoted command says, and whether what the user sees is a message at all. Both were wrong in the reference implementation, and both were wrong in the way CS-0211 had just documented — one decision, several renderers, no shared answer.

The prelude. § 8.3.1 joins a shell_block’s lines under a set -e prefix, which is the implementation’s line and not the author’s. Four sites quoted a failing command and each decided independently whether to strip it. The final diagnostic stripped, correctly. The test report’s blocked-by line stripped through a private stripper that knew only the set -e\n spelling and therefore quoted the empty block’s bare set -e back at the reader. The duplicate-output diagnostic stripped through a second private stripper that did not require the newline, so a payload beginning set -euo pipefail was quoted as uo pipefail. And the progress line did not strip at all, so every failing block on the screen opened with

command exited with code 1: set -e

one row above a final diagnostic quoting the same failure correctly. The rule is stated over every report rather than over “the diagnostic”, because it was the row nobody thought of as a diagnostic that was wrong.

The transport. A Lua host reports a failure by raising a string, so the reference implementation encodes the structured failure into that string and decodes it where the failure is rendered. It had two producers and one decoding site, and the path without one was reachable from the simplest Cookfile that can fail:

register
cook.sh("false")

which printed COOK_CMD_FAILED:{"line":0,"exit_code":1,"command":"false","stdout":"","stderr":""} and exited. A register-phase cook.sh failure is not a task failure — the run never got as far as a task — so it travelled a path that had no decoder, and the private wire format arrived at the terminal as the entire user-facing output of the build.

And CS-0211’s own rule was unmet on the register side. That "line":0 is not incidental. CS-0211 recorded that the reference implementation’s register phase “walked the Lua stack for the Cookfile frame” while its execute phase supplied a constant; for cook.sh the register phase supplied the constant too. The stack walk it described belonged to recipe registration. CS-0211 fixed the phase it had looked at, wrote the rule requiring both, and left the other half of its own MUST unmet — which is what happens when a rule is verified against the site that motivated it rather than against every site it governs. § 24.1‘s account is corrected accordingly, and register-phase cook.sh now calls the same shared stack walk CS-0211 introduced.

What changes for authors. A failing register-block cook.sh reports Cookfile:LINE: command failed (exit N): CMD where it reported an encoded payload. A call inside a module reports the Cookfile line that entered the module, because the shared stack walk CS-0211 introduced skips the module’s own frames: an author is pointed at the line they can act on rather than into a file they did not write, and never at Cookfile:0. A failing shell_block quotes the body and not the prelude, in every place it is quoted.

Backward compatibility. Strictly additive for authors, and nothing here is a determinant: the quoted text, the location and the encoding are all diagnostic presentation. No Cookfile changes meaning, no key is affected, no CACHE_VERSION bump. The exit code for a register-phase command failure is unchanged (1), though its classification moves from “some other error” to “command failed”, which is what it always was.

Conformance. No grammar change and no parse fixture: nothing here is parse-visible, and a parse-only fixture could not see any of it — which is the corpus rule this entry is an instance of. It is pinned end-to-end against the built binary instead, where the defect lived: a two-line failing cook body whose whole output must contain neither the prelude nor the encoding, and a register block whose cook.sh("false") must report Cookfile:2: with the same two absences, alongside its module-reached counterpart, which must report the Cookfile line of the module call. Each location is pinned as an exact string rather than by forbidding Cookfile:0, because an assertion that only forbids the old defect passes under more than one rule and can therefore certify the wrong one — which is how the first draft of this entry came to describe a behaviour the implementation does not have.

CS-0214 — a tools { } probe’s value is its trigger, computed once [#changes.cs-0214]

Date: 2026-08-09 Version: v0.18 Sections affected: § 22.5.2 (the tools producer bullet: the value’s hash and the fingerprint’s TOOLS hash MUST be one computation; the value MUST NOT depend on an external hashing or path-resolution utility; the unresolvable-name failure MUST hold on a cache hit as well as a miss). Reference implementation: cli/crates/cook-contracts/src/probe/value.rs (TOOLS_IDENTITY_PRODUCE, encode_tools_identity), cli/crates/cook-luagen/src/probe.rs (the lowering), cli/crates/cook-probe/src/eval/mod.rs (the interception). COOK-416.

Summary. § 22.5.2 said a tools { } implementation MUST content-hash the resolved binary and MUST make that hash both the value and the re-run trigger. It did not say the two were the same computation, and the reference implementation read the silence as permission for two. The trigger was computed in Rust — which to resolve, SHA-256 over the bytes, folded into the fingerprint’s TOOLS section (§ 22.5.4). The value was computed by a Lua program the code generator wrote into the lowering, which shelled out to command -v to resolve and sha256sum … | cut -d' ' -f1 to digest.

Two implementations of one identity, in two languages, with two resolvers, reading the file at two different moments in the run, agreeing only because both happened to land on lowercase-hex SHA-256. Nothing tested that they agreed, and neither one’s comments named the other.

Why “they agree today” is not an answer. This identity is not diagnostic text. It is the value a consuming unit seals on, so it composes cache keys on a content-addressed store that is shared between machines (§ 17.1.1). Divergence between the trigger and the value on that path is not a wrong number, it is a machine reusing an artifact built against a toolchain it does not have. And the two halves were already drifting apart in ways nobody had to introduce: CS-0212 gave the Rust half a memo that revalidates so it cannot answer for a binary the run itself rebuilt, a discipline the shell-out has no way to observe; command -v and which do not agree on shell builtins; and the two read the file at whatever moments the schedule happens to put them.

The portability half. sha256sum is GNU coreutils. Stock macOS ships shasum and openssl, not sha256sum, so the emitted producer could not compute anything at all there — a Cookfile feature that fails on a supported platform for a reason no diagnostic explains. The rule is written over the dependency rather than over the tool name, because naming the tool would invite the fix that keeps the shape: probe for sha256sum, fall back to shasum -a 256, and now the digest depends on which of two utilities the host has, which is where a shared key learns to mean two things.

What changes. The lowering stops authoring the identity. tools { } lowers to a reserved produce sentinel — @tools-identity, deliberately not lexable as Lua, mirroring CS-0148’s @files-manifest — and the implementation synthesises the value from the probe’s own resolved inputs.tools, the same name-to-content-hash pairs it has just folded into the fingerprint. Trigger and value become one computation by construction rather than two under an obligation to match, and no shell, no hashing utility and no second resolver is involved.

The unresolvable-name rule moves with it, and gets stronger in the process. It used to live inside the emitted producer, which put it behind the cache: a stored value would serve a probe whose tool had since been uninstalled, and the diagnostic § 22.5.2 requires appeared only on the runs that happened to produce. It is now checked before the cache is consulted, so a host missing a declared tool is told so on every run.

A second failure the deleted producer had by accident, now required on purpose. PATH resolution tests for executability, not readability, so a name can resolve to a binary whose bytes cannot be read. § 22.5.4 folds such a tool as a missing input, which is correct for a fingerprint: a placeholder that collides with every other unreadable tool costs a false MISS, and a false miss is a rebuild. Carrying that same placeholder into the VALUE would be the opposite error. Two machines each failing to read a different toolchain would compose identical value bytes and identical fingerprints, and one could be served the other’s sealed artifact — the exact failure this entry exists to close, arriving through the other door. The shell-out could not reach that state, because sha256sum exited non-zero and failed the probe; the synthesised producer has to be told, so § 22.5.2 now requires the failure and forbids the placeholder, and § 22.5.4‘s row says which of the two scopes it governs.

Backward compatibility. No CACHE_VERSION bump, and the value bytes are unchanged: the synthesised value is the same canonical JSON object, keyed by tool name, each entry { "hash": "<lowercase hex>" }, that the Lua producer built. Because a consuming unit seals on the probe’s VALUE (§ 17.1.1), no consumer’s key moves and no sealed artifact becomes unreachable.

One key does move, once, by construction, and it is the probe’s own: the produce source string is section 3 of the § 22.5.4 fingerprint, so every existing tools { } probe re-fingerprints on first run under this entry, re-produces, and yields byte-identical bytes. That is the same event as editing the probe’s body, it costs a probe evaluation rather than a rebuild, and every unit sealing the probe stays a hit across it.

Conformance. No grammar change and no new fixture: the surface an author writes is unchanged in every particular, and the existing probe-as-tools positive still pins it. The behaviour is pinned where it lives — the value’s canonical bytes against a digest computed outside the implementation, the synthesised value’s hash against the hash the fingerprint folded for the same name, and an unresolvable name against the required diagnostic on a run with a cache wired.

CS-0213 — one name and one sentence per both-phase door [#changes.cs-0213]

Date: 2026-08-09 Version: v0.18 Sections affected: § 24 (a new normative rule in the chapter preamble, binding every section in it), § 24.7.1 (the unknown-referent diagnostic pinned as one sentence across phases, and stated to report the name as the author wrote it). Reference implementation: cli/crates/cook-contracts/src/registration/mod.rs (the door names and the §24.7 sentence), cli/crates/cook-lua-stdlib/src/{member_api,probes_api,var_proxy}.rs (the doors whose whole implementation is shared), cli/crates/cook-execute/src/pool.rs and cli/crates/cook-register/src/{capture,unit_api,dep_output_api,module_loader,var_api}.rs (the two hosts), cli/crates/cook-engine/tests/both_phase_door_agreement.rs (the cross-phase agreement test). COOK-439.

Summary. This chapter’s preamble has always said the behavioural contract is identical across phases. It never said the two phases owe the author the same NAME and the same SENTENCE, and nothing in the Standard connected the register-only guards of § C.6.12 to the helpers they refuse. So each door in this chapter existed twice — once where registration installs it, once where execution does — with nothing but coincidence holding the two spellings together.

CS-0211 already found one instance and fixed it as a fact about cook.sh: a failure located in one phase and anonymous in the other, because the two hosts answered “which line called this” independently. It was never a fact about cook.sh. It is a fact about having two hosts, and every door in this chapter has the same exposure. § 24 states it once instead of once per defect.

What changes, in order of how much of it is new. The genuinely new requirement is the third: the register-only guard of § C.6.12 MUST refuse the same name the register phase installs. Chapter 24 never governed register-only helpers at all, and nothing anywhere said the guard and the installer owed each other a spelling — a guard naming something nothing installs refuses nothing, and the author is told only that they attempted to call a nil value.

The second is a sharpening rather than an addition. “The error model … is identical across phases” reads as raise-versus-return; it does not say the two phases owe the author the same SENTENCE, and § 24.7.1‘s unknown-referent error was written once per phase on the strength of that gap.

The first — one name per door, every phase — is close to a restatement, and is written down anyway. Each §24.x already pairs a Signature with Phase: Both, which implies it. What it does not do is say so anywhere a reader of the implementation would look, and the implication was not enough: the names were spelled independently in both hosts for the life of the project.

What does not change. Where a section states phase-different behaviour, the phases refuse for different reasons and say different things: § 24.4.2‘s execute-phase form is withdrawn (CS-0074), § 24.5.3‘s pair likewise (CS-0200), and var’s read-only refusal names a config block at register phase because at execute phase that advice would be wrong. The rule requires one answer to one question asked twice; it does not require one answer to two different questions.

Why the rule is about diagnostics and not only about behaviour. A diagnostic is the part of a both-phase surface an author actually meets, and it is the part nothing checks. Two implementations of one door can pass every functional test while telling the author two different things about the same mistake — which is exactly the state § 24.7.1‘s unknown-referent error was in, written once per phase in two format spellings close enough to look deliberate and far enough apart that no mechanical check paired them. The first improvement to either wording would have made the two phases disagree, silently, in front of a user.

CS-0212 — a memo may skip a read, not answer for bytes that are gone [#changes.cs-0212]

Date: 2026-08-08 Version: v0.18 Sections affected: § 24.9 (the memoization permission qualified so it cannot swallow the call-time requirement above it; Note 24.9.1’s description of the reference implementation corrected). Reference implementation: cli/crates/cook-cache/src/statmemo.rs (the memo and its revalidation), cli/crates/cook-cache/src/probe.rs (tool_identity, resolve_tool_hash), cli/crates/cook-lua-stdlib/src/tools_api.rs (the door). COOK-414.

Summary. § 24.9 required the call to “resolve and hash the binary as it exists at call time” and then, in the same breath, permitted an implementation to “memoize the hash per resolved path within a single run”. Read together the second clause repealed the first, and the reference implementation took the reading the sentence allowed: a process-wide map from resolved path to hash, populated on first read and never invalidated, justified in its own comment by “one run = one process, so a process-wide memo cannot go stale across builds”.

That justification answers the wrong question. The hazard was never staleness ACROSS runs; it is a binary the run itself rebuilds. Probe units are scheduled work, so a tools input can name a binary an earlier unit produced, and an execute-phase produce body can call cook.tools.id on one. In both cases the memo answered with the identity the binary had before it was rebuilt, and that identity was folded into a probe fingerprint (§ 22.5.4) and into whatever value the body sealed. On a content-addressed store meant to be shared (§ 17.1.1), an identity that describes a file nobody has is not a slow answer, it is a wrong one: two machines whose toolchains genuinely differ can compose the same key.

What changes. The permission is qualified rather than withdrawn. Memoizing is the right thing to do (the same tool is hashed once per probe node, and a toolchain binary is tens of megabytes), so the rule is now about what a memo must DO before it answers, not about whether one may exist: re-check what the host makes cheaply observable about the file, and re-read when that observation has moved. The normative call-time requirement is unchanged; this entry stops the next clause from taking it back.

The qualification is deliberately not written as “the memo cannot answer for content the file no longer has”, which is the sentence this entry first carried. No memo built on a stat can guarantee that, so the absolute form would have been unmeetable by the very implementation it was written to describe, and would have asserted away a residual window on a false-hit path. It is written over the observation instead, and paired with a SHOULD to state what the observation cannot see. An implementation that hides its residual window satisfies a rule that forbids having one; it cannot satisfy a rule that requires naming it.

Revalidation is the discipline, not invalidation-on-write. The reference implementation’s other per-run memo (COOK-306, over mtime) is armed at the start of a run and permanently disarmed by cook’s first write, which is correct there and would be wrong here: a memo over stat cannot revalidate itself, because the stat IS the check it is avoiding, while a memo over a file’s CONTENT can, for one metadata call against a multi-megabyte read. Arm/disarm would also have killed the memo in the workloads it exists for, since the first executed command disarms and the register phase, where module code calls this API, runs entirely disarmed. This is informative; the Standard requires only that the memo not answer for content that is gone.

Backward compatibility. No CACHE_VERSION bump, and the reason is worth stating precisely rather than as “no digest changed”. The hash function, the fingerprint composition and the recorded shape are untouched, and a run in a fresh process computes exactly what it always did. What does change is the VALUE some fingerprints take within a run that rebuilds a tool it also hashes, and those values were wrong. A wrong key going right is a miss against whatever it was wrongly stored under, which is a rebuild; the entries it wrongly composed are unreachable rather than dangerous, because nothing composes them again. There is no reader that must reject an older writer’s record, which is the only thing the version exists to do.

Conformance. No grammar change and no parse fixture: this is neither parse-visible nor register-visible, and the surface of cook.tools.id is unchanged in shape, signature, phase and return value. It is pinned where the behaviour lives, against a memo instance: a file rewritten between two lookups must hash to its new content, including when the rewrite reproduces both the modification time and the length, and a read counter proves the unchanged case is still read once rather than proving it by asserting a stale digest.

CS-0211 — a cook.sh failure is located, and located the same way in both phases [#changes.cs-0211]

Date: 2026-08-09 Version: v0.18 Sections affected: § 24.1 (two new normative paragraphs: a determinable line MUST be carried, an indeterminable one MUST NOT be substituted for, and the two phases MUST NOT differ), App. E (this entry). Reference implementation: cli/crates/cook-lua-stdlib/src/source_line.rs (new — the one stack walk), cli/crates/cook-register/src/capture.rs (now calls it), cli/crates/cook-execute/src/pool.rs (cook.sh asks for the line instead of passing a constant), cli/crates/cook-contracts/src/command_failure/mod.rs (located() — the one place that says zero is the absence of a line), cli/crates/cook-cli/src/pipeline.rs and cli/crates/cook-engine/src/executor.rs (both renderers ask it). COOK-422.

Summary. § 24.1 required a non-zero exit to be reported as a Lua runtime error and said nothing about where the call was. That silence is not neutral, because cook.sh is a both-phase surface: two separate Lua hosts implement it, each can answer “which line called this” on its own, and a section that does not require them to agree is a section that permits them to disagree.

They disagreed. The reference implementation’s register phase walked the Lua stack for the Cookfile frame and reported Cookfile:LINE: command failed (exit N): CMD; its execute phase passed a literal 0. So the same failing command was located when it failed during registration and anonymous when it failed inside a >{ … } body, a lua_line, or a chore body — the phase deciding whether the author was told where to look.

The second half of the same defect: what a producer stores as “no line” and what a renderer does with it are one decision, and it had two answers. The implementation had two renderers reading the same line field. One omitted the location when it was zero; the other printed command at line 0 exited with code N, which is the substitute this entry forbids — a reader cannot tell it from a real line, and a probe produce body’s failure reached the terminal that way. The rule is therefore phrased over what an implementation can determine, not over a sentinel value, and the reference implementation now asks one predicate (CommandFailure::located) at both renderers rather than re-deciding == 0 at each.

Why the rule is phrased over determinability rather than over a value. An implementation cannot always answer. A probe’s produce body is Lua whose chunk is not the Cookfile, so a cook.sh inside it has no Cookfile line to report; the honest answer is no location. The tempting alternative — report the line of the declaration the body belongs to — is worse than silence, because a reader cannot distinguish “line 4 is where this failed” from “line 4 is where the thing containing the failure was written”, and will act on the first reading. The rule therefore forbids the substitute rather than requiring an answer the phase does not have.

Why phase symmetry is stated at all. § 24‘s preamble already says the behavioural contract is identical across phases, but that sentence lists “signatures, error model, working-directory rooting” — and a location inside an error message reads as presentation rather than as the error model, which is precisely how it survived unexamined. Saying it here means the next both-phase surface that grows a diagnostic has a precedent to be measured against, rather than a preamble to be interpreted.

Backward compatibility. Strictly additive for authors: a failure that previously reported no location now reports one, and a failure that previously reported a location is unchanged. No Cookfile changes meaning, no cache key is affected, and no CACHE_VERSION bump is warranted — the line is diagnostic text, never a determinant, exactly as § 8.3.1‘s line tagging is not.

Conformance. No conformance case is added. Per CONTRIBUTING’s spec-first rule, a conformance case is required only if the change is observable from a Cookfile at the parse level; there is no grammar or surface-syntax change here and a conforming Cookfile is spelled identically before and after. The behaviour is pinned by the reference implementation’s tests instead: cli/crates/cook-lua-stdlib/src/tests/source_line_tests.rs (the walk reports the calling line, skips frames from other chunks and reports the line that entered them, matches a chunk name by suffix, and answers nothing when the target chunk is absent) and cli/crates/cook-execute/src/tests/pool_tests.rs (a_failing_cook_sh_in_a_lua_body_reports_its_cookfile_line, and its counterpart pinning that a probe produce body still reports no location rather than a substitute one).

CS-0210 — the dependency edges are the resolution’s edges [#changes.cs-0210]

Date: 2026-08-08 Version: v0.18 Sections affected: § 10.2.4 (independence extended from positions to analyses), § 10.6 (a new bullet beside the existing prohibition on inventing edges from path equality: every name reference creates its edge). Reference implementation: cli/crates/cook-luagen/src/resolver.rs (accessor_ref — the NAME.ACCESSOR split of § 10.2.1 — and recipe_ref), cli/crates/cook-luagen/src/dep_ref.rs, whose output reaches the graph through cook-luagen’s unified requires field. (This entry originally also named cli/crates/cook-plan/src/inferred_deps.rs as a consumer; that module had had no caller since the unified-DAG cut and was deleted at COOK-423.) COOK-415.

Summary. CS-0184 stated that a token means the same thing in every position. It said nothing about the other way a token gets examined: an implementation also reads a step’s placeholders to work out which recipes that step depends on, and that reading was never held to the resolution it is supposedly reading.

In the reference implementation the two had been written twice, and they disagreed. Resolution walks § 10.2 in order, so a dotted token is a builtin only when its suffix is a path accessor: $<in.stem> is an own-input accessor, and $<in.foo> — foo is not in the closed set — falls through to step 2 and resolves as the recipe in.foo, which is a reachable name, because import in ./other is legal (§ 10.2.2 binds recipe declarations, and an import alias declares no recipe). The dependency analyser instead skipped any token beginning in., out. or env. on a prefix test of its own.

What that actually cost, measured rather than predicted. The two halves of the divergence behave differently, and the difference is instructive enough to record:

  • In a body or a chore, ordering survived. The reference implementation lowers $<in.foo> to a cook.dep_output call, and that call independently establishes the per-unit edge § 10.6 permits as a refinement of the coarse one. What was lost is the whole-recipe barrier the name reference is supposed to grant — visible in cook why as dep_order where the rule says barrier. A second mechanism was quietly covering for the first, which is exactly why nobody found this by using it.
  • In a cook output pattern, nothing covered for it. $<in.foo.stem> lowers to a path accessor on the iteration item, not to a cook.dep_output call, so the analyser’s answer is the only thing that puts the producer in the build closure. Against the pre-fix implementation, cook app on such a recipe planned zero nodes, ran nothing, and exited successfully.

The first case is the one that argues for the rule. A build that is correct only because a different mechanism happened to answer is not a build anyone can reason about, and the mechanism that was silent gave no sign of it.

Why this direction is the dangerous one. The neighbouring bullet has long forbidden inventing an edge from path-string equality, and that prohibition is the easier one to live with: an implementation that infers too much produces a build that is over-ordered and slow, and the excess is visible in cook why. An implementation that recognises too little produces a build that is under-ordered, or does nothing at all and says so in a success message. The two bullets now sit together so that neither reads as the whole rule.

A pre-existing non-conformance this entry makes nameable, and does not fix. § 10.2.2 reserves only the four accessors plus in and out as a recipe’s final segment, so out_1 is a legal recipe name — and $<out_1.stem> resolves at step 2 in a cook output pattern (where the reference implementation tries the accessor split before the builtin match, so that dep-driven iteration keeps working) and at step 1 everywhere else. That is a CS-0184 violation older than this entry, and under this entry’s rule it also splits the edge set from the output pattern’s own reading. Reordering the two tests does not fix it; the fix is at the declaration site, in the reserved-segment rule, and it is a language change of its own. Recorded here rather than left for the next audit to rediscover.

Backward compatibility. Almost entirely additive at the graph level: edges appear where a reference already resolved to a recipe and no edge was recorded. Nothing that ordered correctly reorders, and a build that was silently racing becomes correctly ordered — which can change observed timing and can surface a genuine cycle the missing edge was hiding. That cycle is the defect being reported, not introduced.

One class of edge is withdrawn rather than added, and it is the same rule working in the other direction. An import alias is unconstrained text, so import a:b ./other.cook puts a:b.recipe in scope — but § 10.2 dispatches a colon-bearing token to the probe-value path (§ 22.5.7) before it reaches step 2, so that token never substituted the recipe’s output; it reported an undeclared probe key. The old edge pointed at a producer nothing referenced. Deriving the edges from the resolution drops it, which is what “the same answer” means when the resolution’s answer is not a recipe.

No CACHE_VERSION bump. Edges are ordering, not key material: a unit’s key is composed from its command, inputs and determinants, none of which this entry changes, so a newly-ordered build hits the same cache entries in a different order.

Conformance. No corpus fixture. A positive case would have to declare an import alias named in to exhibit the divergence, which pins an incidental permission — that aliases are unreserved — as though it were the rule, and the rule is about where an answer comes from rather than which spellings it covers. What is pinned instead is the agreement itself, at the level where both halves are visible: a test runs the dependency analyser and the resolver over one corpus of idents spanning every step of § 10.2 and asserts they name the same referent, and a second pins that the resolver’s answer to “what recipe does this name” does not vary with iteration mode or declared output count — the property that lets an analyser ask the question without inventing a step context. Both were verified to bite by mutation (restoring the prefix tests fails them).

The behavioural claims in this entry were checked against two builds of the reference implementation — one with the fix, one with the prefix tests restored — over the same project, rather than reasoned from the source. That is what corrected them: this entry’s first draft asserted an unordered build in the body case, and the run showed the build ordered correctly for a reason the draft had not accounted for.

CS-0209 — a chore parameter value is the value that was supplied [#changes.cs-0209]

Date: 2026-08-09 Version: v0.18 Sections affected: § 7.1.2 (new normative paragraph: value fidelity through lowering, and why § 3.1 does not reach an argv-supplied value). § 7.1.2.1, § 7.1.2.2 and § 7.1.3 are covered by reference and are not amended — the new paragraph names the two transformations they already specify as the only permitted ones, and states that a variadic binds per element. Reference implementation: cli/crates/cook-contracts/src/lua_string/ (literal joins escape_double_quoted), cli/crates/cook-contracts/src/registration/mod.rs (door_call, probe_subst_call), cli/crates/cook-register/src/engine.rs (the chore-param prelude), cli/crates/cook-luagen/ (its crate-local escaper deleted). COOK-440.

Summary. Every clause about chore parameters said what a value is — a Lua local, an exported environment variable, a shell-quoted placeholder expansion — and no clause said the value survives being any of them. It has to be said because a bound value is not carried to the body as data: the implementation writes it into a generated Lua prelude as a double-quoted literal, and a value containing a quote, a backslash or a control character is then a value one escaping bug away from arriving as different text. § 3.1 does not cover this. That section governs the bytes of a Cookfile, and an argv-supplied value never appears in one — it can carry a byte the Cookfile grammar itself would never admit. This entry states the requirement where it belongs, in § 7.1.2, for both channels a value reaches the body through.

Not a behaviour change, and the interesting part is why it needed writing down anyway. The reference implementation has satisfied this since COOK-398, and did not before it: cook-register’s chore-param prelude wrote a control character as a numeric escape of one or two digits, where Lua’s decimal escape consumes up to three, so a value carrying such a character immediately before a digit bound as a single different character — silently, in a program that loaded and ran. The escaper cook-luagen used on the same generated program left a carriage return raw, which Lua refuses inside a short literal, so a value carrying one killed the chore while loading a program the author never wrote. A control character reaches a parameter through argv, which admits every byte but NUL; through a declared default, since a Cookfile is UTF-8 text and § 3.1 admits one; or through a Lua-expression default’s return, which admits NUL as well. Both were fixed as implementation defects under a rule nobody had written, which is the shape of finding that this appendix exists to stop recurring: a defect argued against an unstated requirement is a defect whose fix nothing holds in place.

One law, one name. COOK-398 moved the escaping rule to a single function; COOK-440 finished the job by deleting the two crate-local aliases that still reached it under other names, so a grep for the law now finds every consumer, and by giving the call it appears inside — cook.<door>("<value>") — one composer beside the door names it composes, rather than four hand-written copies of fifteen characters of Lua.

Conformance. No corpus fixture, and the reason is structural rather than a judgement call: the conformance harness registers a Cookfile with no target and no argv, so it cannot reach a parameter binding at all, and the prelude the value is written into is loaded on an execute-phase worker, which the corpus has no runner for. A fixture would pin the parse of a chore header and nothing else — the pre-COOK-398 implementation passes that. The fact is pinned instead where argv exists: cli/crates/cook-cli/tests/chore_params.rs runs the real binary with a value carrying a quote, a backslash, a carriage return and a control character followed by a digit, for a scalar and for each element of a variadic. Verified to bite by mutation — restoring either historical defect fails it, one as changed text and one as a syntax error in generated source. The law underneath is separately checked against Lua itself in cli/crates/cook-lua-stdlib/src/tests/lua_string_law_tests.rs, which round-trips a generated literal through lua.load: the escaper’s own unit tests can only agree with whoever wrote them, and both historical defects were written by someone who believed the escaper was right.

No CACHE_VERSION bump. Nothing an emitter writes changes; the generated text is byte-identical before and after.

CS-0208 — a Cookfile splice stops mistaking a long bracket for code [#changes.cs-0208]

Date: 2026-08-09 Version: v0.18 Sections affected: § 22.13 (“Locating without a field grammar” restated as three requirements: opaque strings and comments at every spelling, a top-level key rather than a substring, and the end-of-code insertion point). § 3.9.1 is unchanged and is cited here because it is the rule the grammar half was already violating. § C.25 (why the rules name string literals rather than quoted strings, and why a nested key is refused rather than edited). Reference implementation: cli/crates/cook-contracts/src/lua_scan/ (new home), cli/crates/cook-luagen/src/lua_scan.rs (its copy deleted, now a consumer), cli/crates/cook-cookfile/src/lib.rs, tree-sitter-cook/src/scanner.c (scan_module_call_tail). COOK-403.

Summary. § 22.13 already required that a } inside a string literal or a comment must not close a list, and that field match a key rather than a substring. The reference implementation satisfied neither for half of Lua’s spellings, and that section’s wording was thin enough that “half” looked like conformance. This entry states those rules at the level they have to hold at. Note the asymmetry with the grammar half below: § 3.9.1 was never thin — it has always enumerated both long-bracket forms at any =-level and required the opaque-span state to carry across lines — so that half is a plain implementation defect and nothing there changes.

What was wrong, measured rather than reasoned about. cook_cc.bin({ links = { [[a}b]], "c" } }) spliced "d" produced [[a, "d"}b]] — the entry inserted into the middle of the author’s string literal, in a file that still parsed. A commented-out -- links = { "old" } above the real field was matched as the key, so the entry landed inside the comment this whole layer exists to preserve. defines = { "links=1" } earlier in the same call made the real links unfindable and the call was reported as not-a-list. And { opts = { links = … }, links = … } edited the nested list, producing a file that parses, reads correctly, and links against a list the author never touched.

Three spellings, one fact. The common root was that “where does a string or comment begin and end” was answered three times inside one file at three fidelities: the brace matcher and the insertion anchor each tracked " and ', and the key scan tracked nothing at all. That is why the field-key defects existed alongside two scanners that would have refused them. The answer is now one function, in the lowest crate that can hold it, shared with the other consumer that already needed the same answer.

The grammar changes too, in the same direction. scan_module_call_tail in the reference grammar’s external scanner had the mirror-image gap: a long-bracket string was not opaque, so a } or an apostrophe inside one ran the module-call token off its statement, and a --[[ … ]] comment ran to end of line and swallowed the braces that closed the call. cook-lang — the authority on what a Cookfile means — accepted every one of those files, so the divergence surfaced as cook.cookfile.* reporting a syntax error in a file that has none. The scanner now uses the leveled long-bracket helpers already present in that file for >{ … } bodies, which is where the correct implementation had been sitting all along.

Backward compatibility. No surface is added or removed. Every change is in the direction of an edit that was silently wrong becoming either correct or refused: a splice into a long-bracket literal, a comment or a nested list now edits the intended field or fails naming it, and Cookfiles the reference grammar previously rejected now parse. One shape moves the other way, and it moves toward refusal: an unterminated long bracket inside a module call now runs to end of file, so the file is a syntax error rather than a statement that ended at the next newline. That follows from § 3.9.1‘s cross-line carry, and a span whose end the author never wrote is not a span an implementation should guess the end of. No CACHE_VERSION bump; nothing here participates in a cache key.

CS-0207 — installed modules move to .cook/modules/, and the package directory stops being two things [#changes.cs-0207]

Date: 2026-08-08 Version: v0.18 Sections affected: § 12.1 (step 2 and Example 12.1.1 name the new root), § 12.2 (the candidate list collapses from four .lua paths to two, plus one native path instead of two), § 12.5 (the directory literal), § 24.2 (the “four-path resolution order” clauses restated; the not-found diagnostic), § 27.1.1 (the install layout), § 27.1.2 (removed — the vendoring escape hatch and the cook modules install non-overwrite rule), § C.11 (the locality paragraph rewritten; the hand-vendored-priority paragraph removed; a stale “§7” citation corrected to §12). Reference implementation: cli/crates/cook-contracts/src/layout/mod.rs, cli/crates/cook-lua-stdlib/src/module_loader.rs, cli/crates/cook-cli/src/modules/{driver,lockfile}.rs, cli/crates/cook-cli/src/pipeline.rs (the managed .gitignore block). COOK-431.

Summary. cook_modules/ was two things wearing one directory: the package manager’s install root, and the only legal home for a hand-authored module. No ignore rule can be correct for a directory that holds both, and four symptoms fell out of it — this repository’s own .gitignore carried two contradictory rules for it, one of which silently swallowed a fixture and shipped v0.13.0 against a tree CI rejected (COOK-412, COOK-430); the block cook init writes for users covers cook_modules/lib/ but not the share/lua/5.4/ an installed pure-Lua rock lands in, so a fresh project’s first cook modules install drops somebody else’s source into the user’s repo looking exactly like their own; a Lua file already in the project could not be reached at all; and cook itself kept two hand-authored build modules in the package directory.

CS-0206 took the second thing away: a hand-authored module is now addressed by path and lives where it belongs. This entry takes the first. The install root is .cook/modules/, a child of the build-output root that already holds cache/, probes/ and logs/ — wholly ignorable under the existing .cook/ rule, regenerable from cook.toml + cook.lock, and containing nothing an author wrote.

The four-candidate order collapses; it does not move. § 12.2 probed cook_modules/<name>.lua and cook_modules/<name>/init.lua (hand-vendored, top level) ahead of the share/lua/5.4/ pair, with the same split for native modules. The top-level pair is gone, not relocated: it existed only to let a project shadow a rock by dropping a file next to it, and § 27.1.2 built a whole workflow on that precedence — a project MAY hand-vendor a copy at the top of the tree, and cook modules install MUST NOT overwrite it.

That workflow is withdrawn. It is not a capability lost but a capability subsumed: with source addressed by path, an author who needs a patched module points use at their own file, which is a file they can see, review, and diff — rather than shadowing a name and relying on a search order to notice. Shadowing by precedence has the property that nothing in the Cookfile records that it happened; a reader sees use cook_cc and has to know the search order and inspect the tree to learn which cook_cc ran. A path says which file it means.

So there is no precedence question left to specify. What remains is one lookup with two candidates.

Why under .cook/ rather than a new top-level directory. The alternative on the table was a sibling .cookmodules/. .cook/ won because it is already the answer to “where does cook put what it generates”, and a second top-level entry would make that answer two answers. The cost is stated rather than glossed: rm -rf .cook is an established habit — it is why the persistent probe-value cache deliberately lives outside the project — and it now deletes installed rocks too. That is recoverable by cook modules install off the manifest and the lockfile, which is the same property that made the directory ignorable in the first place; a tree that could not be regenerated would not have belonged under .cook/ at all. The module-not-found diagnostic says so, so that a user who wiped .cook/ reads the remedy instead of inferring it.

A hard cut, not a deprecation window, and the diagnostic is why that is affordable. The alternative was to keep resolving cook_modules/ behind a warning. It was refused on a specific ground rather than on pre-1.0 licence: a dual-read window has to keep cook_modules/?.lua in the search path for the whole of its life, and that path IS the conflation this entry exists to delete. The window would ship the defect it is a window for, and every project adopted during it would be adopting the old shape.

What a window buys is that a user with a stale tree is told what happened rather than left staring at a missing module. That is bought instead by the diagnostic: when resolution fails and a cook_modules/ directory exists in the working directory, the not-found message names the move, names cook modules install, and names the path form. A stale tree can never be silently loaded — only reported — which is the half of a window that was worth having, without the half that was not.

Backward compatibility. Breaking, and deliberately so. A project with an installed cook_modules/ tree resolves nothing until cook modules install repopulates .cook/modules/; the manifest and the lockfile are unchanged, so that is one command and no edit. A project with a hand-authored module at cook_modules/<name>.lua must move the file and switch the declaration to CS-0206’s path form; the two changes are one line each and the diagnostic names both.

No CACHE_VERSION bump. The recorded module-input strings move with the tree — a determinant that was cook_modules/share/lua/5.4/x.lua is now .cook/modules/share/lua/5.4/x.lua — which composes a key nothing is stored under: a cold miss and a rebuild, never a false hit, because every listed path’s content is folded into the key it composes (§ 17.1.1.3). The record’s shape is unchanged, which is what a version exists to reject.

The relocated root stays inside the unit’s working directory, which is the property CS-0204’s confinement depends on: .cook/modules/... strips against the working directory exactly as cook_modules/... did, so an installed module remains a determinant of the units that load it. That is verified rather than assumed, end-to-end against the built binary.

Conformance. No new fixture pins the directory name — a corpus case would assert a location this entry’s own argument says is free to move (§ 12.5 names a lookup, not a contract), which is the mistake CS-0204 refused when it declined to key on the search directory. What is pinned is the lookup: 053-rocks-share-lua-resolution continues to resolve a share/lua/5.4-installed module by name, relocated into the new root, and the layout crate’s tests pin the two-candidate order, the composed package.path / package.cpath, and the not-found diagnostic including its legacy-directory clause. The end-to-end half — a rock-installed module edited between runs rebuilds the unit that loaded it — runs against the built binary.

CS-0206 — a Cookfile may use a Lua file that lives where it belongs [#changes.cs-0206]

Date: 2026-08-08 Version: v0.18 Sections affected: App. A.2 (use_declaration gains use_alias and use_path; the argument-discrimination rule; the use_path constraint), App. A.5 (LUA_IDENT now has two sites), § 5.1 (both forms and both rejection sets; Examples 5.1.1 and 5.1.3), § 12.1 (the two forms, their disjointness, the alias derivation for each, the duplicate-alias rule; Example 12.1.2; Note 12.1.1 rewritten), § 12.2 (the filesystem-sharing advice replaced; the per-Cookfile isolation rule stands), § 12.2.1 (new — the confinement rule and its reason), § 12.3.2 and § 12.3.3 (the key is (working_dir, target)), § 12.5 (two kinds of local module; the stale two-candidate restatement withdrawn), § 24.2 (a path target; containment enforced at the door; the path form binds through this call; Note 24.2.1 on dofile), § C.11.0 (new). COOK-431.

Summary. A Lua file already in a project could not be used from a Cookfile. § 12.5 defined a local module as “a file in the project’s cook_modules/ directory”, and § 12.2 said so on purpose: “sharing module code between Cookfiles happens via the filesystem (a vendored copy, a symlink, or a shared parent)”. So a helper at build/helpers.lua had to be copied or symlinked into a package directory before a use could see it — into the same directory a package manager installs into, which is why that directory could never be given a correct ignore rule (COOK-412, COOK-430) and why this Standard’s own repository had four hand-authored files sitting in one.

use therefore gains a path form: use ./build/helpers.lua, or use fmt ./tools/lua/formatting.lua when the derived alias is not what the author wants. The file stays where it belongs, tracked and reviewed like the rest of the project, reached without a second copy.

Why this is a grammar change and not a lexer relaxation. CS-0035 constrained use_name to LUA_IDENT and stated the reason: the name is dropped verbatim into a local NAME = … binding, so a non-identifier spelling yields malformed generated Lua. That reason still holds, and admitting a path by widening the name production would have re-broken it. The declaration instead gained a second alternative with its own production, its own constraints, and its own diagnostics, so that use my-mod keeps failing as a malformed name rather than being re-read as a path missing a suffix. The discriminator is a trailing .lua rather than “does this fail to lex as an identifier”, because a name admits no . at all: the two alternatives are disjoint by construction and nothing has to be tried and backtracked.

The alias is explicit or derived, and the derivation revives a rule that had no reachable input. With an explicit use_alias the binding is stated. Without one it is the basename minus .lua, put through § 12.1‘s hyphen-to-underscore rewrite — a rewrite that, since CS-0035 rejected hyphens in a name, had described a spelling no conforming Cookfile could write. That was reported as an unreachable normative rule (COOK-436) and it would have been fair to delete it. Deleting it was refused: a file basename passes through no name production and is under no obligation to be an identifier, so use ./build/my-helpers.lua binding my_helpers is exactly what the rule always said, arriving at the first input it ever had. A derived identifier that is still not a LUA_IDENT after the rewrite is refused with a diagnostic naming the explicit form, rather than mangled further.

Two declarations binding one identifier to two different targets are refused, for the reason C.11.1 gives for duplicate import: last-wins would silently redirect every call through the alias, and the mistake is cheapest to see at the line that made it. Two declarations naming the same target are not a conflict; § 12.3.2 already makes the second a memo hit.

Containment is the cache’s boundary, surfaced in the language. A use path is tree-relative, forward-only, not absolute, and — unlike import, which it is otherwise modelled on — may not use the // workspace-root sigil. It may not leave the declaring Cookfile’s own directory subtree, by spelling or through a symlink.

This is more confined than import, and the inversion is the point. CS-0204 keys a unit on the resolved path of every module it loaded, recorded relative to the unit’s working directory so that a reader on another machine can re-hash it against its own tree; a path outside that window has no such form, and folding a host-absolute one into a shared key would name a location the other machine does not have — the machine-inferred identity § 17.1.1 forbids. An implementation that admitted use //build/helpers.lua would arrive at the load with two options, both bad: refuse it anyway, making the grammar a lie, or load it and leave it out of the determinant set, which serves one machine’s answer to a machine whose helpers.lua differs. The second is silent — no warning, no rebuild, no attribution — and is the failure class this Standard is least willing to ship. Refusing the spelling is the same decision taken where an author can act on it. The cost is accepted and named in § C.11.0: a monorepo cannot reach one root-level helper from every member, and must either give each member a copy or publish the logic as a package, which resolves inside the working directory and keys correctly.

The rule is enforced at the resolver, not only at the parser. A use declaration is validated at parse time, but cook.load_module takes a string a body computed, so a check that lived only in the parser would be a check on spellings rather than on the system.

A path-loaded module is a module, and binds through the same door. It gets the register-phase lifecycle, init(), per-VM memoisation, and cycle detection, keyed (working_dir, target) where a name and a path can never collide. It binds by local helpers = cook.load_module("./build/helpers.lua") and not through a resolver of its own — the obligation CS-0205 made normative one entry ago, for the reason it gave: a binding that reaches the module by any other route leaves the module outside CS-0204’s determinant set, in the one place nobody would look.

What was not decided, and why it is written down anyway. dofile and loadfile stay unsanctioned and unbanned; Note 24.2.1 records instead that they are not doors — CS-0204 observes cook.load_module and require, and a body that reads Lua through dofile composes a key with no term for the file it ran. Banning them is a separate surface change with its own blast radius, and the argument for the path form does not need it: before this entry dofile was the only way to reach a file outside the package directory, which made the hazard hard to avoid; it is neither necessary nor excusable now, and the note exists so that reaching past the path form is a legible choice rather than an accident.

Backward compatibility, and the one place it is not purely additive. Every use this Standard ever ADMITTED is admitted now, spelled identically, with identical diagnostics — including all four CS-0035 rejections, which the trailing-.lua discriminator was chosen to preserve. No Lua API removal; cook.load_module accepts a superset of what it accepted. No CACHE_VERSION bump: a path-loaded module enters the determinant set through the CS-0204 machinery unchanged, and the recorded shape is the same relative-path list it already was.

What is NOT additive is that two spellings the reference implementation had been accepting outside the grammar now fail. Its lexer parsed the first token of a use line and discarded the remainder, so use cpp # the C module and use cpp proto both parsed as use cpp — while tree-sitter-cook rejected both, because App. A.2 has never had a production for either. This entry gives the line a second argument position, which makes the discarded remainder meaningful and the divergence untenable: the two readers now agree, and they agree on what the grammar always said. A trailing comment gets its own diagnostic rather than being counted as an argument, since # opens a comment everywhere else in a Cookfile and “takes at most two arguments” would name nothing an author could act on.

Whitespace inside a quoted path is refused for the same reason and was found the same way. App. A.5’s path_segment admits none, and the grammar matched; the lexer’s argument splitter honours quoting so that a path CAN be quoted, and that leniency accepted use h "./my helpers.lua" in one conforming reader and rejected it in the other. A Cookfile whose meaning two readers disagree about is worse than either answer, so the rule moved into the shared path law where both doors reach it.

Conformance. Positive: 063-use-path-form (bare path, quoted path, explicit alias, and a hyphenated basename whose alias is derived — all four in one Cookfile, each pinned by the alias column of its parse.txt; this corpus is parse-only, so what it pins is the derivation, and the binding is pinned where it executes), and 064-use-path-from-execute (017-use-from-execute over the new form, because the two forms reach one binding by different derivations and a codegen that gets one right can get the other wrong). Negative: 065-use-path-dotdot-rejected, 066-use-path-absolute-rejected, 067-use-path-sigil-rejected, 068-use-path-derived-alias-rejected, 069-use-duplicate-alias-rejected, 070-use-name-with-second-argument-rejected, 071-use-path-whitespace-rejected, 072-use-trailing-comment-rejected.

The tree-sitter grammar expresses SIX of those eight rejections syntactically — the .lua discriminator is what makes the name and path token sets disjoint, and a .. segment, a leading /, a // sigil, whitespace and a third argument are all shapes a token cannot take. Two are not syntactic and are recorded on the harness’s semantic skip list, naming the phase that enforces each: the derived alias’s identifier check is a derivation over a basename, and the identical path text is legal one position over (use fmt ./build/9lives.lua), so a grammar that refused the path would refuse a legal Cookfile; and the duplicate-alias rule is a whole-Cookfile check that no per-declaration rule can see. A third obligation, the symlink escape, reads the filesystem and is not corpus-shaped at all.

Every negative in this set uses a well-formed cook step as its recipe body rather than the bare echo unreachable this corpus usually writes. That is not cosmetic. A loose shell command in a recipe body is itself a CS-0134 rejection, so a fixture carrying one is “rejected” whether or not the rule it names exists — and two of these were, until the masking error was removed and deleting the rule under test left them green. The Rust harness’s error-substring check would still have caught it; the tree-sitter harness, which asserts only that a parse failed, would not have.

The containment and keying halves are not parse-visible either, and are pinned where they execute: cook-lua-stdlib’s loader tests for path resolution, normalisation to one memo key, refusal at the door (including a symlink out of the tree), and an assertion that a path-loaded module survives relative_module_paths into the recorded determinant set; cook-luagen/tests/use_alias_execute_binding.rs for the emitted binding across all four execute-phase body kinds plus the register chunk, and for both derivations; and the surface fixture 39-use-path-form, which runs the real binary over a path-loaded module and then edits it and requires the dependent unit to rebuild with the NEW bytes — the same run pinning that an unedited run is a cache hit, so the rebuild bar cannot be met by a unit that rebuilds forever.

CS-0205 — a use alias is bound in every execute-phase body, not just the one that worked [#changes.cs-0205]

Date: 2026-08-08 Version: v0.18 Sections affected: § 12.1 (the visibility list names each execute-phase body kind; Note 12.1.2 new), § 24.2 (the binding obligation restated as a MUST over every execute-phase body kind, required to go through cook.load_module; the reference-gated option and its conditions). Reference implementation: cli/crates/cook-contracts/src/module_binding/ (the law — §12.2 alias derivation, the two door names, the binding expression), cli/crates/cook-luagen/src/use_prelude.rs (the prelude, spelled once), cli/crates/cook-luagen/src/lua_scan.rs (identifier_occurs), cli/crates/cook-luagen/src/{cook_step,test_step,probe,recipe}.rs (the emission sites), cli/crates/cook-lua-stdlib/src/{module_loader,module_observer}.rs (the doors, installed against the same constants). COOK-433.

Summary. § 24.2 said the codegen “prepends the same binding to every execute-phase body unit’s lua_code payload”. Two sites did. Every other execute-phase body kind — a cook step body, a test step body, and a probe’s produce body — grew its own payload emission and none of them carried the prelude, so a Cookfile that declared use greet and wrote test >{ greet.say("world") } died at run time with attempt to index a nil value (global 'greet'), while the explicit cook.load_module("greet") spelling in the same position worked. Resolution at execute phase was never the problem; the binding simply was not there.

Why “every body unit” was the wrong shape of sentence. A single sentence about “every payload” reads as one obligation and implements as N, one per emission site, and nothing counts them. The visibility list in § 12.1 now names the execute-phase body kinds individually, so a new body kind arrives as an amendment to a list rather than as an unstated instance of a generality — and a conformance reader can check the list against an implementation.

The binding MUST route through cook.load_module. Newly normative, and not a restatement of how it happens to be implemented: CS-0204 made module source a determinant by observing the loader and require, the two doors module code can enter through. An implementation that bound the alias by some private path — handing the worker VM a pre-resolved table, say — would produce a unit that used a module while nothing recorded that it had, which is the CS-0204 defect reopened in the one place nobody would look for it. The obligation is written where the alias is bound so the two entries cannot drift apart.

The door is one decision, so it is written once (informative). Two crates answer a use declaration and never call each other: the code generator COMPOSES local foo = cook.load_module("foo"), and the Lua host INSTALLS the function that call names and wraps require, the second door. The composition also carried a hand-inlined copy of § 12.1‘s hyphen-to-underscore rewrite at each emission site. Emitter and consumer of one literal in different crates is the shape a shared kernel exists for, and the failure mode of drift here is not a broken build: a binding composed against a name the loader no longer installs stops passing through the door CS-0204 observes, so the module silently leaves the unit’s determinant set and a content-addressed key that travels between machines is composed without it. In the reference implementation the alias derivation, the two door names (cook.load_module, require) and the binding expression now live in cook_contracts::module_binding; the loader installs under the shared constant, the observer wraps under it, and a test executes the composed binding text against the installed loader and asserts the observer recorded the file, so a side that re-hardcodes the literal instead of importing it goes red (renaming the shared constant itself moves both sides at once, which is the point of having one). What stays with the emitter is framing, not law: the register chunk terminates each binding with a newline, an execute-phase prelude joins them with ; onto the body’s first line, and a Cookfile with config blocks folds the generated-by comment onto the first binding’s line to preserve CS-0126’s source alignment.

A reference-gated binding is conformant, and unconditional binding is refused. The prelude binds a Lua local. A local is reachable only by code that names it lexically — load compiles in the global environment and _ENV lookups never see it — so a body that does not name the alias cannot observe whether it was bound, and skipping it is unobservable rather than a weakening. Binding unconditionally is not neutral: it evaluates the module’s top-level chunk and init() on a worker VM for a body that never asked, which is a runtime error for a module whose top level uses register-only API (§ C.6.12) — so a Cookfile that uses a register-oriented module and has any Lua-bodied step would break — and under CS-0204 it makes that module’s source a cache determinant of every Lua-bodied unit in the Cookfile, so one edit to one module invalidates work that never touched it. That is the same pessimisation CS-0204 refused when it declined to key on the search directory.

The decision MUST be made from the body’s Lua source, treating a free occurrence of the alias — an identifier outside strings and comments that is not the field or method half of an access — as a reference. Both directions of a wrong answer are defects, and neither is the cheap one. A missed reference is this entry’s defect verbatim. A spurious one is not the wasted module load it looks like: it evaluates the module on a worker VM for a body that never asked, so local opts = {} opts.greet = 1 in a test body would fail a Cookfile that worked before, for a use of a register-oriented module — this entry’s own argument against unconditional binding, arriving through the gate meant to prevent it. t.greet and t:greet() are therefore not references, while x..greet.f() is: the dot before greet there is the second half of a concatenation, and an implementation that checks only the single preceding byte gets that case wrong in the reinstate-the-defect direction. In the reference implementation this is one walk shared with the scan that decides a test body’s iteration mode, since both ask the same question of the same kind of source (informative).

Backward compatibility. No grammar change, no Lua API change, no CACHE_VERSION bump. Every behaviour change is from a runtime error toward a working build: a body that named an alias failed before and resolves now. A body that named no alias is byte-identical. Cache keys move only for units that gain the prelude — a one-time miss and a rebuild, never a false hit, since the emitted body text is folded into the key. The prelude is emitted as a single statement glued onto the body’s first line rather than on a line of its own, so an execute-phase error still reports the author’s Cookfile line (§ 19); the two pre-existing sites, which had compensated by subtracting the prelude’s height from the reported line, no longer need to — a subtraction that could underflow for a body near the top of a file with several use declarations.

Conformance. positive/017-use-from-execute is the fixture for this contract and is parse-only, which is how the defect survived: the codegen corpus that its notes deferred to asserts only that lowering does not error, and the runtime check its notes deferred to had been deleted with examples/v03-phase-split in the 2026-07-06 overhaul and never rebuilt. Deferring coverage to a place is not coverage. Both halves now have executed homes, named in the fixture’s notes so a future move updates them: cli/crates/cook-luagen/tests/use_alias_execute_binding.rs asserts the prelude in the payload of all four body kinds (and its absence for an unreferencing body, and that a files { } producer’s reserved produce sentinel is untouched), and the surface fixture 38-use-alias-in-execute-bodies runs the real binary over all four kinds and then edits a module reachable ONLY through the alias, which must rebuild the unit — the same run pinning that an unedited run is a cache hit, so the rebuild bar cannot be met by a unit that rebuilds forever. Two further recipes pin the gate’s precision rather than its presence: a body whose only occurrence of an alias is a table field must run without loading that (register-oriented) module, and a body reaching the alias through .. must load it. All three bars are verified to bite by mutation — reverting the codegen change fails the fixture on this entry’s exact diagnostic, dropping the field-access rule fails it on the register-only-API error, and dropping the .. exception fails it on the nil-global again.

CS-0204 — a module is source, and the units that load it are keyed on it [#changes.cs-0204]

Date: 2026-08-08 Version: v0.18 Sections affected: § 17.1.1 (module sources join the declared-input-contents term), § 17.1.1.3 (new — module source is a determinant; keyed on resolved paths; observed not predicted; judged against itself; folded into the one key), §22.5.4.1 “Module source” (new — the probe fingerprint’s second stage and its reconstruction rule), § 12.3.6 (new — where in the lifecycle the obligation falls, and the register-phase verdict). Reference implementation: cli/crates/cook-lua-stdlib/src/module_observer.rs (the sink and the require door), cli/crates/cook-lua-stdlib/src/module_loader.rs, cli/crates/cook-luaotp/src/pool.rs (per-item drain), cli/crates/cook-contracts/src/{context,cache}/ (the laws), cli/crates/cook-cache/src/check.rs, cli/crates/cook-engine/src/executor.rs, cli/crates/cook-probe/src/eval/. COOK-432.

Summary. Module source was a determinant of nothing. A cook body that called cook.load_module("helper") and wrote what the module returned was served from cache after helper.lua changed — the old bytes, reported as a hit, with cook why naming no cause because the module file was not in the determinant set at all. The same hole ran through a probe’s produce body: § 22.5.4‘s seven fingerprint sections fold the key, the produce source, env, tools, files and upstream fingerprints, and no module source, so a probe’s stored VALUE stayed addressable at an unchanged fingerprint across a module edit.

Both are worse than ordinary staleness. The key is content-addressed and the store is shareable (§ 17.1.1), so a unit keyed without its module contents composes the same key on two machines whose modules differ, and each is served the other’s answer: a poisoned entry escapes the machine that made it. And nothing was visible — no warning, no attribution, no rebuild.

The rule is written over resolved paths, deliberately. The cheap fix was to key every Lua-bodied unit on the module search directory. It was refused twice over. It makes one edit to one module invalidate every unit in the project, including every unit that never loaded it — a cache that stops being worth having exactly when a project is big enough to have modules. And § 12.5 names a lookup, not a contract: the directory it searches is free to move, and a rule written against a location must be rewritten each time it does, while a rule written against the paths resolution actually selected survives untouched.

Observed, not predicted, and through both doors. Which modules a body loads is a fact about the run: the name can be computed, the load can sit behind a branch, and a multi-file module requires submodules its caller never names. So the loader reports what it resolved. It reports on a memo hit too (§ 12.3.2 suppresses re-evaluation, not the dependency — a worker VM is reused across units, and the second unit to ask for a module must be keyed on it just as the first was). And the obligation covers Lua’s own require, not only cook.load_module: require is how a multi-file module reaches its own submodules and the ONLY way a native module can be reached at all, since § 12.5‘s four candidates are all .lua. Observing only the door this Standard names would have keyed cook_cc/init.lua and left every file it requires outside the key.

Register phase is sound, and the entry says why rather than leaving it folklore. A register-phase module reaches a work unit only through that unit’s DECLARATION — emitted command text, declared inputs and outputs, sealed probe keys — and every one of those is already a determinant. Verified rather than assumed: a maker whose emitted command string changed rebuilt on the next run and produced the new bytes. The one register-phase evaluation the argument does not cover is a probe produce body run during a member-source pre-pass, whose result is a VALUE addressed by a fingerprint and not by a declaration; §22.5.4.1 “Module source” binds it on whichever VM evaluates it.

Judged against itself. The recorded module set is compared to its own recorded content, never to a predicted set — the absorb-and-forget rule § 17.3 rule 3 already applies to depfile discoveries. It is sound for the same reason: every way the loaded set can change traces back to a determinant that is already keyed (the body text, a module’s own content, the environment, a sealed probe value). The set is kept in its own recorded list rather than merged into the declared inputs, because a declared input is judged by comparing the record against the DECLARATION and a module appears in no declaration — merged, every module-loading unit would report an input-set change against its own record forever, which is permanent churn rather than a false green but is refused all the same (§ 17.5, CS-0169).

Two levels, twice. A cold reader cannot know which modules a body will load, so both stores gain the candidate-key shape of § 17.3.1: the path sets of prior successful runs are retained under the DECLARED-only key, re-hashed against the reader’s own tree, and the composed full key probed. A set that no longer describes that tree composes a key nothing is stored under — a safe miss, never a wrong hit, because every listed path’s content is folded into the key it composes. For a probe the same shape makes its cache reachable at all: without reconstruction a keyed probe that loads a module would re-produce on every run forever.

Backward compatibility. CACHE_VERSION is bumped 9 → 10, and both halves of the bump are what a version exists to reject: a step record gains a module-record slice that an index written by 9 has no bytes for, and an entry published by 9 sits at a key composed from fewer terms, so a 10 that read either would be reading a record that means something else. Superseded indexes are swept, not migrated (CS-0166). A probe value published before this entry is addressed by a fingerprint identical to its declared one, so probes that load no module keep hitting; a probe that does load one re-produces once and republishes under the folded fingerprint. No grammar change, no Lua API change, and no Cookfile changes meaning: every behaviour change is from a silent wrong answer toward a rebuild, or from an unattributed rebuild toward one that names the module.

cook why gains a cause it could not previously express, module changed: <path>, kept distinct from input changed because it answers a different question — nothing the author declared moved; the code their body ran did.

Known gap. cook why’s SHARED-tier prediction composes a unit’s key from declared inputs alone, so for a module-loading unit it predicts a miss the executor then serves as a hit. This is not new with this entry — the same prediction omits depfile-discovered inputs and has since they existed — but the determinant class is new, so it is recorded rather than left to be rediscovered. Local attribution is unaffected: the miss path runs the real check, so module changed reaches the report.

Conformance. Not conformance-fixture territory: the rule is about cache identity and the observation of a run, neither of which a parse or register fixture can see. Pinned instead beside each law — the observer (both doors, memo hits, failed loads, stdlib names, per-item draining), the record shape and its binary encoding, the check’s module walk, the fold and manifest laws — and end-to-end against the built binary over this entry’s two repro cases: the cook.load_module body, and a keyed probe whose produce body loads a module, both of which must produce the new value on the run after the edit and must be fully cached on a run with no edit at all.

Superseded in part by CS-0244. This entry’s unit-level half — § 17.1.1.3, a work unit is keyed on the module source it loads — is fully implemented and stands untouched, and so is everything this entry says about it: the resolved-paths rule, both doors, the memo-hit obligation, the judged-against-itself rule, the register-phase argument save its closing sentence, cook why’s module changed cause, and the “Known gap” paragraph, which is about a work unit’s SHARED-tier prediction and not about a probe at all. What CS-0244 removes is this entry’s probe-level half, along with the rest of the probe fingerprint: §22.5.4.1 “Module source” (cat.probes.module-source), which composed a probe’s fingerprint with the module source its produce body loaded; the Summary’s closing sentence about the seven fingerprint sections and the probe VALUE they left addressable; the “Two levels, twice” paragraph’s final sentence, the candidate-key reconstruction that made a keyed probe’s cache reachable; the “Backward compatibility” paragraph’s sentence about a probe value addressed by its declared fingerprint; the “Register phase is sound” paragraph’s closing sentence, which excepted a pre-pass produce body from the register-phase argument on the ground that its result was a value addressed by a fingerprint rather than by a declaration — with no fingerprint there is no such exception, and § 22.5.10‘s static-input rule is what governs a pre-pass body instead; and the “Conformance” paragraph’s second repro case, the keyed probe whose produce body loads a module. A probe was never served from a cache after CS-0243, so composing a fingerprint for one had nothing left to address. A reader of this entry should not conclude the whole of it went.

CS-0203 — cook.probes.scope exists at register phase, and the Lua host is spelled once [#changes.cs-0203]

Date: 2026-08-05 Version: v0.18 Sections affected: § 24.4.3 (available on both VMs, stated normatively; the availability banner corrected; per-phase semantics of the scoped view spelled out), § 24.4.1 (the stale nil-on-miss sentence aligned with CS-0152), § 24.2 (informative note extended to the whole loader). Reference implementation: cli/crates/cook-lua-stdlib/src/module_loader.rs (the shared loader), cli/crates/cook-contracts/src/layout/ and src/probe/ and src/probe_key/ (the laws). COOK-412.

Summary. Three cook.probes.scope statements could not all be true: the § 24.4.3 banner said the API is “not available on the execute-phase VM”; § 22.5.8 (CS-0152) normatively required an execute-phase scoped get to report the full prefixed key in its unmaterialised-read error; and §12.7-era module code used the scoped pattern at register phase. The reference implementation resolved the contradiction in the worst available direction: scope was installed on the execute VM (working scoped get, erroring set) and never installed on the register VM — so the register-phase pattern this section defines died with attempt to call a nil value (field 'scope'), an accident of omission nothing declared deliberate.

This entry settles it the way the rest of the Standard already leans: cook.probes.scope MUST be available on both VMs, delegating to each phase’s get/set semantics; the banner’s availability claim is deleted; the §24.4.1 sentence “an unknown key returns nil” — a rule CS-0152 withdrew at its normative home but left standing here — is aligned. The label-colon rule (MUST NOT contain ':'), previously enforced nowhere, is enforced on both VMs from one shared validation.

Defect, and what made it invisible. The register and execute Lua hosts each spelled the whole host surface privately: cook.load_module (register memoized by module name, the worker by (working_dir, name) — the worker matching § 12.3.3‘s letter, the register VM only its spirit), the probe-produce lowering (chunk name and wrapper duplicated to the byte; one drifted line would have made a produce body’s error report different line numbers per phase), and the renamed-cook.cache stub (a character-identical error literal, twice). Divergence between two hand-mirrored copies is exactly where the scope asymmetry hid: neither copy was wrong enough to fail, so nothing compared them. The reference implementation now installs one loader, one lowering, one stub on both VMs, with the pure decisions (memo key, cycle/not-found diagnostics, lowering bytes, scoped-key composition, label validation) defined once (informative).

Conformance. Fixture 37-register-probes-scope pins both halves: a module whose init() writes through a scope view at register phase and whose scoped and full-key reads agree; and a colon label refused with the shared diagnostic. Verified to bite by mutation (dropping the label prefix from the scoped set fails the fixture).

Backward compatibility. Register-phase Cookfiles and modules gain a surface that previously errored; nothing that worked changes meaning. The unified module-not-found and read-failure diagnostics change wording between phases’ previously-divergent texts; no fixture pinned the old wordings. No CACHE_VERSION bump: the loader stores no cached artifacts and probe lowering is byte-identical to both prior copies.

CS-0202 — cook why reports the edges the engine imposes [#changes.cs-0202]

Date: 2026-08-05 Version: v0.18 Sections affected: § 17.1.6.3 (the reported unit-to-unit edges are the execution DAG’s, stated normatively, with the two consequences: declared barriers survive fine coverage; unit-less recipes forward). Reference implementation: cli/crates/cook-contracts/src/unit_graph/ (the wiring law — plan, declared_coarse_deps, toposort_recipes), cli/crates/cook-engine/src/dag_builder.rs (lowering), cli/crates/cook-graph/src/dag_data.rs (rendering). COOK-402.

Summary. The graph renderer behind cook why derived its structure by hand-mirroring the engine’s DAG builder — a second implementation of the barrier, step-group, probe, dep_order and dep-list wiring rules, with no agreement test — and it drifted from the engine twice, in both directions:

  • It still implemented the fine-covered narrowing rule this Standard withdrew (CS-0161, “Rejected alternative”): a declared requires whose producer any unit of the recipe also referenced with cook.dep_order was suppressed from the output. The engine schedules that barrier regardless — the shipped design is strictly additive, pinned by dep_order_does_not_suppress_a_declared_require_recipe_barrier — so the tool reported parallelism that does not exist, in exactly the “why is my build serial” case the query exists to answer. The suppression was also unfixable where it stood: the closure edge map it branched on merges requires with orders (names reached only through fine refs), so it could not distinguish an orders-only dependency (correctly barrier-free) from a declared requires plus a fine ref (must keep its barrier), and used the recipe’s dep_edges as a proxy for both.
  • It recorded no terminal units for a recipe whose barrier ends empty, where the engine forwards the recipe’s own deps’ leaves (leaf pass-through). A dependency routed through a unit-less meta-target (recipe middle : producer with no body) therefore vanished from the output entirely — both the coarse and the fine form.

The fix is structural, not a third derivation. The wiring decision moves to the shared kernel: cook_contracts::unit_graph::plan is a pure function from the registered RecipeUnits of a topologically-sorted closure to the planned graph — the origin of every node (recipe and unit index, or synthesised probe key) and, per dependency, the provenance that imposed it (sequential barrier, group entry, probe consumption, per-unit dep_order, whole-recipe barrier). The engine LOWERS the plan into the Dag<WorkNode> it executes — payload construction, env merging, the presatisfied rule, and the §16.1.1 output-collision gate are all that remain there — and the renderer maps the same plan to display edges. The mirrored wiring rules are deleted from both sides; the renderer’s remaining derivation is the display layer the plan does not carry (file nodes, declared/discovered input edges, producer→consumer data edges, labels, staleness), and it no longer depends on the engine crate at all.

What CS-0170’s suppression wanted (“a barrier in the output denotes a real whole-recipe barrier”) is preserved by construction, in the direction that is true: the coarse dep set handed to the builder is filtered to the recipe’s declared requires by one shared helper (declared_coarse_deps), so an orders-only dependency renders dep_order-only because the engine imposes nothing coarser — not because a renderer cancelled an edge an author declared. That entry’s fine-covered-suppression paragraph is superseded by this one. The same shared filter also closes a third, quieter drift: the why determinant path stamped the merged closure map onto its explanation DAG unfiltered, giving it coarse edges the run path never had.

Backward compatibility. cook why output only; no grammar, Lua API, or cache observable changes, and no CACHE_VERSION bump. The rendered graph gains edges that were wrongly absent (declared-but-fine-covered barriers; dependencies through unit-less targets) and loses none that were rightly present. The wire schema is unchanged in shape (schema 4); consumers see additional edges.

Conformance. The query is a tooling affordance and is not conformance-checked (CS-0170 precedent). Pinned by unit tests beside the law (unit_graph: provenance per kind, additive barrier, pass-through routing, dangling-dep_edges rejection, probe pruning) and on the renderer’s output over the same shapes — and end-to-end in why_render.rs: a declared require_recipe fine-covered by cook.dep_order renders both kinds; a dependency through a unit-less meta-target reaches the real producer. The e2e module’s doc comment had asserted the withdrawn rule as load-bearing while no test exercised it; it now states the additive contract, and the tests exist.

CS-0201 — one probe-key grammar, at every site that names one [#changes.cs-0201]

Date: 2026-08-01 Version: v0.18 Sections affected: § A.3.2 (PROBE_SEG loses . and the segment cap; TOOL_NAME becomes its own production; both charset notes rewritten, superseding CS-0131 and CS-0181), § 22.5.2 (the three-segment rejection becomes an empty-segment rejection; a dotted-key rejection is added; the both-spellings-everywhere requirement is stated). Reference implementation: cli/crates/cook-contracts/src/probe_key/ (new — the one grammar), cli/crates/cook-lang/src/{lexer,disposition,cook_line}.rs. COOK-408.

Summary. Five sites named a probe key and no two agreed:

keyprobe declcook.probe()$<ref>sealingredients
plainyesyesyesyesyes
demo:veryesyesyesyesyes
cc-versionyesyesyesnono
cc.versionyesyesyesnono
cc:find:raylibnoyesyesnoyes

So probe cc-version declared a key that could be referenced from a command and then neither sealed nor consumed, and cc:find:raylib — three segments, and cook_cc’s ordinary case — could not be sealed at all. Sealing a discovered library is precisely the pin a cache-trust story exists to offer, and the flagship module’s own keys were unpinnable.

The two rejection sites also blamed different things for one cause: seal reported “malformed probe ref”, while ingredients reported “unexpected trailing content ‘-version’” because its scanner stopped at the hyphen and called the remainder a trailer.

How it happened. CS-0131 widened the declaration and the sigil scanner to PROBE_SEG; seal and ingredients were not touched. Separately, cook.probe(key) never validated anything, so when modules began minting cc:find:<name> the “at most IDENT:IDENT” cap died without anyone deciding it should. A cap enforced on one of two declaration paths is not a rule.

The grammar. One production, in cook-contracts, used by every site:

PROBE_KEY ::= PROBE_SEG (":" PROBE_SEG)*
PROBE_SEG ::= [A-Za-z_] [A-Za-z0-9_-]*

with two spellings — bare, matching PROBE_KEY, and quoted, any non-empty string — and every site accepting both. The Lua API’s string argument IS the quoted form, so cook.probe("g++") stays legal and no module migrates.

Why . left. It is member access in a probe reference. With . inside a segment, $<demo:cc-version.ver> is either field ver of demo:cc-version or the key demo:cc-version.ver, and nothing in the text separates the readings. Resolving by longest match against the declared set was rejected as a fix: it would make an existing reference change meaning when an unrelated probe is declared. CS-0131 admitted . before references had member access; the features are incompatible and member access is worth more. The diagnostic names member access as the cause rather than reporting a malformed key, because a bare refusal reads as a typo over a spelling an earlier version accepted.

Why TOOL_NAME split off. It was PROBE_SEG (CS-0181), conflating “a segment of a key” with “the name of an executable”. Only the first has the ambiguity, and only the second needs the dot — python3.11 is a real tool name. They are separate productions now.

Conformance. Corpus swept before the change: no bare probe declaration anywhere in standard/conformance, examples, cli/e2e-fixtures or cook-modules uses a ., and the only dynamically-built module keys are cc:find:<library> and cc:compiler:<name>, both PROBE_SEG-safe. Breakage is therefore nil.

Four negatives are retired, because they pinned rules this entry withdraws: cook-disposition-seal-quoted-ref, cook-disposition-seal-triple-colon, probe-triple-colon-name, test-seal-quoted-ref. ingredients-probe-ref-malformed is repointed from a four-segment key to an empty segment, which is the rule that survived. One negative is added, probe-key-dotted-bare, pinning the diagnostic rather than the refusal. One positive is added, probe-key-grammar-register-ok, declaring a hyphenated, a two-segment, a three-segment and a quoted key and then sealing three and consuming one — the four spellings across the three sites that used to disagree. It carries register_ok.txt rather than parse.txt alone, because a parse-only case would pass against an implementation that accepted the spelling and then failed to resolve it.

No CACHE_VERSION bump: a key’s spelling is unchanged, and the keys that were previously unspellable had no cache entries to invalidate.

CS-0200 — cook.export is register-phase, and the clause that said otherwise contradicted itself [#changes.cs-0200]

Date: 2026-08-01 Version: v0.18 Sections affected: § 24.5 (Phase changes from Both to Register only; the permissive per-worker-store paragraph is replaced by new § 24.5.3 stating why), § 12.3.4 (its cook.export MUST is withdrawn), § 12.7.4 (the “all are both-phase” sentence is split). Reference implementation: cli/crates/cook-luaotp/src/pool.rs (install_execute_phase_cook_export now refuses both functions by name). COOK-399.

Summary. The Standard required two things of this channel that could not both hold. § 12.3.4 said a register-time cook.export MUST be observable to an execute-phase cook.import, “[Pinned by CS-0071]”. § 24.5 said an implementation MAY “use a per-worker scratch store when the recipe body is both producer and consumer”, with the minimum bar being that values “round-trip within a single worker VM”, also “[Pinned by CS-0071]”. A per-worker store cannot make a register-time value observable, so the permission and the requirement were mutually exclusive. Neither had a conformance fixture; the citation was to a pin that did not exist.

The reference implementation satisfied the permission and violated the requirement. Its worker VM created an empty Lua table in the globals under _cook_execute_exports and never seeded it from the register-phase store, so an execute-phase cook.import returned nil for every register-time export — not intermittently, always. Verified against the shipped binary: a Cookfile that exports { a = "hello-from-register" } in a register block and imports it from a >{ … } recipe body wrote NIL.

The per-worker store was independently unsound. Units are dispatched from a shared queue (WorkerPool::submit) to whichever worker is free, so “within a single worker VM” is not a scope an author can rely on: two units of one recipe routinely land on different VMs, and an execute-phase export was therefore visible or invisible according to scheduling.

Why withdrawn rather than implemented. Nothing needs the execute-phase half. The transitive-link walk that motivates the whole channel (§ 28.4) runs at register time, because its product is the compile and link command lines, and those must exist before any unit is captured. Reading an export also forces the referent’s recipe body (§ 22.9) — a register-phase act by definition. A corpus sweep found no module and no Cookfile calling either function from an execute-phase body: cook_cc’s record_export and transitive.lua are both register-phase, as is compile_db.lua, which feeds the § 22.10 hook.

This is the treatment § 24.4.2 already gave cook.probes.set at CS-0074, for the same reason: a write into a per-VM store that no other unit can observe is refused by name rather than allowed to half-work. The two APIs are siblings and now agree.

Behavioural change. cook.export or cook.import called from an execute-phase VM raises, naming the function, the phase, and this entry, and pointing at the register-phase target maker or at cook.probe for a value that must be produced at execute time. Previously cook.import returned nil and cook.export wrote into a table nothing else could read. No Cookfile that built correctly changes behaviour: there was no correct execute-phase use, only a silent one.

Conformance. A positive fixture, export-import-register-phase-register-ok, pins the surviving half: a register-phase export round-trips to a register-phase import, and an absent name is nil rather than an error. Verified to bite by mutation. The refusal is pinned end-to-end in cli/crates/cook-cli/tests/worker_vm_api.rs and deliberately NOT in the corpus: the conformance harness stops after register, so an execute-phase claim made there would pass against an implementation that never ran a worker. Pin the fact, not the syntax.

No CACHE_VERSION bump and no key change: exports never contributed to a cache key.

CS-0199 — cook-fingerprint is dissolved; a stratum that never existed [#changes.cs-0199]

Date: 2026-08-01 Version: v0.18 Sections affected: None normative. The Cookfile language and execution semantics are unchanged; this entry exists because the reference-implementation paths cited throughout this appendix no longer resolve, and a reader following them deserves the forwarding address. Reference implementation: cli/crates/cook-fingerprint (deleted), cli/crates/cook-contracts/src/{consumes,context,envkey,evict,hash,pathlaw} and src/cache/{cas,step}.rs (its effect-free half), cli/crates/cook-cache/src/{check,probe,statmemo,resolve,cas_backend}.rs (its acting half). COOK-418.

Summary. cook-fingerprint was created as the home for “hashing law”, a stratum in cook-contracts’ sharing constitution with the dependency budget ”+ xxhash”. The stratum did not exist. The bar that crate enforces is about EFFECTS — tests/layout.rs fails the build on std::fs, std::env, std::process — and says nothing about dependencies; xxhash, sha2 and globset compute and reach nothing. The constitution’s own worked-example list carried hash_str as “law, but xxhash-bearing”, which is the mistake stated in one line.

A crate made for a boundary that is not there fills with whatever is adjacent. This one accumulated the cache’s IO: seven dependencies, remove_file, remove_dir, fs::write into the working tree, three calls to which::which, and a process-global stat memo — inside the crate the table described as pure hashing law. Two costs were live rather than theoretical. evict.rs, whose module doc has said since the CAS-hygiene milestone that it exists “precisely so Cook Cloud can reuse this exact policy server-side, where a client is not permitted to enumerate or delete”, could only be linked by also linking the deletion code. And a reader trusting the budget would call hash_file from a context that must not touch disk.

What moved where. Effect-free to cook-contracts: consumes, context, envkey, evict, hash_str, the path and output-shape rules (pathlaw), the step-index records (cache::step), and the content-addressed store’s keys, artifact metadata, determinant manifests and reserved indices (cache::cas) — all of which have two ends, which is that crate’s admission bar verbatim. Acting half to cook-cache: cache validity and artifact restore (check), tool and probe resolution (probe), the stat memo (statmemo), path resolution against a real tree (resolve), and the CacheBackend trait (cas_backend).

The trait went with the implementations rather than with the law. A trait definition would have passed the purity test; it is the port to the outside world, and a port belongs where its implementations are.

Not fixed by this, recorded so it is not assumed. The stat memo’s arm/disarm invariant is still owned by convention at eight call sites across four crates. It cannot be enforced at the write site: cook-shell, which spawns the commands that write the files, depends on cook-contracts alone and refuses the edge deliberately. Moving the memo relocated the hazard correctly; making it structural is open.

Conformance. No grammar change, no CACHE_VERSION bump, no key change: every moved function computes what it computed before, and the two lexical path normalisers consolidated in flight (lexically_normalize and normalize_lexical, byte-identical in behaviour, private, differently named, no agreement test) were already agreeing. One near-miss is worth recording: the consolidated escapes_base was first written counting .. depth alone, dropping the original’s RootDir/Prefix arm, which is what makes an absolute path count as escaping its base. That arm gates whether a declared input may sit outside the project. A tidy-up during a move is exactly where such a thing is lost, and it is now pinned by a test that says so.

CS-0198 — an export either round-trips or raises [#changes.cs-0198]

Date: 2026-07-31 Version: v0.18 Sections affected: § 24.5.1 (info gains the § 22.5.5 serialisability constraint), App. E (this entry). Reference implementation: cli/crates/cook-lua-stdlib/src/json_codec.rs (new single home), cli/crates/cook-register/src/{export_api,module_loader,probe_value}.rs, cli/crates/cook-luaotp/src/probe_value.rs (COOK-388).

Summary. The § 22.5.5 Lua↔JSON conversion existed five times across three crates. The two copies serving the phases that must agree byte-for-byte — the register pre-pass and the execute-phase worker pool, which serialize probe values into the same store and the same seal_contribution — were manually-synchronized twins; they now share one implementation in cook-lua-stdlib, the mlua-bearing crate both consumers already depend on (the old copy headers rejected only cook-contracts, which stays mlua-free; they predate the CS-0123 stdlib edge and never considered it). A third, weaker walker on the module-export path (cook.export, module-context cook.probes.set) silently mutilated what the validating walker rejects: mixed string/integer keys dropped, array holes compacted, NaN to null, non-UTF-8 lossy-substituted, cycles a stack overflow. It is retired.

Behavioural change. cook.export (and module-context cook.probes.set) now REJECTS an unserialisable info with a Lua error naming the offending path, where it previously published a silently different value. § 24.5.1 previously said the Standard does not constrain info’s shape beyond “a Lua table”; it now requires § 22.5.5 serialisability, because the value crosses VM boundaries as JSON and a constraint the implementation enforces by mutilation is not an absence of constraint. Corpus swept before the change: the only production exporter (cook_cc’s record_export, all vendored copies) publishes a clean shape; no Cookfile or module relied on the lossy behaviour.

Also fixed. The JSON→Lua direction had three impls, one drifted: cook.json_decode/cook.yaml_decode’s walker turned a number outside i64/f64 range into 0.0 where the probe-path twins raise. One walker now serves all callers.

Conformance. The codec suite (union of the two former per-crate suites, plus the two-VM agreement pin) lives beside the single implementation. The repro: a module exporting { includes = {"inc"}, [1] = "positional" } builds silently pre-change and raises cook.export('lossy_target'): non-serialisable value at . (mixed string/integer keys not allowed) post-change. Cache keys are unchanged (keysnap zero-diff): the validating walkers’ bytes are what both phases already produced.

CS-0197 — a fan-out member can finally declare what it reads [#changes.cs-0197]

Date: 2026-07-31 Version: v0.18 Sections affected: § 22.5.10 (two-category discriminator extended; new “member file inputs” paragraph), App. A ingredients_step production, App. E (this entry). Reference implementation: cli/crates/cook-lang/src/{ast,cook_line}.rs, cli/crates/cook-luagen/src/{recipe,cook_step}.rs.

Summary. ingredients <probe> "GLOB"… — quoted glob patterns trailing a fan-out’s probe source — resolve at register time and fold into every member unit’s declared inputs. Before this entry a member-fanout cook unit was keyed on its member and its command text and nothing else: a body reading a file per member (cp src/$<in.id>.txt $<out>) read a file that was in no key, so editing the source served a stale artifact, and two unrelated projects with identical Cookfile text and member data served each other’s artifacts through the shared store. The author could not fix it — the mixed spelling was a parse error (“unexpected trailing content”), $<file:PATH> was refused in this position and then removed outright (CS-0187), and cook.add_unit from a register block was the only escape.

Why this spelling. It is the one the defect report reached for first, it composes the CS-0131 two-category discriminator rather than adding a keyword (bare names the source; quoted strings are globs — the categories were already distinct, so mixing them is unambiguous), and it reuses ordinary ingredient resolution wholesale. The declaration is deliberately coarse: the glob set resolves once per step, outside the member loop, and every member’s key folds the same file set, so an edit to any matched file re-keys all members. Coarse-grained correctness beats fine-grained staleness.

Reserved, on purpose. Per-member precision — a pattern whose member accessors resolve inside the loop, keying each member on only its own file — is a future amendment; it needs the hoisting rule made conditional and a decision about accessor scope in this position. Refusing a body that reads outside its declared set is also out of scope: § 17.1.1 has the engine infer no determinant of its own, and under-declaration remains the author’s ledger everywhere else in the language. What this entry removes is the position where the ledger could not be balanced at all.

Backward compatibility. Additive: the bare form is unchanged, and the mixed form was previously a parse error. No key moves for any existing Cookfile. The tree-sitter grammar does not yet parse the mixed form (the known-stale parity ledger, COOK-260’s family).

Conformance. Fixture 36-fanout-file-inputs pins the defect’s own repro: build, edit src/a.txt, rebuild — the member re-keys and rebuilds where it previously served the stale artifact; an untouched rerun stays cached. Parser unit tests pin the mixed parse, the empty case, and both error shapes (unquoted trailing content, unterminated glob). Against the pre-change implementation the fixture fails at parse with the exact diagnostic the defect report quotes.

CS-0196 — project identity is configured or empty, never the directory’s name [#changes.cs-0196]

Date: 2026-07-31 Version: v0.18 Sections affected: § 17.1.1 (new normative paragraph: project identity), App. E (this entry). Reference implementation: cli/crates/cook-cache/src/cloud_config.rs (project_id_for_keys replaces project_id_or_fallback), cli/crates/cook-engine/src/run.rs, cli/crates/cook-cli/src/pipeline.rs (the register-phase CacheContext is finally wired), cli/crates/cook-register/src/engine.rs (the COOK-359 probe_cache_ctx split collapses back into one context).

Summary. Every registered unit has always carried an empty project_id and the baseline env denylist: register_cookfile accepted a CacheContext and every production caller passed None, so [cloud] project never reached a key and [cache] ignore_env never reached a fingerprint. The blocker was real — the only identity on offer was project_id_or_fallback, which uses the checkout directory’s name, and wiring THAT would have made two clones of one repo under cook/ and cook-fork/ stop sharing, which is precisely the portability the single key exists to provide. The decision this entry records: key-side project identity is the configured name or empty, and never location-derived.

What follows from the decision.

  • Unconfigured keys do not move. Empty was the accidental value; empty is now the specified value. The cook360 key snapshot is byte-identical across the change (1225/1225 lines, zero diff), so there is no CACHE_VERSION bump and no store invalidation.
  • Configuring [cloud] project now means something. Two projects configured with distinct names stop sharing otherwise-identical units — verified to be new behaviour (the pre-change build serves project “globex” an artifact recorded under project “acme”).
  • [cache] ignore_env reaches unit keys, because registered units now compute their env contribution against the configured denylist rather than the baseline.
  • The COOK-359 split heals. register_cookfile’s separate probe_cache_ctx existed solely because wiring the identity-carrying context was unsafe; one context now serves probe pre-pass and unit registration alike.
  • project_id_or_fallback is deleted. The directory-name fallback survives nowhere: display surfaces read namespaces from records, which carry the specified identity.

Backward compatibility. Unconfigured projects: byte-identical keys, no change. Projects with [cloud] project set: their configuration takes effect for the first time — a one-time re-key of that project’s units, which is the configuration finally meaning what it says. Same shape for [cache] ignore_env users.

Conformance. cook-cli/tests/checkout_identity.rs: two checkouts of one workspace under different directory names share entries through a common store (the portability pin), and two distinct configured identities do not share (fails against the pre-change implementation, which serves one project the other’s artifact).

CS-0195 — an output-pattern probe reference renders by the law, at register time [#changes.cs-0195]

Date: 2026-07-31 Version: v0.18 Sections affected: § 22.5.10 (new normative paragraph: probe-value references in output patterns), App. E (this entry). Reference implementation: cli/crates/cook-register/src/module_loader.rs (cook.__probe_subst), cli/crates/cook-luagen/src/{resolver,template,sigil}.rs (Resolved::ProbeRef loses its pre-built access expression; lua_access deleted).

Summary. The last probe-reference position still rendering through Lua’s tostring — a fan-out cook step’s output pattern — now renders through the CS-0192 law. The rendering is register-time by necessity (an output path must be known to register the unit) and resolves against the pre-pass store § 22.5.10 already mandates; scalars render as their canonical token, and a composite, null, or absent member is a register-phase diagnostic instead of a heap address in a declared output path.

Why this was worse than the command case. CS-0192’s motivating defect put nondeterministic bytes on a command line, where the cache never noticed because keys hash pre-substitution text. An output pattern is different: the rendered path is the unit’s declared output — it names the artifact, keys stale-output reconciliation, and lands in the store. A tostring-rendered table put table: 0x… into all of that, fresh every run.

One renderer, and the end of lua_access. With this entry, every position that substitutes a probe value — a cook/test command (worker, CS-0192), a chore step (drain thread, CS-0193), an output pattern (register VM, this entry) — goes through cook_contracts::sigil::subst::substitute over a JSON value. Nothing emits a cook.probes.get(...) source chain any more, so Resolved::ProbeRef’s pre-built access field and cook-luagen’s lua_access — the render half CS-0188’s module note said would stay “in the crate that emits Lua” — are deleted. The register VM exposes cook.__probe_subst(ident), an internal helper the codegen calls; it is not author-facing surface.

Composite rendering: the §9.3 question, answered. A fan-out member field ($<in.FIELD>) rendering a nested value as canonical JSON (COOK-302) is data-shaped: the pattern iterates the data, and JSON is its one stable spelling. A probe reference addresses configuration, and a composite where a path segment is expected is an authoring error; JSON braces and quotes in a filename would be a trap dressed as a feature. Error now also stays compatible with defining a rendering later, which is CS-0192’s own reasoning at this position.

Backward compatibility. Scalar output-pattern references render identically (canonical token; the corpus parity fixture is unchanged). Composite/null/absent references previously produced a heap-address path or a leaked Lua error; both become named register-phase diagnostics. No CACHE_VERSION bump: no working key moves.

Conformance. The fan-out fixture corpus already pins scalar output-pattern substitution (28-probe-sigil-output-parity); a new fixture pins the composite diagnostic and the absent-member diagnostic at register phase, asserting no unit registers and no output path is created.

CS-0194 — the drain thread is a role, and the terminal owns the whole chore body [#changes.cs-0194]

Date: 2026-07-31 Version: v0.18 Sections affected: § 7.3 (drain-thread sentence rewritten from mechanism to property; CS-0193’s exemption paragraph extended to execute-Lua steps), App. E (this entry). Reference implementation: cli/crates/cook-luaotp/src/pool.rs (run_shell_in_worker gains the drain-designated terminal path), cli/crates/cook-engine/src/executor.rs (the OutputLine re-forwarding for chore Lua results deleted), cli/crates/cook-luagen/src/recipe.rs (emit_chore_body_unit exports param env).

Summary. §7.3 said an execute-Lua chore step runs “on the chore’s drain thread (the controlling terminal), not on a worker VM.” The reference implementation ran it on a worker VM anyway — serially, blocked, under the drain window — and the sentence’s mechanism-shaped prohibition hid the defect that mattered: the body’s output split across two destinations. print/io.write bypassed capture and reached the terminal from the worker thread, while the same body’s cook.sh streams were captured into the unit sink and re-emitted through the asynchronous progress channel — a different descriptor, with no ordering between the two. One body, two output paths, interleaving unspecified.

The resolution: designate, don’t relocate. The drain thread becomes a role. During a chore window — where only one body can be running, by the single-drain rule — the implementation designates one execution context as the drain, and a worker VM so designated IS the drain thread. The normative content moves from structure to the observable properties: serial source-order execution with nothing else in flight, and the whole body’s output — print, io.write, and both streams of every cook.sh call — reaching the controlling terminal directly, in call order. A cook.sh call on a drain-designated worker now writes its captured streams through to the terminal at call completion, each to its own descriptor; the spawn stays captured so § 24.1‘s return contract (stdout as a string) is untouched. The engine’s OutputLine re-forwarding for chore Lua results is deleted rather than kept as a dead path: the sink arrives empty by construction.

CS-0193’s boundary moves with it. That entry declared interactive shell steps terminal-owned and left execute-Lua steps outside the paragraph. They now reach the same destination by designation, so the exemption reads whole: the terminal owns a chore body’s output in its entirety, and CS-0188/CS-0189 attribution and observation apply to neither half of it.

Defect fixed under §7.1.2.1, no rule change. That section has always required every declared parameter exported into every shell child spawned by the chore body. Only the shell-step emission carried the env table; a cook.sh child of a Lua step saw no parameters. The Lua-bundle unit now carries the same table. The rule needed no amendment — the implementation needed the rest of it.

Backward compatibility. Chore Lua cook.sh output moves from the progress stream (stderr, unit-labelled, after a channel hop) to the terminal’s own descriptors in call order — strictly closer to what §7.3 promised. cook.sh return values, failure shapes, and non-chore units are untouched.

Conformance. The chore fixture gains a Lua-step leg: a body interleaving print and cook.sh asserts combined output in order (output_order), and a parametric chore’s Lua step proves the param reaches a cook.sh child’s environment. Assertions ride the harness’s captured process output, which IS the terminal’s destination when cook’s stdio is a pipe.

CS-0193 — a chore step is a unit, and the terminal owns its output [#changes.cs-0193]

Date: 2026-07-31 Version: v0.18 Sections affected: § 7.3 (one-invocation-per-step, probe references, output ownership — all new normative paragraphs; Example 7.3.1 corrected), § 7.4 Note 7.4.1 (corrected), § 22.5.7 (uniform-application sentence gains chore shell steps), App. E (this entry). Reference implementation: cli/crates/cook-luagen/src/{template,recipe}.rs, cli/crates/cook-register/src/unit_api.rs, cli/crates/cook-luaotp/src/pool.rs (renderer made pub), cli/crates/cook-engine/src/executor.rs.

Summary. A chore shell_command step becomes what Note 7.4.1 always claimed it was: an ordinary (uncached, interactive) unit. Its probe references follow § 22.5.7 — literal in the captured command, key unioned into probes, substituted per CS-0192 before the spawn — its per-line invocation semantics are stated instead of accidental, and its output is declared to belong to the controlling terminal, a stated exemption from CS-0188/CS-0189 rather than a hole in them.

Probe references were unusable, not different. The chore expansion path rendered $<key:field> as a register-time tostring(cook.probes.get(...)), where cook.probes.get raises “called outside of a module context” unless a prepass happened to hold the key; and the interactive registration arm never scanned for probe keys, so no DAG edge and no demand scheduling existed either way. Zero tests covered the combination. Defining the behaviour as § 22.5.7‘s is therefore a definition, not a migration: there was no working surface to migrate. With the reference literal in the command string, the worker-side substitution of CS-0192 applies unchanged — one renderer, and a table in a chore command is the same typed diagnostic it is in a cook command.

Per-step semantics stated. A chore body has always executed one /bin/sh -c per indented line; nothing said so, and Example 7.3.1 showed a bare cd build followed by an rsync as though state persisted across lines. The three consequences — a failing step stops the body and fails dependents, process state does not persist between steps, each step is one unit of progress — are now normative, and the example composes its cd into the step that needs it. shell_block composition (§ 8.3.1, CS-0190) is expressly scoped away from chore bodies: per-step failure granularity and per-step progress are the chore’s interface, not a missing optimisation.

Why interactive output is exempt from ownership, by statement. CS-0188 gave a unit’s output one owner and CS-0189 recorded an observation for every unit — and chore shell steps satisfied neither, silently, because cache_meta: None skipped the whole record path. The exemption is now stated with its reason: an interactive step’s stdio IS the controlling terminal (§ 16.2 requires the child to see a real TTY, and § 7.1‘s own worked example is fzf), so capturing its streams would break the property that makes it interactive. What an interactive session prints belongs to the session. The road not taken — capture-by-default with a per-step TTY opt-in — was considered and rejected here: it re-introduces the marker CS-0134 removed, inverts § 7.3‘s stated default, and breaks every full-screen tool in every existing chore. If batch-shaped chores later want owned output, that is a surface decision for its own entry, made with a marker design in hand. Execute-Lua steps in chore bodies are deliberately outside this entry (their split print/cook.sh output paths are a known defect tracked separately).

Backward compatibility. Chore probe references: previously an error or a heap address, now working substitution or a typed diagnostic — nothing functioning changes. Per-step rules: behaviour is unchanged; the Standard catches up. Output: unchanged. No CACHE_VERSION bump; chore units still have no cache key.

Conformance. One fixture pins the probe-reference arc end to end (scalar substitutes into a chore step; composite is the CS-0192 diagnostic and later steps do not run), per-step failure (a failing middle step cancels the tail and fails the chore), and cd non-persistence (a bare cd sub on one line does not move the next line). All assertions are by file content or absence, since an interactive step’s output is by definition not captured.

CS-0192 — a probe value substitutes as the value, not as Lua’s opinion of it [#changes.cs-0192]

Date: 2026-07-29 Version: v0.18 Sections affected: § 22.5.7 (substitution rules rewritten), App. E (this entry). Reference implementation: cli/crates/cook-luaotp/src/pool.rs (resolve_probe_sigils).

Summary. Probe-value substitution is now defined over the value’s JSON type (§ 22.5.5): a string renders verbatim, a number renders as its canonical JSON token, a boolean as true/false, and null, arrays and objects in command position are execute-phase diagnostics. The rendering was previously inherited, by reference, from Lua’s tostring.

What the old rule delivered. § 22.5.7 required the value to be “a string (or coercible via tostring)”. Coercible-via-tostring is every Lua value, so the rule excluded nothing and defined nothing; what an author observed was whatever Lua’s C library prints. Measured on cook 0.10.1 (COOK-380):

probe valuesubstituted as
"hello"hello
4242
0.1 + 0.20.3
3.03.0
truetrue
{"a","b"}table: 0x7f1788005660
{k="vee"}table: 0x7f178800db50

The last two rows are heap addresses: two runs of the same build put different bytes on the same command line. The cache did not notice, because a unit’s key hashes the pre-substitution command text — which is also why this entry needs no CACHE_VERSION bump and moves no keys — but the executed bytes were nondeterministic, and an absent member substituted as the four bytes nil, a shell word that runs.

The decision, and its edges.

  • Strings render verbatim — unchanged, and the only case the old rule actually specified.
  • Numbers render as their canonical JSON token, the same bytes any hash of the value folds. This retires Lua’s %.14g (under which 0.1 + 0.2 rendered as 0.3) rather than reimplementing it in Rust, where reproducing it wrong would change command text silently. The observable change is confined to non-integer floats — and a corpus sweep of every published module (cook-modules), the cook examples tree, and dhewm3-cook found no probe returning one; the change is real but currently theoretical. “The bytes in your command are the bytes in your hash” is the invariant worth having.
  • null is an execute-phase diagnostic in command position. The Lua-facing boundary is untouched: cook.probes.get on a materialised null value still returns nil without error (§ 22.5.8, CS-0152), which produce bodies rely on (cook.probes.get(KEY) or { … }).
  • Arrays and objects are execute-phase diagnostics directing the author to a scalar path. Rendering a composite is left undefined on purpose: the motivating case (a flag list) wants -O2 -Wall, consistency with every serialising site wants ["-O2","-Wall"], and silently choosing either trades a loud nondeterminism bug for a quiet quoting bug — unquoted, a shell globs those brackets. An error now can compatibly become either rendering later; shipping a rendering now forecloses the other. When a real consumer wants JSON-in-command (a jq pipeline) or a join accessor, each is its own small entry.
  • Addressing failures (field on a non-object, index into a non-array, absent member, absent index, non-numeric index) are diagnostics naming the placeholder and what was found, never renderings. The absent-member case previously rendered nil.

The read view is preserved. Addressing operates on the canonical value with CS-0157’s per-run tool-path annotation merged in, exactly as cook.probes.get reads it, so $<KEY.NAME.path> keeps substituting where the tool resolves now. An implementation rendering from stored bytes MUST apply the same shape-scoped merge (a hash-bearing entry under the tool’s own name, no author-provided path) before addressing, or the sigil view and the Lua view drift — the disagreement § 22.5.7 exists to prevent.

Migration. Breaking in the table, null, and absent-member cases, all of which previously substituted text no author will have written down: a heap address that changed every run, or the word nil. A build that depended on either was broken in a way it could not see; it now fails with a diagnostic naming the placeholder. Non-integer float rendering changes from %.14g to shortest-round-trip; no published probe value is affected.

Conformance. One fixture, cli/e2e-fixtures/surface/31-probe-subst-json-rendering, pins each rendering row (string, integer, float, boolean, addressed member and index) and each diagnostic (array, object, null, absent member) through the real pipeline, plus unit tests against the law itself in cook-contracts/src/sigil/tests/subst_tests.rs — including the number-token-equals-canonical-bytes invariant. Diagnostics assert on the error text’s placeholder name; rendering cases assert by file content written by the substituted command, not by output matching, because a failure diagnostic echoes the whole composed command and any marker string in it would match whether or not the command ran (the CS-0190 lesson).

CS-0189 — the observation: what a run did, recorded for every unit [#changes.cs-0189]

Date: 2026-07-28 Version: v0.18 Sections affected: § 17.1.1.1 (“what a hit replays” restated around a thing that now exists), § 17.1.5.1 (new §17.1.5.1, normative), § 17.4 (CS-0186’s withdrawal block replaced: rules 3 and 4 reinstated for every unit kind rather than restored for tests), § 17.1.6 (observed history now reads the record rather than scraping retained build logs), App. E (this entry).

Summary. A cache hit gave back bytes, or a verdict, and nothing about what the run printed or how long it took. This records that for every unit, producing and observing alike, and settles the question CS-0186 left open for both at once.

Why it was open. CS-0186 unified the record and shipped it deliberately at parity: a test unit’s hit replayed its verdict and nothing else, exactly as a cook unit’s hit replayed no build log. The removed test-result store was the only thing in the system carrying a payload, and inheriting its behaviour for one step kind would have re-created the divergence that milestone existed to remove. § 17.4‘s rules 3 and 4 were withdrawn rather than left standing, precisely so this could be designed once.

What an observation is. What running a unit was seen to do beyond the bytes it wrote: its output as an ordered, stream-tagged sequence; its duration; and when it was recorded. Three deliberate absences:

  • No outcome field. The record exists only for a run that succeeded, so an outcome on it would have one legal value, and a field with one legal value is a constant that can drift from what it duplicates. § 17.1.1.1 already says the existence of the record is the verdict.
  • No position in a local history. A timestamp, because an observation may be served to a machine with no history of its own, where “three builds ago” names nothing.
  • No stronger ordering claim than the mechanism supports. § 24.1‘s guarantee (CS-0188) is preserved across a unit’s calls, not within one spawn, whose two streams are separately buffered. The recorded shape must not imply an interleaving nobody observed.

Where it lives. The streams are kept out of whatever index an implementation reads wholesale to judge freshness: a chatty unit would otherwise be re-read on every invocation of every build to answer a question no freshness check asks. Scalars may sit there. Retention must be bounded, truncation must be recorded, and an observation should be evicted before the artifacts of its own unit — losing an observation loses a log, losing an artifact loses a build.

Replay is opt-in, and that is the point. An implementation MUST NOT replay by default. 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 exactly the time it saved. CS-0186’s two constraints survive intact and now bound something that exists: a replayed duration MUST NOT be reported as measured, and replayed output MUST NOT be presented as produced in this run.

Rule 4, finally. An observing unit is admitted to § 17.1.3 on the same terms as any other unit, so a result recorded on machine A is fetchable on machine B — the cross-machine portability § 17.4 rule 4 promised in 2026 and never delivered, because the store it described was project-local. The consequence is worth stating plainly rather than discovering: a test can pass because someone else’s run of it passed. That is what content-addressing means, and it is the same trade a producing unit already makes when it restores a teammate’s artifact rather than rebuilding. Where the inference is unsound because a unit depends on something its declaration does not name, the remedy already exists: name it with a seal, or withhold the unit with local.

Backward compatibility. The record schema version bumps, which invalidates existing indexes. That is a cold rebuild, never a false hit. No surface changes.

CS-0191 — a test is a unit with a name [#changes.cs-0191]

Date: 2026-07-28 Version: v0.18 Sections affected: § 22.4 (the test-unit field table: iteration_item removed, its meaning moved to member; the naming paragraph restated), App. E (this entry). Reference implementation: cli/crates/cook-contracts/src/unit/mod.rs, cli/crates/cook-luagen/src/test_step.rs, cli/crates/cook-register/src/unit_api.rs, cli/crates/cook-luaotp/src/pool.rs, cli/crates/cook-engine/src/{executor,dag_builder,why}.rs.

Summary. iteration_item is removed from the cook.add_unit surface; member carries it. CS-0185 made a test register through the one function, CS-0186 made it cache through the one record, and this finishes the sentence at the runner: a test unit runs a command or a Lua body exactly as a cook unit does, and what remains distinguishing it is the name it is reported under.

The surface change. The field table carried one row, iteration_item, already described as “the test-reporting spelling of member” with the note that an implementation MAY carry one field for both. Permitting two spellings of one fact is what let the reference implementation emit both, from the same expression, in the one arm where a test unit had a data member at all:

cook.add_unit({ step_kind = "test", …,
iteration_item = cook.member_to_string(item),
member = cook.member_to_string(item) })

The MAY is now a MUST. There is one field.

What went with it, and why none of it is observable. The reference implementation carried a dedicated internal payload for test units. Beyond iteration_item it held two fields the language cannot set:

  • timeout, hardcoded at u64::MAX since CS-0135 removed the modifier that set it, making the kill loop that read it unreachable. That unreachable loop was the only reason a test spawn drained its pipes by hand rather than through the one primitive CS-0188 introduced.
  • should_fail, hardcoded false on the same terms, with inversion written into the body instead (test { ! grep -q … }).

Their tests asserted timeout == 30 against a value built by hand, which no register path could produce. A field the surface cannot set is not a feature awaiting a caller; it is a constant with a struct around it.

TestOutcome::TimedOut and its event survive as unreachable-by-construction rather than being deleted, because reinstating a time bound is a language decision and this entry does not make one.

Backward compatibility. Breaking only for a caller passing iteration_item to cook.add_unit directly, which no published module does — codegen was the only producer. Nothing about a Cookfile changes: test steps, their fan-out, their identities and their labels are all as they were.

Conformance. 25-member-fanout-test-tracks-its-producer gains an assertion on the per-member label, which is the one observable this entry moves between fields.

The rest of the corpus does exercise the paths this touches — fan-out caching, cached replay, failing-test reporting, blocked dependents — and all of it passes unchanged. But it asserted counts (2 cached, 1 failed), and counts would still pass if every member of a fan-out reported under the same name. The discriminator itself was pinned nowhere, which is precisely the field being collapsed.

CS-0190 — a shell_block is one invocation, and says so [#changes.cs-0190]

Date: 2026-07-28 Version: v0.18 Sections affected: § 8.3.1 (new §8.3.1, normative), App. E (this entry). Reference implementation: cli/crates/cook-contracts/src/shell_block/mod.rs (new), cli/crates/cook-luagen/src/{cook_step,test_step}.rs.

Summary. A { … } body denotes one shell invocation, composed by joining the block’s lines with LF under a set -e prefix and passing the result to a single /bin/sh -c. The reference implementation has always done this. The Standard has never said so.

Why it needed saying. It decides three things an author can observe, and all three were only inferable from an example:

  • whether a failing line stops the block (it does, and that is set -e);
  • whether a cd, a shell variable or a trap on one line survives to the next (it does, because there is no second process to lose it);
  • whether the block counts as one spawn or several when its output is attributed, which § 24.1‘s CS-0188 ordering guarantee is stated over.

What existed instead was § 9.3‘s worked example showing a command field of set -e\ngcc -c main.c -o build/main.o, and a historical note in § 15.2 describing a retired mechanism that joined adjacent shell lines “into a single set -e-prefixed cook.sh call”. A rule that decides whether a build stops at its first error should not have to be reconstructed from a worked example of something else.

Why it is in cook-contracts. The rule was implemented twice in cook-luagen, byte-identically, once for cook bodies and once for test bodies, differing only by a parameter one copy ignored. It is a pure function of the block’s lines with no side effects, and it is a decision every part of the system has to agree on, which is the test this crate exists to apply. Two copies of a rule this load-bearing are two chances for a build to stop meaning what its author wrote.

COOK-391 completed the law: the chore-path composer in cook-luagen/recipe.rs (a third, hand-rolled copy — the one that actually reached /bin/sh for chore shell runs) now routes through compose(); the display-side prelude strippers in cook-cli and cook-engine collapsed into one strip_set_e beside compose() as its stated inverse (with the empty-block edge — compose(&[]) has no trailing LF — given one answer and a round-trip test); and WorkPayload::display_name, which filtered any set -e LINE anywhere, now strips exactly the prelude.

Backward compatibility. None needed: no behaviour changes. This states what was already true and removes the second implementation of it.

Conformance. One fixture, cli/e2e-fixtures/surface/30-shell-block-one-invocation, pinning each of the three consequences separately through the real pipeline: a Cookfile, codegen and a real spawn, rather than by composing the shell text by hand and asserting on it. The corpus does exercise multi-line bodies throughout, but only incidentally — nothing in it would have failed had the rule silently become one invocation per line.

The abort case asserts by file absence rather than by output matching, because a failure diagnostic echoes the whole composed command: any marker string on the unreached line appears in the output whether or not that line ran. A file exists only if something created it.

CS-0188 — a unit’s output has one owner [#changes.cs-0188]

Date: 2026-07-27 Version: v0.18 Sections affected: § 24.1 (both streams captured and attributed to the calling unit; ordering across calls; the “streamed to the worker’s stderr” sentence replaced), § 22.5.7 (the “rewrite the command into a Lua step” mechanism mandate withdrawn; CS-0127’s chunk-sandbox clause withdrawn with it; Example 22.5.7.1’s closing sentence restated), App. E (this entry). Reference implementation: cli/crates/cook-shell (new), cli/crates/cook-contracts/src/{output,sigil}/mod.rs, cli/crates/cook-register/src/{capture,unit_api}.rs, cli/crates/cook-luagen/src/sigil.rs, cli/crates/cook-luaotp/src/pool.rs, cli/crates/cook-engine/src/{executor,verify}.rs.

Where the reference implementation puts the substitution (informative). It resolves each reference in the execute-phase worker, immediately before the command is spawned, by reading through cook.probes.get and rendering with Lua’s own tostring. Nothing is generated: the worker holds both the resolved probe store and a live VM, so it needs no source to evaluate. The rendering is left in Lua on purpose. It is observable — the result lands in the command a build runs — and Lua’s number formatting is not what a Rust reimplementation would produce (%.14g renders 0.1 + 0.2 as 0.3, and an integer-valued float comes back 3.0 rather than 3), so reproducing it independently would be an opportunity to change command text silently. § 22.5.7‘s substitution rules are unchanged by this entry, and what they ought to say — in particular that a table-valued probe currently interpolates a heap address, which differs between runs — is deliberately left open.

The parse of a $<IDENT> placeholder moved to cook-contracts in the same change, leaving the cook.probes.get(...) render in cook-luagen. CS-0074 had argued against exactly that split, on the ground that the two are one concept because every consumer emitted Lua. Withdrawing the rewrite makes that false: the register phase no longer emits Lua for a probe reference, and the worker needs the parse with no render at all.

Summary. Running a shell command was implemented six times and cook.sh twice, and the copies disagreed about what a unit’s output is. This entry states the observable rule once (both streams are captured, both are attributed to the unit that produced them, and the order of a body’s calls is preserved) and withdraws the one place where the Standard had specified a mechanism whose only observable effect was to break that rule.

What was actually wrong. § 22.5.7 required an implementation to rewrite a probe-referencing command into a Lua step calling cook.probes.get. § 24.1 said a cook.sh call’s stderr is “streamed to the worker’s stderr”. Put together, in the reference implementation, they produced this:

probe tool:path
    { printf '/bin/echo' }

recipe plain
    cook "out/plain.txt" { echo "OUT"; echo "ERR" >&2; touch $<out> }

recipe sigil
    cook "out/sigil.txt" { $<tool:path> "OUT"; echo "ERR" >&2; touch $<out> }

plain prints both lines. sigil prints nothing at all. Both steps run and both write their output file. The mandated rewrite sends the second down the Lua path, where the successful call’s stderr was discarded rather than streamed anywhere and the chunk reported no captured lines, so the presence of a probe reference in the command text decided whether the step could be heard from.

Neither half was a language promise. The rewrite was a mechanism prescription in a section about substitution; the stderr sentence named a destination rather than an owner, and a destination is the wrong thing to name, because the worker’s stderr is one file descriptor shared by every unit running in parallel and bytes on it carry nothing identifying their producer.

Attribution, not destination. § 24.1 now requires both streams to be attributed to the calling unit and to reach the user’s diagnostic channel carrying their stream of origin. Ordering is stated with the limit that the mechanism actually admits: a body calling cook.sh three times reports those calls in order, while within one call stdout and stderr are separately buffered, so their relative interleaving is not recoverable and MUST NOT be presented as though it were. Recovering it would mean a merged pipe or a pty, which would destroy the stdout/stderr separation that a JUnit system-out / system-err split needs.

What does not change. Every observable rule of § 22.5.7 survives: the placeholder forms, the colon discriminator that separates a probe reference from a register-time sigil, CS-0122’s requirement that a reference reach register-time capture as literal text in a string-valued command, and the malformed-placeholder diagnostic. Substitution remains execute-phase, because a probe’s value is. What is no longer specified is how an implementation gets the value into the command.

CS-0127’s clause that the rewritten chunk must run under the originating step kind’s sandbox policy is withdrawn with the rewrite. It narrows nothing on the way out: the Lua sandbox confines what Lua may do, never what a command spawned through /bin/sh may do, so a probe-referencing command was never more confined than the plain command beside it.

Backward compatibility. No surface changes and no migration. Cookfiles that produced no output now produce theirs. A conforming implementation that already satisfied § 24.1‘s streaming sentence honestly satisfies the new rule by adding attribution.

No CACHE_VERSION bump: command_hash composition is unmoved, and an implementation MUST keep it tied to one side of the substitution, resolved or unresolved, consistently. A unit’s key must not depend on which mechanism resolved its placeholders.

Conformance. One fixture is added, pinning the defect above: two cook steps whose commands differ only in whether one mentions a probe MUST report the same output, on both streams, each attributed to its own unit. It asserts what a run PRINTS rather than what a Cookfile parses to, so it lands in the surface end-to-end harness (cli/e2e-fixtures/surface/28-probe-sigil-output-parity) rather than in standard/conformance/, whose corpus is parse, codegen and register-phase. Nothing is added to the tree-sitter skip list, because nothing here is a parse-level claim.

The same fixture pins the one thing CS-0188 deliberately does not make uniform: a probe’s shell producer lowers to a cook.sh call whose stdout is the probe’s value (§ 22.5.2), so that stdout is not also logged. Its stderr is, and had nowhere to go before. The rule is not about probes as a step kind — it is that a stream consumed as a value is not log output — and an implementation observes it wherever that is true.

CS-0186 — one record for every unit; the test-result store is removed [#changes.cs-0186]

Date: 2026-07-27 Version: v0.18 Sections affected: § 17.1.1 (the definition of a cacheable unit no longer requires a declared output), § 17.1.1.1 (new §17.1.1.1: effect kinds; the identity of an output-less unit), § 17.1.1.2 (new §17.1.1.2: a declared input is a path or a pattern; late resolution; consumes, moved here from § 17.4 and generalised), § 17.1.3 (non-publication follows from the empty output list, not from the step kind; pinned is inert on an observing unit), § 17.4 (dissolved to what is specific to tests; rules 1, 3 and 4 withdrawn or moved), § 8.6 (the differentiator restated as the reporter), § 22.1 (inputs entries classified; consumes listed here), § 22.4 (consumes no longer a test-unit addition), § 22.6 (new §22.6; former §22.6 onward renumbered to §22.7–§22.13), App. E (this entry). Reference implementation: cli/crates/cook-cache/src/test_cache.rs (deleted), cli/crates/cook-engine/src/{run,executor,why}.rs, cli/crates/cook-register/src/unit_api.rs, cli/crates/cook-fingerprint/src/lib.rs, cli/crates/cook-luagen/src/test_step.rs.

Summary. A test unit is cached by the same record, under the same rules, in the same store as every other unit. The parallel test-result store is removed. This is the second half of CS-0185: that entry gave every unit one registration function, and this one gives every unit one cache.

A cacheable unit is not one that declares outputs. § 17.1.1 opened “A cacheable work unit — a step with at least one declared output — has exactly one cache key”, and that parenthetical was a definition. It made declaring outputs the criterion for being cacheable, which is precisely the inference CS-0185 removed at the registration surface: an empty output list and being a test are independent facts. The criterion is now that the unit’s register-phase record enables caching (§ 17.1‘s last paragraph, unchanged), and the output list decides something else entirely — what a hit gives back.

Two effect kinds, derived and never stored. A unit that declares outputs produces: a hit restores their bytes. A unit that declares none observes: a hit replays its verdict. The kind is a function of the declaration and MUST NOT be recorded on the record, because a stored kind can contradict the declaration it came from and there is then no ground on which to prefer either. An implementation MUST reject a record whose evidence contradicts the 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 — a declared terminal output (dist/**) may legitimately resolve to zero files.

Identity. A producing unit is identified within its index by a declared output path (§ 17.5 rule 2), which CS-0169 made claimable by at most one unit. An observing unit has no output path, so it MUST be identified by a digest of its declaration — the input paths it declares and the command text or Lua chunk it runs.

Three exclusions from that digest are normative, and each of them is load-bearing:

  • Not the input contents. An identity that moves whenever the contents move can never be compared against a prior record, because there never is one under the same identity. Every invalidation then presents as a first-ever build, and an implementation offering § 17.1.6‘s attribution can report a key but never a cause. Identity says which unit; the recorded determinants and input records say whether it may be replayed. Conflating them costs the second question its answer.

  • Not the recipe name, and not the source position. § 17.4 already required that moving a test within a recipe, or a recipe between Cookfiles, MUST NOT bust its cache. That requirement is unchanged and now binds the identity as well as the digest.

  • Not a distinguishing tag of any kind beyond the determinants the declaration carries. Where two units in one index are separated by no such determinant they are indistinguishable, and they therefore share one record. This is CS-0169’s rule applied to the case it did not reach: an identity is claimable by at most one unit, and two units that no determinant separates are one unit for caching purposes. Replaying one’s record for the other cannot be observed.

    The effective seal key set is one of those determinants, and the exclusion as first drafted omitted it. Seal values are execute-phase and so genuinely cannot enter a register-phase identity; the key set can, and must, because leaving it out makes test { ./run } seal toolchain and a bare test { ./run } in one recipe indistinguishable. They then share a record whose recorded seal contribution can match only one of them, so each invalidates the other on every run. That is not a false green — the two are separated by a determinant that is recorded, and a mismatched one rebuilds — but permanent churn is the other outcome CS-0169 exists to refuse, and an identity that cannot tell two units apart is where it comes from.

An implementation MUST NOT let an observing unit’s identity collide with a producing unit’s declared output path within one index. How it keeps them apart is implementation-defined.

Note (informative). The reference implementation writes both identities under one convention, <identity>@<env-contribution> with the suffix omitted when that contribution is zero, so the two differ only in what stands as the identity: a declared output path for a producing unit, and for an observing unit : followed by the xxh3_64 of its NUL-separated declared input paths and its command hash. The leading : is what keeps the spaces apart — a workspace-relative output path does not open with one — and the paths are deduplicated before hashing, order-preserving, so that a path reached both through inputs and through a step-group dependency does not change what the unit is.

It replaces <first-input>@<command-hash>, which was weakly unique in three ways that only mattered once output-less units were actually cached: every unit sharing a first input and a command text collided, nothing past the first input reached the identity at all, and a unit declaring no inputs keyed as the empty string plus its command hash. The digest is instead injective over the declaration, so two observing units share an identity exactly when no determinant separates them, which is the case the third exclusion above requires to share a record.

What a hit replays, and what it no longer does. § 17.4 rule 3 required a hit to reproduce the originally-recorded (stdout, stderr, duration_secs, success) quadruple. Only success survives, and it is withdrawn as a separate requirement because rule 2 already implies it: only a passed outcome is recordable, so a hit is a pass.

The recorded streams and duration are withdrawn outright. They were the one thing the parallel store carried that the step index did not, and they are a divergence from every other unit kind rather than a decision anyone took: a cook unit’s hit replays no log and reports no elapsed time. Whether a cache hit should reproduce what a run printed is a real question, and it is a question about every unit kind at once — a restored build log is worth exactly as much as a restored test log. It is deliberately left open here rather than settled for one kind by inheritance from a store that is being deleted. Until it is answered, an implementation MUST NOT report a replayed duration as though it were measured.

A test is a test because of how it is reported. § 8.6 distinguished a test from a cook step by restating § 8.4‘s output rules in the negative — it “reports pass/fail rather than producing artifacts”. With the output list now carrying its own meaning, the two halves of that sentence are separate claims and only one of them is the definition. step_kind selects the reporter: a test unit is a target of the implementation’s test runner, contributes to its pass/fail tally and its process exit status (§ 19.2), and is named in its report. Declaring no outputs is a consequence of what a test is for, not the thing that makes it one — which is what keeps the door open for a later output-less step kind that is not a test, and is why CS-0185 required that an output-less unit MUST NOT be treated as a test.

A test’s inputs are declared, not reconstructed. The entry above unifies the record; this clause unifies what goes into it. § 17.4 rule 1 now requires the implicit iteration source (§ 8.6.1) to be resolved into the unit’s declared input set at register phase, and forbids recovering it afterwards by inspecting the unit’s position in the graph.

The reference implementation did the latter, and it is worth being precise about the cost, because the code looked reasonable. Recovering a unit’s inputs from its DAG predecessors is only sound for units known to be tests — a cook unit’s predecessors include ordering barriers it does not read — so the step kind became a fact the cache had to consult, and one function in the engine existed solely to answer “is this a test”. Having consulted it, the engine then held an input set the declaration did not contain, so nothing downstream could see what a unit was keyed on by reading what it declared: cook why, a verifier and a reader of the record were each looking at half the answer. Resolving at registration keeps the declaration complete and leaves the cache with one rule for every unit, which is the whole of this milestone’s thesis applied one layer up.

Per-item folding reaches every iteration source, which fixes the Standard’s own example. CS-0182 required each unit of a one-to-one fan-out to fold only its own item, and scoped that to an ingredients source, explicitly excluding the case where the source is a preceding cook step’s outputs. § 8.6‘s Example 8.6.1 — ingredients "src/*.c", cook "build/$<in.stem>", test { ./$<in> } — is exactly the excluded case, and measured on the reference implementation both of its test units recorded all four paths, both binaries and both sources. Editing one source re-ran every test unit in the fan-out. That is the defect CS-0182 was written to name, surviving in the shape the section uses to teach the feature.

The exclusion rested on a prohibition — an upstream output MUST NOT be reclassified as the unit’s own input in order to narrow it — and the reasoning 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 one binary. Declaring it is not a reclassification; it is the declaration finally being accurate. The prohibition is withdrawn for the one-to-one case and retained where the fold is genuinely wholesale, which is single-unit mode.

Declaring the item rather than the ingredient behind it also restores the early cutoff this section’s own note describes. The unit for build/a declares build/a; editing src/a.c rebuilds it, and the test re-runs only if those bytes moved. Folding the ingredient too would re-run the test on every recompile that changed nothing — and, because one ingredient set is shared by the whole fan-out, would re-run every other unit as well.

One new register-phase function. § 22.6 (cook.prior_outputs, §22.6 new; former §22.6 onward renumbered) returns the declared outputs of the preceding output-producing step in the enclosing recipe body. It exists because resolving the iteration source in the lowering is not sufficient: a recipe body’s units do not all come from steps the parser lowered. A module target-maker declares its units by calling cook.add_unit directly, so a lowering resolving the source from the syntax it had just translated saw nothing for them, and a test following such a unit found no source at all — which the reference implementation’s own surface corpus caught. Asking at register phase sees every unit in the body however it was registered.

It takes an optional member, and that argument is what a data-member fan-out needs. A test step inside an ingredients <probe> recipe emits one unit per member, and each of those units reads its own member’s artifact: the unit for member a must declare the outputs of the producing unit for a, and MUST NOT declare the whole fan-out’s. Declaring all of them costs the fan-out its per-member reuse (§ 17.1 observable 5, the defect this entry fixes elsewhere); declaring none of them is worse and is what the reference implementation did — the member made the unit cacheable while the empty input set left it keyed on nothing its producer wrote, so a producer that began emitting a failing artifact was replayed as a pass. That is a false green in the exact feature the member clause above was added to enable, and the surface corpus did not catch it because a fan-out test that passes cold passes warm either way. Where the preceding step gathered rather than fanned out, the argument falls back to that step’s whole output set.

A declared input is a path or a pattern, and which one is not a property of its spelling. § 17.1.1.2 is new, and it exists because unifying the input derivation exposed a defect that predates it in one half and was introduced by it in the other. A unit’s declared entries now include both files it reads and consumed outputs no file yet answers to, so something has to say which is which; deciding it at match time by scanning for *, ? and [ decides it wrongly for every file whose name contains one. app/[slug]/page.tsx is the flagship Next.js routing convention, and a resolved ingredients glob hands exactly that name to the cache as a file it has already seen. Re-reading it as a character class expands it, matches nothing, and drops it — identically on the check side and the record side, so the two agree, the unit reports a hit, and the file it was keyed on has changed. The classification is therefore made once, when the unit is registered, by the phase that knows where the entry came from, and is carried on the declaration. This is the same move as the entry above, one level down: what a unit reads is declared, not re-derived.

consumes narrows an expansion, and never a declared path (amending CS-0175). CS-0175 introduced consumes when a test unit’s inputs and the predecessor outputs it folded were two sets computed by two rules, and its second safeguard — the full consumed set stays the produced-upstream filter, so an excluded artifact cannot re-enter through the test’s own ingredients — was how one rule kept the other honest. With one declared input set there is no second rule, and this entry’s first draft dropped the safeguard on the ground that there was no longer a route by which an excluded entry could re-enter. That was wrong twice over: it left a sentence in § 17.4 requiring both that an exclusion not be defeated by the unit’s own glob and that a file both name be an input, which are opposite requirements; and the implementation applied the filter to the whole resolved set, so a unit declaring src/own.txt alongside a dependency’s dist/** and consuming *.mjs lost src/own.txt from its key altogether — editing a file the author named outright moved nothing. The rule that replaces it holds both halves without contradiction, because it narrows by provenance rather than by set membership: 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 as a path is an input because the unit says it reads it. consumes moves to § 22.1 with it — what it narrows is an input pattern, and nothing about that asks which step kind the unit came from. Its matching base is stated for the first time: the candidate path as the unit declares it, working-directory-relative.

A materialised data member is something to key on. The source-less rule (CS-0138) is generalised from “a test with no ingredients and no consumed output” to “a unit with no declared output, no declared input, and no materialised data member”. Stated over all three it is one rule for every unit rather than a rule about tests, and it fires only where it must: a cook unit always declares an output, so it never fires.

The rule is asked twice, because the answer can change. Registration asks it of the declared list; the ready-time check asks it again of the resolved one. The two disagree in exactly one direction — a declaration that is non-empty and resolves to nothing, which is what a pattern covering a directory its producer left empty does. This entry’s first draft asked it only at registration, arguing that registration settles it; it does not, and the section that argued so had already written down why: a declaration that resolves to the empty set on the consuming side silently under-keys the unit against its dependency. A cook "dist/**" step whose command creates only a subdirectory, followed by a test, gave that test one declared input, no resolved input, and a key over nothing at all — served for the life of the store. The ready-time test is stated over all three terms rather than over the resolved inputs alone, because a unit fanned out over ingredients <probe> may legitimately declare no file and be keyed on its member.

The member clause closes a gap of the same family as the one above. § 17.1 observable 5 has always required per-member independence — “editing one member re-runs only that unit” — and the reference implementation folds the canonical member into the unit’s command hash, so the determinant was present and correct. But the source test admitted only files, so a test step fanned out over ingredients <probe> had no key at all and re-ran on every invocation, while its cook sibling over the same source cached per member. Data-driven fan-out on a test step delivered parallelism and no reuse.

Cross-machine replay is a known gap. § 17.4 rule 4 required a result recorded on machine A to be a hit for the same inputs on machine B. It was never delivered: the removed store was project-local, under the project’s own cache directory, and nothing uploaded or fetched from it. Folding observing units into the step index does not deliver it either, because the shared store is addressed by artifact and an observing unit has none. The rule is withdrawn rather than left standing unimplemented; reinstating it is bound to the payload question above, since a shared observation is the same object as a replayed one. Recorded here so the withdrawal is not mistaken for a narrowing of the cache’s cross-machine guarantee, which is unchanged for every producing unit (§ 17.1.1).

Backward compatibility. Breaking for cached test results, in the safe direction. Every test unit’s identity changes shape, so records written under the old store address nothing the new one looks up: they miss and the unit re-runs. A cold miss, never a false hit. The old store’s files are not migrated — superseded records are deleted, not read (CS-0166) — and an implementation SHOULD sweep them.

Two reported values change for a cached test. Its duration is no longer the recorded original, so an implementation emitting JUnit reports a zero time for it and a suite total that omits it; and a machine-readable report’s captured-output fields are empty. Neither affects a test that actually ran. No surface syntax changes and no Cookfile behaves differently.

Conformance. No grammar change and no parse-corpus fixture: a key composition is not parse-visible, and that corpus pins parse-level AST shape (compare CS-0182). The behaviour is pinned end-to-end instead, by four surface fixtures running the real binary — a declared input whose name contains a glob metacharacter, a data-member fan-out test against a producer that turns bad, a declaration that resolves to the empty set, and a consumes allowlist over a unit that also declares a path of its own. Each asserts the post-fix contract at the step where the pre-fix implementation served a stale hit, and each was written from a reproduction rather than from the rule.

That is worth recording as a method note, because the first cut of this entry shipped with four such stale hits live and a fully green suite: 2542 workspace tests, 296 surface tests, the conformance corpus, and a cache-key snapshot diff that showed no cook-unit key moving. None of them could have caught these. A key snapshot compares KEYS, and every one of the four was a defect in what the key is computed FROM — an input silently dropped from the set is invisible in the digest, and a false green is by construction a test that passes. What found them was reading the input-set computation adversarially and running the repro.

No CACHE_VERSION bump. CS-0185’s conformance paragraph anticipated that this entry would bump it independently; it does not, and the reason is worth stating precisely, because the first statement of it was not.

The bump was expected to be forced by the record growing a place to hold the observation. With the observation withdrawn, the recorded shape is byte-for-byte what it was, and a version exists to reject a shape this build cannot read. That much stands. What does not stand is the claim that the only change is which units write records and under what identity. Producing units’ recorded input sets change too — patterns are expanded where they previously were not, duplicates are dropped, and consumes can narrow — so a binary from before this entry and one from after, sharing a .cook, each judge the other’s record stale and overwrite it. Every run rebuilds; no run serves a wrong result.

The conclusion holds on those terms and not on the earlier ones: the bump is declined because nothing here can produce a wrong answer under an old reader, not because nothing here changed. Mixing binaries across the boundary is correct and slow, which is what a cache is allowed to be. It will be forced by whatever answers the payload question.

CS-0184 — a placeholder means the same thing wherever it is written [#changes.cs-0184]

Date: 2026-07-27 Version: v0.18 Sections affected: § 10.2 (new §10.2.4, position independence). Reference implementation: cli/crates/cook-luagen/src/{resolver,template,sigil,cook_step,test_step}.rs, cli/crates/cook-register/src/unit_api.rs.

Summary. § 10.2 defined resolution as a closed function with no silent fallthrough, and § 9.2 said every well-lexed placeholder resolves or fails loudly. Neither said the answer must be the SAME in every position, because it did not occur to anyone that it could differ. It did. This entry states the rule the closed function always implied, and records the conformance failures that went unnoticed for want of it.

What diverged. The reference implementation shared one placeholder scanner and then answered what a token means in twelve places, five of which never consulted the resolver. The same token in a cook body and a test body gave different answers:

tokencook bodytest body
$<env.HOME>the CS-0172 migration diagnostic”no config block declares ‘env.HOME’; declare it with var.env.HOME = ...”
$<in.bogus>the unresolved-token diagnostica Lua runtime failure, attempt to call a nil value (field 'bogus')
$<dep[in]>the fan-out-only diagnostic”no config block declares ‘dep[in]‘“
$<probe:key>resolved from the probe cache”no config block declares ‘probe:key’”

Two of those diagnostics instruct the author to declare a variable whose name is not a legal variable name, so following the advice cannot work.

Positions are not literal. A cook output pattern carrying no iteration driver was classified “literal” by a check that looked only for $<in…> and $<recipe.ACCESSOR>, and the emitter then read that classification as “contains no placeholders” and emitted the pattern verbatim. cook "out/all-$<suffix>.o" therefore declared an output path containing the raw sigil, which /bin/sh parsed as a redirection: the build wrote a file named out/all-$, reported no diagnostic, and left suffix out of the unit’s key, so changing it rebuilt nothing. Classification says which iteration a pattern declares; it never said whether the pattern needs substituting, and the new paragraph forbids conflating the two.

Refusal is not substitution. The same unification exposed why a position may have to refuse. A probe-value reference in a test shell command cannot be resolved by routing it to the resolver like every other token: a test unit’s command runs verbatim, so the substitution would have to happen at register time, where a demand-scheduled probe has not been produced and answers with no value. Reading it there would replace a wrong diagnostic with a wrong value, which is worse. The reference implementation refuses, naming the reason, from one site covering both test paths.

Known gap. § 22.5.7 requires a probe-value reference in a cook OR test body of an ingredients <probe> fan-out recipe to reach unit capture as literal text and be rewritten into an execute-time cook.probes.get chunk. The reference implementation does not implement the test half and refuses instead, because test units carry no execute-time substitution step. The Standard is not weakened to match: the requirement stands and the implementation is non-conforming on that clause, pending the unification of cook and test step execution. Recorded here so the gap is not mistaken for a rule.

Conformance. A placeholder that previously resolved keeps resolving to the same value; no Cookfile that built correctly changes meaning. Three classes of Cookfile change behaviour, all from silence toward a diagnostic or a correct substitution: an output pattern with a non-driver placeholder now substitutes it (and keys on it) instead of emitting it raw; a test body now reports the same diagnostic a cook body always did; a probe-value reference in a test command is refused with a diagnostic naming the reason rather than reported as an undeclared variable. No grammar change and no CACHE_VERSION bump — a substituted output pattern addresses a different path than the raw-sigil one did, which is a cold miss and never a false hit.

CS-0254 — preserved modification times do not hide changed content [#changes.cs-0254]

Date: 2026-09-06 Version: v0.18 Sections affected: § 17, § 22.1.2.

Summary. Clarifies content validation for equal-length and different-length writes that preserve modification time, across declared, discovered, module, and output records. Local metadata shortcuts need stronger evidence than mtime plus length, observed before hashing; this evidence never enters portable content keys. Empty input markers and record-disposition output authority retain their existing rules.

Backward compatibility. The reference implementation bumps its record schema from 11 to 12 and rejects old mtime-only indexes. This causes a one-time rebuild. Platforms without sufficient metadata re-read content conservatively.

Conformance notes. Retained public cache-decision tests preserved_mtime_{same,different}_length_{input,module,output,discovered} change real file bytes and restore the prior mtime. The old validator serves stale hits; the corrected validator reports the changed path or output drift. Metadata-refresh, schema-rejection, stat-memo, and graph-reporting tests cover the supporting boundaries.

CS-0255 — exclusions match logical gathered paths [#changes.cs-0255]

Date: 2026-09-07 Version: v0.18 Sections affected: § 8.2, § 21.2.1, § 22.3.

Summary. Ordinary exclusions match already gathered logical paths rather than expanding unrelated filesystem trees. Member/workspace anchors, segment matching, group order, duplicate groups, empty-include warnings, and malformed-pattern diagnostics are preserved. Parent-component exclusions retain existence-sensitive filesystem resolution.

Backward compatibility. An unrelated physical filename that normalizes to the same spelling no longer removes a gathered path. This corrects an incidental aliasing behavior; it does not introduce a new physical-filename encoding. Cache validation, metadata evidence, source membership discovery, and record schema are unchanged.

Conformance notes. Positive corpus case gather-excludes-logical-paths pins the syntax and AST. Public Lua and recipe-context tests prove logical-path alias handling, grouping, warnings, and fresh membership. A Linux directory-open observation proves ordinary excludes do not traverse an unrelated tree; it fails with the previous expansion path.

CS-0256 — faithful child recipe commands from chores [#changes.cs-0256]

Status: Accepted. Surface: additive, v0.18.

cook.child_command(name) composes a safely quoted child invocation using the actual executable, canonical entry/workspace, selected preset, CLI overrides and invocation policies. It is chore-only and refuses missing host context. This lets module chores stage existing named targets without guessing PATH, flattening member variables or losing CLI-only publish-off policy. Execution remains with existing interactive uncached units; no scheduler or materializer dependency is introduced. See § 22.15. Native CLI, terminal and complementary positive corpus proofs accompany the reference implementation.

The native composition proof also exposed an existing chore terminal defect: owned process groups were never made foreground, so a controlling-terminal read stopped them with SIGTTIN. The shared shell executor now hands an inherited terminal to the existing owned group before spawn and restores prior ownership on completion, spawn failure and cancellation. Captured/nonterminal streams and persistent signal dispositions retain their existing behavior.

CS-0257 — a probe registered in a recipe body has no recipe scope [#changes.cs-0257]

Date: 2026-10-17 Version: v0.18 Sections affected: § 22.5.6.

Summary. States what § 22.5.3 already implied: cook.probe is a register-phase call with no recipe scope, so a probe registered inside one recipe’s body resolves for a unit of any recipe that names it, and that unit is ordered after it and, by seal, keyed on its value. The reference implementation had planned body-scope probes per recipe: a unit in another recipe passed the resolution check, got no probe node and no edge, and folded absent bytes into its seal — so its key never moved with the probe, and asking for that recipe alone never ran the probe. A key that resolves but is scheduled for no consumer is now required to be a diagnostic naming the key, never a dropped edge. The reference implementation also checked a unit’s probe references when its own recipe body closed, not at end of pass; resolution is now at end of pass, as § 22.5.6 states, so a seal over a probe registered by a body that runs later resolves.

Backward compatibility. A Cookfile that sealed a probe registered in another recipe’s body was silently under-keyed; it now re-runs once the probe’s value moves, and the probe runs when only the sealing recipe is requested. A seal over a probe registered by a body that runs later, formerly rejected as naming no probe, now resolves. No valid Cookfile is newly rejected: the diagnostic is reachable only through an implementation defect.

Conformance notes. Public CLI test seal_over_a_probe_registered_in_another_recipes_body_is_scheduled changes the sealed value and requests only the non-declaring recipe; it fails against the previous planner. Unit-graph tests pin one shared node across both topological orders and the diagnostic for an unserved key.