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. A removal now ships its
@deprecatedalias for at least one minor before a major takes it, sox upgradeacross 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 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, 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, 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, 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, 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, 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, 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, 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, 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, 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, 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, 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, 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, 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, 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, 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, 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, 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, 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, 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, in order |
| 10.x → 11.0.0 | 7 | the 11.0.0 section, in order |
| 9.x → 10.0.0 | 19 | the 10.0.0 section, in order |
| 8.x → 9.0.0 | 5 | the 9.0.0 section, in order |
| 7.x → 8.0.0 | 6 | the 8.0.0 section, in order |
| 6.x → 7.0.0 | 4 | the 7.0.0 section, in order |
| 5.x → 6.0.0 | 7 | the 6.0.0 section, 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, in order |
| 3.0.0 → 4.0.0 | 25, from a sweep that closed every known gap | the 4.0.0 section, in order |
| 2.0.0 → 3.0.0 | 10, all from a five-agent bug sweep | the 3.0.0 section, in order |
| 1.x → 2.0.0 | 33 | the 2.0.0 section, 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:
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.
Each entry changes a surface the table below covers.
Move to whatever
latestis — only the 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 unscopedcreate-ultimate,@ultimat3/scrapingand@ultimat3/flagsincluded — 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
- 29.x → 30.0.0, entry by entry
- 28.x → 29.0.0, entry by entry
- 27.x → 28.0.0, entry by entry
- 26.x → 27.0.0, entry by entry
- 25.x → 26.0.0, entry by entry
- 24.x → 25.0.0, entry by entry
- 23.x → 24.0.0, entry by entry
- 22.x → 23.0.0, entry by entry
- 21.x → 22.0.0, entry by entry
- 20.x → 21.0.0, entry by entry
- 19.x → 20.0.0, entry by entry
- 18.x → 19.0.0, entry by entry
- 17.x → 18.0.0, entry by entry
- 16.x → 17.0.0, entry by entry
- 15.x → 16.0.0, entry by entry
- 14.x → 15.0.0, entry by entry
- 13.x → 14.0.0, entry by entry
- 12.x → 13.0.0, entry by entry
- 11.x → 12.0.0, entry by entry
- 10.x → 11.0.0, entry by entry
- 9.x → 10.0.0, entry by entry
- 8.x → 9.0.0, entry by entry
- 7.x → 8.0.0, entry by entry
- 6.x → 7.0.0, entry by entry
- 5.x → 6.0.0, entry by entry
- 4.1.0 → 5.0.0, entry by entry
- 3.0.0 → 4.0.0, entry by entry
- 2.0.0 → 3.0.0, entry by entry
- 1.x → 2.0.0, entry by entry
What semver covers
The covered and uncovered surface, and what each bump means, are on 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.
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.
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, 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. 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) |
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.
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).
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) |
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, Adapters) |
Where the facts live
| Source | Contents |
|---|---|
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 | the twelve milestones, 0–10 shipped. Milestone 11's two-platform deploy proof is the one item still open |
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 | 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. Code index: Error codes.