commit 96608783797124dd00d823fc222bddf3ffa0891e
parent 17b1cc89e8496f6ccdf10e84326b174494792a1e
Author: Ryan Sepassi <rsepassi@gmail.com>
Date: Tue, 2 Jun 2026 14:57:07 -0700
plan: kit build v2
Diffstat:
| M | doc/plan/BUILD.md | | | 680 | ++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------------- |
1 file changed, 450 insertions(+), 230 deletions(-)
diff --git a/doc/plan/BUILD.md b/doc/plan/BUILD.md
@@ -2,9 +2,10 @@
> **Status: design, not yet built.** This is a forward-looking roadmap for a new
> subsystem layered on the content store in [../DISTRIBUTE.md](../DISTRIBUTE.md)
-> (`<kit/cas.h>`). It specifies the on-disk storage state machine, the
-> coordinator's in-memory state and caching algorithm, and the command protocol
-> recipes use to talk back to the coordinator.
+> (`<kit/cas.h>`). It specifies the configuration model, the on-disk storage
+> state machine, the coordinator's in-memory state and caching algorithm, the
+> command protocol recipes use to talk back, and how builds share work over a
+> network (remote CAS + signed traces).
>
> **Naming, to avoid confusion:** this is *not* [../BUILD.md](../BUILD.md), which
> documents how kit itself is built (the Makefile, `KIT_*_ENABLED` component
@@ -16,14 +17,13 @@
## What it is
A **build coordinator** is a long-lived process that turns a *build request*
-(build target `T` under configuration `C`) into a materialized **output tree** on
-disk, doing the least work necessary. It does this by running **recipes** —
-opaque executables, typically shell scripts — that produce output directories,
-and by caching every recipe result keyed by the exact set of inputs that
-produced it. A second request whose inputs are unchanged returns the cached
-output without re-running anything; a request whose *direct dependencies'*
-outputs are unchanged (even if some deep source changed) can still skip its own
-recipe.
+(target `T` under configuration `C`) into a materialized **output tree** on disk,
+doing the least work necessary. It does this by running **recipes** — opaque
+executables, typically shell scripts — that produce output directories, and by
+caching every recipe result keyed by the exact set of inputs that produced it. A
+second request whose inputs are unchanged returns the cached output without
+re-running anything; a request whose *direct dependencies'* outputs are unchanged
+(even if some deep source changed) can still skip its own recipe.
The whole design rests on two properties:
@@ -32,47 +32,91 @@ The whole design rests on two properties:
equality, never timestamps.
- **Determinism.** A recipe is assumed to be a pure function of its *declared*
inputs: identical declared inputs ⇒ byte-identical output tree. Caching is only
- as correct as this assumption (see [Determinism and hermeticity](#determinism-and-hermeticity)).
+ as correct as this assumption (see [Determinism](#determinism-and-hermeticity)).
-It reuses the existing CAS wholesale: source-file bytes, output directories, and
-trace bodies are all CAS blobs and trees. The coordinator adds two things the
-content store does not have — a *mutable* per-target trace index, and the
-resolution algorithm that drives it.
+It reuses the existing CAS wholesale: source-file bytes, output directories,
+config snapshots, and trace bodies are all CAS blobs and trees. The coordinator
+adds two things the content store does not have — a *mutable* per-target trace
+index, and the resolution algorithm that drives it.
## Concepts and vocabulary
| Term | Meaning |
|------|---------|
-| **Configuration** `C` | An immutable key→value map snapshotted for the life of one build. Supplies tunables (target triple, opt level, feature flags) to recipes. |
-| **Target** `T` | A named unit of work, e.g. `//app:server`. The build definition maps each target name to exactly one recipe + argv. |
-| **Recipe** | The executable the coordinator runs to produce a target's output. Identified by `recipe-id` = hash of (recipe bytes ‖ argv). |
+| **Target** `T` | A named unit of work, e.g. `//app:server`. A build definition maps each target name to one recipe + local argv. |
+| **Recipe** | The executable the coordinator runs to produce a target's output. Identified by `recipe-id` = `BLAKE2b(recipe file bytes)`. |
+| **Configuration** | A key→value map of tunables (target triple, opt level, feature flags). Two scopes — *propagated* and *local* — see [below](#configuration-model). Identified by `config-id` = `BLAKE2b(canonical map)`. |
| **Output tree** | The directory a recipe produces, captured as a CAS tree (`tree-id`). The unit a build request returns. |
| **Base inputs** | The *leaves* of the dependency graph, dynamically requested by recipes: **config values**, **source files**, and **globs**. |
-| **Target dep** | A dependency of one target on the *output tree* of another target. The graph's interior edges. |
-| **Shallow trace** | A record of one build: its direct base inputs + its direct target deps (by `tree-id`) → its output `tree-id`. |
-| **Deep trace** | A record of one build: the *transitive* closure of base inputs (target deps recursively expanded to their leaves) → its output `tree-id`. |
+| **Target dep** | A dependency of one target on the *output tree* of another. The graph's interior edges, created by `need`. |
+| **Shallow trace** | A record of one build: its direct base inputs + its direct target deps (by output `tree-id`) → its output `tree-id`. |
+| **Deep trace** | A record of one build: the *transitive* closure of source/glob leaves + the root `config-id` → its output `tree-id`. |
| **Tree cache** | On-disk *materialized* output directories, keyed by `tree-id`, ready to hand back as a filesystem path. |
The three base-input kinds are exactly the dynamically-requested leaves: a recipe
asks the coordinator for a config value, the bytes of a source file, or the
expansion of a glob, and each request is logged as a dependency so a later
-rebuild knows precisely what to re-check. A glob's "value" is the sorted set of
-matched paths *and* their content — see [glob](#glob-pattern) below.
+rebuild knows precisely what to re-check.
+
+## Configuration model
+
+Configuration has two scopes, distinguished by visibility:
+
+- **Propagated configuration** flows down the whole subtree. The top-level
+ request supplies it; a recipe reads a value with `config-get <key>` (logged as a
+ config dep); and a `need` may **overlay** it for the sub-build it triggers. A
+ target's *effective* propagated config = its inherited config with overlays
+ applied along the `need` path from the root. It is canonicalized to byte-stable
+ text and content-addressed: `config-id = BLAKE2b(canonical map)`, stored as a
+ CAS blob so any recorded `config-id` resolves back to the actual map.
+
+- **Local configuration** is the target's **argv**, fixed by the build
+ definition. It is visible *only* to that one target's recipe (delivered as the
+ process's actual argv) and is **not** propagated to deps. Treating argv as
+ config keeps the model uniform: everything a recipe sees is configuration, some
+ inherited, some local. Local config is recorded per trace but is not part of
+ `config-id`. It too is serialized to a canonical CAS blob, `argv-id`.
+
+**Serialized for replay, not just hashed.** Both the effective config map and the
+argv vector are persisted *by value* as canonical, content-addressed CAS blobs
+(`config-id`, `argv-id`) — never reduced to an opaque one-way hash. A trace
+references them by id, and they are part of its reachable closure (GC-rooted, and
+bundled with [shared traces](#shared-traces-trusted-as-signed-packages)). This is
+what lets the shallow path **replay every `need` request exactly** — reconstruct
+the dep's target name, its full effective config with overlays applied, and its
+argv — even when driving from an imported shallow trace with no access to the
+original build definition or workspace. Source files and globs are the opposite
+case: kept by hash only, because they are *verified* (did this still hash the
+same?), never replayed, and their bytes already live in the CAS.
+
+The **resolution identity** of a build is therefore `(target-name, config-id)`,
+where `config-id` is the effective *propagated* config. Local argv is determined
+by the target name (via the build definition), so it does not widen the key. Two
+requests `(T, C1)` and `(T, C2)` — the same target under different propagated
+config — are genuinely distinct builds; both may be in flight at once.
+
+`recipe-id` covers only the recipe *file bytes*. Two other things that can change
+a target's behavior — *which* recipe it maps to, and its argv — live in the
+**build definition**, which the coordinator reads to resolve `T`. That read is
+itself a tracked source dependency of `T`, so editing a target's stanza
+(repointing its recipe, or changing argv) invalidates its traces through the
+ordinary source-leaf mechanism.
### Why two trace kinds
-The deep trace answers *"has anything in my entire world changed?"* in one pass
-over a flat input set — no graph walk. When it matches, the output is determined
-and we skip straight to materialization.
+The deep trace answers *"is this exact configuration unchanged and has any source
+moved?"* in one pass over a flat leaf set — no graph walk. When it matches, the
+output is determined and we skip straight to materialization. It is the inner
+dev-loop fast path: edit code, rebuild under the same config.
-The shallow trace answers the harder case the deep trace gives up on: *some deep
-source changed, but did it actually change my dependencies' outputs?* A
-formatting-only edit to a header, a comment change, a touched-but-identical file
-— these perturb base inputs without perturbing the dep output that consumes
-them. The shallow path rebuilds the *direct* deps (cheaply, via their own
-traces), compares their resulting `tree-id`s against what the trace recorded, and
-if they match, the recipe can still be skipped. This is the payoff of recording
-deps by output identity rather than by input identity.
+The shallow trace handles everything the deep path defers — a changed source that
+might not actually move a dep's output, and *any* configuration change. It
+rebuilds the *direct* deps (cheaply, via their own traces, threading the new
+config down through `need`), compares their resulting `tree-id`s against what it
+recorded, and if they match, the recipe is still skipped. This is the payoff of
+recording deps by *output* identity rather than by input identity: a subtree that
+ignores a changed config key, or absorbs a comment-only edit, produces the same
+output tree and short-circuits its parent's recipe.
## On-disk storage
@@ -82,8 +126,8 @@ immutable and self-verifying; exactly one class of object is mutable.
```
<store>/
cas/ # the shared content store, per DISTRIBUTE.md
- blob/<pp>/<blob-id> # raw file bytes
- tree/<pp>/<tree-id> # canonical directory manifests
+ blob/<pp>/<blob-id> # raw file bytes (sources, config maps, argv vectors, trace bodies)
+ tree/<pp>/<tree-id> # canonical directory manifests (output trees)
... # (chunk/, index/ as in DISTRIBUTE.md)
build/
trace/<pp>/<trace-id> # IMMUTABLE canonical trace bodies (deep + shallow)
@@ -93,10 +137,12 @@ immutable and self-verifying; exactly one class of object is mutable.
```
`<pp>` is the first two lowercase hex chars of the relevant id (the DISTRIBUTE.md
-convention). `trace-id` = BLAKE2b-256 of the canonical trace body.
+convention). `trace-id` = `BLAKE2b` of the canonical trace body.
`target-key` = `BLAKE2b("kit build target v1" ‖ target-name)` — note it depends
on the target *name only*, never on config or recipe-id, so a target's index
-entry is stable while its inputs churn.
+entry is a stable handle while its inputs churn. Config maps and trace bodies are
+ordinary CAS blobs; only `target/` records and the `cache/` materializations are
+build-specific.
### Trace bodies (immutable, content-addressed)
@@ -108,56 +154,74 @@ match — fail safe). All ids are lowercase hex. Config values, source bytes, an
glob expansions are stored *by hash*, never inline, so a body's size is
independent of its inputs' sizes.
-**Shallow trace** — direct inputs and direct deps:
+**Shallow trace** — direct inputs and direct deps, for fine-grained rechecking:
```
kit-build-shallow 1
target //app:server
recipe <recipe-id>
output <output-tree-id>
-[config] ; sorted by key
-opt <value-hash>
-triple <value-hash>
+config <config-id> ; this target's effective propagated config (full map blob)
+argv <argv-id> ; this target's local config (serialized vector blob)
+[config] ; CONSUMED propagated key NAMES, sorted (values live in the map)
+opt
[source] ; sorted by path
src/main.c <blob-id>
+build.kit <blob-id> ; the build definition stanza is a tracked source
[glob] ; sorted by pattern
src/*.c <glob-result-hash>
-[dep] ; sorted by dep target-name
-//lib:core <dep-output-tree-id>
-//lib:util <dep-output-tree-id>
+[dep] ; sorted by (dep-name, dep-config-id) — each row replays one `need`
+//lib:core <dep-config-id> <dep-output-tree-id>
```
-**Deep trace** — transitive base-input closure, *no* `[dep]` section (deps are
-dissolved into their leaves):
+**Deep trace** — root config-id plus the transitive source/glob closure, *no*
+`[dep]` and *no* per-key `[config]` section:
```
kit-build-deep 1
target //app:server
recipe <recipe-id>
output <output-tree-id>
-[config] ; union over the whole closure, sorted, deduped
-opt <value-hash>
-triple <value-hash>
-[source]
+root-config <config-id> ; this target's effective propagated config (full map blob)
+argv <argv-id> ; local config (serialized vector blob)
+[source] ; union over the whole closure, sorted, deduped
src/main.c <blob-id>
lib/core.c <blob-id> ; pulled in transitively from //lib:core
[glob]
src/*.c <glob-result-hash>
```
-- `<value-hash>` = `BLAKE2b(config-value-bytes)`. The literal value is recoverable
- from the config snapshot; the trace stores only the hash so it is uniform and
- size-stable. A *requested-but-unset* key is recorded with a reserved
- sentinel hash so that later *adding* the key invalidates the trace.
-- `<blob-id>` = the source file's CAS blob id (`kit_blob_info`).
-- `<glob-result-hash>` = `BLAKE2b` of the canonical listing of `(path, blob-id)`
- pairs the pattern matched (sorted by path). It changes if any matched file is
- added, removed, or edited — so a single glob row covers the existence *and*
- content of its whole match set, and files read through a glob need no separate
- `[source]` rows.
-- `recipe-id` is recorded as an ordinary input and verified on lookup, so editing
- a recipe (or its argv, or which recipe a target maps to) invalidates traces the
- same way a source edit does.
+- `config <config-id>` and `argv <argv-id>` reference the **serialized** effective
+ config map and argv vector — canonical CAS blobs, kept by value and bundled with
+ shared traces — so the target's invocation and every `need` it issues replay
+ *exactly*, not merely check (see [Configuration model](#configuration-model)).
+- `[config]` lists the **names** of consumed propagated keys (consumed whether set
+ *or* unset); the values live in the `config-id` map this trace references. A key
+ *absent* from that map was consumed while unset, so later *adding* it busts the
+ trace — no sentinel value needed. Matching compares, for each consumed key, the
+ request's value against the built map's value (absent == unset).
+- `<blob-id>` = the source file's CAS blob id (`kit_blob_info`); source and glob
+ leaves are kept by hash because they are *verified*, never replayed.
+- `<glob-result-hash>` = `BLAKE2b` of the canonical `(path, blob-id)` listing the
+ pattern matched (sorted by path). It changes if any matched file is added,
+ removed, or edited — so one glob row covers the existence *and* content of its
+ whole match set, and files read through a glob need no separate `[source]` rows.
+- `[dep]` rows carry the **dep's config-id** (and output `tree-id`); together with
+ the dep's own serialized config/argv this re-resolves each dep under the exact
+ propagated config it used, overlays included.
+
+**Why the deep trace needs only `root-config` + source/glob leaves.** If the
+request's `config-id` equals `root-config` *and* every source/glob leaf still
+matches the live workspace, then every recipe in the closure has byte-identical
+inputs — so every `need` overlay it computes is identical, every effective config
+downstream is identical, and by determinism the output is identical. Any input
+that could perturb a downstream overlay (a source a recipe branches on, a config
+value) is itself either a recorded source/glob leaf or folded into `root-config`,
+so nothing escapes the check. Using the whole `root-config` (rather than just
+consumed keys) is a deliberate, conservative simplification: an *unconsumed*
+config change busts the deep fast path, but the [shallow path](#resolution-algorithm)
+recovers — it re-runs only the recipes whose output actually changed, so unrelated
+subtrees re-verify by hash without re-running.
### Target record (mutable, the only mutable object)
@@ -175,158 +239,222 @@ shallow <trace-id>
Each row points at a trace body in `build/trace/`. On every successful resolution
the coordinator prepends the fresh trace, de-duplicates, and truncates each kind
-to a small cap (`KIT_BUILD_RECORD_CAP`, e.g. 8) — a bounded MRU window. A trace
-older than the window ages out and becomes GC-eligible. Multiple candidates exist
-because the same target may have been built under several distinct input
-combinations recently (e.g. two config values flipped back and forth).
+to a small cap (`KIT_BUILD_RECORD_CAP`, e.g. 8) — a bounded MRU window. Older
+traces age out and become GC-eligible. Multiple candidates exist because the same
+target may have been built under several distinct input/config combinations
+recently (e.g. two config values flipped back and forth).
### Storage state machine (atomicity and crash safety)
The store must never hand back a wrong output after a crash. The rules:
-1. **Content objects** (`cas/blob`, `cas/tree`, `build/trace`, and a materialized
- `build/cache/<tree-id>/`) are written into `build/tmp/`, fsync'd, then
- **atomically renamed** to their final content-keyed path. A half-written object
- only ever exists under `tmp/` and is orphaned, never under its content key.
- Re-deriving the same content re-creates the identical path — writes are
+1. **Content objects** (`cas/blob`, `cas/tree`, `build/trace`, config maps, and a
+ materialized `build/cache/<tree-id>/`) are written into `build/tmp/`, fsync'd,
+ then **atomically renamed** to their final content-keyed path. A half-written
+ object only ever exists under `tmp/` and is orphaned, never under its content
+ key. Re-deriving the same content re-creates the identical path — writes are
idempotent, so a racing or retried producer is harmless.
-2. **The target record is the only ordering-sensitive state**, and it is updated
- by **read-modify-write into `tmp/` then atomic rename**. A reader therefore
- always sees a complete prior version or a complete next version, never a torn
- one. A crash loses *at most* the most recent record update; losing a record
- entry is *safe* — the next build re-derives and rewrites it. A corrupt record
- (failed `kit-build-record 1` parse) is treated as empty.
+2. **The target record is the only ordering-sensitive state**, updated by
+ **read-modify-write into `tmp/` then atomic rename**. A reader always sees a
+ complete prior or complete next version, never a torn one. A crash loses *at
+ most* the most recent record update; losing a record entry is *safe* — the next
+ build re-derives and rewrites it. A corrupt record (failed `kit-build-record 1`
+ parse) is treated as empty.
3. **Recipe sandboxes** live in `build/tmp/run-<n>/`. On success the output subdir
is ingested into the CAS (`kit_cas_add_tree_from_dir`) and the sandbox is
removed; on failure or crash it is orphaned and swept later.
**Invariant.** Anything reachable under a content key (`blob/`, `tree/`,
-`trace/`) is complete and matches its key. A target record may *dangle* — point
-at a trace that GC removed — and readers tolerate that by treating the missing
+`trace/`) is complete and matches its key. A target record may *dangle* — point at
+a trace that GC removed — and readers tolerate that by treating the missing
candidate as absent. The store degrades toward "rebuild," never toward "wrong
answer."
**Concurrency.** Two coordinators sharing a store are safe for content writes
(idempotent atomic renames). Target-record updates are last-writer-wins; both
-writers wrote *valid* records, so the only loss is MRU ordering, which costs at
-most a rebuild. An optional per-target-key advisory lock around the
-read-modify-write removes even that.
-
-**GC** (out of scope to implement now, in scope to design for): mark-and-sweep
-rooted at the live target records → reachable trace bodies → referenced
-trees/blobs; sweep unreachable `trace/`, `cas/`, and `cache/`. The tree cache and
-CAS are independently size-boundable by LRU since both are re-derivable.
+writers wrote *valid* records, so the only loss is MRU ordering, costing at most a
+rebuild. An optional per-target-key advisory lock around the read-modify-write
+removes even that.
## Build coordinator state and caching
### In-memory state
-All state hangs off one `BuildCoordinator` context (no globals — the project
+All state hangs off one `KitBuildCoordinator` context (no globals — the project
rule). For the life of the process it memoizes every base-input probe and every
target resolution, so a diamond in the graph is built once and a source file is
hashed once:
-| Field | Contents | Invalidated |
-|-------|----------|-------------|
-| `config` | The immutable configuration snapshot (key→value). | Never (per process). |
-| `source_hashes` | `path → blob-id` (with stat for sanity). | Never (per process). |
-| `glob_results` | `pattern → (sorted paths, glob-result-hash)`. | Never (per process). |
-| `targets` | `target-name → resolution state`: `in-progress` \| `done(output-tree-id, deep-input-set, materialized-path)`. | Never (per process). |
-| `cas` | Open `KitCas*` handle over `cas/`. | — |
-| `store` | Paths + the target-record reader/writer over `build/`. | — |
-
-"Cached over the life of the coordinator process" (the request brief) means
-exactly these memo tables: the coordinator assumes the workspace does not change
-*under it* mid-build. Each table also doubles as cycle/dedup control — a
-`target-name` found `in-progress` when requested again is a dependency cycle and
-is an error.
-
-A resolved target yields two things its parent needs: the **output `tree-id`**
-(to satisfy a `need`) and the **deep-input-set** (its deep trace's leaf closure,
-to fold into the parent's own deep trace).
+| Field | Contents | Notes |
+|-------|----------|-------|
+| `source_hashes` | `path → blob-id` (+ stat). | Per-process memo. |
+| `glob_results` | `pattern → (sorted paths, glob-result-hash)`. | Per-process memo. |
+| `config_maps` | `config-id → map`, and overlay results. | Immutable; content-addressed. |
+| `targets` | `(target-name, config-id) → future` of `{output-tree-id, deep-leafset, path}`. | Memo **and** in-flight dedup. |
+| `cas` | Open `KitCas*` handle over `cas/`. | Plus optional remote (below). |
+| `store` | Paths + target-record reader/writer over `build/`. | — |
+| `jobs` | Semaphore bounding concurrent recipe processes. | The global parallelism limit. |
+
+"Cached over the life of the coordinator process" means exactly these memo
+tables: the coordinator assumes the workspace does not change *under it*
+mid-build.
+
+The `targets` table is keyed by the full `(target-name, config-id)` and holds a
+**future**, not a flag — so concurrent `need`s of the same `(T, config-id)` await
+one resolution rather than racing (see [Parallelism](#parallelism)). A resolved
+build yields the **output `tree-id`** (to satisfy a `need`), a materialized
+**path**, and its **deep leafset** (the transitive source/glob closure, to fold
+into a parent's deep trace).
+
+### Cycle detection
+
+The `targets` memo alone cannot catch a cycle — a self-dependent target would
+simply await its own unresolved future and deadlock. So each resolution carries an
+explicit **build chain**: the ordered list of `(target-name, config-id)` frames
+from the root request down to here. A `need` whose `(dep, config-id)` already
+appears on the chain is a dependency cycle; the chain *is* the error message
+(`//a → //b → //a`). The chain is per-path and distinct from the cross-path memo:
+the same `(T, config-id)` may appear in many chains (a shared dep) but never twice
+in one.
### Resolution algorithm
-A request for `(T, C)` runs three phases. `C` is fixed for the process; the
-config-dependence of `T` is *recorded in and verified against* its traces, so the
-coordinator keys nothing by config and a change to an *unconsumed* config key
-never busts `T`.
+A request is `resolve(T, cfg, chain)` where `cfg` is the effective propagated
+config (with `cfg.id` its config-id). The config-dependence is recorded in and
+verified against the traces; the *deep* path checks whole-config-id equality, the
+*shallow* path checks consumed keys.
```
-resolve(T):
- if T in targets: # memoized / cycle check
- return targets[T] or error("cycle")
- mark T in-progress
-
- # ---- Phase 1: deep-trace fast path ----------------------------------
- for each deep trace D of T (newest first):
- if refresh(D.base_inputs) all match current state: # rehash sources,
- return materialize(D.output) # re-run globs,
- # recompare config,
- # recheck recipe-id
- # ---- Phase 2: shallow-trace path ------------------------------------
- for each shallow trace S of T (newest first):
- if any of S.direct_base_inputs changed: continue # this trace can't hold
- ok = true
- for each (dep, recorded_tree_id) in S.deps:
- cur = resolve(dep) # recurse
- if cur.output_tree_id != recorded_tree_id: ok = false; break
+resolve(T, cfg, chain):
+ key = (T, cfg.id)
+ if key in chain: error cycle(chain + key)
+ if key in targets: return await targets[key] # cross-path memo / in-flight dedup
+ targets[key] = new future ; chain' = chain + key
+
+ # ---- Phase 1: deep fast path (same config, did sources move?) -------
+ for D in deep_traces(T), newest-first:
+ if D.root_config == cfg.id
+ and D.argv == argv_id(T)
+ and refresh(D.source_glob_leaves) all match live: # memoized rehash / reglob
+ return done(materialize(D.output, T, cfg), D.leafset)
+
+ # ---- Phase 2: shallow path (config and/or sources moved) ------------
+ for S in shallow_traces(T), newest-first:
+ if S.recipe != recipe_id(T): continue
+ if S.argv != argv_id(T): continue # local config
+ M = config_by_id(S.config) # the built map (serialized blob)
+ if any consumed key in S.config-keys differs between cfg and M: continue
+ if any direct source/glob leaf of S changed: continue
+ ok = true ; child_leafsets = []
+ for (dep, dep_cfg_id, recorded_tree) in S.deps: # may run in parallel
+ r = resolve(dep, config_by_id(dep_cfg_id), chain') # replay the need: map recovered by id
+ if r.output != recorded_tree: ok = false; break
+ child_leafsets.push(r.leafset)
if ok:
- write_deep_trace(T, union of S.direct_base_inputs
- and each dep's deep-input-set) # refresh the deep trace
- return materialize(S.output)
+ leafset = union(direct source/glob leaves of S, child_leafsets)
+ write_deep_trace(T, cfg.id, leafset, S.output) # refresh the deep trace
+ return done(materialize(S.output, T, cfg), leafset)
+
# ---- Phase 3: run the recipe ----------------------------------------
- return run_recipe(T)
+ return run_recipe(T, cfg, chain')
```
with the materialization ladder shared by every cache hit:
```
-materialize(tree-id):
- if tree-id in build/cache/ : return that path # already on disk
+materialize(tree-id, T, cfg):
+ if tree-id in build/cache/ : return that path # already on disk
if tree-id in cas/ : restore into build/cache/, return path
- else : return run_recipe(T) # CAS evicted: rebuild
+ if remote configured : fetch tree-id (+ blobs) into cas/, verify, restore
+ else : return run_recipe(T, cfg) # bytes gone: rebuild
```
-The non-obvious move is **Phase 2 succeeding after a base input changed.** A
-changed source busts the *deep* trace (Phase 1 gives up), but if that source only
-feeds a dep whose recipe maps it to an unchanged output (`tree-id` identical),
-the dep compare in Phase 2 still passes and `T`'s own recipe is skipped. The
-deep trace is the cheap "nothing moved" check; the shallow trace is the "did the
-churn actually reach me?" check. On a Phase-2 hit we also write a *fresh* deep
-trace from the now-known closure, so the next request gets the Phase-1 fast path
-back.
+This is the brief's flow, sharpened by config:
+
+- *Phase 1* is the deep fast path: identical config-id and an all-clear source/glob
+ refresh ⇒ reuse. `refresh` hits the per-process memo, so after the first target
+ touches a file or glob the rest of the closure is free.
+- *Phase 2* is the shallow path, taken when Phase 1 finds nothing (config changed,
+ or a source moved). It guards on the target's own consumed config and direct
+ leaves, then re-resolves each recorded dep **under the dep's recorded config-id**
+ and compares outputs. Re-using the recorded `dep_cfg_id` is correct: if the
+ parent's consumed config were different, the consumed-key guard above would have
+ failed first and the parent would re-run, recomputing its `need` overlays.
+- A Phase-2 hit also writes a *fresh* deep trace from the now-known closure, so the
+ next request gets the Phase-1 fast path back.
+- *Phase 3* runs the recipe only when no trace holds, or when a hit's bytes were
+ evicted and no remote can supply them.
+
+The non-obvious win is **Phase 2 succeeding after an input changed.** A changed
+source busts the deep trace (Phase 1 gives up), but if that source only feeds a dep
+whose recipe maps it to an unchanged output (`tree-id` identical), the dep compare
+passes and `T`'s recipe is skipped. The deep trace is the cheap "nothing moved"
+check; the shallow trace is the "did the churn actually reach me?" check.
### Running a recipe
```
-run_recipe(T):
- sandbox = build/tmp/run-<n>/ ; out = sandbox/out/
- spawn recipe with env: { KIT_BUILD_SOCK, KIT_BUILD_OUT=out,
- KIT_BUILD_TARGET=T, workspace root }
- service the recipe's protocol requests, logging each as a dep (next section)
+run_recipe(T, cfg, chain):
+ acquire jobs # global parallelism limit
+ sandbox = build/tmp/run-<n>/ ; out = sandbox/out/
+ spawn recipe (argv = argv(T), recovered by argv_id) with env:
+ { KIT_BUILD_SOCK, KIT_BUILD_OUT=out, KIT_BUILD_TARGET=T, workspace root }
+ service the recipe's protocol requests, logging each as a dep (next section);
+ config-get reads cfg; need recurses resolve(...) under cfg ⊕ overrides
+ release jobs
on nonzero exit: propagate failure, write NO trace
on success:
- output-tree-id = kit_cas_add_tree_from_dir(out)
- write_shallow_trace(T, direct inputs logged this run, dep tree-ids,
- output-tree-id)
- write_deep_trace (T, transitive base-input closure, output-tree-id)
+ output = kit_cas_add_tree_from_dir(out)
+ put serialized cfg map (cfg.id) and argv vector (argv_id(T)) into the CAS
+ write_shallow_trace(T, recipe_id, argv_id, cfg.id, consumed key names,
+ direct leaves, dep edges with their config-ids, output)
+ write_deep_trace (T, cfg.id, argv_id, transitive source/glob closure, output)
prepend both to T's target record (dedup, truncate to cap)
- install out in build/cache/<output-tree-id>/
- return { output-tree-id, deep-input-set, path }
+ install out in build/cache/<output>/
+ return done({output, path}, leafset)
```
Writing *both* trace kinds on every real build is what lets a later request take
whichever path fits the change it sees.
+### Parallelism
+
+The coordinator owns all parallelism: it is the single process that launches and
+manages every recipe, so the overall job limit is configured in it (the `jobs`
+semaphore). Independent `need`s resolve concurrently up to that limit; the
+`targets` futures guarantee a shared `(T, config-id)` is built once even when many
+parents request it at the same instant. Scheduling is at recipe granularity — a
+recipe may parallelize internally, but the coordinator counts it as one job.
+Cycle detection composes with concurrency through the per-path build chain
+described above; the global memo handles dedup, the chain handles cycles.
+
## Recipe protocol
-Recipes communicate back to the coordinator over a transport left unspecified
-here — a pipe on a well-known fd, a unix socket named in `$KIT_BUILD_SOCK`, or
-an in-process callback table for recipes that are kit library calls rather than
-subprocesses. The command *set* is fixed; the wire is not.
+Recipes communicate back over a **transport the driver defines** and the library
+abstracts behind a host vtable — the same "host supplies all side effects"
+principle the rest of kit follows. The library defines the *command set* and the
+length-prefixed request/response framing; the host supplies connect/read/write.
+
+```c
+typedef struct KitBuildTransport {
+ /* Accept one recipe connection (the recipe dialed the endpoint named in
+ * $KIT_BUILD_SOCK); read/write length-prefixed frames; close. */
+ int (*accept)(void* user, KitBuildConn** out);
+ int (*read_frame)(void* user, KitBuildConn*, uint8_t* buf, size_t cap, size_t* n);
+ int (*write_frame)(void* user, KitBuildConn*, const uint8_t* buf, size_t n);
+ void (*close)(void* user, KitBuildConn*);
+ void* user;
+} KitBuildTransport;
+```
+
+Defaults and portability: a **unix-domain socket** (Linux, macOS, FreeBSD, and
+Windows 10+, which all support `AF_UNIX`) named in `$KIT_BUILD_SOCK`, or an
+**anonymous pipe pair** on inherited fds where a socket is undesirable; Windows
+may instead use a **named pipe**. An **in-process** transport (a direct callback
+table) serves recipes that are kit library calls rather than subprocesses. The
+command set is identical across all of them.
Every request both **returns a value** and **logs a dependency**. The logged
dependency is what lands in the shallow trace and (after expansion) the deep
@@ -336,89 +464,181 @@ command.
| Command | Returns | Dependency logged |
|---------|---------|-------------------|
-| `config-get <key>` | the config value (or *unset*) | config dep: `(key, value-hash)`, sentinel hash if unset |
-| `source <path>` | `blob-id` + an absolute path to read | source dep: `(path, blob-id)` |
+| `config-get <key>` | the propagated value (or *unset*) | config dep: key recorded as consumed; its value is the one in the effective `config-id` map (absent = unset) |
+| `source <path>` | `blob-id` + a path to read | source dep: `(path, blob-id)` |
| `glob <pattern>` | sorted list of matching paths | glob dep: `(pattern, glob-result-hash)` |
-| `need <target>...` | each dep's output `tree-id` + a materialized path | target dep edge: `(dep, output-tree-id)` |
-
-- **`config-get`** records the value's hash, so changing a *consumed* key
- invalidates while changing an unconsumed key does not. If a changed consumed
- key causes the recipe to branch and request a *new* key next time, the old trace
- already fails to match on the changed key, so the new key-set is discovered on
- the rebuild — the scheme is self-correcting and never needs to predict the
- input set ahead of time.
-- **`source`** hands back a path inside the workspace (or, in a hermetic variant,
- a path staged into the sandbox) plus the blob-id the recipe's read is pinned to.
+| `need <target> [k=v…]` | each dep's output `tree-id` + a path | target dep edge: `(dep, dep-config-id, output-tree-id)` |
+
+- **`config-get`** reads the target's *effective propagated* config and records the
+ value's hash, so changing a consumed key invalidates while changing an unconsumed
+ key does not. If a changed consumed key makes the recipe branch and request a
+ *new* key next time, the old trace already fails to match on the changed key, so
+ the new key-set is discovered on the rebuild — self-correcting, never needing to
+ predict the input set ahead of time. Local config (argv) is *not* read here; it
+ arrives as the process's argv.
+- **`source`** hands back a path inside the workspace (or, in a hermetic variant, a
+ path staged into the sandbox) plus the blob-id the read is pinned to.
- **`glob`** records the whole match set's content via `glob-result-hash`; the
recipe then reads the returned paths without further declaration.
-- **`need`** is the dynamic-dependency primitive. The coordinator `resolve()`s
- each target recursively (Phases 1–3), returns its output `tree-id` and a
- readable materialized path, and records the edge by *output identity*. This is
- what the shallow path re-checks. `need` is also where cycles are caught.
+- **`need`** is the dynamic-dependency primitive. The optional `k=v` pairs
+ **overlay** propagated config for that sub-build (local argv never propagates).
+ The coordinator resolves `(dep, cfg ⊕ overrides)` recursively, returns its output
+ `tree-id` and a readable path, and records the edge with the dep's `config-id`
+ so the shallow path can re-resolve identically. `need` is also where cycles are
+ caught.
- **Outputs** need no command: the recipe writes under `$KIT_BUILD_OUT` and the
coordinator snapshots that directory on exit.
+## Remote CAS and shared traces
+
+Two independent network capabilities, split along the DISTRIBUTE.md line —
+**content is trustless (hash-verified); claims are trusted (signed).**
+
+### Remote object fetch (trustless, via a fetch recipe)
+
+The coordinator performs no network I/O itself. When `materialize` (or a blob
+restore) needs an object that is absent locally and a remote is configured, it
+invokes a user-provided **fetch recipe** — an executable or command template, e.g.
+`curl -fsS -o "$KIT_FETCH_OUT" "https://cache.example/{kind}/{pp}/{id}"`, rendered
+with `{kind}` ∈ `blob`/`tree`, `{pp}`, `{id}` exactly like DISTRIBUTE.md's
+external-fetch templates. The fetched bytes land in `build/tmp/` and are
+**verified against the requested content id** before being installed into the
+local CAS; a corrupt or malicious mirror fails the hash check and is discarded. So
+the remote and the fetch recipe are *untrusted* — the existing self-verifying CAS
+makes that safe. This adds one rung to the materialization ladder (tree cache →
+local CAS → remote fetch → run recipe) and lets a clean checkout *download* an
+output instead of rebuilding it.
+
+### Shared traces (trusted, as signed packages)
+
+A trace is a **claim**: "these inputs ⇒ this output tree." Unlike content, it is
+**not self-verifying** — confirming it means re-running the (assumed-deterministic)
+recipe, which is exactly the work we are trying to avoid. To safely import someone
+else's trace we must **trust the claimant**. Traces are therefore shared as
+**signed trace bundles**, reusing the DISTRIBUTE.md package + minisign + trust
+machinery wholesale:
+
+- A bundle is a signed manifest (`kit-build-traces 1`, signed exactly like a
+ `kit-package` manifest) listing `(target-name, kind, trace-id, output-tree-id)`
+ claims, carried in a `.kpkg` (portable tar.gz or native kpkg) alongside the
+ trace bodies and the **serialized config-map and argv blobs** they reference
+ (required — without them an imported shallow trace cannot replay its `need`s),
+ plus, optionally, the referenced output trees/blobs.
+- Trust is the DISTRIBUTE.md model unchanged: verify the minisign signature
+ against the trusted-keys file (`-p KEY`, the anchor file, or `--tofu`); the
+ signed trusted comment binds the signature to the manifest hash.
+- Import: verify signature → anchor key → install the trace bodies into
+ `build/trace/` and prepend them to the relevant target records. Output bytes come
+ from the bundle or from the remote CAS — either way **hash-verified** on use.
+
+The result is a precise security split: a local build can now deep/shallow-**hit**
+on a remote builder's trace — obtaining the output *without running the recipe* —
+while trusting only the *signed claim*; the output bytes themselves remain
+trustless (verified by `tree-id`/`blob-id`). Trusting a trace signer is exactly
+like trusting a package signer in DISTRIBUTE.md: it is trust in their build
+outputs, gated by the trusted-keys allowlist, and auditable by re-running under
+[verify mode](#determinism-and-hermeticity).
+
## Determinism and hermeticity
Cache correctness is exactly the assumption *output = f(declared inputs)*,
-deterministically. The design's guard rails:
+deterministically.
- **Declared-inputs-are-complete.** Reading an undeclared file is a hermeticity
- violation. It cannot be *detected* without sandboxing/FS tracing, but the
- protocol makes declaration the only way to get an input, and the brief is to
- enforce it later (a sandbox that denies undeclared reads, or an `strace`/FUSE
- layer that records them). Until then it is a contract recipes must honor.
-- **Determinism.** Timestamps, RNG, network fetches, and `$RANDOM`-style
- nondeterminism break the model: the second build's captured tree differs from
- the trace's recorded output, and the cache would serve a stale-but-believed-
- current result. Mitigations: declare such inputs (a "now" config value, a
- pinned URL+hash), or mark a target **no-cache** so it always runs Phase 3.
+ violation. The protocol makes declaration the only *intended* way to get an
+ input, but **enforcement is deferred** (see below): for now it is a contract
+ recipes must honor.
+- **Determinism.** Timestamps, RNG, and unpinned network fetches break the model —
+ a second build's captured tree differs from the recorded output, and the cache
+ would serve a stale-but-believed-current result. Mitigations: declare such
+ inputs (a "now" config value, a pinned URL+hash), or mark a target **no-cache**
+ so it always runs Phase 3.
- **Verify mode.** A diagnostic mode re-runs a recipe whose trace says "unchanged"
and compares the fresh `tree-id` to the recorded one; a mismatch flags a
- nondeterministic or under-declared recipe. This is the recommended way to audit
- recipes before trusting their traces.
+ nondeterministic or under-declared recipe — the recommended audit before
+ trusting (or signing and sharing) traces.
## Worked example
`//app:server` depends on `//lib:core`; the recipe globs `src/*.c`, reads
-`src/main.c`, and consumes config `opt`.
-
-1. **Cold build.** No record. Phase 3 runs both recipes. `//lib:core` builds,
- yields `tree L0`. `//app:server` runs, logs `config opt`, `glob src/*.c`,
- `source src/main.c`, `need //lib:core → L0`; yields `tree A0`. Shallow + deep
- traces written for both. `build/cache/A0/` materialized, path returned.
-2. **No-op rebuild.** Phase 1 deep trace of `//app:server` refreshes `opt`,
- `src/*.c`, `src/main.c`, and the *transitively folded* `lib/core.c` — all
- memoized, all match. `A0` is in the tree cache. Returns instantly, nothing
- re-run, no graph walk.
-3. **Comment-only edit to `lib/core.c`.** `lib/core.c`'s blob-id changed → the
- `//app:server` deep trace fails Phase 1 (its folded `[source]` moved). Phase 2:
- direct base inputs (`opt`, `src/*.c`, `src/main.c`) are unchanged, so probe the
- one dep — `resolve(//lib:core)`. That recurse's *own* recipe re-runs (its
- source moved) but emits a byte-identical `tree L0` (comment stripped). The dep
- compare `L0 == L0` passes → `//app:server`'s recipe is **skipped**, `A0`
- restored, and a refreshed deep trace written so step 2's fast path returns.
-4. **Flip `opt`.** `//app:server`'s deep and shallow traces both fail on the
- `config opt` row → Phase 3 re-runs its recipe. `//lib:core` (which never
- consumed `opt`) is untouched by Phase 1 and returns `L0` from cache.
-
-## Open questions / future work
-
-- **Transport.** Pipe vs unix socket vs in-process table; framing and the exact
- request/response encoding. Deliberately unspecified above.
-- **Hermeticity enforcement.** Sandbox that denies undeclared reads; FS-trace
- fallback to *detect* (not prevent) them and warn.
-- **Remote CAS.** The `materialize` ladder's "restore from CAS" naturally extends
- to a remote/shared cache (the DISTRIBUTE.md external-object fetch hints are the
- precedent) so a clean checkout can download outputs instead of rebuilding.
-- **Compact deep traces.** A flat transitive leaf set can be large for deep
- graphs. An alternative stores a merkle of per-dep deep-input hashes (composing
- deep traces by reference) at the cost of a shallow walk on refresh — a
- size/latency trade to revisit if flat traces bloat.
-- **GC policy.** Concrete roots, schedule, and LRU bounds for `trace/`, the CAS,
- and the tree cache.
-- **Parallelism.** Independent `need`s can resolve concurrently; the memo table
- must then carry futures (`in-progress` becomes "await") rather than a flag.
-- **Public surface.** Whether this lands as a libkit subsystem behind a public
- header (the `kit/cas.h` precedent) with a thin `kit build` CLI, gated by a
- `KIT_BUILD_ENABLED` flag.
+`src/main.c`, and consumes config `opt`. The request is under config `C0` (so
+`config-id = c0`).
+
+1. **Cold build.** No record. Phase 3 runs both recipes. `//lib:core` builds →
+ `tree L0`. `//app:server` logs `config opt`, `glob src/*.c`, `source src/main.c`,
+ `need //lib:core` (under `c0` → `(//lib:core, c0)`); yields `tree A0`. Both trace
+ kinds written for both targets. `build/cache/A0/` materialized, path returned.
+2. **No-op rebuild (same `C0`).** Phase 1: `//app:server`'s deep trace has
+ `root-config c0` (matches) and refreshes `opt` via config-id, `src/*.c`,
+ `src/main.c`, and the folded `lib/core.c` — all memoized, all match. `A0` is in
+ the tree cache. Returns instantly, nothing re-run, no graph walk.
+3. **Comment-only edit to `lib/core.c`.** `lib/core.c`'s blob-id moved → the deep
+ trace fails Phase 1 (a folded source leaf changed). Phase 2: direct leaves
+ (`opt`, `src/*.c`, `src/main.c`) unchanged, so probe the one dep —
+ `resolve(//lib:core, c0)`. Its recipe re-runs (its source moved) but emits a
+ byte-identical `L0` (comment stripped). `L0 == L0` ⇒ `//app:server`'s recipe is
+ **skipped**, `A0` restored, a refreshed deep trace written so step 2's fast path
+ returns next time.
+4. **Flip `opt` (now `C1`, `config-id = c1`).** Phase 1 is skipped outright:
+ `//app:server`'s deep trace has `root-config c0 ≠ c1`. Phase 2 under `c1`:
+ `opt` is a consumed key whose value differs between `c1` and the trace's built
+ map → no shallow trace holds → Phase 3 re-runs `//app:server`. Its `need //lib:core` carries no `opt` override,
+ so the dep resolves as `(//lib:core, c1)`; but `//lib:core` never consumes
+ `opt`, so its shallow trace under `c1` matches by consumed-keys (empty) and
+ direct leaves, and `L0` is reused without re-running. Only the one recipe that
+ actually depends on `opt` re-ran.
+
+## Decided / deferred / open
+
+**Decided.**
+
+- **Transport** is abstracted behind a host vtable the driver defines; defaults are
+ a unix socket or anonymous pipe, with a Windows named-pipe fallback and an
+ in-process variant. Supported hosts: Linux, macOS, Windows, FreeBSD.
+- **Parallelism** lives in the coordinator (the `jobs` semaphore); futures dedup
+ shared targets; the per-path chain detects cycles.
+- **Remote CAS** delegates fetching to a user-provided fetch recipe and verifies by
+ content id (trustless).
+- **Trace sharing** ships traces as signed `.kpkg` bundles over the DISTRIBUTE.md
+ trust model (trusted), because traces are claims, not self-verifying content.
+- **Public surface** is a public header `<kit/build.h>` plus a `kit build` driver
+ command, gated by `KIT_BUILD_ENABLED` (the `kit/cas.h` + `kit cas` precedent). A
+ thin CLI parses flags and supplies the transport/host vtables; the coordinator
+ and trace model live in the library.
+
+**Deferred.**
+
+- **Hermeticity enforcement.** No sandbox-deny or FS-trace at first; the protocol
+ defines the contract, enforcement comes later (deny undeclared reads, or
+ `strace`/FUSE detection that warns).
+- **GC.** Deferred. When built: mark-and-sweep rooted at live target records →
+ reachable trace bodies → referenced trees/blobs/config-maps/argv vectors; sweep
+ unreachable `trace/`, `cas/`, and `cache/`, with LRU size bounds on the
+ (re-derivable) tree cache and CAS.
+
+**Open — compact deep traces.** The flat deep leafset re-lists every transitive
+source in *every* ancestor: O(Σ closure sizes) bytes and O(closure) work to
+refresh. A **structural** representation fixes both and is the natural form of the
+"efficient set union" the closure already needs:
+
+- Store each target's deep leafset as a content-addressed object
+ `deep-set = { direct source/glob leaves, [child-deep-set-id…] }`, with
+ `deep-set-id = BLAKE2b(canonical(direct ‖ child-ids))`. The fully-flattened set
+ is the recursive union, but it is never materialized — an ancestor stores only
+ its own direct slice plus 32-byte pointers to its children's deep-sets. A leaf
+ shared by many ancestors is stored once (a Merkle DAG, structurally sharing like
+ the CAS itself). This *is* the union operation: building a parent's deep-set is
+ "concatenate sorted direct leaves with child-set pointers," not "merge N flat
+ arrays."
+- **Faster checking** falls out of the same structure. Refresh memoizes
+ `deep-set-id → valid?` for the whole build: a subtree checked once is reused by
+ every parent that points at it, and an *unchanged* subtree is recognized by
+ pointer/hash equality at its root without descending to leaves. Change detection
+ becomes O(changed frontier) instead of O(closure): recompute current deep-set-ids
+ bottom-up (memoized, so O(distinct subtrees) = O(nodes)), and where a recomputed
+ id equals the recorded one, the whole subtree is provably unchanged and skipped.
+- The cost is that refresh walks the DAG rather than streaming one flat list, and
+ the child deep-set objects must be present (another store dependency, GC-rooted
+ like traces). The flat form in this doc is the *semantic* definition; the
+ structural form is the recommended physical representation once flat traces bloat
+ — adopt it together with the in-memory union it mirrors.