§ 16 — Cross-recipe ordering and interactive drain
16. Cross-recipe ordering and interactive drain
Normative. This chapter defines how cross-recipe dependencies enforce execution ordering, the output-path-uniqueness rule that prevents silent races, and the interactive-step drain behaviour.
16.1. Cross-recipe ordering
Cross-recipe ordering is established only by name references (§ 10 of this Standard). For each name reference from recipe A to recipe B — in a dep-list, bare placeholder, accessor placeholder, or a register-phase cook.require_recipe call (§ 22.9) — a conforming implementation MUST treat B as a prerequisite of A:
- An implementation MUST validate that
Bis defined in scope before the execute phase begins, and MUST reject the Cookfile with a diagnostic namingAandBotherwise. - An implementation MUST reject the Cookfile with a cycle diagnostic if the transitive closure of all cross-recipe edges contains a cycle, before executing any work unit.
- A placeholder-sourced edge MAY be refined per § 10.6. A dep-list-sourced edge MUST NOT be refined.
- No other source of cross-recipe ordering is defined. Specifically, string equality between a path literal of
Aand an output path literal ofBdoes NOT establish an edge. This holds for every literal path surface alike:gatherline literals,cookstep output literals, and theinputs[]/outputs[]entries ofcook.add_unit(§ 22.1). Where such a match between two recipes in one build closure is detectable, it is diagnosed, not ordered — § 16.1.2 states the rule and the surfaces it covers, which are narrower than the surfaces this prohibition binds. See § C.16.1.
16.1.1. Output-path uniqueness across recipes
Two distinct recipes A and B MUST NOT both declare the same canonical output path on cook steps unless the cross-recipe ordering rules of § 16.1 establish a path between them in either direction. The canonical output path of a cook step is the path produced by joining the recipe’s working directory with the step’s declared output literal.
A conforming implementation MUST detect this condition at plan time, before any work unit of either recipe runs, and MUST reject the build with a diagnostic naming both colliding recipes and the shared canonical output path.
The rationale is execution-order safety: under parallel execution (--jobs > 1), two recipes with no enforced ordering that write to the same path race silently — the artifact’s contents become non-deterministic, and downstream cache keys derived from the artifact become unstable. Requiring an explicit dependency edge collapses the race to a defined ordering. Where two recipes legitimately produce the same artifact, authors merge them into one recipe (single declaration site) or add an explicit : <recipe> dep edge to serialize them.
The rule applies to the declared output paths of cook steps; it does not apply to incidental writes performed by a step’s command body to paths it has not declared, which the Standard does not track.
Example 16.1.1
recipe writer_a
cook "build/shared.bin" { printf 'a' > $<out> }
recipe writer_b
cook "build/shared.bin" { printf 'b' > $<out> }
recipe build: writer_a writer_b
The Cookfile MUST be rejected at plan time. writer_a and writer_b both declare the canonical output build/shared.bin and there is no dep-list edge between them, so the implementation cannot serialize their writes. The diagnostic MUST name both writer_a and writer_b and the path build/shared.bin.
Note 16.1.1
The check is plan-time, not execute-time: an implementation that defers detection until a write-write conflict occurs in the filesystem fails the requirement, because a successful first run can mask a race that fails on a subsequent run. The check looks at canonical paths derived from the source, not at filesystem state.
16.1.2. Undeclared read-after-write across recipes
Where a recipe A declares a canonical path as a literal output entry, and a recipe B declares that same canonical path as a literal input entry, and both A and B are in the same build closure, the cross-recipe ordering rules of § 16.1 MUST establish an ordering path from B to A — that is, B MUST transitively require A.
The surfaces in scope for this rule are exactly:
| Side | Surfaces in scope |
|---|---|
A’s literal output entry | the outputs[] entries of cook.add_unit (§ 22.1); the output path literals of cook steps |
B’s literal input entry | the inputs[] entries of cook.add_unit (§ 22.1) |
Every surface named above is source-declared: its value is fixed by the text of the Cookfile and is knowable without consulting the filesystem. That is what makes the rule decidable at plan time, and the enumeration is deliberately closed — see Note 16.1.2.2 for the surface this rule does not cover, and why.
A conforming implementation MUST detect this condition at plan time, before any work unit of either recipe runs, and MUST reject the build with a diagnostic naming both recipes and the shared canonical path.
§16.1.1 takes precedence. Where a build closure violates both this rule and § 16.1.1, a conforming implementation MUST report the § 16.1.1 collision. Until that collision is repaired the path has two unordered writers, so “the producer” this rule would name is ambiguous: naming either one is arbitrary, and directing the author to require it would silence this diagnostic while leaving the write-write race in place. § 16.1.1 is also the more fundamental rule — it must hold before the question this rule asks, is the write ordered before the read?, is even well-posed. An implementation MUST NOT report this rule in place of a § 16.1.1 collision it has detected in the same closure.
The implementation MUST reject; it MUST NOT synthesise an edge. Repairing the ordering by inferring a dependency from the path match would reverse § 10.6, which states that only name references create edges and that an implementation MUST NOT infer an edge from path-string equality. This rule does not weaken that prohibition — it makes the author’s unmet expectation loud instead of silent. The author supplies the missing edge by naming A.
The predicate is directed, and this is where §16.1.2 diverges from §16.1.1. §16.1.1’s predicate is undirected — it accepts an ordering path in either direction — because a write-write collision is serialised by either ordering: whichever recipe runs second wins, and the outcome is at least defined. Read-after-write is asymmetric. Only B requires A is correct, because only that ordering places the write before the read. The reverse path, A requires B, is not exculpatory: it orders the reader before the writer, which is not an ambiguous race that scheduling might happen to win but a deterministic stale-or-missing read — strictly worse than no ordering at all. An undirected predicate would classify that arrangement as “dep-related” and accept it. §16.1.2 therefore passes only when B transitively requires A; every other arrangement, the reverse path included, MUST be diagnosed. The two sibling rules use different predicates deliberately; this is not an inconsistency between them. See § C.16.3.
The rule is scoped to the build closure, and this is where §16.1.2 diverges from § 22.1.2. A literal output is not build-owned: an author who runs cook producer && cook consumer — building A, then separately building B against the file A left on disk — is doing something legitimate, and an implementation MUST NOT reject B on the grounds that some recipe elsewhere in the workspace happens to declare the same literal path. The condition is a race only when both recipes are scheduled together, so only then is it diagnosed. § 22.1.2 is workspace-wide by contrast because terminality there is an ownership claim over a pattern or directory, which holds regardless of what is currently being built. See § C.16.2.
Terminal outputs — glob patterns and directory outputs — are the business of § 22.1.2 and are outside this rule; an implementation MUST NOT report both conditions for the same pair.
Example 16.1.2
recipe producer
cook "build/gen.a" { mkdir -p build && printf 'a' > $<out> }
recipe consumer
cook.add_unit({
inputs = { "build/gen.a" },
outputs = { "out.bin" },
command = "cp build/gen.a out.bin",
})
recipe all: producer consumer
Building all MUST be rejected at plan time. consumer declares the literal path build/gen.a as an inputs[] entry, producer declares that same path as a cook-step output literal, and both are in the closure — but consumer does not require producer, so nothing orders the write before the read. The diagnostic MUST name both producer and consumer and the path build/gen.a. Note that recipe all: producer consumer does not repair this: a dep-list is a set of prerequisites, not a sequence, and imposes no ordering among its own entries.
Both declaration sites here are in scope per the table above, and — this is the point of the example — the rejection does not depend on whether build/gen.a is present on disk. It is a cold build closure: nothing has been built, the path does not exist, and the plan is still rejected. That is the case the rule exists to catch, because it is the case that would otherwise race.
Reversing the dep — recipe producer: consumer — MUST still be rejected, even though an ordering path now exists between the two recipes. That path runs the wrong way: consumer would read build/gen.a before producer writes it, on every run. The author repairs the Cookfile by naming producer from consumer (recipe consumer: producer, $<producer>, or cook.require_recipe("producer")) — not by adding an edge in whichever direction happens to silence the diagnostic.
Note 16.1.2.1
Building consumer alone is not rejected: producer is not in that closure, so the rule does not apply. Such a build fails on its own terms when build/gen.a is absent, and succeeds when a previous cook producer left it in place — which is the workflow the closure scoping deliberately preserves.
Note 16.1.2.2 — why gather literals are not covered
A path literal on a gather line is not an input surface for this rule, and an implementation is not required to diagnose a read-after-write that reaches B only that way.
This is not an oversight but a consequence of what gather means. A gather pattern is a filesystem glob, resolved against the disk when the declaring recipe registers (§ 21.2.1). It does not denote a path; it denotes the set of files that pattern currently matches. A literal is simply a glob with no metacharacters, so it is subject to the same resolution: when build/gen.a does not yet exist, gather "build/gen.a" matches zero files and contributes no input entry at all — the recipe registers with an empty input set and the required empty-expansion warning (§ 21.2.1), and there is nothing for this rule to compare against.
Covering that surface would therefore invert the rule’s value. It would fall silent on precisely the cold build the race lives in — nothing built yet, so nothing matched — and speak up only once a stale build/gen.a from an earlier run already sat on disk, which is the state in which the read at least finds a file. A check whose sensitivity depends on the filesystem state it is meant to protect is worse than no check: it would report the hazard exactly when it had already been survived, and stay quiet when it had not. It would also contradict Note 16.1.1, which holds that the check “looks at canonical paths derived from the source, not at filesystem state” — the property that makes both §16.1.1 and §16.1.2 decidable at plan time.
The prohibition of § 16.1 is a different matter and is not narrowed here: a gather literal that happens to equal another recipe’s output path still creates no ordering edge, and an implementation still MUST NOT infer one from it. That prohibition is a rule about what MUST NOT happen and holds unconditionally, whatever the disk contains. What this note narrows is only the positive obligation to detect — an obligation that can be discharged only where the implementation can see a declared path without asking the filesystem what exists.
An author who wants the ordering must name the producer, which is what § 16.1 requires of every cross-recipe dependency regardless of this rule.
16.2. Interactive step draining
An interactive unit — a chore-body shell_command (§ 8.8), which is implicitly interactive by the chore default (§ 7.3) — MUST, at execute time, drain the implementation’s concurrent execute-phase work before its command begins. Draining means: at the instant the interactive unit is about to run, no other work unit of any recipe MUST be in progress. The implementation MUST wait until every then-running work unit has completed before beginning the interactive command.
An interactive unit MUST execute on a thread that inherits the controlling terminal of the Cook process, so that the spawned child inherits stdin, stdout, and stderr attached to a real TTY. The Standard does not prescribe which thread; it requires only the inheritance property and the drain property.
After an interactive unit completes, execution resumes under the normal rules: newly-ready units MAY be dispatched in parallel again. The drain applies only while the interactive unit is pending and running.
Example 16.2.1
recipe app
cook "build/app" { gcc main.c -o $<out> }
chore dev: app
./build/app
echo "exited"
Interactive units live in a chore body (§ 7), where every shell step is interactive by default (§ 7.3); recipe app builds build/app as a declarative cook step, and chore dev depends on it. When the interactive ./build/app step is reached at execute time, any other work in progress is drained, and it runs on a thread with the controlling TTY. Once it completes, echo "exited" runs — it too runs on the drain thread. The cook step of app MUST have completed before ./build/app begins, by the dependency edge dev: app (§ 16.1).
Note 16.2.1
Draining is stronger than “runs on a single thread”: it also excludes the interactive unit from running while any other work unit is still active anywhere in the build. This mirrors the common user expectation that a chore’s interactive command is a standalone interactive session with no concurrent output from background jobs.