commit 38da65309e91f80e291d29534719835b64843062
parent c2795974bd602276efb43a46f376c2028ad55366
Author: Ryan Sepassi <rsepassi@gmail.com>
Date: Wed, 17 Jun 2026 22:09:25 -0700
build: pin module and protocol contracts
Diffstat:
2 files changed, 129 insertions(+), 10 deletions(-)
diff --git a/doc/plan/BUILD_INTERNALS.md b/doc/plan/BUILD_INTERNALS.md
@@ -255,22 +255,82 @@ dependency; an input read but not requested is invisible to the cache.
**Depends on.** `build` (L0), `<kit/core.h>`, `<kit/build_coord.h>` (`KitBuildKV`,
`KitBuildConn`).
+**Wire format.** This is the source of truth for `build_proto_*` and the protocol
+goldens. The `KitBuildTransport` vtable hands codecs a complete frame body. For
+byte-stream transports, the transport wraps each body as:
+
+```
+u32le body_len
+u8[body_len] body
+```
+
+`body_len` must be `<= BUILD_FRAME_MAX` (65536). In-process transports may pass
+the body directly, but must enforce the same cap. The body format is:
+
+- integers are fixed-width little-endian (`u8`, `u16le`, `u32le`, `u64le`);
+- `str` is `u16le len` followed by `len` raw bytes, no trailing NUL;
+- decoders reject trailing bytes, truncated fields, unknown command/status values,
+ count overflow beyond caller storage, and command-specific length overflow
+ (`BUILD_TARGET_MAX`, `BUILD_KEY_MAX`, `BUILD_VAL_MAX`, `BUILD_PATH_MAX`,
+ `BUILD_PATTERN_MAX`);
+- text bytes are not escaped by the protocol. The producer for each semantic field
+ is responsible for rejecting bytes the corresponding canonical format cannot
+ store (for v1: no embedded NUL or newline in target/path/key/value/argv).
+
+Request bodies:
+
+| Command | Body |
+|---------|------|
+| `CONFIG_GET` | `u8 cmd, str key` |
+| `SOURCE` | `u8 cmd, str workspace_path` |
+| `GLOB` | `u8 cmd, str pattern` |
+| `NEED` | `u8 cmd, str target, u16 noverrides, {str key, str value}*, u16 argc, {str arg}*` |
+| `NEED_SUBMIT` | same as `NEED` |
+| `NEED_AWAIT` | `u8 cmd, u64 token` |
+
+Response bodies begin with `u8 status`. `ERROR` is valid for any command and has
+the common body `u8 ERROR, u16 kit_status, str message`; clients return the
+`KitStatus` value and may surface `message` through diagnostics.
+
+Non-error response bodies:
+
+| For command | Status | Body |
+|-------------|--------|------|
+| `CONFIG_GET` | `OK` | `u8 OK, str value` |
+| `CONFIG_GET` | `UNSET` | `u8 UNSET` |
+| `SOURCE` | `OK` | `u8 OK, u8[32] blob_id, str realpath` |
+| `SOURCE` | `ABSENT` | `u8 ABSENT` |
+| `GLOB` | `OK` | `u8 OK, u16 npaths, {str path}*` (one batch) |
+| `GLOB` | `GLOB_END` | `u8 GLOB_END` |
+| `NEED` | `OK` | `u8 OK, u8[32] output_tree, str materialized_path` |
+| `NEED_SUBMIT` | `OK` | `u8 OK, u64 token` |
+| `NEED_AWAIT` | `OK` | `u8 OK, u8[32] output_tree, str materialized_path` |
+
+Glob is the only multi-frame response: zero or more `GLOB/OK` path-batch frames,
+terminated by exactly one `GLOB/GLOB_END`. Empty globs send only `GLOB_END`.
+Every other command is exactly one request body and one response body.
+
**Pseudocode.**
```c
int build_proto_encode_req(req, buf, cap, n):
cursor c over buf (cap ≥ BUILD_FRAME_MAX)
- put u8 req.cmd; put_slice(req.arg)
- if cmd==NEED or cmd==NEED_SUBMIT: put u16 noverrides; for kv: put_slice(kv.key), put_slice(kv.value)
- put u16 argc; for a: put_slice(a)
- if cmd==NEED_AWAIT: put u64 req.token
+ put u8 req.cmd
+ if cmd==CONFIG_GET/SOURCE/GLOB: put_slice(req.arg)
+ if cmd==NEED or cmd==NEED_SUBMIT:
+ put_slice(req.arg) // target
+ put u16 noverrides; for kv: put_slice(kv.key), put_slice(kv.value)
+ put u16 argc; for a: put_slice(a)
+ if cmd==NEED_AWAIT:
+ put u64 req.token
*n = c.len; return (c overran) ? BUILD_ERR : BUILD_OK
int build_proto_decode_resp(buf, len, cmd, out, glob_cb, glob_user):
out.status = get_u8
+ if status==ERROR: out.error_status=get_u16; out.text=get_slice; return OK
switch cmd:
CONFIG_GET: if status==UNSET return OK(present=0); else out.text = get_slice
- SOURCE: get_bytes(out.id,32); out.text = get_slice // realpath
+ SOURCE: if status==ABSENT return OK(present=0); get_bytes(out.id,32); out.text = get_slice
NEED, AWAIT: get_bytes(out.id,32); out.text = get_slice // tree-id + path
NEED_SUBMIT: out.token = get_u64 // coordinator-assigned
GLOB: u32 m = get_u32; repeat m: p = get_slice; if glob_cb(user, p) != 0 break // streamed
@@ -279,7 +339,19 @@ int build_proto_decode_resp(buf, len, cmd, out, glob_cb, glob_user):
`build_proto_decode_req` fills caller-provided `ovr_storage` / `argv_storage` for a
`need`/`need-submit`, and reads `req.token` for `need-await`. Frames are bounded by
-`BUILD_FRAME_MAX`. **No interface gap.**
+`BUILD_FRAME_MAX`.
+
+**Required tests before dependents start.**
+- golden hex frames for every request and every response shape above, including
+ `ERROR`, `SOURCE/ABSENT`, empty `GLOB`, multi-batch `GLOB`, and `NEED` with both
+ overlays and argv;
+- malformed corpus: truncated integer, truncated string, trailing byte, unknown
+ command/status, over-cap frame, count larger than caller storage, wrong response
+ shape for the command, non-final data after `GLOB_END`;
+- round-trip property: valid structs encode→decode byte-identically after
+ re-encoding.
+
+**No interface gap.**
---
@@ -978,3 +1050,39 @@ spike headers.
[worked example](BUILD.md#worked-example).
- **Wave E:** `src/api/build.c` composition + the `kit build` driver command +
`KIT_BUILD_ENABLED` gating, then the Makefile test targets.
+
+## Parallel-agent handoff contracts
+
+Each module owner should treat its header as the contract and this table as the
+acceptance gate. A module is "done" only when its focused tests land with it and no
+downstream module has to reinterpret its bytes, ownership rules, or error behavior.
+Keep module tests targeted (`make test-build-<module>` once wired); do not require an
+end-to-end coordinator to validate L0-L2 work.
+
+| Module | Owner edits | May depend on | Must provide | Required focused tests | Must not do |
+|--------|-------------|---------------|--------------|------------------------|-------------|
+| `build` | `src/build/build.c`, `src/build/build.h` comments only if needed | public hash/core/CAS APIs | `build_target_key`, `build_glob_result_hash`, `build_diagf`; stable constants | fixed id vectors for target-key and glob-result; sorted path/blob listing; invalid/overflow inputs | no filesystem, no coordinator state, no allocation except bounded scratch/writer |
+| `cfg` | `src/build/cfg.c`, `src/build/cfg.h` | `build` | sorted propagated map; positional argv; canonical emit/parse/id; overlay | emit golden bytes and ids; parse rejects duplicates/out-of-order/unknown forms; overlay replaces and preserves sort; empty argv id vector | no CAS I/O, no ambient config/env reads, no hidden defaults |
+| `defn` | `src/build/defn.c`, `src/build/defn.h` | `build` | strict `kit-build 1` parser and lookup | canonical definition golden; sorted-unique target rejection; unrelated stanza edit does not affect `find(T)` result | no recipe hashing, no source dep recording, no argv defaults |
+| `trace` | `src/build/trace.c`, `src/build/trace.h` | `build` | shallow/deep/deepset/record emit/parse/id/prepend | golden bodies+ids for all four formats; absent source row; non-canonical rejection; record MRU dedup and per-kind cap | no store/CAS access, no matching against live workspace |
+| `protocol` | `src/build/protocol.c`, `src/build/protocol.h` | `build`, public slices | exact wire codec specified above | golden hex frames; malformed corpus; glob multi-frame decode; round-trip re-encode | no transport I/O, no malloc, no coordinator side effects |
+| `client` | `src/build/client.c`, public `<kit/build_coord.h>` only if a real API gap appears | `protocol`, `KitBuildTransport` | public `kit_build_client_*` round-trips; borrowed response slices valid until next call | loopback scripted server for each public call; error mapping; absent source; glob callback streaming; close releases conn | no coordinator/store access, no filesystem, no process spawning |
+| `store` | `src/build/store.c`, `src/build/store.h` | `build`, `trace`, public CAS, host vtables | path layout; trace put/get; target record RMW; cache materialize; sandbox lifecycle | fake `KitBuildStoreIo` tests for idempotence, malformed-record-as-empty, crash injection, cache verify, sandbox cleanup | no trace matching, no recipe execution, no remote fetch |
+| `remote` | `src/build/remote.c`, `src/build/remote.h` | `build`, public CAS, `KitBuildExec` | fetch-template rendering; verified blob/tree/deepset install; try remotes in order | fake exec good/corrupt/missing; hash mismatch rejected; second remote succeeds; deepset fetched as blob | no trust decisions, no trace import, no network API beyond exec |
+| `coord` | `src/build/coord.c`, `src/build/coord.h` | `cfg`, `defn`, `store`, `trace`, public CAS | context open/close; source/glob/config/argv/recipe memos; deepset intern/load; futures; stats; jobs semaphore | fake workspace memo counts; config/argv by-id load; recursive deepset load; sequential degrade; future dedup; stats bumps under lock | no resolution phase policy, no recipe spawn loop |
+| `resolve` | `src/build/resolve.c`, `src/build/resolve.h` | `coord`, `cfg`, `trace`; calls `build_run_recipe` by declared seam | chain/cycle detection; Phase 1/2/3 selection; materialization ladder; leafset refresh/union | deep hit; shallow hit after changed dep with same output; recipe-id bust; materialize miss; cycle path; exact stats deltas | no process I/O, no protocol service loop |
+| `runner` | `src/build/runner.c`, `src/build/runner.h` | `coord`, `resolve`, `protocol`, `trace`, `cfg` | spawn environment; protocol service; dep log; job-slot release across needs; trace recording | scripted transport/exec: config/source/glob/need logs; nonzero exit writes no trace; need-submit/await determinism; jobs=1 deep chain | no Phase 1/2 matching policy, no signed bundle logic |
+| `bundle` | `src/build/bundle.c`, `src/build/bundle.h` | `coord`, `store`, public package/CAS | signed trace export/import; trace remote pull | package round-trip; untrusted signer rejected; tofu pin result; imported trace has config/argv/deepset blobs; bad trace id rejected | no object remote fetch, no recipe execution |
+| `api` | `src/api/build_coord.c` or chosen composition TU, Makefile gating | all internal modules | public coordinator, stats, trace import/export, stubs under disabled gate | public API smoke using fake host; unsupported gate; status/diag mapping | no policy hidden in driver |
+| `driver` | `driver/cmd/build_coord.c` or final command name, driver tables | public API only | CLI parses flags, supplies hosted vtables, exposes shell helper subcommands for recipes | `run.sh` subprocess/socket smoke; helper commands call client API; clean env behavior | no internal `src/build/*` includes |
+
+**Cross-agent rules.**
+- Public or internal header changes require updating this table and the owning
+ module's tests in the same commit.
+- If a module needs a new callback or field from another module, add a failing
+ focused test at the seam first; do not tunnel around the boundary with internal
+ includes.
+- All canonical bytes must get checked-in golden tests before any downstream module
+ uses them (`cfg`, `trace`, `protocol`, bundle manifest).
+- A downstream owner may stub an upstream module only at the documented function
+ boundary; stubs must be removed before the wave is marked done.
diff --git a/src/build/protocol.h b/src/build/protocol.h
@@ -16,9 +16,18 @@
* contract that makes caching correct is that an input the recipe reads but
* does not request through one of these commands is invisible to the cache.
*
- * Wire framing: each frame is a single command/response, carried whole by the
- * transport's read_frame/write_frame (the transport owns the length prefix).
- * The body is a compact, byte-stable encoding of the structs below.
+ * Wire framing: each frame is a single command/response body, carried whole by
+ * the transport's read_frame/write_frame. Byte-stream transports encode that
+ * body as:
+ *
+ * u32le body_len
+ * u8[body_len] body
+ *
+ * `body_len` must be <= BUILD_FRAME_MAX. An in-process transport may pass the
+ * body directly, but must enforce the same cap. The body itself is the compact,
+ * byte-stable encoding below. All integers are little-endian fixed-width. All
+ * strings/slices are `u16le len` followed by `len` raw bytes, with no trailing
+ * NUL; decoders reject trailing bytes and command-specific length overflows.
*
* One response exception — GLOB. A glob match set can exceed one frame, so a
* glob response is a SEQUENCE of frames: zero or more match-batch frames, each a
@@ -48,6 +57,7 @@ typedef enum BuildRespStatus {
BUILD_RESP_UNSET = 1, /* config-get on an unset key (not an error) */
BUILD_RESP_ERROR = 2, /* the coordinator failed/refused the request */
BUILD_RESP_GLOB_END = 3, /* terminates a multi-frame glob match stream */
+ BUILD_RESP_ABSENT = 4, /* source on an absent path (not an error) */
} BuildRespStatus;
/* A decoded request. `overrides` and `argv` (need / need-submit only) point into
@@ -67,8 +77,9 @@ typedef struct BuildReq {
* the frame buffer until the next read. */
typedef struct BuildResp {
uint8_t status; /* BuildRespStatus */
+ uint16_t error_status; /* ERROR only: KitStatus value to return */
uint8_t id[BUILD_HASH_LEN]; /* source: blob-id; need/await: output tree-id */
- KitSlice text; /* config value; source/need/await realpath */
+ KitSlice text; /* config value; source/need/await path; error */
uint64_t token; /* need-submit: the coordinator-assigned token */
/* glob results are streamed: the decoder invokes a callback per match rather
* than materializing a list in the struct. */