Skip to content

§ 22 — Register-phase API

22. Register-phase API

Normative. This chapter defines every Cook Lua API surface that is register-phase only. A call to any function in this chapter from execute-phase Lua MUST raise a Lua runtime error per § C.6.12.

22.1. cook.add_unit

Signature. cook.add_unit(spec: table) -> nil Phase. Register-phase only.

cook.add_unit records a single work unit to be executed later. The table argument spec accepts the following fields:

FieldTypeDefaultMeaning
commandstring""Shell command string, run via /bin/sh -c in the execute phase.
lua_codestringabsentLua source executed in the execute phase. If present, command is ignored.
interactivebooleanfalseIf true, the unit runs on the main thread and drains concurrent work (§ 16.2).
lineinteger0Source line number used in diagnostics. Codegen emits it for every Lua-code unit — a runtime error in a >{ … } body reports this line — including a member-fanout body’s units, which reported the default 0 until COOK-396.
cachebooleantrueIf false, the unit is never cached and is re-run on every invocation.
inputsarray of string{}What the unit reads, and what its cache key is therefore keyed on. Each entry is a path or a pattern, classified when the call is made and carried on the declaration thereafter (§ 17.1.1.2); a pattern is expanded against the working tree when the unit becomes ready, so an entry MAY name a consumed output no file yet answers to.
outputstringabsentSingle declared output path (§ 8.4).
outputsarray of stringabsentMultiple declared output paths or glob patterns (§ 8.5, § 22.1.2). Glob patterns are resolved against the unit’s working directory after the command completes (§ 17.6).
gather_groupsarray of arrays of string{}For Lua-code units, the sequence of input groups exposed as input_N (§ 23).
discovered_inputstableabsentIf present, declares a file the command writes during the execute phase that records additional input paths the command consumed (§ 17.3). Subfields: from (string, required, working-directory-relative path) and format (string, required; the only mandatory format is "make").
sharingstring ("local", "pinned", "shared")"shared"Sharing disposition. "local" opts the unit out of the shared store (served from and written to the local cache only); "pinned" is designated-producer / fetch-only (MUST be served from cache, never reproduced by the consumer; a cold miss in both stores is a hard error); "shared" (the default, also the effect of an absent field) participates fully. Surface: the trailing local/pinned cook_mods (§ 8.4.3); effect: § 17.1.3. A value outside the three is a register-phase error.
recordbooleanfalseMarks the unit’s output intrinsically non-reproducible; the cache key is unchanged but byte-equivalence is waived at the cache decision (§ 17.1.4). Surface: the trailing nondet cook_mod (§ 8.4.3); the keyword was renamed from record to nondet, but the cook.add_unit field name remains record.
sealarray of string{}The unit’s per-unit effective seal set as bare probe keys (§ 8.4.3). Each named probe’s canonical value folds into this unit’s cache key at execute phase (§ 17.1.1); the key set is register-time data and creates a DAG edge from each sealed probe to this unit.
probesarray of string{}Probe keys this unit consumes as data (§ 22.5.6). Each resolved key adds a DAG edge from the probe to this unit; an unknown key is a post-pass error. This edge orders and enables substitution but does NOT fold the probe’s value into this unit’s cache key — seal does that (§ 17.1.1). $<key> / $<key.field> command placeholders desugar to a probes entry plus a cook.probes.get read (§ 22.5.7).
consulted_env_keysarray of string, or the string "*"{}Declared-variable names whose current values fold into the unit’s cache key (§ 17.1.2). "*" folds every declared variable. Codegen populates this from $<NAME> sigils and var.NAME static reads; a non-string element is a hard error. The field name predates CS-0172’s namespace split and is retained for compatibility with published modules.
envtable of string→string{}Per-unit environment overrides exported to a shell unit (used by chore shell units to surface bound parameter values, § 7.1.1). Keys and values MUST both be strings.
consumesarray of string{}Glob allowlist narrowing which pattern-derived input paths fold into this unit’s cache key (§ 17.1.1.2). Empty — the default — admits every path a declared pattern expands to. A declared path is never narrowed by it. Matching follows the gitignore convention against the candidate path as the unit declares it, relative to the unit’s working directory: a pattern containing no / matches the candidate’s basename at any depth (*.d.ts); one containing / matches the working-directory-relative path (dist/**/*.mjs). A conforming implementation MUST reject an unparseable pattern with a register-phase error naming the offending entry, and MUST NOT let a filter matching nothing narrow the key.
memberstringabsentA fan-out unit’s data member, canonical-rendered, folded into the command hash so each member gets a distinct fingerprint (§ 8.2). Codegen-populated for gather <probe> fan-out; not hand-authored. It is also what a unit declaring no file is keyed on (§ 17.4 rule 1).
afterarray of string{}Declared output paths of units earlier in the same recipe that this unit must run after (§ 22.1.3, CS-0219). Contributes a per-unit execution edge and nothing else: no entry here folds into the unit’s cache key. An entry naming no such output, or one whose producer is registered later, is a register-phase error.
step_kindstring ("cook", "test", "chore")"cook"The originating step kind. It drives the execute-phase sandbox policy on the resulting unit (§ 25.10) and, for "test", selects the test reporter and makes the unit a target of cook test. "test" was rejected here between CS-0153 and CS-0185, when a test unit was registrable only through a separate cook.add_test; it is now accepted, and that function no longer exists. A conforming implementation MUST reject any other string with a register-phase diagnostic naming step_kind, the received value, and the accepted set.

Field typing (CS-0127). Every field listed in the table above has exactly one accepted Lua type. A conforming implementation MUST reject a value of any other type with a register-phase Lua error that names the field, the received Lua type, and the accepted type. A wrong-typed field MUST NOT be coerced to its default — coercion silently discards author intent (a mistyped cache = "false" string, a mistakenly Lua-quoted line, an inputs list containing a non-string element) and the resulting unit runs with a default the author never asked for. Absence (a nil/missing field) is not a type error and MUST resolve to the field’s documented default.

Field provenance. Several fields — consulted_env_keys, member, step_kind, env, and the disposition trio sharing / seal / record — are normally emitted by the code generator from surface syntax (§ 8.4.3, § 8.2, § 22.5.7) rather than written by hand; cook.add_unit nonetheless accepts each directly and applies the same field-typing discipline (CS-0127). The legacy field name requires (a pre-probes spelling for probe references) is no longer accepted: a conforming implementation MUST reject a non-nil requires with a register-phase diagnostic directing the author to probes.

Output-field exclusivity. A conforming implementation MUST reject spec if both output and outputs are set, raising a Lua runtime error.

Path-shape restriction. Every entry in inputs, output, and outputs is a file path. 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 be accepted (declared outputs are typically not yet built at register time); non-existent input paths MAY be accepted at register time and surfaced later by the cache or input-iteration layer. The diagnostic MUST contain the substring is a directory and the offending path. Cook’s cache hashes file content, so silently accepting a directory would produce an incomplete cache record and the unit would re-run on every invocation. The directory-rejection rule applies only to entries that are literal paths (no glob metacharacters: *, ?, [). Entries containing glob metacharacters are not subject to this check at register time; the post-execute resolver MUST filter directory matches from the resolved set (§ 17.6, CS-0064). An outputs[] entry ending in / is a directory output (CS-0119), not a literal path: it intentionally denotes a directory and is exempt from this directory-rejection rule (see §22.1.2 and the post-execute resolution rules in §17.6).

Payload selection. A conforming implementation MUST classify the recorded unit as follows:

  1. If lua_code is present, the unit is a Lua-code unit; inputs, outputs, and gather_groups are carried on the payload and used by § 23 bindings at execute time.
  2. Otherwise, if interactive is true, the unit is an interactive shell unit.
  3. Otherwise, the unit is a non-interactive shell unit.

Caching. When cache is true, a conforming implementation MUST record cache metadata containing the recipe name, an input-path list, an output-path list, and a command hash. The hash inputs, cache-key derivation, and storage format are implementation-defined (§ 17).

Return value. cook.add_unit returns nothing. A conforming implementation MUST NOT cause the function to block on external work — registration is synchronous by definition (§ 14).

Output uniqueness (CS-0169). Across a single run, at most one work unit may declare any given literal output path. A conforming implementation MUST reject a registration pass in which two units declare the same literal output, with a register-phase diagnostic naming the path and identifying both producing units. The check is run-scoped, not recipe-scoped: two units in different recipes declaring one path is the same defect and MUST be rejected on the same terms.

A graph containing such a pair has no correct execution. The two units race to write one path; whichever loses is then seen as drifted on the following run and rebuilt, handing the loss straight back — so the build reports success and never settles, indefinitely, while the surviving artifact is whichever unit most recently won. Because each unit is individually well-formed, no other rule in the model diagnoses this; it is detectable only as the ambiguity it is, at registration.

This clause governs literal paths only. An output glob (§ 22.1.2) and a build-owned directory output (§ 17.7) declare a claim over a set rather than over a path, and the meaning of overlapping claims there is determined by output reconciliation. Whether two units may make overlapping set-claims is deliberately left open by this clause.

Example 22.1.1

cook.add_unit({
inputs = { "main.c" },
output = "main.o",
command = "gcc -c main.c -o main.o",
})

Records one shell unit with one input and one output. The same registration is produced by the surface form cook "main.o" { gcc -c $<in> -o $<out> } in a single-output cook step (§ 8.4, § 9.3).

Example 22.1.2

cook.add_unit({
inputs = { "src/wasm.rs" },
outputs = { "out.js", "out.wasm" },
lua_code = "os.execute('wasm-pack build')",
gather_groups = { { "src/wasm.rs" } },
})

Records one Lua-code unit with multiple outputs. At execute time, the lua_code runs with inputs, outputs, input, output, and input_1 bound as described in § 23.

Chore-body restriction (§ 7.4). When cook.add_unit is called from within the register-phase body of a chore, a conforming implementation MUST raise a Lua runtime error if cache = true is passed (or if cache is absent, since the default is true). The codegen layer enforces this at the source level by always emitting cache = false for every unit in a chore body; the runtime check is a defence-in-depth guard against register-phase Lua reached from a chore context — for example a bare module_call (§ 8.1) or a register block (§ 5.4) whose body calls cook.add_unit — that bypasses the codegen path.

A conforming implementation MAY use implementation-defined internal markers to track whether a chore body is executing. In the reference implementation the engine brackets every chore body invocation — surface and cook.chore alike — with a host-side guard that sets an internal flag cook.add_unit checks; there is no Lua-visible marker. (Earlier revisions emitted cook._enter_chore() / cook._exit_chore() calls into the generated Lua as a second mechanism for the same flag; COOK-386 removed them.) Any such mechanism is an implementation detail and MUST NOT be relied upon by Cookfile authors.

Note 22.1.1

Adjacent cook.add_unit calls made inside the same register-phase cook.step_group(fn) invocation become siblings in the same scheduler step group (§ 15.1) and MAY run in parallel. Outside a step group they are serial. The surface cook step (§ 8.4, § 8.5) routes through cook.step_group implicitly.

22.1.1. discovered_inputs field

A unit that consumes input files which are not statically known until execute time MAY declare a discovered_inputs table on its cook.add_unit call:

cook.add_unit({
inputs = { "src/a.c" },
output = "build/a.o",
command = "gcc -c $<in> -MMD -MF .cook/deps/a.d -o $<out>",
discovered_inputs = {
from = ".cook/deps/a.d",
format = "make",
},
})

A conforming implementation MUST:

  • accept discovered_inputs as either absent or a Lua table; reject any non-table value with a register-phase Lua error.
  • require both from (non-empty string) and format (string) when the table is present; reject missing or wrong-typed subfields with a register-phase Lua error.
  • require from to be a relative path under the unit’s working directory; reject absolute paths and paths containing .. segments.
  • support format = "make"; MAY support additional formats; MUST raise a register-phase Lua error if format names a value the implementation does not recognise. The diagnostic MUST include the offending value and the list of supported values.

The semantic effect of discovered_inputs on the cache — including the requirement that from MUST NOT also appear in outputs[] — is specified in § 17.3. This subsection defines only the Lua-level field syntax.

22.1.2. Output globs

An entry in outputs[] that contains any of the glob metacharacters *, ?, [ is a glob pattern entry; an entry that contains none of these is a literal path entry. A conforming implementation MUST treat literal entries as before this section (register-time path-shape check per §22.1; post-execute recording of the literal path’s content per § 17.1). For glob pattern entries a conforming implementation:

  • MUST NOT perform the path-shape directory-rejection check on glob entries at register time.
  • MUST expand glob entries against the unit’s working directory immediately after the unit’s command completes successfully, before composing the unit’s StepEntry (§ 17.6).
  • MUST filter out directory matches from the expanded set (CS-0064).
  • An empty resolved set MUST NOT be an error; a conforming implementation MAY persist an empty resolved set as the unit’s StepEntry.outputs.

Trailing ** matches files within all subdirectories. A glob pattern entry of the form <prefix>/** (or the bare pattern **) is interpreted as if written <prefix>/**/* (or **/* respectively), so that the entry matches files at any depth under the prefix. A conforming implementation MAY apply this normalisation either as a textual rewrite before pattern resolution or by treating the trailing-** case directly in its matcher. The normalisation MUST NOT affect patterns whose ** segment is not the final segment (e.g. **/lib/*.so is left unchanged).

The expanded set replaces the unit’s declared outputs[] for the purposes of cache recording, artifact upload, and cache restoration. The unit’s persisted StepEntry.outputs is always a list of literal file records (path, mtime, hash, and local validation evidence); patterns appear nowhere in the cache. Local metadata evidence is subject to § 17 and MUST NOT be folded into portable content keys (CS-0254).

Cross-recipe edges. A recipe whose outputs[] declaration contains any glob pattern entry MUST NOT have any of those patterns syntactically match a literal inputs[] entry declared by any other recipe in the same workspace. A conforming implementation MUST perform this detection at register time by lexically testing each downstream recipe’s literal inputs[] strings against each upstream recipe’s glob pattern set; the detection MUST NOT consult the filesystem and MUST NOT depend on the unit’s working directory or any post-execute state. On a positive match the implementation MUST raise a register-phase error that names both recipes, the offending input path, and the matching pattern. Downstream recipes MAY depend on a recipe with globbed outputs via requires = { ... }; such an edge provides DAG ordering only and MUST NOT propagate cache invalidation through file content. See § C.17.1.

A directory output — an outputs[] entry ending in / — denotes the entire subtree rooted at that path and is, like a glob entry, a non-literal terminal output: for the purposes of this rule it is treated as a recursive-wildcard pattern matching every path within the subtree (the entry with a ** wildcard appended). No other recipe’s literal inputs[] may resolve to a path inside the subtree; downstream recipes MAY order against it via requires = { ... } (ordering only, no content invalidation).

Example 22.1.3

cook.add_unit({
inputs = { "src/app/page.tsx", "src/lib/util.ts", "package.json" },
outputs = { "apps/web/.next/**" },
command = "pnpm --filter web run build",
})

Records one shell unit whose declared output set is resolved by globbing apps/web/.next/** against the unit’s working directory after pnpm --filter web run build completes. The cache record holds a literal file list, hashed in the usual way; a subsequent run with unchanged inputs is a cache hit, and a subsequent run after rm -rf apps/web/.next restores the recorded files from the backend without rerunning the command.

22.1.3. The after field: per-unit ordering within one recipe

A cook.add_unit call MAY declare an after field: an array of strings, each naming a declared output path of an earlier unit of the same recipe. Each entry contributes one per-unit execution edge from the declaring unit to the unit that declares that output. A conforming implementation MUST reject a non-array value, and MUST reject a non-string or empty-string element, naming the field, under § 22.1‘s field-typing discipline (CS-0127). The default is the empty array, and a unit declaring none is unaffected by this section in every respect.

Ordering only. The edge is execution ordering and nothing else. A conforming implementation MUST NOT fold an after entry — or the identity, outputs, or key of the unit it names — into the declaring unit’s cache key. This is the rule § 22.11 already states for cook.dep_order, of which this is the intra-recipe counterpart, and it is not merely a symmetry: a unit whose ordering moved while its command, its declared inputs, its declared outputs and its member held still reads the same bytes and writes the same bytes, so re-running it would be waste. What a unit reads it declares in inputs[], and that is what its key folds. An author who wants a whole probe value in a unit’s key has seal (§ 8.4.3).

Backward references only. An entry MUST name an output declared by a unit registered strictly earlier in the same recipe body. A conforming implementation MUST reject, with a register-phase diagnostic, an entry that names:

  • no output declared by any unit of the recipe — the diagnostic MUST name the offending path and MUST state that an entry names an output of an earlier unit of the same recipe;
  • an output declared by a unit registered at or after the declaring unit — the diagnostic MUST name the offending path and MUST distinguish this case from the one above, because the two are different mistakes: the first is a typo, the second is an emission order the author’s own data does not admit;
  • an output declared by more than one unit of the recipe — the diagnostic MUST name the offending path and the units declaring it. An implementation MUST NOT resolve such a reference to one of them. The output-uniqueness rule of § 22.1 binds literal paths only, so two units MAY legitimately declare one glob or directory output; a reference to it names no single producer, and choosing one would leave the declaring unit racing the other with nothing said.

A unit that declares no output is therefore unnameable by this field, and that follows from the identifier rather than limiting it: a cook.exec unit, an interactive unit and a test unit declare no output, so there is no string that would name one. Within a recipe such units are already ordered by the barrier rules of § 15.1.

Acyclicity is therefore structural: an edge set expressible under this rule cannot contain a cycle, and the forward-reference diagnostic is what enforces it. This is deliberate. A module deriving units from scanned data holds the graph and can report a cycle in the vocabulary the author wrote it in (“circular import foo -> bar -> foo”), where an implementation detecting one here could only report it in unit positions. It also preserves the property that every dependency of a work unit is already registered when that unit is, which is what lets an implementation materialise the graph in one forward pass with no cycle search at all.

Validation MUST be performed once the recipe body has closed, not at the moment of the cook.add_unit call: inside the call the later-registered producer does not yet exist, so the two diagnoses above are indistinguishable.

This is a declaration, not an inference. § 10.6 forbids an implementation from inferring an edge from path-string equality, and names the inputs[] / outputs[] entries of cook.add_unit among the surfaces the prohibition binds. That prohibition stands, unchanged in force. It constrains the evidence from which an edge may be inferred, not the mechanism by which one is declared — exactly the distinction § 22.9 draws for cook.require_recipe. An inputs[] entry that happens to equal some other unit’s outputs[] entry is a coincidence of spelling about which the implementation would be guessing; an after entry is a reference whose entire content is the reference, written by the author (or by a module acting on data the author supplied). Nothing that established no edge before establishes one now.

Why the output path is the identifier. A unit has no name. § 22.1‘s output-uniqueness rule (CS-0169) already guarantees that at most one unit in a run declares any given literal output path, so the declared output is an identifier the model keeps unique without inventing a second namespace to keep unique alongside it. It is also the string the consuming author already holds: the unit that must wait is, in the ordinary case, the one that reads the file. Matching is textual, after normalising a leading ./; a glob-pattern or directory outputs[] entry (§ 22.1.2) is nameable only by its verbatim spelling, since its expansion is not known at registration — and, being outside CS-0169’s literal-path scope, is subject to the ambiguity rejection above rather than to a guess.

The field is after, and deps is not a field. A conforming implementation MUST reject a non-nil deps on the argument table with a register-phase diagnostic naming both real ordering channels: after for a unit of the same recipe, cook.dep_order / cook.dep_output (§ 22.11, § 24.7) for another recipe. deps is the name an author reaches for and is ambiguous between the two; accepting it silently would leave the ordering they asked for simply absent.

Example 22.1.4

local graph = cook.probes.get("scan:mods") -- §{cat.probes.member-source}
cook.step_group(function()
for _, m in ipairs(graph) do -- emitted in dependency order
local after = {}
for _, imported in ipairs(m.imports) do
after[#after + 1] = "build/" .. imported .. ".bmi"
end
cook.add_unit({
outputs = { "build/" .. m.name .. ".bmi" },
inputs = m.sources,
command = "compile " .. m.name,
after = after,
})
end
end)

Registers one unit per scanned module. All of them are siblings in one step group, so they share an entry barrier and MAY run in parallel (§ 22.5); the after edges are additive with that barrier and narrow the parallelism to exactly what the scanned data requires. The graph is a function of the probe value and is complete before any command runs.

22.2. cook.exec and cook.interactive

Signatures.

  • cook.exec(cmd: string, line: integer) -> string
  • cook.interactive(cmd: string, line: integer) -> string

Phase. Register-phase only.

These helpers record shell units from register-phase Lua: cook.exec a non-interactive unit and cook.interactive an interactive one (the interactivity a chore-body shell_command carries by default, § 7.3). Each call records one unit with caching disabled. The return value is the empty string; it is returned only so that callers composing results do not fail on nil concatenation.

A conforming implementation MUST raise a Lua runtime error when cook.exec or cook.interactive is called from execute-phase Lua (a lua_line, a lua_block, or a >{ … } payload). The diagnostic MUST identify the function name and the calling step kind. The same rule applies to other register-only Cook Lua API helpers: cook.add_unit, cook.step_group, cook.recipe. See § C.6.12.

An implementation SHOULD include a brief migration hint in the diagnostic — for example, suggesting a bare module.call() line in the recipe body (§ 8.1) instead of > to record the call at register phase, or a top-level register block (§ 5.4) as an alternative register-phase context (informative).

22.3. cook.recipe

Signature. cook.recipe(name: string, metadata: table, body: function) -> nil Phase. Register-phase only.

Registers a recipe with the given name, metadata (gather, excludes, requires, origin), and body function. Surface recipes (§ 6.1) are compiled to exactly one cook.recipe call. A conforming implementation is not required to expose cook.recipe as a user-facing extension point; its contract here is what the code generator (§ A.1) produces.

Recipe metadata exclusions and cook.resolve_gather(includes, excludes) use the same logical-path exclusion rule (§ 8.2, § 21.2.1), including anchor validation and the parent-component exception. Filtering MUST preserve each include group’s order; the public resolver flattens those groups without removing duplicates across groups. (CS-0255.)

The origin key. The metadata table accepts an optional origin key alongside gather, excludes, and requires. When present, origin MUST be a non-empty string naming the module surface that minted the recipe; the conventional value is the module-qualified name of the function that called cook.recipe (for example, "cook_pnpm.workspace"). It is an annotation: it attributes a registered name to the module that produced it, for the benefit of a reader who did not write that name. Its sole normative effect is on a listing surface (§ 22.7); it MUST NOT affect registration, name uniqueness, dispatch, scheduling, or any cache key. An absent origin is not an error and denotes an unannotated recipe.

An origin attributes a name to the module that produced it, so only a module-minted recipe carries one. A recipe declared as a surface recipe NAME or chore NAME (§ 6.1, § 7.1) is a name the user wrote, and never carries an origin: a conforming implementation’s code generator MUST NOT supply one for a surface declaration, and the discovered set MUST report no origin for a recipe registered that way. The obligation on a module that mints a recipe to supply an origin is normative at § 12.7.8.

A present origin whose value is not a string, or which is the empty string, MUST be rejected with a register-phase Lua error naming the field and the accepted form, rather than being silently coerced to absence. This is the field-typing discipline § 22.1 imposes on cook.add_unit (CS-0127), applied to cook.recipe’s metadata table.

Name uniqueness within a Cookfile. Within a single Cookfile registration pass (§ 13.1), the set of names registered by cook.recipe MUST be unique. This applies regardless of registration site — surface recipe NAME and chore NAME declarations (§ 6.1, § 7.1) and dynamic cook.recipe calls from register blocks (§ 5.4), top-level module calls (§ 4.4), or wrapper Lua functions (such as cook_cc.bin, § 28) all participate in the same uniqueness namespace.

A conforming implementation MUST diagnose a duplicate name with a single error that names the offending recipe and identifies both registration sites by their source line. The diagnostic MUST distinguish surface declarations from register-phase Lua calls. The mechanism by which an implementation distinguishes the two registration paths is implementation-defined; the reference implementation tags surface registrations via a codegen-private helper while leaving the public cook.recipe API unchanged. See § C.21.9.

22.4. Test units

A test unit is an ordinary work unit. It is recorded by cook.add_unit (§ 22.1) with step_kind = "test" and an empty output list; there is no separate registration function. The code generator lowers each test_step (§ 8.6) to exactly one such call.

cook.add_test was that separate function, and is removed (CS-0185). A conforming implementation MUST NOT provide it — not as an alias, not deprecated — and MUST raise a register-phase Lua error when it is called, naming cook.add_unit and the step_kind = "test" form as the replacement, in the manner of § 8.6‘s removed-modifier diagnostics. It carried nothing cook.add_unit lacked: command, lua_code, inputs, seal and line were the same fields under the same names, iteration_item duplicated member, and timeout and should_fail had been inert constants since CS-0135. Two registration paths for one kind of thing meant every property a work unit gained had to be granted to test units separately and afterwards, which is the defect § 8.6 records at length.

Fields. A test unit accepts every field of § 22.1, subject to one constraint and with one addition.

The constraint: a step_kind = "test" unit MUST declare no outputs. A conforming implementation MUST reject output or outputs on such a call with a register-phase error naming the field. An empty output list is what makes a unit’s cache hit replay a recorded outcome rather than restore artifacts (§ 17.4); it is not what makes it a test. step_kind is what makes it a test, and the two MUST NOT be inferred from one another — an implementation deriving “is a test” from an empty output list, or admitting outputs on a test, does not conform.

A test unit adds no fields of its own to § 22.1.

Amended (CS-0191): iteration_item is removed. This table carried one row, iteration_item, described as “the test-reporting spelling of member”, with the note that an implementation MAY carry one field for both. The MAY is now a MUST: there is one field, member (§ 22.1), and a conforming implementation MUST NOT accept a separate iteration_item.

Its meaning is unchanged and moves to member intact: when a test unit’s member is non-nil and non-empty, a conforming implementation MUST include it as a discriminator in the test identity (e.g. recipe:name[item]) and in the displayed label (e.g. recipe@line [item]). For many-to-one and one-shot test steps the code generator MUST pass no member.

Permitting two spellings of one fact meant a fan-out cook unit and the test beside it answered “which member am I” through different fields, and the reference implementation duly emitted both from the same expression — iteration_item = cook.member_to_string(item), member = cook.member_to_string(item) — in the one arm where a test unit had a data member at all. CS-0185 recorded the duplication when it removed cook.add_test; this closes it.

consumes was listed here as a test-unit addition when CS-0175 introduced it. It is a field of § 22.1 (CS-0186): what it narrows is an input pattern’s expansion, and nothing about that asks which step kind the unit came from. A module declaring a producing unit that reads a dependency’s dist/** narrows it by the same field, under the same safeguards, with the same register-phase validation.

Suites are reserved (CS-0185). There is no suite field. A test unit belongs to the recipe that registers it, and a conforming implementation MUST group and report test units by that recipe. A future revision MAY introduce a grouping independent of the recipe; until a design for one exists, an implementation MUST NOT accept a suite field, because the removed one could name nothing its recipe did not already name.

inputs, seal, command, lua_code and line are § 22.1‘s fields and carry § 22.1‘s meanings; they are not restated here, and an implementation MUST NOT give them a second meaning on a test unit. Exactly one of command / lua_code MUST be supplied non-empty, by § 22.1‘s rule.

Test unit naming (CS-0185, superseding CS-0160). A test unit accepts no name field (CS-0135). A conforming implementation MUST derive its name as <recipe>_test<LINE>, where <recipe> is the enclosing recipe’s fully-qualified name and <LINE> is the line field — the Cookfile line of the originating test_step. The derived name is the unit’s label in progress and log output, and it composes with member in the test identity (e.g. checks:checks_test9[src/a.c]) (CS-0191).

CS-0160 derived <N> as the 1-based ordinal of the unit among the test units recorded so far in the enclosing recipe body. Both discriminators are unique within a recipe — two test_steps cannot share a line — but an ordinal must be counted, which requires the registration API to inspect the units already recorded before it can name the one it is recording. The line is already a field of the call. The rule therefore states the cheaper of two equally correct discriminators, and a name is a label rather than a key: it enters no fingerprint (§ 17.4).

A conforming implementation MUST apply field defaults and raise register-phase Lua errors for invalid field values at the time the call is made, not deferred to execute time, per § 22.1.

22.5. cook.step_group

Signature. cook.step_group(fn: function, opts?: table) -> nil — equivalently cook.step_group(opts: table, fn: function); a conforming implementation MUST accept the table on either side of the function. Phase. Register-phase only.

cook.step_group invokes fn synchronously on the register-phase Lua VM. Every cook.add_unit (§ 22.1) call made within the dynamic extent of fn records its unit as a member of a single step group (§ 15.1). Step-group members MAY execute in parallel during the execute phase.

opts.dep_order (list of recipe names). Each entry MUST be handled exactly as a cook.dep_order call (§ 22.11) made as the group’s first statement: the referent’s body is forced, it joins the build closure, and every unit registered inside fn takes a per-unit execution edge to it. Units registered outside the group take no such edge. A conforming implementation MUST reject a non-list value, and MUST reject a list element that is not a string, naming the field.

The parameter exists because the bare call is positional. A cook.dep_order reference applies to every cook.add_unit that follows it and is discarded when the enclosing group closes, so where the author placed the call — and whether a group boundary happens to fall between it and the units it was meant for — decides its meaning. Declaring the names on the group makes the scope syntactic instead: these deps, these units, with no ordering to get wrong. The two forms are otherwise identical, and an implementation MAY implement one in terms of the other.

Note that a recipe body admits only step forms and bare <id>.<id>(...) module calls (§ 6.3), so neither an inline table argument nor a local binding can appear there; opts is reachable from module code, which is ordinary Lua. This is a property of the recipe-body surface, not of this API.

cook.step_group MUST return after fn returns; nested cook.step_group calls within fn are unspecified.

A conforming implementation MUST raise a Lua runtime error when cook.step_group is called from execute-phase Lua. The diagnostic MUST identify the function name and the calling step kind.

Example 22.5.1

cook.step_group(function()
for _, source in ipairs(inputs) do
cook.add_unit({
inputs = { source },
output = source:gsub("%.c$", ".o"),
command = string.format("gcc -c %s -o %s", source, source:gsub("%.c$", ".o")),
})
end
end)

The N units registered inside the function body form one step group; the N compiles MAY run in parallel under the execute phase.

Note 22.5.1

The surface cook step (§ 8.4, § 8.5) routes through cook.step_group implicitly. Authors of Cook modules (Part IV) typically call cook.step_group explicitly to record a fan-out workload.

22.6. cook.prior_outputs

Signature. cook.prior_outputs(member?: string) -> list of string Phase. Register-phase only.

Returns the declared output paths of the preceding output-producing step in the enclosing recipe body: the outputs of the most recently completed cook.step_group (§ 22.5), or of the most recent cook.add_unit (§ 22.1) that declared outputs and was not part of a group, whichever happened later. Returns an empty list when no step in the body has declared an output yet.

With a member argument, it returns the outputs of the preceding step’s unit for that member — the unit registered with a matching member field (§ 22.1) — falling back to the whole preceding-step set when the preceding step did not fan out over members. This is what a test_step lowered over a gather <probe> source needs, and the fallback is what keeps the answer correct when the step it follows gathered rather than fanned out. A conforming implementation MUST NOT return one member’s outputs for another member: a unit that read its own member’s artifact and declared the whole fan-out’s would re-run whenever any sibling’s output moved, and one that declared none at all would be keyed on nothing and replay its first verdict for ever (§ 17.4 rule 1).

This is § 8.6.1‘s iteration source, made available to the code that lowers a test_step. That step’s source is the preceding cook step’s outputs, falling back to the recipe’s resolved gather, and CS-0186 requires the chain to be resolved into the unit’s declared inputs at register phase (§ 17.4 rule 1) rather than reconstructed later from the unit’s position in the graph.

Why it is asked for here rather than computed by the lowering (CS-0186). 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 (§ 12), so a lowering that resolved the source from the syntax it had just translated would see nothing for them, and a test step following such a unit would find no source at all. Asking at register phase sees every unit in the body however it was registered, which is what makes the declared input set complete for both.

An implementation MUST NOT return outputs from an enclosing or sibling recipe: the result is scoped to the body currently being registered. It MUST return an empty list rather than raising when called before any producing step, because that is the ordinary case for a recipe whose source is its gather, and for one that has no source at all.

A conforming implementation MUST raise a Lua runtime error when cook.prior_outputs is called from execute-phase Lua, naming the function, as every register-phase-only API does. (CS-0186 required the diagnostic to name “the calling step kind” as well. Nothing else in this Standard requires that of a register-only guard, no implementation of one carries it, and an execute-phase VM is not the place where a step kind is a fact worth reporting: the clause is withdrawn rather than left as a requirement met nowhere.)

Note 22.6.1

Nothing requires an author to call this. It exists for the lowering and for module code that wants the same fallback chain a test step gets; a hand-written unit declares its inputs outright.

22.7. Recipe discovery

A conforming implementation’s recipe-discovery procedure is the operation that enumerates the names a Cookfile registration pass has made available for dispatch. The discovered set MUST include every recipe registered by cook.recipe (§ 22.3) during the pass, regardless of registration site (surface recipe NAME declarations, surface chore NAME declarations, register block bodies, top-level module calls, or wrapper Lua functions).

A cook NAME dispatch invocation MUST resolve NAME against the unified discovered set. A dispatch invocation that names a recipe registered only through register-phase Lua (for example, by a cook_cc.bin(NAME, …) call inside a register block) MUST execute identically to one naming a surface recipe declaration.

A conforming implementation MAY expose a listing surface (such as a cook list CLI subcommand) that enumerates the same unified discovered set without invoking any recipe body. When such a listing surface exists, it MUST surface register-phase-Lua–registered names alongside surface declarations. When such a listing surface exists and a discovered recipe carries an origin annotation (§ 22.3), the listing MUST surface that recipe’s origin along with its name, so that a name the user did not write is attributable to the module that minted it. A recipe carrying no origin MUST be listed with no such attribution. How an origin is presented — its wording, placement, and layout relative to the name — is implementation-defined. A listing surface implemented as a no-body-invocation variant of the register pass MUST still evaluate config blocks (§ 5.3) and top-level register-block code, since those may affect the set of names that cook.recipe ultimately registers.

An origin annotation does not affect dispatch. A cook NAME invocation that names an origin-annotated recipe MUST execute identically to one naming any other registered recipe, and a conforming implementation MUST NOT refuse such an invocation. This much reaffirms the requirement stated in paragraph 2 of this section rather than amending it: that paragraph already makes identical execution a MUST for every name in the unified discovered set, and an annotation carried on a recipe’s metadata changes nothing about the set NAME resolves against. It is stated again here because a module author may describe a minted recipe as “non-invocable by intent” (§ 12.7.8), and a reader could otherwise take that phrase to license a refusal.

Extending that requirement beyond dispatch: a conforming implementation MUST NOT condition ordering or diagnostics on the presence, absence, or value of an origin either. An annotation records who minted a name; it is not a channel for behaviour.

A module author who describes a minted recipe — a support recipe in particular (§ 12.7.8) — as “non-invocable by intent” is stating authorial intent, and the annotation is the mechanism by which that intent reaches the user: it tells a reader who did not write the name who did, and why it exists. It carries no engine-enforced prohibition. The Standard defines no invocability tier below “registered”; a registered name is dispatchable. See § C.21.10.

22.8. cook.recipe_name

Signature. cook.recipe_name() -> string Phase. Register-phase only.

cook.recipe_name returns the fully-qualified name of the recipe whose body is currently executing in the register phase, consistent with the qualified form § 22.4 names test units by: under an import prefix, the returned name is qualified the same way (e.g. "lib.build" for a recipe build imported under prefix lib), not the bare declared name.

cook.recipe_name therefore differs from the recipe global’s recipe.name field (§ 21.2), which is bound in the same scope but holds the declared (bare) name: in a recipe build imported under prefix lib, recipe.name is "build" while cook.recipe_name() is "lib.build". The two coincide in a root Cookfile, where no prefix applies. A module that needs the recipe’s session-unique identity — to key an export, or to distinguish two like-named recipes imported from different Cookfiles — MUST use cook.recipe_name; a module that needs the name the author wrote, such as for a human-facing artifact filename, SHOULD use recipe.name.

A conforming implementation MUST raise a register-phase Lua error when cook.recipe_name is called outside a recipe body. The diagnostic MUST name the API (cook.recipe_name) and state the inside-a-recipe-body requirement. This obligation applies to any register-phase Lua that is not currently executing inside a recipe body — a top-level call, a register block body, and a member-source probe’s produce body when that produce is evaluated on the register-phase VM as part of the pre-pass (§ 22.5.10) are all outside a recipe body and MUST error. A probe’s produce body is not a recipe body in general; an ordinary (non-member-source) probe’s produce runs on an execute-phase worker VM, where cook.recipe_name is not registered at all, so this MUST does not — and cannot — extend to that case.

Note 22.8.1

Because the returned name is fully-qualified, a module deriving artifact identity from cook.recipe_name() sees the import prefix folded in wherever one applies: a recipe declared recipe idLib and imported under prefix lib yields cook.recipe_name() == "lib.idLib", which — if a module strings it directly into an artifact or path template — can produce forms like liblib.idLib.a or build/obj/lib.idLib/. This is legal but visually ugly, and it creates a tension for a module that resolves link references written against bare recipe names (as authors normally write them, e.g. $<idLib>): such a module would disagree with a qualified derivation taken from cook.recipe_name() inside an imported Cookfile, even though both name the same recipe. Root Cookfiles (no import prefix) are unaffected, since the qualified and bare forms coincide there.

The remedy is already available: recipe.name (§ 21.2) is bound in the same scope and holds the bare declared name. A module deriving a filename SHOULD read recipe.name and reserve cook.recipe_name() for identity, rather than stripping a prefix back off the qualified form. The two accessors are complementary, and choosing between them per use is the module author’s call.

22.9. cook.require_recipe

Signature. cook.require_recipe(name: string) -> nil Phase. Register-phase only.

cook.require_recipe declares, from inside a recipe body, that the enclosing recipe depends on the recipe named name. The call has two effects: it orders the current registration pass, and it establishes a cross-recipe dependency edge.

The register-order guarantee. When cook.require_recipe(N) returns, the body of the recipe named N has been fully evaluated in the current Cookfile registration pass (§ 13.1). A conforming implementation MUST evaluate N’s body to completion before the call returns, if it has not already been evaluated in this pass. A recipe body MUST be evaluated at most once per registration pass: a second cook.require_recipe(N) call — from the same recipe body or any other — MUST NOT re-evaluate N’s body, and MUST return with the guarantee already satisfied by the first evaluation. Everything N’s body registers during that evaluation — its work units, its tests, and any value it publishes via cook.export (§ 24.5) — is therefore observable to the caller when the call returns.

The guarantee is transitive. Forcing N MUST also guarantee that every recipe N itself depends on — by dep-list entry, by requires metadata (§ 22.3), or by N’s own cook.require_recipe calls — has been evaluated first, to the same standard, except where the dependency names a recipe registered in another Cookfile (§ 11.2), whose evaluation this section does not govern. This follows from the guarantee above rather than extending it: N’s body is entitled to read the exports of the recipes N declares it depends on, so a N evaluated before its own dependencies has not been “fully evaluated” in any sense the caller can use. An implementation that orders a pass by sorting the declared dependency graph up front MUST NOT rely on that order to discharge this obligation, because a forced evaluation by construction departs from it: forcing N runs N’s body at the moment of the call, which may precede the point the sort assigned to any of N’s dependencies. Getting this wrong is worse than an outright failure — the forcing caller observes N’s export and is satisfied, while N itself silently read nil for its own dependency, so the resulting mislink is partial and no diagnostic fires.

The at-most-once rule does not discharge the transitive guarantee. A cook.require_recipe(N) that finds N’s body already evaluated MUST NOT re-evaluate it, but it owes the transitive guarantee in full, exactly as the call that first evaluated N did. An implementation that records “N’s body has been evaluated” MUST NOT read that record as also settling N’s dependencies: if that evaluation was performed by the implementation’s own up-front ordering rather than by a force, then nothing has yet stood down whatever skips apply to N’s dependencies (see “The forcing MUST NOT degrade to a silent skip” below), and this call is the first thing to place them in a build closure. Whether an implementation is exposed to this is decided by nothing more than whether it happened to reach N before or after the caller — which is to say, by the recipes’ names — so the already-evaluated case and the not-yet-evaluated case MUST be brought to the same result. Where a dependency of an already-evaluated N cannot be evaluated, the error the previous paragraph requires MUST still be raised: an implementation that raises it only when the force is what evaluates N has made its own designed diagnostic conditional on a spelling.

The edge. The call also establishes a cross-recipe dependency from the calling recipe to N, identical in kind and granularity to a dep-list entry (recipe A : N, § 10.6). The edge is at whole-recipe granularity — every unit of the caller waits for every unit of N — and it MUST NOT be refined to a per-unit edge: § 16.1 reserves refinement to placeholder-sourced edges and forbids it for dep-list-sourced ones, and this edge is a dep-list-sourced edge in all but its spelling. N MUST join the build closure of any invocation that reaches the caller.

A module that wants the register-order guarantee without the whole-recipe barrier MUST NOT call this API: it calls cook.dep_order (§ 22.11) instead, which forces N’s body to the identical standard while recording a per-unit edge. cook.require_recipe remains correct where the barrier is what the caller actually wants — a referent every unit of the caller genuinely consumes.

This is an instance of the name-reference rule, not an exception to it. cook.require_recipe(name) 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. That section’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; the match is a coincidence of spelling that the implementation would be guessing about. cook.require_recipe supplies exactly the evidence § 10.6 demands: the referent recipe is named — by the author directly, or by a module acting on a name the author wrote (e.g. links = {"idLib"}). The edge is authored, not inferred. See § C.16.1.

Consequently § 16.1‘s “No other source of cross-recipe ordering is defined” stands, and this section admits no non-name source of ordering. That section’s own first sentence already scopes ordering to name references — “Cross-recipe ordering is established only by name references” — and its enumeration of where such a reference may appear is amended by this section to name a fourth spelling: a name reference from recipe A to recipe B may appear in a dep-list, a bare placeholder, an accessor placeholder, or a register-phase cook.require_recipe call. The amendment is to the enumeration only; the category it enumerates — name references, and nothing else — is unchanged, which is why the “No other source” closer survives verbatim. § 16.1‘s specific disclaimer — that string equality between a path literal of A and an output path literal of B does not establish an edge — likewise stands, unchanged in force. Its wording is clarified by this entry, in the opposite direction from an exception: where it once named only the input/cook-output pair, it now states that the prohibition holds for every literal path surface alike — gather line literals, cook step output literals, and the inputs[] / outputs[] entries of cook.add_unit (§ 22.1). Nothing that established no edge before establishes one now; the clarification only makes explicit how many surfaces the prohibition already bound. cook.require_recipe is outside all of them, because it never takes a path.

The enumeration is amended rather than merely read broadly because it sits inside a MUST-bearing sentence, closed by “No other source of cross-recipe ordering is defined.” A conformance-test author reading § 16.1 alone would otherwise find a taxonomy with no slot for a cook.require_recipe edge, and correctly conclude that honouring one is non-conforming. Widening the list is what makes this section’s edge legible from the section that governs ordering; asserting the category argument here while leaving that list unable to express it would put the two sections in contradiction.

By contrast, § 10.6‘s prohibition on inferring an edge from path-string equality is not amended, and needs no amendment: cook.require_recipe takes a name, never a path, so it cannot launder a path match into an edge. The two rules are orthogonal — one governs where a name reference may be written, the other governs what does not count as one.

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. cook.require_recipe is not a second dependency channel; it is a third writer to the existing one.

name is bare. cook.require_recipe resolves name against the bare declared names registered in the current Cookfile registration pass — the same namespace the surface dep-list and the requires metadata field (§ 22.3) resolve against. name MUST be a bare declared name; a conforming implementation MUST NOT resolve it against qualified names (§ 11.3). Three reasons:

  1. Consistency of the field it writes. The API contributes to requires, so it MUST resolve the way requires resolves. Any other rule would make one string in one list mean two different things depending on which mechanism placed it there.
  2. Authors write names bare. Link references are written bare — $<idLib>, links = {"idLib"} — and § 10.6 name references are bare.
  3. A prefix is not knowable from inside. An import prefix is assigned by the importer (§ 11.2, § 11.3), so a Cookfile’s own body cannot know which prefix it will be imported under, or whether it will be imported at all. A qualified rule would break links = {"idLib"} the moment that Cookfile were imported under a prefix; the bare rule is prefix-invariant.

This section relates to § 22.8 exactly as Note 22.8.1 anticipates. 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. cook.require_recipe takes a name the author wrote, so its argument is bare. This section is the concrete resolution of the tension Note 22.8.1 flags: a module resolving link references written against bare recipe names now has a dependency API that agrees with them, and needs neither to strip a prefix back off a qualified form nor to guess one.

Errors. A conforming implementation MUST raise a register-phase Lua error in each of the following cases, at the time cook.require_recipe is called. Each diagnostic MUST name the API (cook.require_recipe) and state what the caller must do instead.

  • Called outside a recipe body. The diagnostic MUST state the inside-a-recipe-body requirement. As with § 22.8, this obligation applies to any register-phase Lua that is not currently executing inside a recipe body: a top-level call, a register block body (§ 5.4), and a member-source probe’s produce body when that produce is evaluated on the register-phase VM as part of the pre-pass (§ 22.5.10) are all outside a recipe body and MUST error. An ordinary (non-member-source) probe’s produce runs on an execute-phase worker VM, where cook.require_recipe is not registered at all, so this MUST does not — and cannot — extend to that case.
  • name is not a string, or is the empty string. The value MUST be rejected with an error naming the field (name) and the accepted form, rather than being silently coerced. This is the field-typing discipline § 22.1 imposes on cook.add_unit (CS-0127), applied to cook.require_recipe’s argument.
  • name names the enclosing recipe itself. A recipe MUST NOT require itself. This is the degenerate cycle, and MUST be rejected with its own diagnostic naming the recipe, rather than deferred to cycle detection and reported as a one-element cycle path. The comparison is bare-to-bare — name against the enclosing recipe’s bare declared name (§ 21.2), never against its qualified name (§ 22.8), since under an import prefix the qualified form never equals the bare argument and the check would silently fail to fire.
  • name is not a recipe registered in the current pass. The reference MUST be rejected, with a diagnostic naming both the unresolved name and the requiring recipe. This is § 10.6‘s standing validation requirement — that a named recipe be defined in scope before the execute phase begins — discharged at register phase, where the forcing makes it reachable earlier.
  • The edge closes a cycle. If the edges established by cook.require_recipe, taken together with all other cross-recipe edges (§ 16.1), contain a cycle, the implementation MUST reject the Cookfile with a diagnostic rendering the cycle path. Because the forcing is synchronous, a cycle is reachable during the registration pass itself — A requiring B whose body requires A would otherwise recurse without bound — and MUST be diagnosed as a cycle rather than surfacing as a stack overflow.

The forcing MUST NOT degrade to a silent skip. An implementation may decline to evaluate certain recipe bodies during a pass — a parametric chore that no invocation reaches, say, or a member-fanout recipe whose member source it has not resolved. Where such a skip is decided from information gathered before any body runs, it cannot account for an edge a body declares while running. An implementation MUST NOT let a skip of that kind silently swallow a cook.require_recipe call: if it cannot evaluate N’s body, it MUST raise a register-phase error naming the API, N, and the reason. Registering N with no work units while the edge still places N in the build closure is expressly non-conforming — the register and execute phases would disagree about what is being built, which is the exact failure this section exists to prevent. Two consequences follow for the cases where a skip decision and a forced evaluation meet:

  • A forced recipe is reachable by definition, so a skip predicated on reachability MUST NOT apply to it. This extends to a recipe forced transitively, via the dependencies of a forced recipe: the edge places N in the caller’s build closure, and every recipe N requires is in that closure with it, so a skip that stands down for N but applies to N’s own dependency reopens the same register/execute disagreement one level down. In particular a parametric chore (§ 7.1.1) that is forced MUST be evaluated with no argv supplied, exactly as § 7.5.1 requires of a chore reached as a dependency of the invocation’s target — a required parameter with no default then surfaces a legitimate register-time configuration error, rather than the recipe vanishing.
  • Where evaluation is genuinely unavailable, erroring is conforming; it is the silence that is not. An implementation whose member sources (§ 22.5.10) are resolved by a pre-pass keyed on the static dependency graph MAY reject a cook.require_recipe naming a member-fanout recipe whose source that pre-pass did not resolve. Such an implementation MUST say so, and SHOULD direct the author to declare the dependency statically as well (recipe A : N), which brings N into the pre-pass’s view and makes the same call succeed. This is a bounded implementation limit, not a property of the API: nothing in this section prevents an implementation from resolving the source on demand at force time instead.

Note 22.9.1

The motivating case is a module that must resolve a link reference from inside a recipe body it does not own. A C/C++ target maker resolving links = {"idLib"} reads idLib’s export (§ 24.5) to obtain its library path and transitive link info (§ 28.4). Since CS-0161 that read resolves on its own: cook.import forces the referent’s body when called from inside a recipe body (§ 24.5.2), because forcing is a property of reading an export rather than of declaring an edge. What follows describes why cook.require_recipe was the original answer, and remains the right one wherever the caller wants the whole-recipe edge as well.

Registration order within a pass is dependency-driven, not declaration order: two recipes with no declared dependency between them have no guaranteed relative order, so a module cannot assume that a recipe appearing earlier in the Cookfile has already run. Without a declared dependency, nothing makes idLib’s body run first, and the read silently returns nil — the maker then either fails obscurely or emits a link line missing the library. The author wrote links = {"idLib"} and reasonably expects both the ordering and the edge to follow from it.

cook.require_recipe is what lets a module bootstrap that edge from inside a recipe body it does not own. On seeing links = {"idLib"}, the maker calls cook.require_recipe("idLib"): idLib’s body is forced to completion, so the subsequent cook.import("idLib") resolves, and the recorded edge makes idLib build before the linking recipe. The author declared the dependency in their module’s vocabulary; the module translated it into the Standard’s.

22.10. cook.on_register_complete

Signature. cook.on_register_complete(fn: function) -> nil Phase. Register-phase only.

cook.on_register_complete queues fn to run once, after the current registration pass has finished evaluating every recipe body and performing its whole-graph validation, and before the implementation commits the pass’s module-held state for the register→execute handoff (§ 12.3.4). It is the finalizer counterpart to a module’s init() (§ 12.3.1): init() runs before any recipe body registers; a callback queued through cook.on_register_complete runs after every recipe body has.

Argument type. fn MUST be a Lua function. A conforming implementation MUST reject any other type with a register-phase Lua error naming the API (cook.on_register_complete), the accepted type (function), and the received Lua type, rather than coercing or silently discarding it — the CS-0127 field-typing discipline (§ 22.1) applied to this API’s sole argument.

Ordering and timing. Queued callbacks run on the register-phase Lua VM, exactly once each, in registration order — the order in which each cook.on_register_complete call was made — strictly after every recipe body of the registration pass has been evaluated and after the pass’s whole-graph validation, including the merged cross-recipe cycle check over the edges § 22.9 establishes (“The edge closes a cycle”, § 22.9) and the member-source static-input rule (§ 22.5.10), and before the implementation commits the pass’s module-held state for the handoff of § 12.3.4. Module-held per-VM state (§ 12.3.2) MUST still be live when a callback runs, and any mutation a callback makes to that state MUST be visible to the handoff that follows.

Outside any recipe body. A callback runs outside the dynamic extent of any recipe body. A body-scoped register API called from a callback — cook.add_unit (§ 22.1, including the step_kind = "test" form of § 22.4), cook.recipe_name (§ 22.8), cook.require_recipe (§ 22.9) — MUST raise that API’s ordinary outside-a-recipe-body error. The recipe set and the work-unit set of the pass are closed by the time any callback runs: every callback in the queue observes the same fixed universe of recipes and units.

Recipe and probe registration is rejected. A callback MUST NOT register a recipe via cook.recipe (§ 22.3) or a probe via cook.probe (§ 22.5.3). A conforming implementation MUST reject such a call with a register-phase Lua error naming cook.on_register_complete and the API the callback attempted to call, rather than accepting the registration and silently discarding it. The body-invocation loop has already completed when callbacks run: a recipe minted at this point would never have its body evaluated in the current pass, reproducing the exact register/execute silent-disagreement failure mode that this chapter’s forcing and skip rules (§ 22.9, “The forcing MUST NOT degrade to a silent skip”) exist to close.

Re-queuing. A callback MAY itself call cook.on_register_complete. The newly queued callback is appended to the end of the queue and runs after every callback queued before it, including callbacks queued by other callbacks; a conforming implementation MUST drain the queue until it is empty, preserving registration order and the fires-exactly-once guarantee for every callback, however it was queued.

Errors propagate. An error raised by a callback MUST fail the registration pass with that error, exactly as an error raised from a recipe body fails the pass.

Everything else is unrestricted. Outside the two restrictions above, a callback MAY call any other register-phase or both-phase Cook Lua API reachable from register-phase Lua: cook.import / cook.export (§ 24.5), cook.probes.get (§ 24.4.1), module-held Lua state, fs.* (§ 25), cook.sh (§ 24.1, which executes immediately since it is not itself a recipe body), and cook.load_module (§ 24.2).

Discovery surfaces MAY skip the queue. A listing surface that enumerates the discovered recipe set without invoking any recipe body (§ 22.7) MAY complete its enumeration without draining queued callbacks: because callback registration is rejected (see above), a callback cannot alter the discovered set, so nothing such a surface reports depends on having run them. A call to cook.on_register_complete reached by such a surface MUST still be accepted — it is register-phase Lua like any other — and its callback simply MUST NOT be required to run on that pass.

Note 22.10.1

The motivating case is a module that accumulates per-target state across many recipe bodies and must act on the complete set exactly once, rather than once per target. A compile_commands()-style top-level call in a C/C++ module wants to write one compile_commands.json covering every cc.bin / cc.lib target the Cookfile registers — but the module cannot know, from inside any single target’s body, whether more targets remain to register. Before cook.on_register_complete, the only way to sequence this was a fake ordering dependency — declaring an aggregation recipe that depends on every target, and keeping that list in sync by hand. Queuing the aggregation as a callback removes the fake dependency: the module calls cook.on_register_complete(fn) once, and fn runs with every target’s contribution already recorded, however many targets the Cookfile ultimately declares.

22.11. cook.dep_order

Signature. cook.dep_order(name: string) -> nil Phase. Register-phase only.

cook.dep_order records an ordering-only fine-grained name reference from the current recipe’s subsequently-added units to the recipe named name. It is the ordering half of cook.dep_output (§ 24.7) with the data half removed. Like cook.dep_output, the reference attaches to every cook.add_unit call made after it in the current recipe body, up to the close of the enclosing cook.step_group if any (§ 22.5), producing a per-unit execution edge from each such unit to name’s terminal units. Unlike cook.dep_output, it returns nothing, resolves no output paths, and contributes nothing to the unit’s cache-input set: the referenced recipe’s outputs are not read by this reference, and a conforming implementation MUST NOT fold them — or any other artifact of the reference — into the cache key of the units it attaches to.

Purpose. cook.dep_order exists for a unit that must run after another recipe’s work without consuming its artifacts, and for a module that must force a recipe’s body without imposing a whole-recipe barrier on its caller. The motivating shape is a C/C++ module’s static-library target: the maker names each linked library so it can read that library’s register-time exports (include paths, defines), but no unit of the recipe reads the upstream archive — the archive unit consumes only the recipe’s own objects, and the final consumer of the upstream archive is a downstream binary’s link unit. Forcing through cook.dep_order instead of cook.require_recipe (§ 22.9) makes the exports readable while leaving the recipe’s compile units free to run in one flat wave with the upstream’s, and naming the upstream again immediately before the archive unit places the ordering exactly where it is owed.

Resolution. name resolves exactly as cook.dep_output’s argument does — against the same namespace, with the same self-prefix qualification and alias handling (§ 24.7). Unlike cook.dep_output, a recipe with no terminal outputs is a legal referent: a zero-step meta recipe is a legitimate ordering target, so no terminal-output existence check applies at call time.

The register-order guarantee. When cook.dep_order(N) returns, the body of the recipe named N has been fully evaluated in the current registration pass, to the identical standard cook.require_recipe guarantees (§ 22.9) — including its transitivity and its at-most-once rule, and including the prohibition on degrading the forcing to a silent skip. Everything N’s body registers is therefore observable to the caller when the call returns, so a maker resolving a link reference can read N’s export (§ 24.5) immediately afterwards.

This is what makes cook.dep_order a replacement for cook.require_recipe rather than a companion to it. Forcing and ordering are separable concerns that cook.require_recipe fuses: a module that names a recipe solely to read its exports pays for a whole-recipe barrier it never wanted, and every unit of the caller waits on the referent’s last unit. cook.dep_order supplies the forcing alone and lets the caller place the ordering on the units that actually need it. A conforming implementation MUST raise a register-phase error, naming the API and N, where it cannot evaluate N’s body; a silent skip reproduces exactly the nil-export mislink § 22.9 forbids.

Closure membership is established. N MUST join the build closure of any invocation that reaches the caller, exactly as a dep-list entry or a $<N> step reference does (§ 10.6, CS-0121). Naming a recipe is what places it in the closure, and this API names one; requiring the author to also declare the name through a second surface would make one reference mean less than another spelling of the same reference. What cook.dep_order does NOT contribute is the coarse whole-recipe edge of § 22.9: the ordering it establishes is the per-unit edge above and nothing wider.

Errors. A conforming implementation MUST raise a register-phase Lua error when cook.dep_order is called outside a recipe body, naming the API and stating the inside-a-recipe-body requirement, mirroring cook.dep_output’s error model.

Note 22.11.1

The division of labour among the three edge-writing APIs. All three force the referent’s body and place it in the build closure; they differ in what ordering and what data they carry. cook.require_recipe adds the coarse whole-recipe edge and nothing else — every unit of the caller waits for every unit of the referent. cook.dep_output mints a per-unit consuming edge: paths are returned and folded into that unit’s cache inputs. cook.dep_order mints the per-unit edge alone, with no paths and no cache fold.

A C/C++ module needs the forcing everywhere and the barrier nowhere, so it uses cook.dep_order for the whole link path. The placement matters and follows from § 22.5: a reference accumulated inside a step group is discarded when that group closes, so a maker forces its links inside an empty group — the bodies are evaluated, the exports become readable, and no compile unit inherits an edge — then calls cook.dep_order again immediately before the archive or link unit, where the ordering genuinely belongs. cook.require_recipe stays appropriate for a referent every unit of the caller consumes, such as a generated configuration header, where the coarse barrier is both correct and free.

22.12. cook.chore

Signature. cook.chore(name: string, metadata: table, body: function) -> nil Phase. Register-phase only, and only during module evaluation (see Registration window below).

Registers a chore (§ 7) whose name is namespaced to the module registering it. It is the module-facing sibling of cook.recipe (§ 22.3): the metadata table accepts the same requires and origin keys, plus the __params parameter declarations a surface chore NAME header lowers to (§ 7.1.1). A conforming implementation MUST tag the registration as a chore, so the registered name carries chore semantics in full — it is invocable from the command line, produces no cacheable units, and does not participate in the build DAG.

cook.chore exists because a surface chore NAME block cannot express a verb the module supplies: writing the block is precisely the work the author should not have to do. A module offering project-management commands (cc.add, cc.link) has no way to make them invocable without this API, and no reason to make the author transcribe them.

Namespacing. name MUST contain at least one ., and the segment before the first . MUST identify the registering module: either the module’s name, or the module’s name with a leading cook_ removed. A module named cook_cc may therefore register cc.add or cook_cc.add, and MUST NOT register add, pnpm.add, or any other prefix. A conforming implementation MUST reject a violation with a register-phase Lua error naming the offending name, the claimed prefix, and the prefixes the module may use.

The prefix is not a convention: it is the mechanism by which § 12.7.8‘s name-ownership rule survives a module registering an invocable name at all (see Relation to § 12.7.8 below). An implementation MUST NOT accept an unprefixed chore name from a module under any metadata opt-in.

This is the exact inverse of the declaration-site rule: a surface recipe NAME or chore NAME header MUST NOT contain a dot (§ A.2), because there the name belongs to the author and namespacing would be meaningless. Dots are banned where the author writes the name and required where a module does.

Registration window. A conforming implementation MUST reject a cook.chore call made outside the evaluation of a module’s own chunk — including from a function the module returns and a Cookfile calls later — with a register-phase Lua error stating the requirement. The chores a module offers are therefore established by the use declaration alone (§ 12.1), with no further call required from the author.

The restriction follows from the namespace rule rather than from any property of chores. The check requires the identity of the module registering the chore, and that identity is exact only while the module’s own chunk is running. After a load returns, an implementation can at best know which module was loaded most recently — which is not the same question, and answers it wrongly as soon as a second use intervenes. A namespace rule enforced against a usually-correct identity is not a guarantee, so the window is narrowed to where the identity is certain. See § C.24.

body runs only when the chore is asked for. body is invoked under § 7.6: when the chore is one of the register pass’s targets, or is reachable from one, and never otherwise. A pass dispatching a name has one target; a whole-workspace pass has every recipe, which reaches a chore a recipe requires and does not reach a verb no recipe names; a pass that only reads the graph has none. A module author may therefore write a verb that edits the working tree — which is what § 22.13 is for — without guarding it against passes that are building something else. The registration itself is what a discovery surface reads (§ 22.7), so a chore whose body did not run this pass is still listed and still invocable. An implementation MUST NOT relax this for a chore whose __params is empty; see § C.26 for why that spelling was, and must not again become, the predicate.

Chore semantics are established by the implementation. A chore registered through this API MUST receive the no-caching and interactivity properties of § 7.4 for the full dynamic extent of body. A conforming implementation whose surface chores acquire those properties through generated code MUST NOT rely on that mechanism here: body is an ordinary Lua function that no code generator wrapped. Producing cacheable units from a cook.chore body is a violation of § 7.4, and a silent one — the chore appears to work while caching a command whose purpose is to re-run.

Uniqueness and diagnostics. A chore registered here participates in the same name-uniqueness namespace as every other registration (§ 22.3, “Name uniqueness within a Cookfile”). A conforming implementation MUST distinguish this registration path in a collision diagnostic, and MUST NOT report a source line in the author’s Cookfile for it: the call site lies in the module, so a Cookfile line number would be both wrong and actionable-looking. The same applies to every parameter diagnostic (§ 7.1.1) raised against a chore registered this way — an implementation MUST identify the chore by its origin where one is present, rather than by a declaration line the author cannot open.

Relation to § 12.7.8. That subsection’s categorical MUST — a recipe’s name and its invocability belong to the author of the Cookfile — is scoped to recipes, and does not forbid this API. A recipe is a build target in the DAG the author owns, and minting one silently enlarges what the author’s build does. A chore is a command: uncached, outside the DAG, and inert until invoked by name. cc.add is a tool the module offers, not a target the author should have had to declare. The namespace prefix is what keeps that distinction legible at the point of use — an author reading cook cc.add lib math can see that the verb came from a module and which one, without opening anything.

22.13. cook.cookfile

Signature. cook.cookfile.splice_field(path, recipe, field, entry [, options]) -> true Signature. cook.cookfile.append(path, text) -> true Signature. cook.cookfile.find_call(path, recipe) -> table | nil Signature. cook.cookfile.field_entries(path, recipe, field) -> table | nil Phase. Register-phase only.

Structure-preserving edits to a Cookfile, for the project-management chores a module registers under § 22.12. splice_field inserts entry into the { ... } list named field, within the module call in the recipe named recipe. append adds text at end of file. find_call reports the module call in recipe — a table carrying callee and text — or nil when that recipe holds none. field_entries reports the entries of a field’s list, or nil when there is no such field to report on.

Preservation is normative. A conforming implementation MUST leave every byte of the file outside the inserted range unchanged: comments, blank lines, field order, interior spacing, and non-literal Lua alike. An implementation MUST NOT satisfy these calls by evaluating the Cookfile’s Lua and re-rendering it.

The prohibition is specific because the tempting implementation is the lossy one. A Cookfile is a program, not a data file. Evaluating cook_cc.bin({ standard = cxx_std }) and re-emitting it writes back whatever cxx_std held, drops every comment, and reorders fields by pairs() iteration order. None of that is recoverable and none of it is visible in the result — the author is handed a file that still builds and no longer resembles what they wrote. § 22.12 exists so a module can offer verbs that edit the author’s Cookfile; a verb that quietly reformats it on the way past would make that a bad bargain.

Entries are rendered by the caller. entry and text are inserted verbatim. A conforming implementation MUST NOT quote, escape, or otherwise transform them: 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.

Failure is explicit and total. Where the requested edit cannot be located — no such recipe, no module call within it, no such field, or a field whose value is not a { ... } list — a conforming implementation MUST raise a register-phase Lua error naming what it looked for and where, and MUST leave the file byte-identical. An implementation MUST NOT approximate, insert the field speculatively, or partially write.

This is the property the preservation rule is purchased with. A re-rendering implementation cannot fail this way, because it cannot tell that anything was unusual; it writes a plausible file and reports success. An implementation that locates a span and splices into it knows exactly when it has not found what it was asked for, and the correct response is to say so and change nothing — the author can then make the edit themselves, against a file that is still theirs.

Creating an absent field is opt-in. options is an optional table, and options.create_if_absent = true is its one key. When it is set, and only then, the case “no such field” is answered by writing field = { entry } into the call’s argument table rather than by failing. Absent the table, or with the key set to false, the paragraph above binds unchanged: the field is not created and the file is not written.

A conforming implementation MUST reject an options key it does not recognise and a create_if_absent value that is not a boolean, rather than ignoring either. The two policies differ in whether a file is written, so a misspelled key that quietly meant “refuse” would surface to the author as an implementation declining an edit their verb believed it had asked for.

The default is the way round it is because the refusal is load-bearing and the creation is a convenience. A verb that means to add a field says so; a verb that means to edit one the author already wrote gets told when that field is not there, which is the whole of “failure is explicit and total”. Reversing the default would make every existing caller’s diagnostic into a silent write.

What creating does not relax. Exactly one case changes. A conforming implementation MUST still refuse, under either policy, where the recipe does not exist, where it holds no module call, where the file does not parse, and where the field IS present and its value is not a { ... } list. That last one is the case a permissive implementation gets wrong and cannot detect afterwards: creating a second links beside the author’s links = shared_links leaves a table with one key written twice, the later one winning, and the author’s value discarded without a word.

For the same reason, an implementation that does not match the bracketed key form (see “A field is a key, not a substring” below) MUST NOT create a field whose name is already bound in that form. Reporting ["links"] = { … } as “no such field” is a sound answer when the consequence is a refusal, and an unsound one when the consequence is writing links beside it, since an evaluator reads the two as one key.

A conforming implementation MUST NOT create a field in a call that passes no { ... } argument table. There is nowhere to put a key, and wrapping whatever the call is passed would change what it is passed.

A created field name is checked; a created entry is not. “Entries are rendered by the caller” delegates entry and text, and it does not reach field, which the create path also writes into the file. A conforming implementation MUST refuse to create a field whose name cannot be written as a bare table key — one that is not an identifier, or that is one of the embedded language’s reserved words. The delegation is right for entry because only the caller knows whether it is adding a string, an identifier or a table; it does not transfer, because a field name admits no such variation. It is a key, in one spelling, and an implementation that writes end = { ... } or = { ... } hands back a file that no longer loads, with nothing in the message to say which command wrote it. The empty name is the sharpest case: “a field is a key, not a substring” already requires it to match nothing, so without this rule one input would be refused cleanly under the default policy and would silently corrupt the file under the other.

A created field matches the file’s layout. The created field is one more entry of the call’s argument table, inserted by “the insertion point is the end of the code” below applied to that table rather than to a list within it. Where the author writes the argument table’s entries one per line, a conforming implementation SHOULD give the created field its own line, at the indentation of the entry it follows, carrying a trailing comma if and only if that entry carries one; where the entries share a line, it SHOULD stay on that line. Where the author’s last entry ends in a comment, the created field MUST NOT be placed between that entry and its comment.

This is a SHOULD because it is a question of taste, and it is stated at all because the tasteless answers are damaging rather than merely ugly. A field appended after standard = cxx_std, on the same line puts two entries on one line in a call that has none, and a field inserted at the code anchor of a commented line moves the author’s comment onto a field it was not written about. § 22.13 exists to keep an edit from restyling the author’s file; silently changing its layout convention is a restyle by another name.

Reading a field without editing it. field_entries(path, recipe, field) returns the entries of field’s list, in order, each as the bytes the author wrote — a string entry comes back with its quotes, because the caller compares against the entry it would pass to splice_field, which is also written verbatim. An empty list returns an empty table. A conforming implementation MUST return nil where there is nothing to report on — no such recipe, no module call in it, or no such field — which is find_call’s rule, and MUST raise where the field is present and its value is not a { ... } list, because a caller told nil in that case would go on to create a second key of that name.

The read is part of this subsection rather than left to callers because its obvious wrong implementation is available to every one of them. A verb that must not add math to links twice needs to know what is in links, and searching the call’s text for the entry matches inside sources = { "src/math/main.cpp" }. Answering the question correctly means locating a top-level key and splitting a list without treating a comma inside a nested table, a call, a string or a comment as a separator — which is the same work the rest of this subsection specifies, and which two modules reimplementing it in the embedded language would each get wrong differently.

Sandbox. path is resolved and gated exactly as fs.* paths are (§ 25, CS-0045). A conforming implementation MUST refuse a path outside the project root, before reading the file.

Locating without a field grammar. The recipe and the module call are structural and an implementation is expected to locate them as such. The field is not: a Cookfile’s embedded Lua payloads are opaque to the frame grammar, so no field node exists to find. A conforming implementation locates fields by scanning within the already-located call span. Three requirements bind that scan, and each is a way a naive one silently edits the wrong bytes.

Strings and comments are opaque. A } occurring inside a string literal or a comment MUST NOT be treated as closing the list. This holds for every spelling the embedded language admits, with no exemption for the less common ones: the short strings "…" and '…', the long-bracket strings [[…]] and [=[…]=] at any level, the line comment -- to end of line, and the long-bracket comment --[[…]] at any level, which ends at its closing bracket and NOT at the end of its line. An implementation that understands only some of these forms reads the rest as code, and a brace, an apostrophe or a -- written inside one then decides where the author’s list ends.

A field is a key, not a substring. field MUST match a table key written in the identifier form field =, at the top level of the call’s table argument. links MUST NOT match inside "mathlinks.cpp", inside a string that contains links=, inside a commented-out -- links = { … }, or as the key of a table nested within the argument. Where nothing matches, a conforming implementation MUST fail per “Failure is explicit and total” above rather than edit the nearest thing that resembled a match — including when a nested table has a key of that name, which is the case whose result still parses and still looks like the file the author wrote.

The bracketed key form ["links"] = { … } is a top-level key that this requirement does not reach, and an implementation is NOT required to match it. One that does not MUST report the field as not found; treating an unmatched spelling as licence to match a different key would give up the property the rest of this subsection is for. An implementation that does match it is conforming, provided it matches the same key an evaluator would.

The insertion point is the end of the code. Where an entry is appended to a non-empty list — or a created field to a non-empty argument table — it MUST be inserted after the last byte of code within those braces, which is not necessarily their last non-whitespace byte: a list whose final line ends in a comment would otherwise receive the entry inside that comment. A string literal is code for this purpose and counts to its last byte, so a list ending in [[note -- x]] receives the entry after the closing bracket. Braces holding no code receive the entry at their opening brace, with no separator and no invented padding: {} has no interior style to preserve, and supplying one would be an implementation having an opinion about a file it did not write.

See § C.25.

22.14. cook.materialize

Signature. cook.materialize(key: string, spec: table, produce: function) -> JSON-compatible Lua value

Phase. Registration only. This is Cook’s registration-time cache primitive, not a recipe kind or a target. key is a non-empty local identity; Cook qualifies it by the workspace and module context for cache storage, progress, and diagnostics, using the same prefix-joining rule as qualified recipe names (§ 11.3), so distinct (prefix, key) pairs never collide (an a prefix with key bc is a.bc; an ab prefix with key c is ab.c). A materializer key MUST NOT reserve a recipe name: a recipe named materialize, and a recipe whose name equals a materializer’s local key, are both legal. Materializers MUST NOT appear in target menus or target resolution, and an invocation naming a materializer key MUST be handled exactly as an unknown target.

Value and cache contract. produce runs on a Cook worker and returns one JSON-compatible Lua value: nil, boolean, number, string, or a table composed recursively of those values. Cook validates, canonically serializes, stores, restores, decodes, and returns that value; an author MUST NOT manage an output filename, schema, or a second cache. A warm hit returns the decoded canonical value without running produce; when the ordinary cache key changes, Cook MUST run produce or restore the value from the configured shared cache. The key uses the qualified materializer identity, producer body and captures, and the ordinary cached-unit determinants declared in spec: files, tools, env, and seals. Each is a list of strings and has the corresponding ordinary Cook meaning. The configured local/shared cache policy applies unchanged.

The producer may close over JSON-compatible Lua values and Lua helper functions whose own captures satisfy the same rule. A mutable table MUST occur only once across the producer and helper closure graph; Cook rejects shared or aliased mutable captures because reconstructing them independently would change Lua semantics. Cook also rejects userdata, threads, C functions, recursive captured functions, and non-JSON-compatible tables before running the worker; captured values and helper bodies are cache determinants.

Registration lifecycle. Cook may internally suspend or restart registration while values are prepared, but that mechanism is not user-visible or user-controlled. Once all materializers required by an invocation have restored or run, Cook MUST perform one authoritative recipe registration using their returned values. Independent materializers MAY run in parallel. A materializer MUST NOT declare a dependency on a recipe or another materializer; requires, dependencies, and deps in spec are register-phase errors. There is no cook setup phase, no user-controlled registration pass, and no materializer dependency chain in this version.

Probes and serving. A probe still re-observes whenever registration reaches it; a materializer instead restores or runs according to its ordinary cache key. In cook serve, a change to a materializer’s declared input MUST rerun the complete materialization, registration, and execution pipeline before reporting the new result. Cook MUST report a run or restore as cook materialize <qualified-key> <outcome>. A materialization failure MUST name the local and qualified key, declaration site, and failed determinant or producer, and MUST preserve useful worker output when one exists.

22.15. Faithful child recipe commands

cook.child_command(name) -> string returns a POSIX-shell-quoted command invoking the named target through the current Cook executable. It is available only while an active chore body is registering units. It neither executes the command nor adds a dependency. Pass the result to an existing uncached interactive unit:

cook.add_unit({ command = cook.child_command("build"), cache = false, interactive = true })

The child re-enters the canonical entry Cookfile and workspace root with the current preset, original CLI variable overrides, and invocation policies: publish-off, pruning, automatic GC, job count, output/color, verbosity, log replay, and affected/since selection. Environment and workspace configuration retain their existing inheritance rules. Member configuration remains scoped to its own Cookfile; implementations MUST NOT flatten resolved member variables into global overrides. Local, imported and canonical names follow the same qualification rules as recipe dependencies. A name colliding with a CLI subcommand still selects the recipe. Child invocation does not forward the parent’s chore positional arguments.

Every argument, including empty values and shell metacharacters, MUST remain one literal argument. The reference implementation requires UTF-8 invocation paths. name MUST be a nonempty string without NUL bytes. Calls outside an active chore, or from an embedding host that supplied no invocation context, MUST raise an actionable error; guessing an executable from PATH or a default Cookfile is forbidden. Unknown targets and child failures use the existing CLI failure semantics. Interactive terminal and process ownership remain the existing unit executor’s responsibility. A caller explicitly choosing to invoke itself can recurse; this helper provides no scheduler or recursion mechanism.