versionless

For AI Agents

The canonical compatibility-maintenance workflow — designed to be executed by coding agents while they change code, not cleaned up later.

versionless is built so that an AI agent editing the API keeps compatibility as part of the same edit, not as a follow-up chore. If you are an agent working in a repo that uses versionless, this page is your contract.

The workflow

When you change a schema, a route, or a procedure's input/output:

versionless check --json

check extracts the live API surface, diffs it against the last committed snapshot, and maps every breaking diff to the registered change chain. Each uncovered entry tells you exactly what to write. Here is what removing User.email looks like when nothing covers it:

comparing snapshot 2026-07-21 → head 2026-07-21

GET /users/:id
  [breaking] field-removed: User.email in responses.200 (was string)
    ✗ uncovered
    declare it: schema: (s) => s.on('User', { removed: ['email'] })

trpc:demo.userList
  [breaking] field-removed: User.email in output (was string)
    ✗ uncovered
    declare it: schema: (s) => s.on('User', { removed: ['email'] })

... (5 uncovered total: GET /users, GET /users/:id, POST /users, trpc:demo.userCreate, trpc:demo.userList)

✗ check failed (5 uncovered, 0 warning(s), 0 covered) — run `versionless generate` to scaffold the missing change
  head version 2026-07-21 already has a snapshot — a breaking change needs a new version: bump `current` on your instance first

(--json gives the same report as structured data: pass, uncovered with per-entry hint, warnings, stale.) A breaking change ships in a new version, so when the head version already has a committed snapshot — as above — first bump current on the instance (e.g. to today's date). Then:

versionless generate

scaffolds a change-file stub for the uncovered diffs — stamped with the new current, the affected routes/procedures, the schema declaration pre-filled, and TODO up/down bodies. The stub imports the entry's instance, and check imports every file the changes: glob matches before reading the chain — so the scaffold is registered with no extra wiring. The real stub for the diff above:

/**
 * Generated by `versionless generate` — fill in the TODOs.
 *
 * Uncovered diffs this change must cover:
 *   - [breaking] field-removed: User.email in responses.200 of GET /users/:id (string -> ?)
 *   - [breaking] field-removed: User.email in output of trpc:demo.userList (string -> ?)
 *   ...
 */
import { versionless as v } from "../src/versionless";

export default v.change("2026-07-24", {
  describe: "TODO: describe this change",
  routes: ["GET /users", "GET /users/:id", "POST /users"],
  procedures: ["demo.userCreate", "demo.userList"],
  schema: (s) => {
    s
      .on("User", { removed: ["email"] });
  },
  response: {
    down: (body) => {
      // TODO: convert the CURRENT response shape back into the old one
      return body;
    },
  },
});

Your job is to fill in the transform logic:

  1. up: convert the old wire shape a pinned client still sends into the new shape the handler expects.
  2. down: convert the new shape the handler returns into the old shape the pinned client still expects.
  3. Annotate both parameter and return types — the type layer derives per-version SDK types from your annotations.
  4. Re-run versionless check (must exit 0) and the test suite.
  5. Run versionless snapshot and commit the new .versionless/<version>.json alongside the change file.

A removed response field can't be conjured back for free, so this one is filled by fetching the data the new response no longer carries — or, when that's impossible, marked lossy:

import type { User } from "../src/db/schema";
import { versionless as v } from "../src/versionless";

type UserWithEmail = User & { email: string };

const restoreEmail = async (u: User): Promise<UserWithEmail> => ({
  ...u,
  email: await db.emails.forUser(u.id),   // still available server-side
});

export default v.change("2026-07-24", {
  describe: "user.email removed from all user payloads",
  routes: ["GET /users", "GET /users/:id", "POST /users"],
  procedures: ["demo.userCreate", "demo.userList"],
  schema: (s) => s.on("User", { removed: ["email"] }),
  response: {
    down: async (body: User | User[]): Promise<UserWithEmail | UserWithEmail[]> =>
      Array.isArray(body) ? Promise.all(body.map(restoreEmail)) : restoreEmail(body),
  },
});

If the conversion is impossible without losing data, mark the change lossy: true and say so in describe — check downgrades the failure to a warning (unless CI runs --strict-lossy), and the changelog flags it.

This is the same flow whether the break was deliberate or discovered by check: bump current, cover the diffs with a change, snapshot, commit — one new version per breaking release.

Renames

The differ has no rename detection: renaming User.name to User.fullName appears as a remove + add pair. Declare intent so both sides are covered by one declaration:

schema: (s) => s.on("User", { renamed: { name: "fullName" } }),

with the matching transforms (up maps name → fullName, down maps it back).

Debugging

versionless explain "GET /users/:id" --version 2025-01-01

prints the resolved pipeline for that route and pin: effective version, the chain-vs-jump path chosen, transform order, lossy/passthrough flags, and sunset status. Use it to answer "why is this client seeing this shape?" before touching any transform.

Rules that keep the system honest

  • Never fork a handler for an old version. Handlers speak the current shape only; compatibility lives in change files.
  • Never edit an already-shipped change's transforms to fix a new break — register a new change (or a jump). Shipped changes are history.
  • Renames are remove+add in the diff; declare intent with renamed: { oldField: "newField" } in the schema declaration.
  • Snapshots are committed to git. check in CI is the gate; don't bypass it, make it pass.

versionless init writes this workflow into the repo (as AGENTS.md, or AGENTS.versionless.md when an AGENTS.md already exists) so it travels with the codebase. This docs site also serves /llms.txt and /llms-full.txt for direct agent consumption.