# Upgrading

**`As of 2026-08`. Semver applies from here.** A breaking change to a documented API needs a major. Every `@ultimat3/*` package moves in lockstep — never mix versions. `x new` declares them at `^<version>`, so `bun update` takes a same-major fix before the first hop; `x upgrade` rewrites every pin it moves exact (`20.1.0`, no caret), on purpose — a caret is how `^19.0.0` beside `^19.4.0` came to be mixed, and a range is a silent upgrade. `bun.lock` pins the exact build either way.

> **From v31, one major per 90 days.** The window, what semver covers, the support window and the security exception: [Stability](/stability). A removal now ships its `@deprecated` alias for at least one minor before a major takes it, so `x upgrade` across one major names no edit a minor did not already warn about. The 30 sections below stay as they are.

**Thirty majors have shipped since 1.0.0, and this page walks all thirty** — 2.0.0's 33 entries joined it `As of 2026-08`, and `scripts/changelog-check.ts` now refuses a summary row whose section the page does not carry, which is how they were missing for six releases. [`CHANGELOG.md`](https://github.com/developerz-ai/ultimate/blob/main/CHANGELOG.md) is the source for the majors it still carries, and `git show v<tag>:CHANGELOG.md` for the ones it has archived; none ships a codemod, so every entry is a manual edit the entry itself names. **One section per major**, newest first — read the ones between your pin and your target, oldest first.

| From → to | Breaking entries | Read |
|---|---|---|
| 30.x → 31.0.0 | **74** — plan 101 final hardening: an `AuthServerStore` with a required `getCode` and a `pollDevice` that takes the client; a drain that abandoned work exits 70; validation issues capped at 100 and causes at 4 KiB; storage keys, header values and revision-pinned reads; one policy gate for action and query; a paged read capped at 500 rows over the wire; a `PreferenceStore` asked once per 500 recipients; remote MCP tools not re-exposed unless opted in; **a `(tenant, pk)` index on every tenant table that needs one `x db gen`**; an `IsrStore` that may answer with a promise; `localhost` no longer admitted by a wildcard `allowHosts`; span exporters installed by subscribing to `span.end` on the one instrumentation bus; an app's own Sentry transport replacing the `SENTRY_DSN` one, local sinks marked; `jobs.queues` defaulting to `['default']`; OpenTelemetry's stable http, messaging and gen_ai span attributes; a `defineStorage` disk that reports on the bus; OTLP logs and the semconv histograms on every boot; db, entity, policy and cache observer slots replaced by bus events; a database span for every statement in production; a bus installed from `runtime.ts`, never a realtime setter; one `ADAPTER_PORTS` table and core installers on `@ultimat3/core/adapter`; a `SecretSource` port for the master key; `realtime.transport`/`urlEnv` removed from `app.config.ts`; job, event-bus, mail, idempotency, meter and notify installers on each package's `./adapter`, swapped from `runtime.ts`; the rate-limit store, auth limiters, TOTP guard and scrape driver installed the same way, `httpServer` taking no store; one core clock for scraping and flags; admin audit sinks typed by core's `AuditSink`; the AI gateway installed only as `aiGateway`, never through `configureAi`; factories with `belongsTo`/`hasMany`, `buildList`/`createList` and a `create()` that answers the stored row; `runtime.syncAuthenticate` removed, every port a `runtime.ts` field and `<app>/packages/db/factories.ts` linted on `unit`; the audit sink, error reporter and KDF gate installed on `./adapter`, `runtime.storage` a `StorageDriver` and the last four ports `runtime.ts` fields; port suites on `@ultimat3/testing/conformance` only; a malformed uuid refused before the statement; a push subscription that always names its person; no memory delivery ledger outside development; the embedder and semantic cache as `runtime.ts` ports, `configureAi` keeping `redact` only; metric exporters subscribing to `metric.export` on the bus; sign-in routes at `/auth/sso/:provider` for OAuth and SAML alike; the raw-CDP launcher imported from `@ultimat3/scraping/cdp` only; no framework mails — an app declares its own; `noopMetricExporter` deleted; `AuditPrimitive` gaining `'job'` for GDPR erases; the storage quarantine flow replaced by the scanner's `hold` verdict; one retry shape, core's `RetryPolicy`, for the AI gateway, realtime redials and job `jitter`; `setLogSink` replaced by `installLogSink`, which answers its release; realtime's `Row` renamed `LiveRow`; `Surface` and `SURFACES` imported from `@ultimat3/core` only; a `SearchIndex` with a required `list` and a `reindex()` that sweeps what the table lost; `defineApi` the one way to register actions and queries; `x g app`/`package` refusing slice flags; a searchable row's version strictly increasing, advanced by a custom store in its own write; a targeted upload grant refused while a scanner is installed; a write-path search document replacing a tombstone; an instant in a key spelled ISO 8601; every package's `.` barrel cut to the app API, ports on `./adapter` | [the `31.0.0` section](/upgrading/31), in order |
| 29.x → 30.0.0 | **143** — plan 101 (ten waves), issues #990–#999 and two find-and-fix sweeps: a write past its deadline that rolls back, a closed-transaction refusal, a two-release column drop, **same-tenant composite keys that need one `x db gen`**, `upsertAll` overwriting only what it names, every agent and MCP call held by `mcpConfirmations`, `'null'` refused in `cors.origins`, `isr` and `static` that render as nobody, a query-free `isr` key by default, a pool that refuses past `acquireTimeoutMs`, an upload grant bound to its declared size, mail that refuses an unresolved key, a problem document with `retry`, a worker retire on by default, required `StorageDriver`, `JobDriver` and `PoolProfile` members, a `budgets` step that builds before it weighs, no `bin/check` in a new app, `ui`'s Standard Schema copies removed, no vendor link on the default error page, the render brands at `@ultimat3/core/render-node` only, a required `enqueueMany` on every `JobHandle`, `JobsFacade` and `JobDriver`, an outbox relay that claims 500 rows a pass, `'browser'` in `ERROR_SOURCES`, `assets` and `clientErrors` on `AppConfig`, `pwa`'s `retentionPlan` and `retainBuildIds` removed, a `DefineEvalInput` union, `MCP_PROTOCOL_VERSION` deleted, an MCP revision decided per request with `2026-07-28` the newest, a cross-origin `POST /mcp` refused with `'origin-refused'` in `McpAuthRefusal`, `x mcp serve` answering only a loopback Host, a raw `WorkloadToken` payload, a role that refuses a half-loaded app, a query's own MCP tool wording, no `QueryPoll.ms`, an exercise refused outside `bun test`, an accessibility audit on every `e2eTest`, factory values read off each column's declaration, a memory transaction that holds every write inside it, a 21st gate step that runs every primitive with no test written, a finding for an untenanted child of a tenant-owned table, a memory transaction that refuses a write after it ended and runs nested effects at the root's commit, required `failureCapture` and `time` on `AppConfig`, an `x_failures` table in the schema dump, a description on every page `/llms.txt` lists, a required `TimeZoneConfig.detectedCookie`, an MCP resource read handed its caller, a required `DevCapabilities.queryGraph` and `DevSources.mailTemplates`, an exercise step that plans every statement and reds an unindexed read or a loop over rows, an `OpenGraph` that is a hosted `image` or a captured page and never both, a `postgresAuthAdapter()` that reads the ambient client per call, a `refetch()` that returns a promise, 48 more `UI_KEYS` in a module namespace, charts that draw `null` and negative values and refuse a series that does not match its keys, a two-mode `TabsProps`, a command palette that ranks, two more contrast pairs on `bg-soft`, every `mock.module` put back at its file boundary, a static export that writes image variants as files, `/assets` answering only the variants it declares, an island `mount` that must return its disposer, passkey storage on every `AuthAdapter`, a per-call rate limit split from the fixed one, a signed vote in `McpConfirmationStore.decide`, a meter in the manifest and its contract diff, a hand-rolled websocket refused, bad-token counting on every `PushDeviceStore`, exact history images on Postgres, a policy on every multipart part and completion, `LiveHandle.retry`, a Bearer-only CSRF exemption, no `enqueueInTx`, and an event id from `EventLookup.find` | [the `30.0.0` section](/upgrading/30), in order |
| 28.x → 29.0.0 | **2** — **Bun 1.4.4 as the floor**; a `WORKER_QUEUES` entry the app does not know refuses the worker's boot | [the `29.0.0` section](/upgrading/29), in order |
| 27.x → 28.0.0 | **2** — **Bun 1.4.3 as the floor**; `@ultimat3/ui/jsx-probe` and its `probe`/`unprobe` removed | [the `28.0.0` section](/upgrading/28), in order |
| 26.x → 27.0.0 | **2** — `AsyncContext.exit` required; **`/_storage` and `/media` refuse a key outside every `org/<id>/` prefix unless `defineStorage({ shared })` lists its prefix**, and `Storage.isShared` required | [the `27.0.0` section](/upgrading/27), in order |
| 25.x → 26.0.0 | **6** — an OAuth login that verifies the id token's signature against the provider's published key set by default; Apple out of the OAuth built-ins; `ServerHandle.describe()` deleted; no `api/**/route.ts` file kind; `recover: 'agent'` deleted and scraping at tier 4; one CLI verb per command (`list`, `show`, `delete`) | [the `26.0.0` section](/upgrading/26), in order |
| 24.x → 25.0.0 | **40** — Bun 1.4.2 as the floor; a closed `app.config.ts` that refuses an undeclared key and every deleted one by name, the locales in `defineCatalogs()`; `assertEnvExample`, `resolveSpeculation` and `normalizeAuditRecord` removed, `AuditRecord.name` / `.primitive` required, every value imported from the package that declares it; one spelling per factory, never `create<Thing>`; one `Page` (`entity`'s `Page` and `storage`'s `ListPage` removed), `http.drainTimeoutMs` and `http.buildIdHeader` removed, a `bearerMount` catch-all; one MCP projection (`toolFrom`), one page shape (`nextCursor`, `hasMore`), a per-action rate limit that is the action's own, a redaction-keeping `IdempotencyStore`, `SQL_*` statements off the barrels, the NATS job driver stub deleted, required `WebhookLedger.isDisabled` and `ChangeEvent.write`; one MCP block in core with `McpExposure` and its `name` removed, `contentHash` removed, a scopes map that names every tool, a required `DigestAppend.appender`; **no model chosen for an app — it registers its own and names one**; a widened `Menu` `aria-controls`, a private `FakeElement.listeners`, offline scrapes that state their robots reason, `nearest` removed, a `--cdp-url` capture that fails closed, a page that may not import a repo, ai's `contentHash` renamed `promptHash`, one meaning per value name, every job ack counted; a per-queue `jobs.concurrency`, the framework's own secrets in `.env.example`, an e2e app that inherits no `DATABASE_URL`; a two-argument `resolveGrants`, `WORKER_QUEUES` as the exact set, a `jobWorker` default of 8 slots, and `ChangeEvent.table` | [the `25.0.0` section](/upgrading/25), in order |
| 23.x → 24.0.0 | **202** — a calendar check on `t.date`, `t.url` refusing what the parser would cut, plain objects only, a default its own schema must accept, decimal-only coercion, a stricter `defineConfig`, an unknown `LOG_LEVEL` refused, `retry` and `createFlightGate` refusing a bound that is not one, a child context that aborts with its parent, compound credential names redacted, error `meta` under `extra.meta` in the monitor envelope, per-signal OTLP headers, a sampler that ignores a leftover ratio, wildcard host rules that stop at the network edge, an empty cursor secret counted as unset; then tier 1 — `t()` always interpolating, interval crons through both passes of a fall-back hour, exact cron names, `formatRelative` requiring a zone, a transaction that rejects when its body swallowed a failed statement, `X_DB_COMMIT_UNKNOWN`, nested transaction options refused, sibling nested scopes run in turn under a 30 s wait, a `changed-primary-key` drift kind, `introspect()` reporting catalog types, flag expiries that must be ISO; then an `e:<entity>` purge key on every tagged response, WebP-only `responsiveImage()` by default, `promoteAttachment` requiring its policy, a required `StorageDriver.stat()`, an optional `lastModified`, a `get()` ceiling, image variant keys that keep the source extension, `v2` signed URLs that name their disk; then a required `Driver.transactor()`, `dbDrift` leaving entity, a preload ceiling, `assertAllowed` throwing the decision's own code, a 401 for a denial with no actor, a malformed policy decision that denies; then `ctx.peer` behind its own switch, anonymous browser writes held to same-origin, failed sign-ins metered to a 429, health bodies trimmed for strangers, a `max-age` treated as a shared-cache offer, a body refused without a `content-type`, an awaited browser `close()`; then auth — **a sealed MFA secret that needs `x auth seal-mfa` run once**, a retired `x_auth_failures` table, a reservation-shaped `AuthLimiter`, API keys bound to their owner and its grants, `X_MFA_REQUIRED` carrying a challenge, eight more required `AuthAdapter` members, `oauthLogin` requiring `APP_URL`, an e2e `offline()` that rejects when a page refuses the switch; then `compareValues` removed from query, a `.limit()` that bounds every page, a `single` read answering one row over MCP, `admin:*` declared by `defineAdmin()` rather than by import; then `mutator()` requiring `idempotent: true`, a regenerated `openapi.json` and schema dump, idempotent actions and cache busts that settle with the commit, a `manifest` step that fails on a stale `openapi.json`; then jobs — a final-attempt lapse buried instead of re-claimed, `cancel` refusing finished jobs, step writes fenced on the claim, a uuid-only `runId`, an awaited `purgeExpired()`, `x_job_events` swept; then realtime — **three operator steps: `APP_URL` on the `sync` role as the whole origin allow-list, `REPLICA IDENTITY FULL` for channels with params, and a replication connection that refuses a weak password without TLS** — rows revived across the bus, five interfaces with new required members, stricter channel re-authorization, and an offline queue abandoned when the principal changes; then a catch-all route that answers its bare prefix, a zero-length ISR `ttl` refused, `NativeReason` gaining `'unparsable'`, CSS-module classes scoped in selectors only with every scoped name changed once, a positional `CompiledPattern.specificity`, and a notification tap that opens this app only; then ui — two required `ThemeEnv` members, a fixed `theme.defaultMode` that a "System" pick and an OS change no longer override, the deprecated theme inline script removed, a `Popover` trigger whose `aria-controls` can be absent, and file controls that post only the files they accepted; then a required `peek` on every rate-limit store and an address refused on required routes once its failed credentials are spent; then mcp — a 5xx cause hidden from a remote caller, `db.query` refusing SQL run as text, a meta tool without `destructive` listed as a query, list calls held to their whitelist, failed tokens metered per address; then a `send()` that waits for its transaction's commit and one recipient rule on every mail driver; then `x_notify_digests` in the schema dump, queries publishing `input`, a non-finite manifest fact refused, and a dropped NOT NULL default classed as breaking; then ai — an atomic `BudgetStore.take`, a required `Gateway.callLedger(keys)`, **gateway `actor` and `org` ceilings that count every model call's caller**, a 5xx tool cause hidden from the model, `fnv1a` removed and `HashEmbedder` vectors changed, an unknown finish reason read as truncated, an unscoped vector read refused inside an org's request, a required `VectorStore.prune`, `chunk()` owning `source`, and `numericTolerance` refusing a bound that is not one; then testing — a coded `app()` before boot, `assertDeterministic` by canonical form, `frozenClock` announcing its moves, one module instance per island mount; then scraping — a proxy switch in launch args refused, a required browser-level `target()`, a jar-less browser refused, `burnSession` comparing `savedAt`, `maxDrop` held to a fraction, credential-shaped URL redaction, a declared header that replaces the session's, wildcard hosts resolved and pinned, robots redirects screened per hop, and an undated recording read as stale; then admin — a nullable keyset bound, **edit forms that post `_version` and a 409 for a stale edit**, a required `AdminFormProps.version`, a `'stale'` `CrudResult`, creates and updates decided on what they write, tenant-scoped audit screens, actions and batches that commit with their audit entry, `?scope=*` for no scope, and stricter list filters; then a split sitemap's parts under `/sitemaps/`, a permission's declaration site restored with it, then cli — an export directory a build must prove is its own, a static build that fails on a module that does not load, unread build flags refused, a `--dry-run` that is the write plan, generator names and API bindings refused before writing, an unowned path read as root-wide, test discovery anchored at the root, guards and app entry files under `filesize` and `errors`, SEO answers kept an hour, a scaffold pinned to the repository's `solid-js` and typechecking the root program, **a permission only `defineAdmin()` declared refused by the `policy` step**, **an unrecorded `REPLICA IDENTITY FULL` refused by the `drift` step**, and a registry snapshot that carries permission sites; then a handler's own CSP kept beside the app's, `x routes --json` in every route fix, **serving roles that leave the framework schema to `ROLE=migrate`**, decimal-only ports and proxy hops, stored files served as sandboxed downloads, no readiness grace off the listening roles, a replicator ready only while its stream runs, and cli commands that exit 1 on an unknown name or any finding, a `/_x` SQL panel that runs only a same-origin POST from this machine, and stricter `x doctor`, `x jobs`, `x shot`, `x dev`, `x verify merge`, `tests.run`, `x mcp serve` and `x routes`; then `checkErrorCodesThrown` waiving only the codes a list names; then **a chart that renders a NetworkPolicy per role, a bounded `/tmp` and a Secret every role must have**, and a sync listener on its own port at port 0 | [the `24.0.0` section](/upgrading/24), in order |
| 22.x → 23.0.0 | **66** — an image line that prebuilds the island store, a worker that imports less of the app, a committed schema dump, a stated coverage floor, step deadlines, raw browser requests refused by the gate, a typed-handle repo with `list(limit)` and a generated query with no `orgId` input, admin label keys the `i18n` step now checks, every hand-written job driver and store fenced on its claim, `runJobs` through a real worker, a framework-served admin that replaces the host's pages and now serves the jobs dashboard, an async `AuditLog`, admin writes held to the row scope, and sealed scraping sessions that discard what was stored before | [the `23.0.0` section](/upgrading/23), in order |
| 21.x → 22.0.0 | **23** — two date readers that refuse a non-ISO string instead of reading it in the host's zone, a `helm` release named after the app, `channel()` requiring a policy, a per-mutation outbox, a `sync` role that refuses to boot with nothing to deliver, boot-owned auth tables, `x shot` on raw CDP with no `puppeteer-core`, `realtime.transport` deciding the bus, and removed exports: `Result`, realtime's `backoffDelay`, the e2e driver's move to `@ultimat3/testing`, `startLiveReplicator` leaving it, unreferenced package internals and 236 of the CLI's, a one-time `x db gen` for a re-stamped schema hash, and a query that filters on a column its loader never selected refusing instead of answering `[]` | [the `22.0.0` section](/upgrading/22), in order |
| 20.x → 21.0.0 | **27** — `AsyncState`'s import path, `custom(merge)` over rows rather than outputs, realtime's second conflict vocabulary removed, `isSuperseded` widened, one error path for every typed client, the record envelope on actions that return entity rows, the service worker's outbox flush replaced by a message to open tabs, a third client-scope answer, `last-write-wins` refused without a clock, the realtime client rebuilt around one page store and one read hook, Compose requiring `SYNC_URL`, `x verify`'s duration as wall time, and channels served by declaration only. The client data layer, one entry per removed surface | [the `21.0.0` section](/upgrading/21), in order |
| 19.x → 20.0.0 | **2**, both `@ultimat3/ui` component behaviour and neither a type change — a `DataTable` that keeps its rows while reloading, and a `Button` whose `loading` no longer sets the native `disabled`. Nothing fails to compile; what changes is what a screen does | [the `20.0.0` section](/upgrading/20), in order |
| 18.x → 19.0.0 | **2**, both from the same hole — the service worker had no build behind it, so the config key that steers it and the route it falls back to both had to move | [the `19.0.0` section](/upgrading/19), in order |
| 17.x → 18.0.0 | **5** — a runtime floor that was a minor behind what the CLI emits, two PWA config surfaces that had to grow before an app could be installable, a `--json` shape, and one scraping interface | [the `18.0.0` section](/upgrading/18), in order |
| 16.x → 17.0.0 | **3**, all one sweep — a numeric option that used to accept `NaN` refuses it, at boot or at the call boundary rather than mid-request | [the `17.0.0` section](/upgrading/17), in order |
| 15.x → 16.0.0 | **1** — `matches(/re/)` refuses a construct the two regex engines read differently, at `entity()` time | [the `16.0.0` section](/upgrading/16), in order |
| 14.x → 15.0.0 | **1** — `DriftKind` gains a member, so an exhaustive `switch` with no `default` stops compiling | [the `15.0.0` section](/upgrading/15), in order |
| 13.x → 14.0.0 | **8** — two are security fixes with a behaviour change (a frame verb, a session key), four are types that refused what the runtime already did, and two remove API nothing called | [the `14.0.0` section](/upgrading/14), in order |
| 12.x → 13.0.0 | **2**, both narrow: a service factory's parameter type, and one deleted `PageLike` member with zero call sites anywhere in the repository | [the `13.0.0` section](/upgrading/13), in order |
| 11.x → 12.0.0 | **16**, from the widest sweep since 4.0.0 — a keyset defect that dropped rows, a name that reached the DDL unchecked, and eight interfaces that gained a member | [the `12.0.0` section](/upgrading/12), in order |
| 10.x → 11.0.0 | **7** | [the `11.0.0` section](/upgrading/11), in order |
| 9.x → 10.0.0 | **19** | [the `10.0.0` section](/upgrading/10), in order |
| 8.x → 9.0.0 | **5** | [the `9.0.0` section](/upgrading/9), in order |
| 7.x → 8.0.0 | **6** | [the `8.0.0` section](/upgrading/8), in order |
| 6.x → 7.0.0 | **4** | [the `7.0.0` section](/upgrading/7), in order |
| 5.x → 6.0.0 | **7** | [the `6.0.0` section](/upgrading/6), in order |
| 4.x → 5.0.0 | **2**, over six surfaces, each a declaration that promised what the code did not do | [the `5.0.0` section](/upgrading/5), in order |
| 3.0.0 → 4.0.0 | **25**, from a sweep that closed every known gap | [the `4.0.0` section](/upgrading/4), in order |
| 2.0.0 → 3.0.0 | **10**, all from a five-agent bug sweep | [the `3.0.0` section](/upgrading/3), in order |
| 1.x → 2.0.0 | **33** | [the `2.0.0` section](/upgrading/2), in order |
| 11.x → 20.0.0 | **47** | every major section `CHANGELOG.md` still carries, oldest first |

An entry is a line `CHANGELOG.md` marks `BREAKING —`. The count is derived, never curated:

```sh
grep -cE '^(- \*\*|### )BREAKING —' <(awk '/^## /{u = ($0 == "## [Unreleased]")} !u' CHANGELOG.md)
# 634 As of 2026-11-10 — every RELEASED section, which is the sum of every row above whose section
# the changelog still carries.
```

How that total is counted, and why `[Unreleased]` is cut: the header of [`scripts/changelog-check.ts`](https://github.com/developerz-ai/ultimate/blob/main/scripts/changelog-check.ts).

Each entry changes a surface the table below covers.

> **Move to whatever `latest` is** — only the [footer](#footer) stamps the number, because a version written into a page goes stale on the next tag. All 31 workspaces resolve at one version — 29 `@ultimat3/*` plus the unscoped `create-ultimate`, `@ultimat3/scraping` and `@ultimat3/flags` included — and every tarball since 3.0.0 was published by the release workflow with a provenance attestation. Resolve before you pin, never take it from this page:

| Check | Command | Answer that means "go" |
|---|---|---|
| what `latest` is | `npm view @ultimat3/core version` | the version you are pinning |
| that a package resolves at it | `npm view @ultimat3/scraping@<version> version` | that version, not `E404` |
| that the tarball is attested | `npm view @ultimat3/core dist.attestations` | a `provenance` object |
| every name that must move together | `bun run scripts/release-workflow.ts --json` | the 30 derived names — check each |

- [30.x → 31.0.0, entry by entry](/upgrading/31)
- [29.x → 30.0.0, entry by entry](/upgrading/30)
- [28.x → 29.0.0, entry by entry](/upgrading/29)
- [27.x → 28.0.0, entry by entry](/upgrading/28)
- [26.x → 27.0.0, entry by entry](/upgrading/27)
- [25.x → 26.0.0, entry by entry](/upgrading/26)
- [24.x → 25.0.0, entry by entry](/upgrading/25)
- [23.x → 24.0.0, entry by entry](/upgrading/24)
- [22.x → 23.0.0, entry by entry](/upgrading/23)
- [21.x → 22.0.0, entry by entry](/upgrading/22)
- [20.x → 21.0.0, entry by entry](/upgrading/21)
- [19.x → 20.0.0, entry by entry](/upgrading/20)
- [18.x → 19.0.0, entry by entry](/upgrading/19)
- [17.x → 18.0.0, entry by entry](/upgrading/18)
- [16.x → 17.0.0, entry by entry](/upgrading/17)
- [15.x → 16.0.0, entry by entry](/upgrading/16)
- [14.x → 15.0.0, entry by entry](/upgrading/15)
- [13.x → 14.0.0, entry by entry](/upgrading/14)
- [12.x → 13.0.0, entry by entry](/upgrading/13)
- [11.x → 12.0.0, entry by entry](/upgrading/12)
- [10.x → 11.0.0, entry by entry](/upgrading/11)
- [9.x → 10.0.0, entry by entry](/upgrading/10)
- [8.x → 9.0.0, entry by entry](/upgrading/9)
- [7.x → 8.0.0, entry by entry](/upgrading/8)
- [6.x → 7.0.0, entry by entry](/upgrading/7)
- [5.x → 6.0.0, entry by entry](/upgrading/6)
- [4.1.0 → 5.0.0, entry by entry](/upgrading/5)
- [3.0.0 → 4.0.0, entry by entry](/upgrading/4)
- [2.0.0 → 3.0.0, entry by entry](/upgrading/3)
- [1.x → 2.0.0, entry by entry](/upgrading/2)

## What semver covers

The covered and uncovered surface, and what each bump means, are on [Stability](/stability).

## Release policy

| Rule | Detail |
|---|---|
| Pins | between `@ultimat3/*` packages, exact (`"@ultimat3/core": "<version>"` in each `package.json`). An app declares `^<version>`, as `x new` writes (`packages/cli/src/templates/scaffold-repo.ts`), and `bun.lock` pins the exact build: `bun install --frozen-lockfile` never upgrades silently |
| Lockstep releases | one release bumps all 30 packages — 29 `@ultimat3/*` plus the unscoped `create-ultimate` — to the same version. One version, one commit, one tag. A mixed set is unsupported |
| Published with provenance | npm via OIDC trusted publishing. Every tarball from 3.0.0 onward carries an attestation — verified through 5.0.1; **2.0.0's do not**, that release went out by hand. Per version: `npm view @ultimat3/core@<version> dist.attestations` |
| Breaking changes land with the edit named | **no release has shipped a codemod**, so every `BREAKING —` entry names the manual edit itself; `x upgrade` walks the pins, the Bun floor and the gate, and points at the section |
| Dependency upgrades are framework work | Solid is pinned to **`1.9.14`, the stable line** — Solid 2 is still prerelease (`2.0.0-beta.N`, DOM renderer split into `@solidjs/web`) and every app inherits whatever core this repo pins. Bumping it is a framework release, never an app-level `bun update`. There is no ArkType or Drizzle pin to carry: `@ultimat3/schema` ships dependency-free builtin validators (ArkType is an optional provider you adapt yourself) and `@ultimat3/entity` ships its own `postgresDriver()` |
| Bun floor | `>=1.4.4` (`REQUIRED_BUN`, `As of 2026-11-02`), target 2.0. Below the floor → `X_BUN_VERSION`. It was `>=1.3.0` until 2026-08-27, while `x test` emitted `bun test --isolate` — a flag Bun added in 1.3.13 — so the declared floor named runtimes the CLI could not run on |
| Not shipped `As of 2026-08`, behind the interfaces that ship today | realtime tier 3 (`persist: true`, local-first), the plugin API and multi-region replication. The Redis **job** driver shipped (`@ultimat3/jobs/redis`) and the NATS stub is deleted, `As of 2026-11-02` |

Do not upgrade a transitive dependency of a `@ultimat3/*` package by hand. Open an issue instead — the pin is deliberate.

## `x upgrade`, one major per run

`As of 30.0.0`. Every hop gets its own gate, so a red one is that hop's. No codemod, no `openapi.json` step of its own (`x manifest` writes it): `x verify`'s findings carry what a section's rows do not.

```bash
x upgrade --dry-run --json                  # what this hop would do; writes nothing
x upgrade --json                            # one hop: pins, Bun floor, bun install, the gate
bunx @ultimat3/cli@latest upgrade --json    # an app below 30.0.0, whose own CLI has no such command
```

| # | Do | Why |
|---|---|---|
| 1 | commit or stash first | a dirty tree refuses (`X_UPGRADE_DIRTY_TREE`): git is the only state between runs |
| 2 | run it | exact pins in every workspace, `overrides` included, the Bun floor raised, `bun install`, then `x manifest`, `x doctor`, `x verify` from the CLI just installed |
| 3 | open `data.section.url` | that major's entries, in full. Only what the gate cannot see needs it |
| 4 | close each `data.verify.findings[]` by its `fix`, then `x verify --json` | `data.done` is verify's verdict, and the exit code follows it |
| 5 | review `git diff`, commit | it never commits |
| 6 | re-run until `data.next` is `null` | the pins say where the walk stands |

A repository with pins and no `app.config.ts` hops too: no `x verify` can run, so it runs the `typecheck` and `test` scripts it declares, and `data.proof` (`verify`, `scripts`, `none`) says what `done` rests on.

Refusals name their own fix: `X_UPGRADE_MIXED_PINS` (`x upgrade --to <the lowest pins' next major> --json`), `X_UPGRADE_HOP_TOO_WIDE`, `X_UPGRADE_PIN_UNREADABLE`, `X_UPGRADE_REGISTRY_UNREACHABLE`, `X_UPGRADE_VERSION_UNPUBLISHED`, `X_UPGRADE_INSTALL_FAILED`. `data.bun.manual[]` lists what only you can move: a digest-pinned image, an expression. `data.guards.differs[]` offers `x g guard <name> --force`, and your guard is never touched. A hop below 30.0.0 reports `X_BUDGET_UNMEASURED` until `x build --target static --json` has run. Field by field: [CLI reference](/cli-reference#x-upgrade).

## Breaking-change classes and how each is detected

Nothing here relies on you reading a changelog carefully. Each class is a build error.

| Class | Detected by | Code | Fix |
|---|---|---|---|
| Action/query contract change | `x verify`'s `contract-diff` step, against the committed `x.manifest.json` | `X_MANIFEST_BREAKING` | `x verify --json` to read the finding, then bump the major version or restore the input/output shape |
| Breaking published surface | manifest contract diff, breaking subset | `X_MANIFEST_BREAKING` | bump the app's major version; old clients keep the old shape |
| Schema vs migrations | schema introspection vs migration history | `X_DB_DRIFT` | `x db gen "<message>"` then `x db migrate` |
| Stale generated facts | manifest freshness check | `X_MANIFEST_STALE` | `x manifest` |
| Import-tier change | `scripts/boundaries.ts` re-run over the new tier table | `X_BOUNDARY_VIOLATION` | move the import down a tier or invert the dependency |
| Budget ratchet | a release lowering a default budget | `X_BUDGET_EXCEEDED` | fix the regression, or set an explicit `budget` on the route |
| Config field rename/removal | config schema parse, and the compiler before it — an unknown key is an excess property on `Input<AppConfig>`, so it fails `typecheck` rather than reaching a runtime parse | `X_CONFIG_INVALID` | the cause names the field. No codemod has shipped yet, so this is a manual edit |
| Env schema change | typed env parse at boot | `X_ENV_MISSING` | add the key; fails in ~40ms, not as a later 500 |
| Renamed job step | duplicate/unknown step names in one `run` | `X_STEP_DUPLICATE` | renaming a step invalidates its stored result — treat as a new step |

Budgets ratchet **down** across releases. That is intentional: a framework release that makes bundles smaller should not leave your app's slack unclaimed.

## Version skew during a deploy

A client running build `A` requesting an asset from build `B` is the failure mode that actually breaks PWAs — not caching strategy.

| Mechanism | Behavior |
|---|---|
| Immutable build ID | content hash of the build, stamped into the HTML, every asset path, `x.manifest.json` and `sw.js`'s cache names — the worker half was emitted by no build until [#390](https://github.com/developerz-ai/ultimate/issues/390), `As of 2026-08`. Never a timestamp, never `latest` |
| Client sends its build ID | `X-Ultimate-Build` on RPC, query, and WS handshake — so the server answers "you are stale" instead of guessing |
| N-deploy asset retention | the last **3** builds' assets stay served — `retentionPlan(deploys, keep = 3)` in [`packages/pwa/src/version-skew.ts`](https://github.com/developerz-ai/ultimate/blob/main/packages/pwa/src/version-skew.ts). A count of deploys, with **no time component**: there is no 7-day half, and **no `pwa.retention` field** — `PwaConfig` was `{ enabled, offline, installPrompt, backgroundSync, push }` at 7.0.0 (`installPrompt` is deleted in 8.0.0). Pass `keep` at the call site to hold more |
| `AppUpdateAvailable` signal | a Solid signal flips when the server reports a newer build. Your app renders its own "Update available — reload". No forced navigation, no lost form state |
| Forced reload | **not a capability this framework has, `As of 2026-08`.** 9.0.0 deleted `updateSignal`, `updatePolicy`, `DEFAULT_GRACE_MS` and their types — they computed a grace, a `forced:` flag and a `deadlineAt`, and nothing performed the reload, nor could: `@ultimat3/http` (tier 2) and `@ultimat3/realtime` (tier 3) both sit below `pwa` (tier 4). Notification is complete — read `useConnection().updateAvailable`, or compare the worker's posted `to` with `detectSkew`, and render your own affordance. `x deploy --critical` was **removed in 4.0.0** for the same reason: echoed into the deploy plan, read by nothing |
| Skew is observable | the `/_x` live panel reports the build-ID distribution of connected clients. `x status --json` is **planned**, not shipped |
| The realtime **wire** is versioned separately | `PROTOCOL_VERSION` is a small integer in `@ultimat3/realtime`, `2` `As of 2026-08-24`. It is not the build id and it does not move per release: it moves only when a frame one side writes is a frame the other cannot read. A mismatch is `X_PROTOCOL_VERSION` on the frame — **clients and `sync` nodes are redeployed together across a bump**, because a cursor rides both the client's `subscribe` and the node's `snapshot`, so the skew breaks resume from either end ([Realtime](/realtime#wire-protocol-version)) |

Server behavior on a stale build ID:

| Request | Response |
|---|---|
| Asset within retention | serve it |
| Asset outside retention | `410 Gone` + `X-Ultimate-Build-Current`; the SW serves the fallback and flips `AppUpdateAvailable` |
| Action / query | executed if the contract is compatible; otherwise `X_BUILD_SKEW` with a `fix:` line |
| WS handshake | accepted, then an `update-available` frame carrying the server's `buildId` → signal flips. The socket is **not** killed |

Full detail: [PWA and offline](/pwa-and-offline).

## Migrating jobs between drivers — **still nowhere to migrate to**

**There is no `jobs.driver` field.** 5.0.0 deleted it, because it selected nothing: boot always built the Postgres driver, so `jobs: { driver: 'redis' }` gave you Postgres in silence; since 25.0.0 the key is refused by name (`X_CONFIG_INVALID`). Which driver runs is `setJobDriver(driver)` at boot, and only that.

**There is no driver migration to perform**, `As of 2026-10`. Postgres is the only durable driver — 25.0.0 deleted the NATS and Redis stubs, whose every method threw `X_NOT_IMPLEMENTED` — and `x jobs drain` is planned: it exits `X_NOT_IMPLEMENTED` before the queue boots, pointing at `x jobs list --json`. Its `--to` and `--dry-run` flags went with the drain body in 25.0.0 and are now `X_CLI_BAD_FLAG`. Before that a drain onto either stub leased the pending batch for five minutes, failed every enqueue and nacked it back; and the `memory` target, refused by name since 2026-09, acked every durable row off the source and lost the copy when the command exited — a target that dies with the command is not a migration.

The procedure for the day a second durable driver ships, with the drain itself **planned** — no invocation of it ships, so none is written here:

| Order | Step |
|---|---|
| 1 | deploy with the old driver still installed |
| 2 | drain the old queue onto the new driver — **planned** (`X_NOT_IMPLEMENTED` today) |
| 3 | change the `setJobDriver(…)` call at boot, `x verify`, deploy |
| 4 | confirm with `x jobs list --json` that the old queue is empty before removing its infra |

Job code never changes across a driver: `steps` is a driver member, so step persistence is identical on all of them. The outbox table stays the transactional record. At-least-once delivery is preserved; atomicity is not negotiable ([Jobs and workflows](/jobs-and-workflows)).

## Migrating realtime tiers

| From → to | Change | Notes |
|---|---|---|
| tier 1 → tier 2 | `live: true` on the query | needs a `replicator` role and `orderBy` + `limit` on the `sql` |
| tier 2 → tier 3 | `persist: true` on the **entity** | shipped in 21.0.0, opt-in per entity. No new mutators, no new authz, no new server code ([Realtime](/realtime#tier-3-shipped-in-2100)) |
| `memory` → `nats` transport | set **`NATS_URL`** for every realtime role — the variable decides the bus. There is no `realtime.transport` or `realtime.urlEnv` key (removed in 31.0.0, entry 35); any other `Transport` is `transport` in `apps/<app>/runtime.ts` | roll `sync` and `replicator`; clients reconnect with server-directed backoff. From 22.0.0 to 30.x `realtime.transport` decided the bus and `'nats'` with its variable unset refused the boot (`X_CONFIG_INVALID`); on 21.x, as again from 31.0.0, the presence of `NATS_URL` decides it ([Realtime](/realtime), [Adapters](/adapters#transport)) |

## Where the facts live

| Source | Contents |
|---|---|
| [`CHANGELOG.md`](https://github.com/developerz-ai/ultimate/blob/main/CHANGELOG.md) | Keep a Changelog format, `Added` / `Changed` / `Removed`. A `BREAKING —` entry names its manual edit **inline**; there is no per-entry `Migration` block convention and never a codemod name — `grep -c '\*\*Migration' CHANGELOG.md` answers `9` `As of 2026-08-23`, against 111 breaking entries |
| [`docs/idea/14-roadmap.md`](https://github.com/developerz-ai/ultimate/blob/main/docs/idea/14-roadmap.md) | the twelve milestones, 0–10 shipped. Milestone 11's two-platform deploy proof is the one item still open |
| [`docs/idea/15-risks.md`](https://github.com/developerz-ai/ultimate/blob/main/docs/idea/15-risks.md) | what could still change shape — the sync engine is roughly 70% of total effort |
| [`docs/architecture/19-cutting-a-major.md`](https://github.com/developerz-ai/ultimate/blob/main/docs/architecture/19-cutting-a-major.md) | how this page is maintained: one section per major, written when the first breaking change lands. Maintainer-facing — read it if you are opening a PR against the framework, not if you are upgrading an app |
| `x.manifest.json` | generated, per build. Diff two manifests to see exactly what a release changed in your app |

Read the changelog **backwards from your current pin to the target**, and read the `BREAKING —` entries only — the rest is regenerated for you.

## When an upgrade fails

```
git revert <the upgrade commit>      # or redeploy the previous image tag
x verify --json > verify.json
```

| Situation | Do |
|---|---|
| Prod is already rolling | redeploy the previous image tag. Assets from the previous build are inside the retention window, so sessions survive |
| An entry's named edit did not compile | keep the diff. It is the most useful part of the bug report, and it is the entry that is wrong |
| `x verify` fails on one check | read that step's findings from `x verify --json`, then reproduce it with the command its `fix` names |
| Cause is unclear | `x errors explain <CODE> --json` |

File an issue with `verify.json` attached, your previous and target versions, and the entry you were following. The JSON is the report — do not paraphrase the terminal.

Symptom-first fixes: [Troubleshooting](/troubleshooting). Code index: [Error codes](/error-codes).
