DR-137 — A route's wire contract is enforced on the payload, never on the return type
DR-137 — A route’s wire contract is enforced on the payload, never on the return type
Section titled “DR-137 — A route’s wire contract is enforced on the payload, never on the return type”Context
Section titled “Context”Every /api/okf/* handler declared its return type as
Promise<NextResponse<OkfBundleEnvelope> | NextResponse>. The union’s second
arm exists for the error paths, which return { error: string } bodies.
That second arm is NextResponse<unknown>, and it accepts any body. The
annotation was therefore decorative: it read as a contract and enforced
nothing. Isolated probe, S544:
// with the union arm — NO ERRORasync function p1(): Promise<NextResponse<OkfBundleEnvelope> | NextResponse> { return NextResponse.json({ totally: 'wrong', nope: 42 });}// without it — errorsasync function p2(): Promise<NextResponse<OkfBundleEnvelope>> { return NextResponse.json({ totally: 'wrong', nope: 42 }); // TS2322}This matters here because the OKF graph has two parallel type families: the
server builds the payload with BundleGraphNode etc. (lib/okf/bundle-graph.ts,
server-only — it imports node:fs), and the client reads it as
OkfBundleGraphNode etc. (lib/query/okf.ts, hand-written mirrors). They are
meant to be the same shape and nothing compared them.
Mutation, S544: make the server emit size as a string and add a required
field the client has never heard of. Result before the fix — bun run typecheck clean, 242 OKF tests pass. A second mutation renaming
BundleNavConcept.path was caught only by that module’s own unit test; all six
API-route test files passed, so there was no runtime guard at the boundary
either.
The families did in fact still agree. The system was unguarded, not broken — which is exactly the state that produces a silent break later.
Decision
Section titled “Decision”A route that returns a typed body annotates the success payload at its own declaration:
const payload: OkfBundleEnvelope = { ...graph, nav, log };return NextResponse.json(payload);The | NextResponse arm stays for the error paths. Where a server-side builder
and a client-side wire type are separate declarations of one shape, this
annotation is the contract between them, and every route serving such a shape
must carry it.
Alternatives Considered
Section titled “Alternatives Considered”Drop the | NextResponse arm and type every error body. Would enforce the
contract at the return type, but forces an error-envelope union through every
handler for no gain over annotating one line.
Share one declaration between server and client. The obvious fix, and not
available: bundle-graph.ts imports node:fs and cannot enter a client bundle.
The mirror is deliberate; only the checking was missing.
Runtime validation (Zod) at the boundary. Rejected as the primary mechanism: both sides are our own code, so the failure is a compile-time drift, not untrusted input (see DR-138).
Consequences
Section titled “Consequences”- With the annotations in place, both S544 mutations fail loudly and by name at
graph/route.tsandunion-graph/route.ts. - Annotating
resource/route.ts’s two payloads gaveOkfResourceRecordResultandOkfResourceListResultreal cross-file consumers, clearing their knip findings without a suppression — the two decisions reinforce each other (DR-136). - Any new
/apiroute serving alib/query/*wire type inherits this requirement. The pattern is not OKF-specific; the sameNextResponse<T> | NextResponseshape appears across the API surface and has the same non-enforcement wherever it does. - Not retrofitted beyond
/api/okf/*in S544. Other routes carrying the shape are unaudited.