Spec-First AI Development: OpenAPI Remains the Only Source of Truth

Spec-First AI Development: OpenAPI Remains the Only Source of Truth

LLMs love inventing endpoints. Spec-first development flips the workflow: OpenAPI is law; models only fill implementation gaps inside schema-defined operations. Handlers, clients, and contract tests are generated mechanically — the agent never gets a blank check to mint routes.

⚡ TL;DR: Treat openapi.yaml as the only source of truth. Codegen types + stubs; constrain LLM patches to // agent:impl regions; reject PRs whose routes drift from the spec. Contract tests assert status codes and schemas on every merge. Pair with Node SDK Generation and Structured Outputs for Codegen.

Generate the skeleton, not the product fantasy

# ✅ Mechanical codegen from OpenAPI
npx openapi-typescript openapi/openapi.yaml -o src/gen/api.ts
npx @hey-api/openapi-ts -i openapi/openapi.yaml -o src/gen/client
node scripts/gen-handler-stubs.mjs  # writes src/handlers/*.stub.ts
// src/handlers/createInvoice.stub.ts — GENERATED; do not edit signature
import type { paths } from "../gen/api";

export type CreateInvoice =
  paths["/v1/invoices"]["post"];

/** agent:impl — fill body only; signature is owned by OpenAPI */
export async function createInvoice(
  req: CreateInvoice["requestBody"]["content"]["application/json"],
  ctx: RequestContext
): Promise<CreateInvoice["responses"]["201"]["content"]["application/json"]> {
  // LLM may edit inside this function only
  throw new Error("not implemented");
}

❌ Letting the agent add /v1/invoice-v2 because “it felt cleaner.”

Constrain agent edits to impl regions

// scripts/agent-impl-gate.ts
import { Project } from "ts-morph";

export function assertOnlyImplRegionsTouched(diffPaths: string[]) {
  const proj = new Project({ tsConfigFilePath: "tsconfig.json" });
  for (const p of diffPaths) {
    if (p.startsWith("src/gen/") || p.endsWith(".stub.ts") || p.includes("openapi/")) {
      throw new Error(`forbidden_touch:${p}`);
    }
  }
}

Combine with Cursor rules / Claude hooks so shell cannot rewrite the spec without a labeled spec-change PR. See Claude Code Hooks.

Contract tests lock the wire

// tests/contract/createInvoice.test.ts
import { openapi } from "../helpers/loadSpec";
import { inject } from "../helpers/app";

test("POST /v1/invoices matches 201 schema", async () => {
  const res = await inject("POST", "/v1/invoices", {
    customerId: "cus_1",
    idempotencyKey: "k1",
    lines: [{ sku: "pro", qty: 1 }],
  });
  expect(res.statusCode).toBe(201);
  openapi.assertResponse("post", "/v1/invoices", 201, res.json());
});

If the agent “improves” a field name, CI fails — not production.

PR policy

Change type Allowed path Reviewers
Spec evolution openapi/** + regen API guild
Impl fill src/handlers/** impl body CODEOWNERS
Client bump generated only automerge after contract green
Invented route none blocked by gate

Closing checklist

✅ Dos
– ✅ OpenAPI → types → stubs → agent impl
– ✅ Forbid agent edits under src/gen/ and openapi/
– ✅ Contract-test every operation status + schema
– ✅ Label true spec changes separately from impl PRs
– ✅ Regenerate clients in CI on spec hash change

❌ Don’ts
– ❌ Don’t let models invent paths, verbs, or fields
– ❌ Don’t hand-edit generated clients
– ❌ Don’t skip idempotency fields defined in the spec
– ❌ Don’t merge green unit tests that bypass contract suite

Related reading

Last updated on September 11, 2026


Discover more from CheatCoders

Subscribe to get the latest posts sent to your email.

Comments

No comments yet. Why don’t you start the discussion?

Leave a Reply