Skip to content

§ 22.5 — Probe units

22.5. Probe units

Normative. This chapter specifies the cook.probe Lua API and the probe-unit execution model. A conforming implementation MUST honour every clause in this chapter.

22.5.1. Synopsis

A probe unit is a DAG unit whose output is a Lua value rather than a file. A conforming implementation MUST expose the register-phase API cook.probe(key, opts) (§ 22.5.3) and MUST treat probe units as ordinary participants in the execution DAG (§ 22.5.8). A probe’s value MUST be observed afresh on every invocation in which the probe is reached; it MUST NOT be stored, published, or shared across invocations (§ 22.5.8). A unit that seals a probe (§ 8.4.3.1) is where caching happens: sealing keys the unit on the probe’s observed value (§ 17.1.1), and it is that unit’s cache, not the probe’s, that a conforming implementation shares.

A unit MAY declare a dependency on one or more probe outputs via probes = { key, ... } on cook.add_unit (§ 22.5.6). The implementation MUST add a DAG edge from each named probe to the consuming unit, ordering the unit after the probe and enabling command-placeholder substitution (§ 22.5.7); this edge does not by itself key the consumer — seal (§ 17.1.1) is what folds a probe’s value into a unit’s cache key.

22.5.2. Native probe declaration

In addition to the register-phase cook.probe() API (§ 22.5.3), a conforming implementation MUST accept a native probe declaration: a column-0 construct (a sibling of recipe, register, and chore), implicitly register-phase, that declares one probe. The declaration is surface sugar: it MUST be equivalent to the cook.probe(key, opts) call its header and body denote (§ 22.5.3). The cook.probe() API is retained for dynamic registration; the two register into the same probe table and share the duplicate-key diagnostic of § 22.5.3.

Probe keys share a name space with callables and import aliases (normative; CS-0240). A probe key declared in a Cookfile — by a native probe declaration or by a files or tools declaration (§ 22.5.2.1) — MUST NOT equal the name of a recipe or chore declared in the same Cookfile, nor an import alias declared there. A conforming implementation MUST reject such a Cookfile at parse time with a diagnostic naming both declaration sites, in either declaration order (App. A.2). The rule follows from § 10.2 step 3: with a probe key resolving by name, a name that is two kinds gives $<NAME> two readings and $<NAME.field> three. A colon-qualified key is outside the rule’s reach by construction, since a colon cannot appear in a bare declaration name. The quoted form is NOT outside it: names compare verbatim, so probe "status" denotes the same key as probe status and collides with recipe status exactly as the bare spelling does.

The probe_body (App. A) comprises an optional declarative seal_step (§ 8.4.3) followed by exactly one producer. A conforming implementation MUST reject a probe body that: contains no producer; contains two or more producers; contains two or more seal lines; or contains a seal line after the producer. Each bare seal operand names an upstream probe key and joins the header dependency list (probe NAME: a b) in inputs.requires. Quoted operands desugar to an anonymous files probe whose key joins that same list; !"…" excludes matches with the same adjacency rule as a top-level files declaration. Thus file contents enter this probe’s inputs.requires chain without becoming its producer or iteration source. Probe bodies use seal for determinants; gather is a recipe iteration declaration and is not admitted here.

A producer has no introducer keyword: the producer KIND leads, and the braces are that kind’s body. Since CS-0226 there are four forms — a bare shell block { … }, a json-typed shell block, a lines-typed shell block, and a >{ … } Lua block. The former files { … } and tools { … } producers are removed by CS-0222; envs { … } is removed by CS-0226. A conforming implementation MUST reject each retired spelling with a diagnostic naming its replacement.

22.5.2.1. Named file and tool sets

The top-level forms files NAME and tools NAME declare named determinant sets. Each has an indented body admitting multiple physical lines and multiple items per line. A files item is a quoted glob or the existing !"glob" exclusion marker with no intervening whitespace; a tools item is a TOOL_NAME. Each declaration desugars to a probe of NAME, using the existing file-manifest and tool-identity evaluation semantics below, so seal NAME requires no new seal surface.

These declarations MAY appear at any top-level position. Authors SHOULD place one immediately above the recipe that owns it and SHOULD namespace the name by its Cookfile or module owner: probe keys share one workspace namespace across imports, so this convention mitigates collisions. A named declaration is for a set worth naming; a single inline determinant should use the inline seal form.

A probe’s seal declares its upstream dependencies — NOT an iteration driver. A probe yields one value and never fans out; each named declaration contributes through inputs.requires, using the same dependency path as a header dependency.

Diagnostics. A conforming implementation MUST reject the following probe bodies at parse time with a diagnostic that identifies the probe by name and the offending line:

  • No producer — a conforming implementation MUST reject the probe with a diagnostic indicating that the producer is absent.
  • Two or more producers — a conforming implementation MUST reject the second producer with a diagnostic indicating that at most one producer is permitted per probe.
  • Two or more seal lines — a conforming implementation MUST reject the second seal with a diagnostic indicating that at most one seal is permitted per probe.
  • A seal line appearing after the producer — a conforming implementation MUST reject with a diagnostic indicating that seal must precede the producer.
  • A body step that is neither seal nor a producer (e.g. a cook, test, or other non-producer step) — a conforming implementation MUST reject with a diagnostic indicating that only seal and a producer are permitted in a probe body.
  • A probe name with an EMPTY segment (probe :a, probe a:, probe a::b) — a conforming implementation MUST reject it. CS-0201 removed the former three-or-more-segment rejection: the cap was enforced here and ignored by cook.probe, so modules mint cc:find:raylib and cc:compiler:auto as their ordinary case, and those keys could be neither spelled on the surface nor sealed.
  • A probe name containing . (probe cc.version) — a conforming implementation MUST reject it with a diagnostic naming member access as the cause and offering the quoted form (§ A.3.2, CS-0201).
  • A json or lines kind applied to a >{ … } Lua block (e.g. json >{ … }) — a conforming implementation MUST reject with a diagnostic indicating that json/lines are valid only on a shell block, since a Lua block already returns a structured value.
  • An envs { NAME, … } producer — a conforming implementation MUST reject it with a removed-keyword diagnostic naming an ordinary shell probe such as lines { echo "$NAME" } as the replacement. Separately, a top-level tools NAME declaration MUST contain at least one TOOL_NAME (internal - and . allowed; its own production since CS-0201, because a PROBE_SEG has no .). IDENT and TOOL_NAME both require an [A-Za-z_] head, so tools t followed by cc --version is rejected.

Each segment of a bare probe key is [A-Za-z_][A-Za-z0-9_-]* (§A.3.2 PROBE_SEG, CS-0201), so demo:cc-version is a single two-segment key and cc:find:raylib a three-segment one; there is no cap on segment count. . is not admitted, because it is member access in a probe reference. The quoted STRING form denotes the same key and is the escape hatch for any spelling the bare form cannot express.

A conforming implementation MUST accept both spellings wherever a probe key is named: this declaration, a dependency-list entry, a seal ref (§ 8.4.3), and a gather probe source (§ 22.5.10). Before CS-0201 they disagreed — seal and ingredients admitted neither - nor ., seal refused the quoted form outright, and both capped segments — so a key this section blessed could be declared and referenced from a command and then neither pinned nor consumed.

A shell-block producer ({ … }, json { … }, lines { … }) and the Lua-block producer (>{ … }) reuse the body ::= exec_lua_block | shell_block grammar of § 8.4 (the body-dispatch: >{ is tested before {). The probe value is determined by the producer form; the top-level declaration forms that follow lower to the same probe-value model:

  • >{ … } (Lua block): the probe value is what the block returns (§ 22.5.5).
  • { … } (bare shell block): the probe value is the block’s standard output as a string (one trailing newline trimmed).
  • lines { … } (shell block): the probe value is the block’s standard output split on LF into an array of strings.
  • json { … } (shell block): the probe value is the block’s standard output parsed as one JSON value. Decoding is explicit and validating — a conforming implementation MUST fail the probe (NOT silently fall back to a string) when the output is not well-formed JSON.
  • tools NAME (top-level declaration): the indented body is a whitespace-separated LIST of bare tool names (TOOL_NAME, its own production since CS-0201 — internal - and . both allowed, so tree-sitter, pkg-config and python3.11 are spellable), NOT a shell or Lua body. A conforming implementation MUST PATH-resolve each name and content-hash the resolved binary; the probe value is a JSON object keyed by tool name, each entry an object { "hash": <content hash> }. The hash is the probe value: a change to the resolved binary changes the hash, and because a probe’s value is observed afresh on every invocation it is reached (§ 22.5.8), a sealing unit’s key moves with it automatically — freshness without a volatile annotation. The hash MUST be ONE computation, from ONE resolution of the name: an implementation MUST NOT compute it a second time elsewhere and rely on the two agreeing. Two computations of one identity may agree today and diverge on the run that matters, since they read the file at different moments and can resolve the name differently, and this identity keys a store that crosses machines (§ 17.1.1). For the same reason an implementation MUST NOT make the value depend on an external hashing or path-resolution utility being present on the host: an evaluator that shells out to one computes nothing on a host that lacks it, and a rule about the resolved binary’s content cannot be delegated to a tool whose own availability varies by platform. (CS-0214.) The value MUST NOT contain the resolved path or any other machine-specific location: path is location, not identity, and a location in the canonical bytes would fold into every sealing unit’s key (§ 17.1.1), so identical toolchains at different locations could never share sealed artifacts — the same portability rule the files declaration states for its value. The resolved path is read-view metadata instead: an implementation MUST resolve each declared tool’s current location afresh on every run and MUST surface it to consumers — merged into the value table returned by cook.probes.get (so $<KEY.NAME.path> resolves for invocation) and shown by the key explanation (§ 17.1.6) — while the canonical value bytes, the seal fold, and the .cook/probes/<key>.json record (§ 22.5.8) carry identity only. A consumer therefore always reads where the tool resolves NOW; an observed value can never replay a stale location. (CS-0157.) A name that does not resolve on PATH MUST fail the declaration with a diagnostic naming the tool, and MUST do so regardless of whether this probe’s value has already been observed elsewhere in the same invocation: the failure is a statement about the host the run is on, so an implementation that raises it only during production reports it only when the probe is actually dispatched to a worker VM (CS-0214). A name that RESOLVES but whose bytes cannot be read MUST fail the probe in the same way. An implementation MUST NOT record a placeholder digest for it: a value that says “unreadable” says the same thing on every host, so two machines each failing to read a DIFFERENT toolchain would compose identical value bytes, and one could be served a sealed artifact the other built. This case is reachable — PATH resolution tests for executability, not readability — and it is the one place where § 22.5.4‘s missing-input fold, a deliberately collision-tolerant resolution result, must not be promoted into an identity. (CS-0214.) A conforming implementation MUST reject an empty body.
  • files NAME (top-level declaration, CS-0148/CS-0222): the indented body is a whitespace-separated LIST of quoted glob patterns following gather pattern syntax (!"…" excludes; § 8.2), NOT a shell or Lua body. The glob set is the declaration’s file-input determinant set: it resolves to a concrete file set whose per-file content hashes populate inputs.files (§ 22.5.4), exactly as a gather line would. The probe value is a JSON object keyed by each resolved file’s workspace-relative path, each entry the lowercase-hex content hash of that file — computed once, from the same resolution inputs.files populates (§ 22.5.4), so the value cannot diverge from itself, and it contains no machine-specific absolute paths (it stays portable across a shared store, § 17.1.1). A matched path that does not exist at value time folds as the literal string "<missing>", mirroring § 22.5.4‘s missing-file fold — a glob resolving to nothing it can find is ordinary. A matched path that EXISTS but whose bytes cannot be read (permission denied) MUST fail the probe with a diagnostic naming the path, in the same way and for the same reason as the tools rule above: "<missing>" says the same thing on every host, so two machines each failing to read a DIFFERENT file’s content would compose identical value bytes, and one could be served a sealed artifact built from the other’s actual content — permanently, since nothing about an unreadable file’s bytes ever changes to move the value, and therefore the key of every unit that seals it. An implementation MUST NOT distinguish the two states by the content hash alone (both fold to the same missing-content digest); it MUST check existence separately. (CS-0241.) Adding, removing, or editing a matched file changes the value and therefore the key of every unit that seals this probe — files is the file-set twin of tools, and the idiomatic way to name a set of files as both a sealable determinant and a named gather NAME source. In gather position its manifest keys become the gathered members; each path is bound as the member while the hashes remain the determinant value. A conforming implementation MUST reject an empty or unquoted top-level files body. A declared path resolves, and its content hashes, against the Cookfile that DECLARES the files set — never against a consuming recipe’s own working directory, which may be a different Cookfile’s when the probe is sealed by a recipe elsewhere in the workspace. Both the register-phase resolution and the execute-phase resolution MUST use this one base, so a cross-Cookfile consumer sees the same value the declaring Cookfile’s own recipes would. (CS-0241.)

The json and lines kinds are valid ONLY on the shell-block form; a conforming implementation MUST reject either applied to a >{ … } Lua-block producer with a register-phase diagnostic, since a Lua block already returns a structured value. files and tools are not producer kinds after CS-0222; their bodies belong only to top-level declarations. envs is not a producer kind after CS-0226.

Coexistence. The native probe declaration and the register-phase cook.probe() API register into one probe table within a register pass; a key declared by both MUST fire the duplicate-key diagnostic of § 22.5.3.

Note (informative). A probe whose producer decodes JSON/YAML (json { … }, or cook.json_decode/cook.yaml_decode in a >{ … } body) MAY be consumed through either path: the codecs are both-phase API (§ 24.8, CS-0123), installed on the register pre-pass VM and on every execute-phase worker VM alike.

Lowering (informative). The reference implementation lowers a probe declaration to the equivalent cook.probe(key, opts) call (§ 22.5.3): the header dependency list becomes inputs.requires; and the declaration becomes the opts.produce Lua source string — a >{ … } block verbatim, a shell block wrapped as cook.sh(...) typed by its leading kind (bare → stdout string with one trailing newline trimmed; lines { … } → newline-split array; json { … } → cook.json_decode). A top-level tools declaration becomes an inputs.tools determinant declaration. A files glob list lowers to inputs.files (register-time glob resolution, as for gather) with the reserved opts.produce string "@files-manifest" — not executable Lua (a bare @ is a Lua syntax error, so no hand-written produce body collides with it); the engine intercepts that sentinel and synthesises the value directly from the resolved inputs.files pairs instead of dispatching a worker — one computation, not two that could diverge. A cook.probe() caller MAY pass the sentinel with its own inputs.files to obtain the same synthesised manifest. Editing a shell command or changing the producer kind changes the lowered opts.produce source string, which is what the probe re-executes on the next invocation it is reached; the declaration lowering itself is otherwise unchanged.

22.5.3. The cook.probe API

Signature. cook.probe(key: string, opts: ProbeOpts) -> nil

Phase. Register-phase only.

cook.probe(key, opts) registers a probe unit named key. The function MUST be available on the register-phase VM. Calls from the execute-phase VM MUST raise a Lua runtime error per § C.6.12.

key MUST be a non-empty string. Implementations SHOULD recommend the convention "<module-prefix>:<name>" (e.g. "cc:zlib") but MUST NOT enforce it syntactically.

A second cook.probe call with the same key within one register pass MUST raise a register-phase diagnostic naming both source locations:

probe key 'cc:zlib' declared at <file>:<line>; previously declared at <file>:<line>

opts is a Lua table with the following keys:

KeyTypeRequiredSemantics
inputstableyesDeclared inputs (§ 22.5.4). MAY be an empty table.
producestringyesLua source code (the body of a producing function); the implementation wraps it in function() ... end and invokes it on a worker VM during the execute phase each time the probe is reached (§ 22.5.8). MUST return a serialisable value (§ 22.5.5).

A conforming implementation MUST raise a register-phase error if opts.inputs is absent or not a table, or if opts.produce is absent or not a string.

Example 22.5.3.1

cook.probe("cc:zlib", {
inputs = {
tools = { "pkg-config" },
},
produce = [[
local r = run_pkg_config("zlib")
return { found = r.found, cflags = r.cflags, libs = r.libs }
]],
})

22.5.4. Probe inputs

opts.inputs is a table with optional sub-keys. Omitted sub-keys default to the empty list.

Sub-keyTypeSemantics
toolslist[string]Tool names (e.g. "pkg-config"). Resolved via PATH at probe execution time. The resolved binary’s identity is what a top-level tools declaration’s value is built from (CS-0214, CS-0222), and its location populates the per-run tool-location view of § 22.5.2.1 (CS-0157). A name that does not resolve on PATH resolves as the pair (name, "<missing>") — the missing-input fold — and the probe runs and SHOULD surface a diagnostic. A top-level tools declaration is stricter and MUST fail instead, because there the resolution IS the value a sealing unit is keyed on (§ 17.1.1) and "<missing>" says the same thing on every host (CS-0214, CS-0222).
fileslist[string]File paths relative to the Cookfile root, resolved and content-hashed against the declaring Cookfile. A declared path that does not exist at resolution time resolves as the literal string "<missing>" in place of a content hash — the missing-file fold. That resolution is what a top-level files declaration’s synthesised manifest value is built from (CS-0148).
requireslist[string]Upstream probe keys. Each named probe is an ordering edge and a failure-propagation edge (a produce failure propagates via DAG edge to every named consumer, § 22.5.8), and is the unit CS-0242’s once-per-key dispatch counts over. It is NOT a keying mechanism: naming an upstream probe here does not fold that probe’s value, or anything derived from it, into this probe’s own value. The requires graph among probes MUST be acyclic (§ 22.5.9).

opts.inputs.env is retired (CS-0244). A conforming implementation MUST reject it with a register-phase diagnostic naming CS-0244 and directing the author to an ordinary shell probe over the same variables (§ 22.5.2), sealed by name — the replacement CS-0226 already established for the envs { } producer this sub-key fed.

On a hand-written produce body, no inputs sub-key keys anything: the value is whatever the body returns, and no unit’s cache key moves because of a declaration here. requires is the only sub-key that is a DAG edge — it orders this probe after each named upstream and propagates that upstream’s failure. files and tools are resolved, not scheduled: a path and a tool name are not DAG nodes. A declared tools name is resolved on PATH, which is what populates the per-run tool-location view a consumer reads (§ 22.5.2.1) — the body cannot read that view, since it cannot read its own unmaterialised value; the body’s own tool-location surface is cook.tools.id (§ 24.9), which needs no declaration at all. A declared files path carries one restriction — § 22.5.10‘s static-input rule, which forbids a probe the register pre-pass resolves from declaring a files input that is a declared recipe output of the same Cookfile. seal (§ 17.1.1) is the mechanism that keys a consumer on a probe’s content: it folds the probe’s value into the sealing unit’s key. A bare probes reference (§ 22.5.6) does not: it orders and substitutes but never re-keys its consumer (§ 22.5.7).

22.5.5. Value types

The function produced by wrapping the produce source string (§ 22.5.3) MUST return one of:

  • nil
  • boolean
  • number (integer or float)
  • string (valid UTF-8; see below)
  • A table whose keys are all strings, OR all integers in 1..N with no holes (array shape), and whose values are recursively of these types.

Returning a function, userdata, thread, a mixed-key table, or a table containing a cycle MUST cause the probe to fail with a diagnostic naming the offending path. The diagnostic MUST follow the form:

probe 'cc:zlib' returned a non-serialisable value at .cflags[3] (function)

Probe values are serialised as canonical JSON for storage, materialisation, and cloud transport. The canonical rendering is UTF-8 JSON, pretty-printed with two-space indentation, object keys sorted bytewise ascending, with exactly one trailing LF. There is exactly ONE canonical byte sequence per value; a conforming implementation MUST use those bytes verbatim at every serialising site — the .cook/probes/<key>.json file (§ 22.5.8) and wire transport — without re-encoding. Any hash over a probe value MUST be over the canonical bytes.

The JSON encoding imposes two constraints beyond the value-type list above: a string MUST be valid UTF-8, and a number MUST be finite (NaN and ±Inf are rejected). A violation MUST cause the probe to fail with the non-serialisable diagnostic above, naming the offending path.

22.5.6. Consumer probes

cook.add_unit({ ..., probes = { "key1", "key2" } }) declares that the unit consumes the named probe values. The probes field is an optional list of probe-key strings; omitting it is equivalent to probes = {}. (Distinct from opts.inputs.requires per § 22.5.4, which declares probe-to-probe dependencies inside a probe.)

A conforming implementation MUST, after the register pass completes:

  1. Resolve each key against the set of registered probes. An unknown key MUST raise:
    unit '<name>' lists probe key '<key>' in `probes` but no such probe was declared
  2. Add a DAG edge from each resolved probe to the consumer unit. This edge orders the unit after the probe and is what command-placeholder substitution (§ 22.5.7) depends on; it does NOT key the consumer — seal (§ 17.1.1) is the mechanism that folds a probe’s value into a unit’s cache key.

The relative order of cook.probe and cook.add_unit calls within one register pass is unconstrained; resolution is deferred to end-of-pass.

A probe has no recipe scope (CS-0257). Rule 1 resolves against every probe registered in the pass, wherever its cook.probe call sat — at top level or inside any recipe’s body. A unit that names, by probes, seal, a $<key> sigil or a literal cook.probes.get, a probe registered inside ANOTHER recipe’s body MUST get rule 2’s edge exactly as for a top-level probe, and the probe is scheduled for that unit whether or not the declaring recipe is; the declaring recipe’s own consumers and every other demander share one production (§ 22.5.8, CS-0242). An implementation that resolves a key here but schedules no probe for a consumer of it MUST raise a diagnostic naming the key rather than drop the edge: a sealing unit keyed without its probe folds absent bytes into its key — the alternative, a prior invocation’s .cook/probes record, is forbidden by Always observe — and the key stops moving with the determinant it names.

Rule 1’s sentence is mandated for the probes field, and only for it: it names the field the caller filled in, which is why it is useful to the caller who filled it in. Where an implementation routes some other surface’s refs through this same resolution — a recipe’s seal set is the case that arises, since a sealed probe must be scheduled ahead of the unit exactly as a consumed one is — the ref MUST NOT inherit this sentence. It gets its own surface’s diagnostic (§ 8.4.3.1 rule 4), because an author who wrote seal never wrote a probes list and cannot act on advice about one. (CS-0235.)

22.5.7. Command-template desugaring

Consumer commands MAY include $<key>, $<key.field>, and $<key.field[i]> placeholders that reference probe outputs declared in probes. The implementation MUST detect these placeholders during codegen (or register-time capture for direct cook.add_unit calls) and MUST substitute each one with the referenced probe’s resolved value before the command runs. A probe’s value is execute-phase (§ 17.1.1), so the substitution is too; the unit’s command text carries its placeholders unsubstituted until then.

Amended (CS-0188): how the substitution happens is no longer specified. This section previously required an implementation to rewrite the command into a Lua step that called cook.probes.get(key) and interpolated the result before invoking the shell. That was a mechanism mandate in a section whose subject is an observable result, and the mandated mechanism carried an observable cost this Standard never intended. A rewritten command became an execute-phase Lua chunk, and a Lua chunk’s captured output travelled a different path from a shell command’s, so a step’s output became invisible when, and only when, its command happened to mention a probe. One character of difference in the command text decided whether the step was heard from.

A conforming implementation MUST make a unit’s captured output independent of whether its command contains a probe reference. Whether it substitutes into the command text, rewrites the command into some other executable form, or does something else again, is its own affair, and no part of it is observable to a Cookfile author beyond the substituted values themselves.

The placeholder syntax reuses Cook’s existing $<IDENT> sigil grammar (§ 9.1). A sigil is a probe-value reference when the part of its IDENT preceding any access path names a declared probe key, which is step 3 of § 10.2; the implementation MUST dispatch such a sigil to the substitution path described here. Any other sigil retains its register-time semantics (builtins, recipe names, declared variables).

Amended (CS-0240): the colon was the dispatch rule and is now only a lexing fact. This section previously read “a sigil whose IDENT contains a colon is unambiguously a probe-value reference … a sigil whose IDENT contains no colon retains its existing register-time semantics”, which left the sigil as 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 (§ 22.5.2) mints colon-free keys, so a probe was reachable through every surface except the one this section specifies.

What the colon retains is narrower and needs no rule of its own: no builtin, recipe or declared-variable name may contain one, so a colon-carrying IDENT is a probe reference on sight, which is what lets an implementation dispatch a module-prefixed key at a site holding no probe keyset. The reference implementation’s raw cook.add_unit capture is exactly such a site — § 22.5.6 leaves cook.probe and cook.add_unit call order unconstrained, so at the moment a command string is captured the probe keyset is not yet complete, and only a colon-carrying key can be recognised there.

Because a probe key now resolves by name, one name MUST NOT be two kinds: § 10.2‘s one-name-one-kind rule rejects a Cookfile declaring the same name as a probe key and as a recipe, chore, or import alias.

These rules apply uniformly to probe-value references written in native cook-step bodies (§ 8.4 / § 8.5, including bodies of gather <probe> fan-out recipes), to chore shell_command steps (§ 7.3, CS-0193), and to raw cook.add_unit command strings. In both cases the reference MUST reach register-time unit capture as literal $<...> text inside the unit’s command string; the capture layer performs the rewrite described above. An implementation MUST NOT lower a probe-value reference to a non-string command value (e.g. a deferred Lua function): cook.add_unit MUST reject a command that is not a string with a register-phase diagnostic naming the received type. (CS-0122; this closes a prior defect where the reference implementation emitted a function-valued command for probe-referencing native bodies, which was coerced to the empty string and silently succeeded.)

The literal-text rule applies regardless of which step kind’s body originated the command, including cook and test bodies in gather <probe> fan-out recipes (§ 8.2).

Withdrawn (CS-0188). CS-0127 added here that the rewrite “turns the command into a Lua chunk; that chunk MUST run under the originating step kind’s sandbox policy (§ 25.10), not a default one.” The clause was about the chunk, and it goes with the chunk. 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 no more confined than the plain command beside it. CS-0135’s rule that every step kind is sandboxed identically is unaffected, and where an implementation still produces a chunk for its own reasons, § 25.10 governs it as it governs any other.

Substitution rules (CS-0192). A placeholder addresses a probe value or a part of one. $<key> addresses the probe’s entire value; each .field segment addresses the named member of an object; each [i] segment addresses the one-based element i of an array. Segments MAY chain ($<key.field[2].name> is well-formed under § 9.1‘s ident grammar). Addressing operates on the value’s read view: the canonical value (§ 22.5.5) with a tools declaration’s per-run path annotation merged in (§ 22.5.2, CS-0157), so $<KEY.NAME.path> substitutes where the tool resolves now — the same view cook.probes.get returns.

The addressed value renders into the command text by its JSON type (§ 22.5.5), and the rendering is this Standard’s, not any host language’s:

  • A string renders as its content, verbatim — no quoting, no escaping.
  • A number renders as its canonical JSON number token: 3 for an integer, shortest round-trip decimal for a float (3.0, 0.30000000000000004). The token substituted into the command and the token inside the value’s canonical bytes are the same bytes.
  • A boolean renders as true or false.
  • null MUST raise an execute-phase diagnostic naming the placeholder. A command with null interpolated into it is not a command its author meant. The Lua-facing read is unaffected: a materialised null value still reads as nil from cook.probes.get with no error (§ 22.5.8), which is load-bearing for produce bodies.
  • An array or object MUST raise an execute-phase diagnostic naming the placeholder and the value’s JSON type, and the diagnostic SHOULD direct the author to address a scalar element ($<key.field>, $<key[1]>). Rendering a composite is deliberately left undefined rather than defined by accident: the motivating array case (a flag list) wants a separator join, consistency with every serialising site wants canonical JSON, and an error today permits either tomorrow compatibly, while shipping one forecloses the other. (App. E’s CS-0192 entry carries the rationale.)

Addressing failures are diagnostics, never renderings. A .field segment applied to a non-object, an [i] segment applied to a non-array, a member or index absent from the value, and a non-numeric index MUST each raise an execute-phase diagnostic naming the placeholder and the JSON type or absence it found. (A placeholder referencing a key not in probes remains § 22.5.6‘s register-phase diagnostic.)

Amended (CS-0192): the rendering used to be Lua’s, by reference. The previous rules required the value to be “a string (or coercible via tostring)”, which handed an observable surface of this Standard to Lua’s C library: a number rendered through %.14g (so 0.1 + 0.2 substituted as 0.3), an absent member rendered as the four bytes nil, and a table rendered as its heap address — table: 0x7f1788005660, different bytes on the same command line on every run. App. E’s CS-0192 entry records what changes observably, and the migration note.

Lua-body static scan (CS-0152). An execute-phase Lua step body — a native >{ … } cook body (§ 8.4 / § 8.5) or cook.add_unit’s lua_code string; the former lowers to the latter, so one capture-site rule covers both — has no $<key> sigil surface, but it consumes probe values all the same, via cook.probes.get. A conforming implementation MUST statically scan such a body’s source for calls of the form cook.probes.get("<key>") (double- or single-quoted) whose first argument is a string literal constituting the entire argument — a literal followed by concatenation is not collected, and text inside comments or unrelated string literals MUST be excluded — and MUST union each discovered key into the unit’s probes list. The unioned keys then follow § 22.5.6‘s end-of-pass rules unchanged: an unknown key raises that section’s register-phase diagnostic; a known key gains the DAG edge and demand-driven scheduling under § 22.5.8, exactly as if the author had listed it. Non-literal keys — a variable or expression argument, a concatenation, a cook.probes.scope(...) chain — are exempt from the scan and are governed by § 22.5.8‘s unmaterialised-read error. The scan is a deliberate overapproximation: it performs no reachability analysis, so a literal read in a conditionally-executed branch demands the probe unconditionally — matching the sigil scan above, which likewise demands a probe regardless of whether the line carrying the reference runs. Probe produce bodies are outside this rule’s scope; they register via cook.probe and are ordered by inputs.requires (§ 22.5.4).

A probes reference does not re-key its consumer. Rule 2 of § 22.5.6 adds a DAG edge from each consumed probe to the consumer, which is the ordering this section’s substitution depends on. It does not fold the probe’s value into the consumer’s key: a cached consumer MAY therefore retain output produced from an older observation. An author whose output depends on the current value MUST seal the probe (§ 8.4.3.1); a bare probes reference orders and substitutes but does not key (§ 17.1.1).

Example 22.5.7.1

cook.add_unit({
name = "myapp.o",
inputs = { "myapp.c" },
outputs = { "build/myapp.o" },
probes = { "cc:compiler", "cc:zlib" },
command = "$<cc:compiler.path> -c $<in> -o $<out> $<cc:zlib.cflags>",
})

Before the command runs, $<cc:compiler.path> and $<cc:zlib.cflags> are replaced by the resolved values of the two probes. $<in> and $<out> are register-time sigils and are already substituted by that point (§ 9.1). The unit’s captured output is the same as it would be for the equivalent command with no probe reference in it (CS-0188).

22.5.8. Execution semantics

A probe unit MUST run at most once per build invocation.

A probe’s produce body runs at most once per key, not once per node (CS-0242). A probe’s produce body MUST run at most once per build invocation. This binds the probe’s key, not the unit synthesised for any one demander: when N recipes seal a probe, reference it in a $<key> sigil, list it in a unit’s probes field, or gather it, in one invocation, the build owes that key exactly one execution of produce — never N. Demanders share one production.

The rule holds across phases as well as across recipes. A key the register pre-pass has already resolved — to compute a gather <probe> fan-out’s member set (§ 22.5.10), or by an ordinary register-phase cook.probes.get read (§ 22.5.10, CS-0219) — MUST NOT be produced a second time when the execute phase later dispatches a unit for that same key. The implementation MUST instead serve the value the register pre-pass recorded, which the Always observe rule below names as an explicit exception to its own dispatch requirement. The register pre-pass and the execute phase are two callers of one evaluation, not two evaluations.

Because one node serves every demander of a key, the ordering dependencies that bind that node are those of the demander that mints it — the first, in the implementation’s own scheduling order — and not the union of every demander’s. A conforming implementation MUST NOT be read as guaranteeing that a shared probe node runs after the prerequisite recipes of a demander OTHER than the minting one. A probe that must observe a specific recipe’s artifact declares that artifact as a files input (§ 22.5.4), which orders it by data rather than by whichever consumer happened to reach it first; note that § 22.5.10‘s static-input rule forbids exactly that declaration for a probe the register pre-pass resolves.

None of this loosens the demand law below: “at most once” is a ceiling, not a floor. A probe no reachable recipe demands still does not execute at all, in either phase.

Serving the register phase’s value at execute time is sound in the cases § 22.5.10‘s static-input rule covers, which is what that rule is for: a probe the register pre-pass resolves may not declare a files input that is one of the same Cookfile’s declared recipe outputs, so for such a probe nothing declared can change between the moment the pre-pass resolves it and the moment the execute phase would otherwise have re-produced it, and re-running produce would answer what the pre-pass already answered. The rule is a static check over declarations, and its reach is exactly that: it compares declared paths literally, so a glob files entry or a pattern output is outside it; it does not reach a produce body that reads a build artifact by means other than a declared input, since no static rule can see that. A produce body that observes undeclared state was already outside every guarantee this section makes — its value may be served from the register pre-pass’s own production, the cross-phase exception above, across the very change it observes (§ 22.5.4) — and this rule neither widens nor narrows that.

Always observe (CS-0243). A probe value MUST NOT be served from any store that outlives the invocation. There is no probe-value cache: no hit, no miss, and no stored artifact. A conforming implementation MUST NOT store, publish, or transport a probe’s value, and MUST NOT read a value recorded by an earlier invocation. A probe that is reached therefore observes the world afresh on every invocation in which it is reached, which is the whole of what a probe is for; § 17.1.1 is where the observation is allowed to stop work, by keying a sealing unit on the probe’s value, so a re-observation that answers what it answered before leaves every consumer’s key — and the build’s early cutoff — exactly where it was.

A probe has no branch on this question the way a test unit does: § 17.4 rule 1 states the test-unit law — a source-less test with a non-empty seal is keyed; only a test with neither source nor seal is one-shot. A probe answers no analogous question: every probe — declared inputs or none, sealed by a consumer or not — is always observed, uniformly.

The implementation MUST dispatch the probe to a worker VM, wrap the produce source string in function() ... end, parse and invoke the resulting function, capture the return value, validate it against § 22.5.5, render it to canonical JSON (§ 22.5.5), and populate the per-run probe-value store. If the produce source string is malformed Lua, the resulting parse error MUST fail the unit with a diagnostic naming the probe key and the parse error.

Exactly two cases resolve without dispatching a VM, and neither is a store. A top-level files or tools declaration’s value is synthesised directly from the resolved file/tool pairs (§ 22.5.2) — the same pairs inputs.files / inputs.tools resolve — so the value is one computation over the current tree. A key this invocation’s register pre-pass already resolved is served from that pre-pass’s own value, the cross-phase exception stated above under CS-0242. Both observe the world on this invocation; neither reads a previous invocation’s answer.

An expensive observation belongs in a recipe (CS-0243). An author whose observation is too expensive to repeat on every invocation MUST NOT expect an implementation to memoise it: this section forbids exactly that. The construct for a memoised expensive computation is an ordinary cacheable unit (§ 17.1.1) — a recipe that declares its inputs, writes its result to a declared output, and is consumed downstream as a file. Such a unit is keyed, stored, shared, and served from cache by the whole of § 17, and it re-runs when what it declared moves. A probe is the other thing: an observation of state the build does not produce, whose cost is the price of being told the truth each time. An observation that is not repeated is a memory, and a memory of the host is what § 17.1.1‘s refusal to infer machine identity exists to keep out of a key.

The per-run probe-value store is keyed by probe key. Consumer units are dispatched after their requires-edge predecessors complete; at dispatch time, the store is fully populated for all required probes.

cook.probes.get(key) (§ 24) invoked from execute-phase Lua MUST read from the per-run probe-value store. A cook.probes.get call — including a scoped cook.probes.scope(label).get(key) call, which MUST report the full prefixed label:key — for a key absent from the store MUST raise an execute-phase error that names the key and directs the author to the remediations: reference the probe in the step (a $<key> sigil in a shell body, or probes = {...} on cook.add_unit — § 22.5.6), declare it in the probe’s inputs.requires (§ 22.5.4) when reading from a probe produce body, or seal it (§ 8.4.3.1). A key that IS materialised in the store whose canonical value is null (§ 22.5.5) MUST read as Lua nil with no error — value-was-null is not a miss. (CS-0152; defect COOK-217 — the prior nil-on-miss rule made an undemanded probe a silent wrong-value path, nondeterministically masked whenever a sibling step happened to demand the same key.)

The wrapped produce function MAY raise a Lua error. A conforming implementation MUST treat a raised error as a unit failure and MUST propagate failure to every downstream consumer via the DAG edge, consistent with the general unit-failure semantics of § 13.1.

Demand-driven scheduling. A probe unit MUST NOT execute on an invocation unless at least one scheduled non-probe unit lists the probe’s key in its probes field, directly or transitively through probe-on-probe inputs.requires chains. A conforming implementation MUST silently omit unreached probe nodes from the execution DAG. The implementation MUST NOT observe an unreached probe at all; MUST NOT emit a user-facing diagnostic for it; and MUST NOT log a warning about it at the user-facing diagnostic layer. Debug logging at lower verbosity tiers is permitted.

Example 22.5.8.1

register
cook.probe("env:slow", {
inputs = {},
produce = [[ return { ts = os.time() } ]],
})
recipe foo
cook.add_unit({
name = "u1", inputs = {}, outputs = {"build/foo.txt"},
probes = {"env:slow"},
command = "echo $<env:slow.ts> > build/foo.txt",
})
test { cat build/foo.txt }
recipe bar
test { echo bar }

The bare cook.add_unit(…) line is a register-phase module_call step (§ 8.1, rule 7) — the CS-0134 form of what a pre-CS-0134 Cookfile wrote as >>{ cook.add_unit(…) }. Each test { … } block body is an execute-phase verification unit.

cook foo MUST execute the env:slow probe and u1 exactly once. cook bar MUST execute no probes; env:slow is unreached and MUST NOT be observed.

On-disk layout. No cache storage is involved (Always observe, above): a probe value is never written to the content-addressed store, so no artifact and no meta sidecar exist for it. An implementation whose store still holds probe-value artifacts written under an earlier revision of this Standard MUST NOT serve them, and SHOULD let its ordinary eviction policy reclaim them.

On every probe completion — demand-driven or pre-pass (§ 22.5.10) — the implementation MUST materialise the canonical bytes at .cook/probes/<file> under the project root, where <file> is the probe’s workspace-qualified identity with the characters _, /, and \ escaped for injectivity (the reference implementation rewrites _ → _5f, / → _2f, \ → _5c, so no two distinct qualified identities share a file name), suffixed .json. The root Cookfile’s identity has no prefix; an imported Cookfile’s identity includes its declaring import prefix (CS-0250), so two members declaring the same local key retain separate records. This file is the canonical local copy of the probe value. Within one invocation it is how the cross-phase rule above serves a key the register pre-pass already resolved. If that pre-pass cannot materialise its value, registration MUST fail and MUST NOT mark the key resolved: the execute phase has no same-invocation carrier to serve. An execute-phase materialisation failure remains a warning; the executor already carries the completed value in its per-run store. Across invocations it is a record and not a source: a conforming implementation MUST NOT read it as a probe’s value on a later invocation, and in particular MUST NOT answer a cook.probes.get for an undemanded probe out of it — that read is the error this section requires, not a stale value. A post-hoc explanation surface (§ 17.1.6) reads the file precisely because it is a record of what was observed — except where § 17.1.6.1 has the query resolve a fresh value instead, or reuse one this invocation’s own register pre-pass produced.

22.5.9. Cycle detection

The probe requires graph MUST be acyclic. A conforming implementation MUST detect cycles at the end of the register pass and MUST raise a diagnostic before any unit executes:

probe cycle detected: cc:a -> cc:b -> cc:a

The diagnostic MUST render the full cycle path. The order in which nodes are listed MUST be deterministic (implementation-defined ordering of cycle start); the path MUST close back to the first node.

22.5.10. Bare names as gather sources

A bare gather source selects either a named files declaration, whose resolved manifest keys are path members, or an ordinary probe, whose resolved value MUST be an array and whose array elements are members. A non-array ordinary probe is rejected; a tools declaration is not iterable. For an ordinary probe (§ 22.5.5), gather <key> supplies the array elements as recipe members and gather <key>:<field> selects the array at the named field. The gather source supplies the recipe’s members. An accessor-bearing cook output registers one unit per member; a later all-literal cook output registers one aggregate unit over the preceding outputs; an all-literal first cook step is rejected (CS-0155; § 8.4.1). A fan-out cook step MAY declare multiple output templates; each member’s unit registers every declared output (§ 8.5 applies per member — CS-0150 pins this after the reference implementation registered only the first template, leaving the rest undeclared side effects that stale-output reconciliation later swept). This is a second probe consumption mode alongside the $<key:field> injection of § 22.5.7; the same cook.probe definition serves both.

Source-ref resolution. There is no segment-count limit. A multi-segment source ref is lexically ambiguous between a probe key and a <key>:<field> selector. A conforming implementation MUST resolve the ref against the set of declared probes once registration-time declarations are known: a declared probe whose key equals the entire ref wins; otherwise the segment after the final : is a field selector and the remaining prefix MUST name a declared probe. A ref that names no declared probe under either interpretation MUST be rejected with a register-phase diagnostic naming the full ref.

Two-category discriminator (normative; CS-0131). In gather position the bare-vs-quoted distinction is load-bearing and MUST NOT be flattened: a bare gather cards names a declaration or probe key (a namespace identifier — the fan-out member source of this section), whereas a double-quoted gather "cards" is a literal filesystem glob (§8). The two forms are NOT interchangeable — and it is exactly the discriminator that lets them compose (CS-0197): gather cards "src/*.txt" is a member source followed by literal glob patterns.

Member file inputs (CS-0197). Quoted glob patterns MAY trail the bare source. Each resolves at register time under the ordinary input-resolution rules (§ 21.2.1) — once per step, not per member — and the resolved file set becomes part of every member unit’s declared inputs, content-hashed into each member’s key (§ 17.1.1). This is the surface for “each member’s body reads these files”: before CS-0197 a member-fanout unit was keyed on its member and its command text alone, a body reading src/$<in.id>.txt read a file that was in no key, and no spelling could declare it — the mixed form was a parse error. The declaration is deliberately coarse: an edit to any matched file re-keys every member. A per-member declaration (member accessors resolving inside the pattern) is reserved for a future amendment; refusing a body that reads outside its declared set is likewise out of scope, per § 17.1.1‘s rule that the engine infers no determinant of its own. This is the general law: a double-quoted operand is a literal filesystem string (paths, globs, output patterns); a bare operand is a namespace identifier (module name, probe key, recipe dependency). It differs from the probe_name / probe_ref positions, where bare and STRING denote the same probe key and are interchangeable — there the quotes are only an escape hatch for un-spellable characters, not a category switch.

A gather <probe> source is the canonical case where a recipe’s declared output set shrinks between runs: removing a data member drops the unit (and the output) it drove, so the dropped member’s artifact is swept by stale-output reconciliation (§ 17.7) on the next run.

The whole-member reference $<in> renders records as canonical JSON. In a shell body it MUST be enclosed in single quotes. Bare JSON is subject to shell brace expansion, while double quotes do not protect the JSON’s own quote characters; an implementation MUST reject either form before registration, naming the reference, source line, and single-quote fix. Member-field references are outside this rule because their values need not be composite.

The quoting requirement binds data members only (CS-0234). It applies to the array elements of an ordinary probe. It does NOT apply to a bare gather source naming a files declaration: those members are the manifest’s path members (§ 9.3), a path is a string and never composite, and it therefore quotes — or does not — exactly as a glob gather’s path member does. An implementation MUST NOT reject an unquoted $<in> under a named-files gather, nor under the gather $<recipe> source of § 8.2, Form 3 (CS-0239), whose members are the referenced recipe’s declared output paths and are path members on the same ground. This is the carve-out the Non-array value paragraph below already makes on the same ground: the two source categories this section opens with are one taxonomy, and a rule stated for data members reaches data members only. Where an implementation cannot yet distinguish the two at the point it enforces the rule, it MUST resolve the doubt toward the data-member reading and reject: a false rejection names a fix that is always safe to apply, while a false acceptance ships a silently mangled command.

Recipe sources are not probe sources (CS-0239). § 8.2‘s third gather form takes a $<recipe> reference rather than a bare name, and it is outside this section’s pre-pass and source-ref machinery entirely: there is no probe to resolve, no array shape to check, and no <key>:<field> ambiguity to settle. Its members are the referenced recipe’s declared output paths (§ 10.4.1), learned from that recipe’s own registration, which the name reference has already ordered ahead of the gathering body (§ 10.6). What the two sources DO share is the per-member fan-out this section specifies — one unit per member, an all-literal cook output aggregating rather than iterating, the same stale-output reconciliation when the member set shrinks. Two further properties follow from a recipe member being a path: $<in.ACCESSOR> is a path accessor, and the member path is a declared file input of the unit it drives, so a member re-runs when the artifact it reads changes rather than when its path string changes.

Pre-pass evaluation. A probe that feeds a gather <probe> source MUST be evaluated before unit registration completes, because its array value supplies the recipe’s member set and therefore the cardinality of accessor-bearing steps. 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. The build DAG does not grow after registration; data-member iteration introduces no dynamic registration.

Demand-driven. The pre-pass preserves the demand-driven rule of § 22.5.8: a probe feeding a gather <probe> source MUST NOT be evaluated unless its consuming recipe is reachable from the build target. Recipe-level reachability is determined from recipe headers and dependency edges before per-member fan-out, so the pre-pass evaluates only the probes of recipes that will register units.

Register-phase reads (CS-0219). cook.probes.get(key) (§ 24) invoked from register-phase Lua — a recipe body, a register block, a module body — MUST answer with the resolved value of the probe key declares, when key names a probe declared in this pass. Resolution follows this section’s rules exactly: the same evaluation sequence of § 22.5.8, the same observed value. A key that was already resolved (by the pre-pass above, or by an earlier read) MUST be answered from the resolved value rather than re-produced; a probe MUST NOT be produced more than once per invocation. A key naming no declared probe is outside this rule and retains the meaning § 21.1 gives it.

This is what lets a register-phase body generate its units from data: read the value, then register units whose commands, inputs, outputs and per-unit ordering edges (§ 22.1.3) are functions of it. The build DAG is still complete at the end of registration, and still does not grow afterwards. Nothing here interleaves execution with registration.

Three constraints keep that true:

  • Demand-driven. A probe MUST NOT be resolved because it was declared. A register-phase read resolves the key it names, at the point it names it, and nothing else; a declared probe no register-phase read names MUST NOT be produced and MUST NOT be looked up or observed at all. An implementation MUST NOT satisfy this section by resolving every declared probe before the bodies run.

    The bound this gives is over reads, not over recipe reachability, and the difference is worth stating because it is easy to overclaim. Registration runs the bodies of the recipes the pass registers in order to discover their units, so a read in any body that runs demands its probe whether or not the build target reaches that recipe. This is the same overapproximation § 22.5.6‘s static Lua-body scan already makes for the execute phase, and for the same reason: an implementation that narrowed it would have to decide reachability before it knows what the bodies register. A discovery surface — one that lists what a Cookfile declares without registering units or executing anything (§ 22.7) — MAY skip resolution entirely and answer such a read as though no probe were declared, on the same grounds § 22.10 lets it skip the finalizer queue.

  • Reads from a produce body are confined to inputs.requires. A cook.probes.get call made while a probe’s produce source is running MUST name a key that probe declares in inputs.requires (§ 22.5.4); any other key MUST raise a register-phase error naming both probes and directing the author to declare it. The undeclared read is not merely undisciplined, it is unordered: only a probe’s declared requires gives it a dependency edge (§ 22.5.4) guaranteeing the named upstream has already produced its value by the time this body reads it; an undeclared upstream carries no such guarantee, so the read could race a production that has not happened yet. The rule also keeps a produce source meaning one thing in both phases — on a worker VM the same undeclared read raises § 22.5.8‘s unmaterialised error, because nothing scheduled the upstream — and it is what makes a produce body naming its own key, or two naming each other, a diagnostic: those are not requires edges, so § 22.5.9‘s check cannot see them.

  • A produce body registers nothing. Evaluating a produce source MUST NOT register work units, and MUST NOT be able to reach the enclosing recipe body’s capture state. A body-scope register API called from inside produce MUST raise that API’s ordinary outside-a-recipe-body error (§ 22.1, § 22.2), exactly as it does when the same probe is resolved before any body runs. Otherwise a recipe’s unit set would depend on whether the probe was answered from cache, which is registration output as a function of something other than registration input.

  • Static inputs. The static-input rule below binds every probe the register phase resolves, by whichever route it was reached, including one first resolved by a cook.on_register_complete callback (§ 22.10) — so an implementation checking the rule mid-pass MUST re-check it after the callback queue drains. A read is not an exemption from it.

Static-input rule. A probe resolved during the register phase — as a gather <probe> source, as a transitive inputs.requires of one, or by a register-phase cook.probes.get read — MUST be statically evaluable: its declared inputs (§ 22.5.4) MAY reference files and tools, but MUST NOT depend — directly or transitively through inputs.requires — on a build artifact produced by another recipe. A conforming implementation MUST reject such a dependence with a register-phase diagnostic:

probe '<key>' is resolved at register phase but depends on build artifact '<path>'; a register-resolved probe must be statically evaluable

The diagnostic is worded for the rule rather than for one route into it: a probe reached only by a register-phase read has no gather <probe> source to name, and naming one reported a declaration the Cookfile need not contain.

Iterating over artifacts produced earlier in the same build (the codegen-then-process pattern) would require interleaving execution and registration; it is reserved for a future amendment.

Probe-value references in output patterns (CS-0195). A fan-out cook step’s output pattern MAY contain a $<key:…> probe-value reference. An output path must be known when the unit registers, so the reference resolves at register time, against the pre-pass store this section already requires to be populated — and it renders by § 22.5.7‘s rules exactly: a string verbatim, a number as its canonical JSON token, a boolean as its literal, while null, arrays, objects, absent members, and every mis-typed addressing step are register-phase diagnostics naming the placeholder. A composite has no defined rendering in a filesystem path any more than in a command; before CS-0195 the reference implementation rendered Lua’s tostring here, so a table-valued reference put a heap address — different bytes every run — into a declared output path, which then entered the unit’s identity. Note the distinction from member references: $<in.FIELD> naming a nested member value renders it as canonical key-sorted JSON (§9.3, COOK-302), because a member field is data the pattern iterates over; a probe reference addresses configuration, and addressing a composite where a path segment is expected is an authoring error, stated as such.

Non-array value. An ordinary probe selected as a bare gather source whose resolved value (or selected field) is not an array-shaped table (§ 22.5.5) MUST surface a register-phase diagnostic naming the probe key and the resolved value’s shape. This rejection does not apply to a named files declaration: its manifest keys are the members.

Per-member units. Each array element becomes one data member, bound as item in the consuming recipe’s accessor-bearing (fan-out) steps (§ 8.2). Each per-member unit’s fingerprint folds the materialised element (§ 17), so editing one element re-runs only its unit, while an edit that changes the probe’s resolved array — a matched files declaration’s content, a tools binary, or the produce body itself — changes the array on the next observation and re-runs only the members whose content moved. A literal-output cook_step in the same recipe is not member-iterated: it gathers the preceding step’s collected outputs per § 8.4.1 (CS-0155).

Note (informative). An implementation must learn a recipe’s gather <probe>-feeding probe before running the recipe body (which reads the resolved array). The reference implementation surfaces the source on the recipe’s registration metadata — a __member_source descriptor naming the probe key (and optional field), whose shape and key spellings live in cook_contracts::registration so the codegen emitter and the register-phase parser share one declaration (COOK-390) — so the pre-pass resolves the probe from the recipe header without first executing the body. Recipe-level reachability (above) then bounds the pre-pass to the probes of recipes the build target actually reaches.

22.5.11. Implementation status (CS-0074) (informative)

Informative. This section is a build-log of reference-implementation milestones, retained for historical traceability; it states no requirement beyond those normative in the preceding sections of §22.5.

CS-0102 retired the msgpack encoding referenced below in favour of canonical JSON (§ 22.5.5); CS-0243 removed the probe-value cache that G4 and G5 below record the wiring of, so their CacheBackend get/put halves describe an implementation that no longer exists. CS-0244 further removed the fingerprint computation G4 and G5 describe (resolve_probe_inputs, compute_probe_fingerprint, probe_fingerprint_by_node, upstream_probe_fingerprints) along with the rest of the fingerprint machinery; there is no fingerprint left for a probe to have. The structural milestones stand as recorded.

C4–C6 implemented: requires resolution (§22.5.6), cycle detection (§22.5.9), execute-VM guard (§22.5.3).

F1–F3 implemented: rmpv dependency, Lua→msgpack walker (probe_value.rs), msgpack round-trip tests.

G1 implemented: WorkPayload::Probe dispatched on worker VM; produce source wrapped in function() ... end, return value converted to msgpack bytes, stored in ProbeOutput.

G2 implemented: cook.probes.get(key) on execute-phase VM reads from SharedProbeValueStore; cook.probes.set deprecated and raises a runtime error (CS-0074).

G3 implemented: scheduler populates SharedProbeValueStore when a WorkPayload::Probe WorkResult with probe_output: Some(...) arrives, before scheduling downstream consumers.

G4 implemented: execute_dag accepts probe_units_by_node: &BTreeMap<usize, ProbeUnit> (keyed by DAG node id). Before dispatching a WorkPayload::Probe node to a worker, the scheduler resolves its fingerprint via resolve_probe_inputs + compute_probe_fingerprint and attempts CacheBackend::get. On a hit, the cached msgpack bytes are inserted directly into the per-run probe-value store and the node is completed without worker dispatch (emits NodeCacheHit). On a miss, dispatch proceeds normally. Fingerprints are stored in probe_fingerprint_by_node keyed by DAG node id; upstream_probe_fingerprints is accumulated in topological order as probes complete, so downstream probe fingerprints chain correctly.

G5 implemented: after a worker returns with probe_output: Some(...) and success: true, the scheduler persists the msgpack bytes to CacheBackend via put_bytes using the fingerprint from probe_fingerprint_by_node and ArtifactMeta::as_probe_value(). The upstream fingerprint map is then updated so downstream probes can include this probe’s fingerprint in their own. Three unit tests in executor.rs cover the cache-hit path (skips dispatch, NodeCacheHit emitted), the cache-miss path (worker executes, artifact persisted with correct key), and the invalidation path (changed env var ⇒ different fingerprint ⇒ cache miss even when prior entry exists).

H1 implemented (superseded by H3): the original find_probe_refs {{...}} scanner in cook-luagen/src/template.rs is replaced by the unified $<IDENT> sigil pipeline (H3).

H2 implemented (superseded by H3): the {{...}}-aware expand_command_template path is replaced by H3’s unified expansion.

H3 implemented: sigil.rs IDENT-continue extended to admit :, -, [, ] so probe-key sigils ($<cc:zlib.cflags>, $<demo:cc-version.ver>) tokenise as single spans. resolver.rs added Resolved::ProbeRef { key, access } — when IDENT contains :, parse_probe_ref emits a cook.probes.get Lua expression. template.rs expand_command_template uses the sigil pipeline exclusively; when ProbeRef spans are present the expression is wrapped in function() return ... end for execute-time evaluation, and the set of probe keys is returned for auto-population of requires. The separate {{...}} scanner and find_probe_refs/parse_field_path/probe_ref_to_lua_access helpers are deleted. cook_luagen::sigil is now pub so downstream crates can reuse the scanner.

H4 implemented: cook-register/src/unit_api.rs try_expand_probe_templates now uses the shared sigil scanner (one source of truth) instead of a duplicate inline {{...}} scanner. Probe-shaped sigils (ident contains :) are expanded to cook.probes.get reads via cook.sh(...) LuaChunk; non-probe sigils are preserved as literal text.

Where that shared scanner lives has since moved twice, and this note said cook_luagen::sigil::scan with cook-luagen “added as a non-dev dependency of cook-register” for the whole time neither was true. The sigil scanner is cook_contracts::sigil::scan; the Lua-body scanner that reads a body for the probe keys it fetches is cook_contracts::lua_scan::scan_probe_reads (COOK-421, which moved it out of cook-luagen — it was the last production reason for that dependency edge, and the edge is gone). Both are pure text law with a codegen-phase reader and a register-phase reader that must agree; cook-luagen remains a dev-dependency of cook-register for tests that generate Lua before registering it.

J1 integration test: probe_consumer_end_to_end_first_run_then_cache_hit passes end-to-end (engine wiring for DAG edges and probe dispatch is complete). #[ignore] attribute removed.

EX1–EX3 examples: cli/e2e-fixtures/probe-basic/, cli/e2e-fixtures/probe-chain/, cli/e2e-fixtures/probe-cache-share/ updated to use $<key:field> syntax. All three build and run end-to-end.