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:

  1. 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.
  2. http-api.md is normative prose. Prose cannot be type-checked, and a tripwire test comparing amendment dates only detects drift; it does not prevent it.
  3. 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 are parse()d before handlers run; outbound bodies are parse()d in dev and test), and in CI. http-api.md is split: protocol semantics remain hand-written and normative; the endpoint/shape reference is generated from @kaged/wire and checked for cleanliness in CI.

The package

  • packages/wire, published in-monorepo as @kaged/wire via workspace:*.
  • Runtime dependency: zod only. 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 test parse()s every examples entry 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/response schemas. 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 against contract[key].request before the handler executes. This is the 400 path and runs in all modes, including production.
  • Outbound responses are parse()d against contract[key].response when NODE_ENV !== "production" — throw in tests, log loudly in dev, skipped in prod for cost. This catches handlers that as-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 never default branch, so adding an event type to wire breaks UI compilation until it is handled or explicitly ignored.

Enforcement — boundaries and CI

  • Lint: packages/ui may not import @kaged/daemon; packages/wire may not import anything beyond zod; no package outside wire may 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-code on the generated reference. Drift between schemas and reference docs is structurally impossible, not merely detected.

Documentation split

  • http-api.md remains 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/wire is normative for shapes. A deterministic generator (lexically sorted routes, no formatting cleverness) emits the endpoint and WS-event reference — either http-api-reference.md or marked <!-- generated --> regions within http-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/wire as 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. A v2 tree 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.md prose 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()