kit

kit
git clone https://git.ryansepassi.com/git/kit.git
Log | Files | Refs | README

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:
Mdoc/plan/BUILD_INTERNALS.md | 120+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Msrc/build/protocol.h | 19+++++++++++++++----
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. */