ADR-0039: @kaged/wire is the normative wire contract; daemon and UI must consume it
- Status: Accepted
- Date: 2026-06-12
- Deciders: @karasu
- Supersedes: —
- Superseded by: —
Context
The daemon and the web UI talk over HTTP + WebSocket (ADR-0002, http-api.md). They cannot share code directly: the UI is browser-built and must not import the daemon package, and the daemon must not depend on UI internals. Until now, wire shapes existed in two places — TypeScript types inside the daemon, and whatever the UI hand-typed against the spec prose. That is a duplicate-definition setup, and we already know how this story ends: it is the app-shell.md failure mode (implementation drift against a stale source of truth) replayed at the network boundary, where it surfaces as runtime garbage in the browser instead of a compile error anywhere.
Three facts shape the constraint set:
- The browser ↔ daemon edge is a real trust boundary. TypeScript types evaporate at the network. A shape nobody validates is a shape nobody actually checked — the same class of bug as agent confabulation from invisible context, just on the operator side.
http-api.mdis normative prose. Prose cannot be type-checked, and a tripwire test comparing amendment dates only detects drift; it does not prevent it.- The daemon's route registry is already a string-keyed map (
"POST /api/v1/sessions/:id/messages"→ handler). A contract keyed the same way slots in with near-zero friction.
The question is load-bearing because the API is a public, versioned contract (/api/v1, breaking changes forbidden once shipped). Every future surface — guest UI, CLI, external API consumers — inherits whatever discipline we establish here.
Decision
kaged will define all operator-facing wire shapes — HTTP request/response bodies, error envelopes, and WebSocket messages — exactly once, as Zod schemas in a new workspace package
@kaged/wire. The daemon and the UI must consume this package; neither may declare a parallel wire type. The contract is enforced at the compiler level (the daemon's route registry derives its handler signatures from the contract map; registering a route without a contract entry does not compile), at runtime (inbound bodies areparse()d before handlers run; outbound bodies areparse()d in dev and test), and in CI.http-api.mdis split: protocol semantics remain hand-written and normative; the endpoint/shape reference is generated from@kaged/wireand checked for cleanliness in CI.
The package
packages/wire, published in-monorepo as@kaged/wireviaworkspace:*.- Runtime dependency:
zodonly. No Bun or Node built-ins, no daemon imports, no UI imports. The package must bundle for the browser unmodified."sideEffects": false. - Exports, per domain module (
sessions,projects,issues,local,plugins,ws, …):- Zod schemas for every request body, response body, and WS message.
- Inferred types via
z.infer— there are no hand-written wire types anywhere in the repo. - A contract map: route key →
{ summary, description, auth, errors, request, response }. Route keys are the daemon's existing"METHOD /api/v1/..."strings. - The WS protocol as
z.discriminatedUnion("type", [...])per channel.
- Schema metadata uses Zod 4
.meta()(title,description,examples,deprecated). Examples are real: a testparse()s everyexamplesentry against its own schema.
Enforcement — daemon
- The route registry is generic over the contract map. A handler's signature is derived from its route key's
request/responseschemas. There is no untyped registration path. Adding an endpoint without a wire entry, or returning the wrong shape, is a compile error. - The router
parse()s inbound bodies againstcontract[key].requestbefore the handler executes. This is the 400 path and runs in all modes, including production. - Outbound responses are
parse()d againstcontract[key].responsewhenNODE_ENV !== "production"— throw in tests, log loudly in dev, skipped in prod for cost. This catches handlers thatas-cast their way past the compiler.
Enforcement — UI
- The UI's HTTP client is a thin typed fetcher generated over the same contract map. The UI never hand-types a request or response.
- WS events are consumed through the wire discriminated union; the event switch carries a
satisfies neverdefault branch, so adding an event type to wire breaks UI compilation until it is handled or explicitly ignored.
Enforcement — boundaries and CI
- Lint:
packages/uimay not import@kaged/daemon;packages/wiremay not import anything beyondzod; no package outsidewiremay declare types in the wire namespace. - Tests: bidirectional exhaustiveness — every registered route has a contract entry, every contract entry has a registered handler. One generic test; no per-endpoint shape tests are ever written by hand (that would reintroduce the duplicate definition as test code).
- CI: run the doc generator,
git diff --exit-codeon the generated reference. Drift between schemas and reference docs is structurally impossible, not merely detected.
Documentation split
http-api.mdremains normative for protocol behavior: auth modes and cookie contracts, sidecar header trust, CSRF, warning headers, versioning policy, WS ordering/reconnect semantics, constraints tables, amendment history. This content is cross-cutting and does not belong in code metadata.@kaged/wireis normative for shapes. A deterministic generator (lexically sorted routes, no formatting cleverness) emits the endpoint and WS-event reference — eitherhttp-api-reference.mdor marked<!-- generated -->regions withinhttp-api.md(implementation detail; spec decides).http-api.md§ Purpose must be amended in the same change that lands this ADR: it currently claims normativity over every request/response shape, which this ADR moves to wire.
This is consistent with ADR-0003: the contract map is a spec — authored deliberately, reviewed in PRs like any spec amendment — it just happens to be written in TypeScript and therefore enforceable. Claude Code consuming the generated reference can never implement against a stale shape.
Consequences
What this commits us to
- Maintaining
@kaged/wireas the first stop for any API change: new endpoint → schemas first, handler second, UI third. The compiler enforces the order. - Keeping the package browser-safe forever (zod-only, no platform built-ins).
- Maintaining the reference generator and its CI check.
- Amending
http-api.md§ Purpose, and adding "amend@kaged/wire" to the spec-amendment checklist for any change touching protocol behavior that also moves shapes. - Versioning discipline: the contract map is
/api/v1's shape ledger. Av2tree means a parallel contract map, not mutations to v1 schemas.
What this forecloses
- Ad-hoc response shapes. No handler may return anything not declared in wire — including "temporary" debug fields.
- Hand-written wire types anywhere in daemon or UI, including in tests.
- Treating
http-api.mdprose as authoritative for shapes. Shape disputes are settled by the schema, full stop.
What becomes easier
- UI development: typed client for free, malformed WS events fail loudly instead of rendering garbage.
- API review: a wire diff is the complete, machine-checked surface-area diff of an API change.
- Future consumers: guest UI, CLI, or an OpenAPI export (
z.toJSONSchema()is an afternoon of work when a consumer exists — explicitly deferred until then). - Examples in docs are guaranteed valid; one whole class of doc rot is eliminated.
What becomes harder
- The contract map is a merge hotspot when multiple endpoints land in parallel. Acceptable for a solo operator; revisit module-splitting granularity if it ever hurts.
- Zod lands in the browser bundle (~10–15 KB gz with tree-shaking in Zod 4). Acceptable; the runtime WS validation is worth more than the bytes.
- Quick hacks get slower on purpose: you cannot stub an endpoint without declaring its shape. This is the point.
- Behavioral drift between spec prose and implementation is not solved by this ADR — only shape drift is. Protocol semantics still rely on the amendment checklist discipline.
Alternatives considered
Plain shared types package (no schemas)
A @kaged/api-types with TypeScript types only. Solves the import problem, costs nothing at runtime — and validates nothing at the actual trust boundary. Types evaporate at the network; a daemon bug or stale client still produces silent garbage in the UI. Rejected: the runtime parse() at the edge is most of the value.
Contract framework (ts-rest / oRPC / tRPC)
These implement exactly this pattern, batteries included. Rejected: tRPC abandons the versioned REST surface that http-api.md makes a public contract; ts-rest/oRPC would bolt a framework's contract DSL onto our deliberately hand-rolled Bun.serve() router and put a third-party project on the critical path of our public API. Plain zod schemas plus our existing route map gets ~90% of the benefit with zero new load-bearing dependencies. We don't force shit, including on ourselves.
OpenAPI-first (YAML source, generate types both directions)
Industry-standard, great external tooling. Rejected for now: hand-maintained OpenAPI YAML is its own drift-prone artifact, codegen output is a worse source of truth than z.infer, and there is no runtime validation without bolting a validator on anyway. Wire can emit OpenAPI later via z.toJSONSchema() if an external consumer materializes; authoring in it buys nothing today.
Status quo (daemon-internal types, UI types by hand against spec prose)
What we have. Already producing the type-mismatch bugs that prompted this ADR. Rejected by evidence.
References
- ADR-0002 — web-first; HTTP+WS is the load-bearing transport
- ADR-0003 — doc-first; specs are the contract code follows
- ADR-0006 — prior art: JSON Schema + Zod validation for the DSL
docs/specs/http-api.md— § Purpose requires amendment alongside this ADR- Zod 4 metadata & JSON Schema:
.meta(), registries,z.toJSONSchema()