-
Notifications
You must be signed in to change notification settings - Fork 0
Upgrading
As of 2026-08. Semver applies from here. A breaking change to a documented API needs a major. Every @ultimat3/* version is pinned exactly and moves in lockstep — never mix versions.
Twenty-two majors have shipped, and this page walks all twenty-two — 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 |
|---|---|---|
| 23.x → 24.0.0 |
31 so far, and unreleased — 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, and flag expiries that must be ISO |
the 23.x → 24.0.0 section below. Its entries sit under [Unreleased] in CHANGELOG.md until the tag |
| 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)
# 163 As of 2026-10-02 — every RELEASED section, which is the sum of every row above whose section
# the changelog still carries. `[Unreleased]` is cut by the awk deliberately: a bare whole-file
# grep agrees with this number only while that section is empty, so it moved on every PR that
# landed a breaking change and moved BACK when the release promoted the section — a count that can
# only be right between merges, whose repair is a number the next release invalidates. Corrected
# 2026-08-26, after it failed exactly that way.
#
# The count is SMALLER than the number of entries this page walks, and that is the archive, not a
# discrepancy: `CHANGELOG.md` keeps the recent releases and `git show v10.0.0:CHANGELOG.md` has the
# rest. THIS page is not truncated with it — a reader upgrading across four majors needs every
# walkthrough in order, so every major keeps its row and its section here. `changelog-check` reads
# the oldest `## X.Y.Z` heading still in the changelog as the retention boundary and stops asking
# for a count below it. At a TAGGED commit
# `[Unreleased]` holds none, and that is the state the rule below checks — a `BREAKING —` line left
# there at a tag is
# X_DOC_CHANGELOG_UNRELEASED_BREAKING, and the release promotes the section rather than appending one.
# Scope the count to one section to read a single row. The range is that section's own heading line
# to the line before the next `## `, and `grep -n '^## ' CHANGELOG.md` prints both —
# sed -n '<start>,<end>p' CHANGELOG.md | grep -cE '^(- \*\*|### )BREAKING —'
# Line numbers are deliberately not written here: every release moves them.
# `bun run changelog-check` compares both directions: each row against its OWN section, and the
# line above against the file.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 |
Thirty-one entries so far — 24.0.0 is in flight, and this section tracks CHANGELOG.md's
[Unreleased] entries in their order: grouped by package, lowest tier first. No legacy path, no
codemod, no compatibility shim — every break is a build error or an X_* error naming the rewrite.
As of 2026-10 slices 01 and 02 have landed: @ultimat3/schema and @ultimat3/core, then tier 1 —
i18n, time, db, flags. A later slice appends
its rows below the last one and never renumbers.
| # | Do | What you see until you do | Entries |
|---|---|---|---|
| 1 | pin every @ultimat3/* to the one new version, bun install
|
nothing yet — a mixed install is untested | — |
| 2 |
bun run typecheck, then x verify --only typecheck,unit
|
X_SCHEMA_DEFAULT_INVALID or X_CONFIG_INVALID at the first import of the file that declares it |
4, 6 |
| 3 | read the deploy environment: LOG_LEVEL, ULTIMATE_CURSOR_SECRET, OTEL_EXPORTER_OTLP_TRACES_HEADERS, OTEL_EXPORTER_OTLP_METRICS_HEADERS, OTEL_TRACES_SAMPLER
|
a boot that exits on X_INVARIANT or X_CURSOR_SECRET_DEV; a collector that rejects one signal; every root trace sampled where a leftover ratio thinned them |
7, 13, 14, 16 |
| 4 |
x verify --only unit,contract,e2e and fix the tests it fails |
a date, URL, object or query number that validated and is now refused; a redacted field a test read | 1–3, 5, 8–11 |
| 5 | repoint error-monitor rules from extra.<key> to extra.meta.<key>; add an exact host rule for each internal address a wildcard used to admit |
a saved search that matches nothing; a refused request to 127.0.0.1
|
12, 15 |
| 6 |
bun run typecheck again for the tier-1 types: add zone to each formatRelative call, wildcardTime to a hand-built CronExpression, case 'changed-primary-key': to a DriftKind switch, .expression to a CatalogColumn.generated read |
TS2741 / TS2339 at each site | 18, 20, 26, 28 |
| 7 | load the app once (x verify --only unit) and fix each cron and flag declaration it refuses |
X_CRON_INVALID, X_FLAG_EXPIRY_INVALID at the first import of the declaring file |
19, 30 |
| 8 |
x db gen where the database holds an object entry 29 lists; commit packages/db/schema/
|
a dump that no longer matches the one the gate regenerates | 29 |
| 9 |
x verify --only unit,contract,job,live and fix the tests it fails; read every catch and every Promise.all inside a withTransaction body |
X_DB_TRANSACTION_ABORTED where a call used to resolve; X_DB_SIBLING_SCOPE_TIMEOUT after a 30 s wait; ⟦name⟧ in a rendered string; a relative date counted in calendar days |
17, 20–25, 27, 31 |
| 10 | x verify |
green, or a finding whose fix: is the edit |
— |
Tier 0 — @ultimat3/schema (1–5), @ultimat3/core (6–16). Tier 1 — @ultimat3/i18n (17),
@ultimat3/time (18–21), @ultimat3/db (22–29), @ultimat3/flags (30–31).
| # | Surface | Costs you an edit if |
|---|---|---|
| 1 |
t.date, isIsoDateTime, fromIso, timestamp() columns, feed dates, DateTime
|
a fixture, seed, import or client sends a day its month does not have ('2026-02-30', '2026-04-31'), month 00/13, day 00/32. Refused at validation; it used to roll over into the next month. Correct the date at its source |
| 2 | t.url |
a value has a leading or trailing space or control character, or a tab, CR or LF anywhere. value.trim() before validating; an interior tab, CR or LF survives .trim() — strip or percent-encode it at the source. A stored row that already holds one fails the next time it is validated |
| 3 |
t.object, t.record, t.money
|
you pass a Map, a Date or a class instance. Pass a plain object: { ...instance }, Object.fromEntries(map). Null-prototype objects are still accepted |
| 4 | .default(v) |
v fails the schema it is declared on (t.number.min(5).default(1)). X_SCHEMA_DEFAULT_INVALID at the first import of the file; the cause quotes the rule. Edit the default, or relax the rule |
| 5 | HTTP query and form coercion | a client sends 0x10, 0b11 or 0o17 for a number. It stays a string and fails as expected a number. Send decimal |
| 6 | defineConfig |
app.config.ts or an overlay holds: an unknown roles entry, jobs.backoff, database.driver or theme.defaultMode; a non-boolean database.ssl, realtime.enabled or ai.mcp.expose ('false' from an env variable read as on — write process.env.X === 'true'); auth.signInPath or ai.mcp.path with no leading /; cache.tiers: []; an empty or non-string jobs.queues entry; one locale twice (['EN', 'en']); a section set to null. X_CONFIG_INVALID names each key |
| 7 | LOG_LEVEL |
a deploy sets a value that is not trace, debug, info, warn, error, fatal or silent, lower-case — DEBUG and verbose included. The process exits at import (X_INVARIANT); it used to log at info. Unset and empty are unchanged |
| 8 |
retry(), retryDecision()
|
attempts can be NaN, infinite, negative or a fraction, or timeBudgetMs NaN or infinite — typically Number(process.env.X) on an unset variable. X_INVARIANT before the first try. Parse and default the value before passing it. attempts: 0 still runs once |
| 9 | createFlightGate |
maxConcurrent or maxQueued is NaN, infinite, negative or a fraction (X_INVARIANT at construction), or maxConcurrent is 0 and you expected callers to wait: each is refused with X_FLIGHT_GATE_OVERLOADED
|
| 10 | withChildContext({ signal }) |
the child's work was meant to survive the request. The child's signal now aborts when the parent's does. Move that work to a job |
| 11 | redaction: logs, audit rows, the error monitor | a test, a log query or an audit reader expects the value of a field named like a credential — currentPassword, mfaSecret, resetToken, recoveryCode, webhookSecret, passwordHash, tokenHash, keyHash. It reads [redacted]. A name ending in token (NPM_TOKEN, confirmationToken), qualified key material (privateKey, AWS_ACCESS_KEY_ID) and a value embedding a credential (connectionString, databaseUrl) read [redacted] too. Ask isRedactedKey('<name>') for any other name. idempotencyToken, continuationToken, maxTokens, cacheKey and code are unchanged |
| 12 | the Sentry envelope | a monitor rule, alert or saved search reads an error's meta key at extra.<key>. It is extra.meta.<key>. scope.extra keys stay at extra.<key>, except fix, docs, stack, requestId and actorId, which the framework's own values now win |
| 13 |
OTEL_EXPORTER_OTLP_TRACES_HEADERS, OTEL_EXPORTER_OTLP_METRICS_HEADERS
|
a deploy sets either one and also OTEL_EXPORTER_OTLP_HEADERS. The per-signal variable was ignored; it now replaces the generic one for that signal. Put every header the signal needs in it, or unset it |
| 14 | OTEL_TRACES_SAMPLER=parentbased_always_on |
OTEL_TRACES_SAMPLER_ARG is also set. The ratio is ignored and every root is sampled. For a ratio: OTEL_TRACES_SAMPLER=parentbased_traceidratio
|
| 15 |
hostDecision, every allowHosts list |
a '*' or '*.suffix' rule was how a request reached a loopback, private, link-local or metadata address literal. Add the exact rule: allowHosts: ['*', '127.0.0.1']. Hostnames are unaffected, including one that resolves inward |
| 16 | ULTIMATE_CURSOR_SECRET |
a compose file or chart sets it to the empty string. Outside local development the boot is X_CURSOR_SECRET_DEV: x secrets set ULTIMATE_CURSOR_SECRET. Cursors issued under the empty key stop verifying — clients restart from page one |
| 17 |
t(key) with no vars |
a caller relied on the template coming back raw: a key whose message holds {name} or {{. It renders ⟦name⟧ and {. Pass the vars, or read the template with t.raw(key)
|
| 18 | interval crons, CronExpression
|
a task with * or */n in its minute or hour field must not run twice in a fall-back hour — make the body idempotent for that hour, or give it a fixed time. A hand-built CronExpression adds wildcardTime (TS2741) |
| 19 | cron expressions | one spells a name loosely (mond, thurs, sept) or has an extra - or / part (1-5-7, 1/2/3). X_CRON_INVALID where the task is declared. Write mon or monday; write one range, one step |
| 20 | formatRelative |
always: add zone (TS2741). A test that asserted on "tomorrow" / "in N days" re-reads its expectation — the count is midnights crossed in that zone |
| 21 |
addDaysInZone, formatDuration, formatDurationIso, plainDateUtc, addPlainDays, plainDateIn
|
a computed argument can be a fraction, NaN, infinite, or a date outside years 0000–9999. X_SCHEDULE_INVALID or X_INVARIANT at the call. Round or default the number before passing it |
| 22 | withTransaction |
a body catches a statement error and carries on. X_DB_TRANSACTION_ABORTED; it used to resolve with nothing stored. await withTransaction(() => fallible()).catch(fallback) around the statement, or rethrow |
| 23 |
X_DB_COMMIT_UNKNOWN, onRollback
|
you retry on X_DB_UNAVAILABLE from a commit, or an onRollback undo had to run when the socket died mid-COMMIT. Neither list runs now. In a psql "$DATABASE_URL" session, select a row the transaction wrote; re-run only when it is absent |
| 24 | a nested withTransaction(fn, options)
|
it passes isolation, readOnly: true, deferrable: true or another client (X_INVARIANT). Move the first three to the outermost call; run the other client's work after the outer scope returns |
| 25 | sibling nested withTransaction scopes |
a nested body awaits another scope under the same parent (X_DB_SIBLING_SCOPE_TIMEOUT after 30 s — it is a cycle), code relied on two siblings interleaving under Promise.all, or the first sibling runs longer than 30 s. Await them in sequence: await withTransaction(first); await withTransaction(second). For a long first sibling pass { siblingWaitMs }; 0 removes the deadline |
| 26 |
DriftKind, the drift check |
you switch over DriftKind exhaustively: add case 'changed-primary-key':. A table re-keyed by hand now fails the check; the finding's fix: is one psql "$DATABASE_URL" -c '…' command to run against that database, then x db migrate re-checks |
| 27 | introspect() |
you compare ColumnDescription.dataType to 'numeric', 'ARRAY' or 'USER-DEFINED', or assume every IndexDescription.columns entry is a column name. Compare to numeric(12,2), text[], the enum's name; skip entries in parentheses |
| 28 |
CatalogColumn.generated from @ultimat3/db/schema-dump
|
you read it as a string. Read generated.expression; generated.storage says stored or virtual
|
| 29 | packages/db/schema/unrendered.sql |
the database holds extended statistics, forced row security, non-default column storage, an unpopulated materialized view, or a trigger on a partitioned table. x db gen, commit the directory |
| 30 | a temporary flag's expiresAt
|
it is not ISO-8601 ('December 1, 2026', '12/01/2026') or names a day its month lacks. X_FLAG_EXPIRY_INVALID at declaration. Write '2026-12-01'
|
| 31 | configureFlags({ reportEveryMs }) |
the value can be NaN, infinite, negative or a fraction — Number(process.env.X) unset. X_INVARIANT. Parse and default it first; 0 is legal |
| Surface | What changed |
|---|---|
| a union in HTTP coercion | every member is tried: t.union(t.literal('auto'), t.number) reads '12' as 12. A string a member accepts as a string is never converted |
SchemaError#toJSON() |
carries retry: 'terminal'; a bigint or a cycle in meta no longer throws |
| image errors | a header declaring a zero, negative, fractional or NaN size is X_IMAGE_DECODE_FAILED, was X_IMAGE_TOO_LARGE. A HEIC (mif1 only) is no longer sniffed as AVIF |
| OTLP export | an endpoint with a query string keeps it after the signal path; a NaN or infinite attribute is dropped |
X_VERIFY_STEP_TIMEOUT |
names the test file still running and its fix: runs it; --json findings gain meta
|
fix: lines |
X_REGISTRAR_MISSING / X_REGISTRAR_CONFLICT name the owning package; X_SECRETS_KEY_INVALID names the key file when the file is what is wrong |
x db gen and a changed primaryKey
|
the migration is written — drop <table>_pkey, add the new key, drop not null for a declared-nullable column leaving it. X_MIGRATION_IRREVERSIBLE for a key another table's foreign key references, and for a new key over a column the same migration adds with no default, or a null one: add and backfill the column in one migration, change the key in the next |
x tasks |
next, last and upcoming are unchanged in form; they come from isoInZone in @ultimat3/time
|
| db error codes | a syscall error (EPIPE, E2BIG) is X_DB_UNAVAILABLE, was X_DB_STATEMENT_FAILED; a ragged array or Invalid Date parameter is X_INVARIANT on both drivers, was X_DB_UNAVAILABLE
|
addBusinessDays, businessDaysBetween
|
the wall time survives a DST day; a date the zone skipped is not counted |
Accept-Language |
a q that is not a plain decimal ranks 0, not 1 |
| a zone that is not a string |
X_TIMEZONE_INVALID from every zoned function in @ultimat3/time, was a bare TypeError
|
grep -rnE "\.default\(" apps packages --include=*.ts --include=*.tsx
grep -rnE "retry\(|retryDecision\(|createFlightGate\(|withChildContext\(|allowHosts" apps packages --include=*.ts --include=*.tsx
grep -rnE "LOG_LEVEL|ULTIMATE_CURSOR_SECRET|OTEL_(EXPORTER_OTLP_(TRACES|METRICS)_HEADERS|TRACES_SAMPLER)" docker .github apps packages
grep -rnE "(ssl|enabled|expose): *process\.env" apps packages --include=*.ts
grep -rnE "withTransaction\(" -A12 apps packages --include=*.ts | grep -E "catch|Promise\.all|isolation|readOnly|deferrable|client:"
grep -rnE "formatRelative\(|addDaysInZone\(|formatDuration(Iso)?\(|cron: |expiresAt|reportEveryMs|introspect\(|\.generated\b|DriftKind" apps packages --include=*.ts --include=*.tsxTier 0: the typecheck step finds none of 1–16 — every entry is a value, not a type. A typed
app.config.ts already refused most of entry 6 at compile time; the ones it did not are
'/'-less paths, empty lists and a locale spelled twice. Entries 4 and 6 throw at the first import;
7 and 16 at boot; 8 and 9 where the call is made. Entries 1–3, 5, 10 and 11 need the unit,
contract and e2e suites; 12–15 need a read of the deploy environment and the monitor.
Tier 1: the typecheck step finds 20, and 18, 26 and 28 where a literal, a switch or a string
read exists. Entries 19 and 30 throw at the first import of the declaring file; 24 and 31 at the
call. It finds none of 17, 21–23, 25, 27 or 29 — run the unit, contract, job and live suites, and
read every catch and every Promise.all inside a withTransaction body.
Sixty-six entries, the 23.0.0 section of CHANGELOG.md, in its order — which is the order
an existing app meets them.
Six things an agent-written app does differently from 23.0.0 on. Each is the idiom the generators now emit, so copying generated code copies it.
| Do | Instead of | Read |
|---|---|---|
read and write through the typed handle: db.<table>.where({ id }).one(), repo.list(limit)
|
sql template literals and an orgId argument in repo.ts — the handle scopes every read to the actor's org |
Entities and migrations → The repo |
call the server from an island through browserClient / browserQueries (apps/web/shared/browser-client.ts) |
fetch( — the gate refuses it |
Client data |
store a credential in a text().sealed() column |
a plaintext column, or a vault of your own | Entities and migrations |
record a run's ending in onSettled
|
a status column written from the body, or a second job that polls | Jobs and workflows |
keep the tests x g emits, and raise the floor in x.verify.json when coverage rises |
deleting an emitted test, or a floor nobody states | Testing → Coverage |
operate the queue at /admin/jobs, and give an operator a screen by declaring resources: / actions: (when, batch) on defineAdmin()
|
a hand-written jobs page, or an admin page per operation | Admin dashboard |
Run each step; the entries it closes are in the last column.
| # | Do | What you see until you do | Entries |
|---|---|---|---|
| 1 | pin every @ultimat3/* to the one new version, bun install
|
nothing yet — a mixed install is untested | — |
| 2 | add RUN bun node_modules/@ultimat3/cli/src/bin.ts build --target prebuilt to docker/Dockerfile, after the runtime stage's COPY . ., above ENV NODE_ENV=production
|
every web pod logs X_IMAGE_NOT_PREBUILT at boot and compiles every island and stylesheet |
1, 2 |
| 3 |
x db gen, then commit packages/db/schema/ and packages/db/.gitattributes
|
the drift step is X_SCHEMA_DUMP_DRIFT
|
3–6 |
| 4 |
x verify --only unit --json, then paste the coverage line its finding carries into x.verify.json
|
the unit step is X_COVERAGE_FLOOR_UNSTATED
|
7–10 |
| 5 | rewrite each raw request the boundaries step names (table under entry 11) |
X_BROWSER_TRANSPORT_BYPASS / X_BROWSER_SERVER_BARREL, one finding per site |
11, 12 |
| 6 |
git mv a defineAdmin() under apps/*/src/ to apps/admin/app/admin/admin.ts; then x manifest and commit x.manifest.json
|
the manifest step is X_ADMIN_UNSCANNED, then X_MANIFEST_DRIFT
|
13, 14 |
| 7 |
x doctor --json, then x g guard <name> for each name in data.guards.missing you adopt, and fix what x verify --only boundaries reports |
nothing — upgrading installs no guard | — |
| 8 | drop --feature <other> from x g resource and --live from every x g but query in your scripts; read x tasks --json rows by key; git mv apps/admin/src/pages apps/admin/app/admin/pages
|
X_CLI_BAD_FLAG |
17–22 |
| 9 |
bun run typecheck and fix each job, driver, export, scraping, admin and type-union site it names |
TS2741 / TS2345 / TS2339 / TS2353 at each hand-built literal, driver, store or option | 23–43, 55–57, 63, 64 |
| 10 | delete the admin host: every page.tsx under an admin URL — /admin/jobs included — the AdminRepo adapter, the admin action routes, a hand-written jobs nav item; declare defineAdmin({ entities, db })
|
X_ADMIN_REPO_UNBOUND at load, X_ROUTE_DUPLICATE for each page file, X_ADMIN_PAGE_PATH_INVALID for a pages: entry under /jobs
|
49–55 |
| 11 |
x verify --only policy,i18n, then grant every permission it names in the role map and add every admin key it names to each non-en catalog |
X_PERMISSION_UNGRANTED, one per permission a mounted admin route asks for; X_CATALOG_MISSING_KEYS per locale |
15, 16 |
| 12 |
x verify --only unit,job and fix the tests it fails |
a test that leaned on runJobs calling the body directly or on a lease that never renewed, an event published in beforeAll, a row action's old redirect, an admin write or action the row scope now refuses, a social tag on a noindex page |
44–46, 48, 58–61 |
| 13 |
x secrets init where no master key exists; correct any credential a site had refused before the first scrape |
X_SEAL_KEY_MISSING before the browser opens; every stored session is logged in again |
62, 65, 66 |
| 14 | x verify |
green, or a finding whose fix: is the edit — X_PACKAGE_SHAPE for a published package's fixture |
47 |
| # | Surface | Costs you an edit if |
|---|---|---|
| 1 | the app image | your docker/Dockerfile predates 23.0.0. x build --target docker no longer writes .x/islands/; the store is built inside the image. Add the step-2 line. app.config.ts must import with no deployment environment while NODE_ENV is unset — the line runs above ENV NODE_ENV=production
|
| 2 |
ROLE=worker, ROLE=scheduler
|
a job reaches a defineService, defineStorage or defineCatalogs only through a module that also imports a component. Import that module from apps/web/api/index.ts. The image needs x.manifest.json and a stamped BUILD_ID for the smaller load; without either the role imports everything and logs the fix. A gap is X_ROLE_LOAD_INCOMPLETE, at boot and in the manifest step |
| 3 | packages/db/schema/ |
the app has a migration. Step 3. A release that changes a framework table changes framework/, and this one does: x_jobs, x_outbox, x_scheduler_state, and four new tables (x_job_pauses, x_job_workers, x_job_counters, x_admin_audit). x db gen needs @electric-sql/pglite: bun add -d @electric-sql/pglite # then: x db gen
|
| 4 | an extension the embedded database does not ship (vector, postgis) |
a migration creates one. Set TEST_DATABASE_URL (or DATABASE_URL) to a server that has it, with a role that may create a database, in CI and wherever x db gen runs. Contrib extensions (citext, pgcrypto, …) need nothing |
| 5 |
x db gen's exit code |
a script treats exit 1 as "nothing written". It now exits 1 after writing the migration when the dump fails; the cause names the migration — keep it |
| 6 |
x db migrate, DriftKind
|
your dev database holds a trigger, function, view, type or sequence no migration creates (unexpected-object, X_DB_DRIFT) — add the migration or drop the object. An exhaustive switch over DriftKind adds case 'unexpected-object':
|
| 7 |
x.verify.json coverage
|
always. Step 4. Under 95 the line needs a "why"; an exclude entry is { "glob": "…", "why": "…" }. Keep the floor at the measured number — it is X_COVERAGE_FLOOR_STALE once the tree passes it by 1.5 points, and X_COVERAGE_BELOW_FLOOR names the ten worst files when it drops |
| 8 | a slow gate step | a step runs past 8 minutes (unit, contract, live, job, e2e, eval) or 5 (every other): X_VERIFY_STEP_TIMEOUT. Raise one with "stepTimeoutMs": { "unit": 900000 } in x.verify.json
|
| 9 |
x verify --json in a pipeline |
you parse 2>&1 as JSON. Read stdout only; stderr carries one {"step","ok","ms"} line per finished step |
| 10 | test-order dependence | a unit test relied on which files shared a --parallel worker. The step runs slices of at most 16 files, one process each |
| 11 |
fetch(, new WebSocket(, new XMLHttpRequest(, new EventSource( in browser code |
any *.island.tsx, anything that calls clientTransport/pageClient, or anything they import holds one. The finding names the line; rewrite with the table below |
| 12 | a value import of @ultimat3/entity / @ultimat3/query in browser code |
import @ultimat3/entity/record or @ultimat3/query/client; import type is unaffected |
| 13 | x.manifest.json |
always: step 6. It gains an admin section (each resource's filters, sorts, scopes, row scope, sections, formGroups, related, actions, and the audit store's kind), a concurrency member on a job that declares one, onSettled: true on one that declares that |
| 14 |
defineAdmin() under apps/*/src/
|
your admin is declared there — the reference app's was. No boot imports that directory, so /admin was never mounted; the manifest step now says so: X_ADMIN_UNSCANNED. git mv apps/admin/src/index.ts apps/admin/app/admin/admin.ts, repoint its relative imports, x verify --only manifest
|
| 15 | the policy step and a mounted admin |
a role should open the admin but the role map does not grant every permission its routes ask for. Each one is X_PERMISSION_UNGRANTED, one finding per permission per mount: grant admin:read and <table>:read|write|delete — and job:read / job:manage for the jobs dashboard, audit:read for the audit screen — in the role map |
| 16 | the i18n step and a mounted admin |
a locale lacks a key a mounted admin renders — resource titles, field labels, sections, scopes, columns, the nav, action labels (admin.action.<name> with no labelKey) and their input labels, the branding key: AdminApp.catalogKeys(). The step was green while the page drew ⟦admin.<table>.title⟧; it is now X_CATALOG_MISSING_KEYS naming each. en is answered by the framework's catalog; a non-en app adds the framework admin keys to its own |
| 17 |
x g entity, x g resource
|
you generate after upgrading. New repos read through db.<table>, export list(limit) (was listByOrg(orgId, limit)), and refuse a read under an actor with no org (X_TENANCY_ACTOR_ORG_REQUIRED). Existing repos keep working. The first x g entity in an older app writes packages/db/src/client.ts and exits with X_DB_HANDLE_UNREGISTERED naming the line for packages/db/src/index.ts
|
| 18 | x g resource <name> --feature <other> |
a script passes it. Drop the flag (X_CLI_BAD_FLAG) |
| 19 |
x g <kind> --live for any kind but query
|
a script passes it. It was ignored on a resource (whose list query is already live); it is X_CLI_BAD_FLAG, fix x g query <name>-feed --feature <name> --live
|
| 20 | a query x g query / x g resource generates |
you generate after upgrading. The read takes { limit } — no orgId input, no .where({ orgId }): the org is the actor's, scoped by the typed handle. The generated can<X>Read checks only that the actor has an org. Already-generated slices keep working; a caller of a newly generated one passes { limit }
|
| 21 | x g admin:page |
a script or doc expects apps/admin/src/pages/, which the app scan never imported. It writes apps/admin/app/admin/pages/ by default: git mv apps/admin/src/pages apps/admin/app/admin/pages, then update the import in apps/admin/app/admin/admin.ts
|
| 22 | x tasks |
a script runs it with no queue reachable, or reads --json rows by position. It opens the queue when a task is declared; rows gain lastMs, last, lastFiredAtMs
|
| 23 |
<job>.run({ … }) by hand |
a test calls it. Add finalAttempt: isFinalAttempt(<job>.retry, attempt) and progress: () => undefined
|
| 24 | a hand-built job literal or exhaustive switch
|
over JobOutcome ('refused', 'dropped'), QueueStats (failed), WorkerStats (refused, dropped, pollDelayMs), JobHandle, JobDescriptor, JobRecord, JobTrace. Add the member the compiler names |
| 25 | a hand-written JobDriver
|
you ship one. ack/nack take { workerId, claim } and answer Promise<boolean>: driver.ack(id, claimOf(claimed)). Increment a claim ordinal in claim(), fence both settles on it, file a row failed when nackState(options) says so, and answer an existing EnqueueRequest.id with that job and deduped: true
|
| 26 | a hand-written JobIntrospection, or a caller of list()
|
you implement one: add every member TS2741 names — promoteMany among them, which the admin's jobs dashboard calls. A caller that asked list() for more than 200 rows walks pages with list({ after: jobCursor(lastRow) }); over 200 or a foreign cursor is X_JOB_PAGE_INVALID
|
| 27 | a hand-written LeaseStore, SchedulerState, LeaderElection, or createFleetSlots caller |
add holders(key); fire(driver, { task, occurrenceMs, jobs }); renewEveryMs; read acquire's SlotGrant
|
| 28 | a custom EventBus, createPgEventBus({ clock })
|
add stored and now(); delete the clock option. eventPrompt() on a bus with stored: false is X_DRIVER_UNAVAILABLE outside development and test |
| 29 | a staged enqueue()
|
you read its id or runId: they are now the job's own. A custom OutboxStore persists runId
|
| 30 | retry: { deadLetter: false } |
a job declares it. An exhausted run is now failed (outcome dropped) instead of re-run forever — check what relied on the loop |
| 31 |
concurrency: 0, a negative, a fraction |
caught as X_INVARIANT. It is X_JOB_DECLARATION_INVALID
|
| 32 | lastError |
you compare it to a rendered message. It ends with — fix: … now; compare a prefix |
| 33 | the memory job driver | a test relied on a Date, an undefined member or a non-enumerable property surviving the queue. The payload is its JSON form, as on Postgres |
| 34 | exportRows({ sink }) |
always: sink is a thunk, sink: () => disk('exports'), resolved per write. A value is a type error — at runtime definition.sink is not a function on the first part — and evaluated disk() at module load, before boot |
| 35 |
rpc({ pathStyle }) in island code, a match on X_ROUTE_NOT_FOUND
|
delete the pathStyle — the document carries it. A wrong style is X_CONTRACT_DRIFT (still 404) |
| 36 | a live query's source | it names an org by hand. Drop the argument: the source is read as the subscriber's tenant. LiveQueryDefinition.snapshot receives { input, tenant }
|
| 37 |
EntityCore<Row>['$schema'], a hand-built RecordProjection
|
read the type off the entity() result (typeof posts.$schema); add sealed: [] to the projection |
| 38 | x secrets rotate |
something asserts on secrets.enc.json's exact contents. It now keeps ULTIMATE_SECRETS_RETIRED_KEYS; drop a key with x secrets rotate --drop <keyId> once nothing is sealed under it |
| 39 |
interface X extends LinkProps / PaginationProps / DataTableProps<Row>
|
extend one member: TextLinkProps/ButtonLinkProps, PaginationCallbackProps/PaginationLinkProps, DataTableCallbackProps<Row>/DataTableLinkProps<Row>
|
| 40 |
guard.check(root) in a test |
pass guardSources(root) from @ultimat3/cli as the second argument |
| 41 |
checkAppBoundaries, a switch over BuildTarget
|
delete the import; add case 'prebuilt':
|
| 42 | a hand-written e2e driver | read the third argument, { timeoutMs }
|
| 43 | a hand-written E2eApp double |
you build one. E2eApp has a required log(): string — the spawned app's bounded output, which a failed e2e test now prints (its last 40 lines). Add log: () => ''
|
| 44 |
runJobs in a test |
a test relied on it calling the body directly. Each pass is a real worker's tick(): keyed concurrency waits, whenBusy: 'fail' refuses with X_JOB_KEY_BUSY, and runs claimed in one pass run concurrently. A test that expected two runs of one key to both complete sees a refusal or a wait |
| 45 |
clock.advance() past the visibility timeout in a runJobs test |
a run is in flight while the test advances. runJobs renews its lease on the test clock as a real worker does, so the lapse is X_JOB_LEASE_LOST, as it would be in production. A test that cancels a running job then awaits runJobs.drain() adds clock.advance(1) after the cancel |
| 46 | the jobs event bus in a test | a test published an event in beforeAll and read it in a test. The app's test preload resets the bus before every test: publish inside the test |
| 47 | a non-private workspace package with a src/**/*-fixture.ts
|
you publish one. Its files must carry !src/**/*-fixture.ts, and a fixture reachable from an entry point is X_PACKAGE_SHAPE naming the file to rename. Private packages — every generated app's — are exempt |
| 48 |
og:* / article:* / twitter:* on a robots: { index: false } page |
a test or a crawler expected them. A document that may not be indexed carries none |
| 49 | defineAdmin |
always: defineAdmin({ entities, db }) (adminEntitiesOf(db) lists every entity on the handle). Delete each page.tsx under an admin URL, the AdminRepo adapter and the admin action routes. Without db: X_ADMIN_REPO_UNBOUND; a leftover page: X_ROUTE_DUPLICATE
|
| 50 |
guardedPage, AdminRouteConfig.component, RegisteredRepo, adminRouteConfig(route)
|
guardedScreen(app, route, body) or route.respond(…); adminRouteConfig(app, route). The admin view components take no handlers and no loading
|
| 51 |
AdminList, pageRequestOf, adminList
|
you render the list yourself: hrefFor={(location) => listHref(basePath, resource, location)} plus request, scope, counts. An unknown URL parameter is X_ADMIN_FILTER_INVALID, not ignored |
| 52 | the tenant column in the admin | a form posted it. It is stamped from the actor; the posted value is ignored |
| 53 |
describeRoutes(), Stylesheet
|
you list routes or build a Stylesheet by hand: mounted routes carry mount, a stylesheet carries claimed
|
| 54 |
/admin/jobs/*, a hand-written jobs page or nav item |
always: every defineAdmin() now serves the jobs dashboard — resources x_jobs, x_job_queues, x_job_tasks, x_job_workers and the overview at /admin/jobs. Delete the page and the nav item; a pages: entry at those paths is X_ADMIN_PAGE_PATH_INVALID. A test that renders /admin/jobs installs a queue first: setJobDriver(createMemoryDriver())
|
| 55 |
defineAdmin({ jobs }), AdminApp.jobs
|
delete both: the dashboard reads the queue itself. TS2353 names the site |
| 56 | a hand-written AuditLog, or a caller of entries()
|
entries(query) is async and takes AuditQuery (entity, entityId, actorId, orgId, limit, before, changes): write await log.entries(…). A custom log adds atomic(run) and kind
|
| 57 | InvokeResult |
you read decision off a failure. Failures carry kind: 'denied' | 'not-applicable' | 'invalid'; narrow on kind === 'denied' first |
| 58 | a posted row action's redirect | a test asserts its Location. It is the row (303), not the list |
| 59 | a row-scoped resource's create and update | a write put a row outside the actor's rows(actor). It is refused before the repo is called and audited as admin.error.row-out-of-scope. A text gt/lt row scope cannot be decided here and refuses every write |
| 60 | an action on a row the actor cannot see | it ran. A row that is gone or outside rows is refused, when or none: X_ADMIN_ACTION_NOT_APPLICABLE, 409, the same over MCP |
| 61 | permissionsForOperation('admin', op) |
you read a second element. It answers one permission (['admin:read']), not the same one twice |
| 62 | stored scraping sessions | always. Step 13. Sessions and refusal markers stored before 23.0.0 are deleted on first load, so every run logs in again, and a credential the site had already refused is presented once more. Correct it first on a site that locks an account after repeated failures |
| 63 |
storageSessionStore(disk), artifacts: { storage }
|
pass a thunk: storageSessionStore(() => disk('sessions'))
|
| 64 | a hand-built PromptRequest, AuthContext, ScrapeSecrets, ScrapeReport, SessionInit, a createPrompt call |
add the members TS2741 names; createPrompt({ scrape, handler, page, runId, clock, … })
|
| 65 |
meta.cdpUrl of X_SCRAPE_CDP_ATTACH_FAILED
|
you read the full URL from it. It is wss://host:port
|
| 66 |
localBrowser({ proxy }) with credentials |
your launcher's page has no authenticate() (X_SCRAPE_EGRESS_UNSUPPORTED). Upgrade the launcher or drop the credentials from the URL |
Entry 11, the rewrite for each raw request:
| It was | Write |
|---|---|
fetch('/api/…', { method: 'POST' }) — an action |
await browserClient.<action>(input) |
fetch('/_x/query/…') — a read |
useQuery(<QUERY_REF>, input); outside a component, await browserQueries.<query>(input)
|
new WebSocket(…) / new EventSource(…)
|
useQuery(<QUERY_REF>, input) for rows, useChannel(<CHANNEL_REF>, params, { onEvent }) for events |
an upload, fetch or XHR to a signed URL |
await uploadFile({ file, grant, onProgress }) from @ultimat3/storage
|
| anything else |
await clientTransport({ method: 'GET', url }) from @ultimat3/core/page
|
An app scaffolded before 23.0.0 has no apps/web/shared/browser-client.ts; write it as
Client data shows, and add export type Api = typeof api to apps/web/api/index.ts.
grep -rnE "fetch\(|new (WebSocket|XMLHttpRequest|EventSource)\(" apps packages --include=*.tsx --include=*.ts | grep -v -E "route\.ts|\.test\.ts"
grep -rnE "\.run\(\{|ack\(|nack\(|E2eApp|createPgEventBus|storageSessionStore\(|createPrompt\(|guardedPage|adminRouteConfig\(|checkAppBoundaries|pathStyle|exportRows\(|\.entries\(|InvokeResult|permissionsForOperation\('admin'" apps packages --include=*.ts --include=*.tsx
grep -rlE "page\.tsx" apps/admin
grep -rlE "defineAdmin\(" apps/*/src
ls apps/admin/src/pages 2>/dev/null
grep -rnE "x g [a-z:]+ .*--(live|feature)" scripts package.json .github 2>/dev/nullThe typecheck step finds entries 23–29, 34, 37, 39–43, 50, 51, 55–57, 63 and 64. The gate finds
3, 4, 7, 11–16, 47 and 54; x db migrate finds 6; x g finds 18 and 19. It does not find entry 2
(a worker that misses a side-effect registration logs it at boot), 20, 21, 32, 33, 36, 44–46, 48,
58–61 or 62 — run the unit, job, live and scraping suites, and read the first boot's log.
Twenty-three entries, the 22.0.0 section of CHANGELOG.md. Entries 4, 7, 9, 10, 11, 12, 13, 14, 18, 20 and 21 are compile
errors, and so are parts of 3 (planDeploy's fourth argument), 6 (a declaration with no policy)
and 19 (selectTransport's second argument, 'redis'). Entry 15 throws when a sync pod boots,
entry 19 when any realtime role boots with a transport and NATS_URL that disagree, and entry 6
when a channel module loads. Entries 1, 2 and 5 compile unchanged and refuse a string they used to
accept; entry 17 changes a meta.code, and entry 23 throws where a query silently answered [].
Entry 3 also installs a new helm release beside the old one unless you name it. Entry 16 is a
dependency you can drop, entry 22 is one x db gen if drift asks for it, and entry 8 needs no
edit. 22.0.0 is plan 101's deep sweep: every framework package, both tracked apps and the deploy
path.
| # | Surface | Costs you an edit if |
|---|---|---|
| 1 | t.date |
you send a date that is not ISO-8601 in shape ('March 14, 2026', '3/14/2026', '12'). It is refused at validation now (coerceQuery no longer rewrites it). new Date read it at the host's local midnight, so the stored day depended on TZ. Send '2026-03-14'. Epoch-ms numbers and Date objects are unchanged |
| 2 |
timestamp() columns |
you write a string with no Z and no offset ('2026-03-14T09:00') or a non-ISO one. Refused now; it used to be parsed in the host's zone. Write '2026-03-14T09:00:00Z' or a Date
|
| 3 |
x deploy --method helm, planDeploy
|
you deployed with helm before. The release is now named after app.config.ts's name, not the literal app, so the next deploy installs a second release beside the old one. Keep the old one with x deploy --method helm --release app, or helm uninstall app after the new one is up. The deploy now waits for the rollout (default --timeout 15m) and reports data.rollout. planDeploy(image, 'helm', root) needs a fourth argument, { release, namespace, timeout }. --release, --namespace and --timeout are refused on --method compose
|
| 4 | invokeAdminAction({ … expectedConfirmation }) |
you pass expectedConfirmation. Delete it; the gate derives the token from the action's entity and subject.id, and the browser still echoes confirmationToken(entity, id) in confirmation. With both fields omitted a destructive action used to run unconfirmed; it is refused now |
| 5 |
@ultimat3/ui DateTime, toDate
|
you pass a non-ISO string ('August 14, 2026 09:00', '8/14/2026'). Pass an ISO date, a date-time with Z or an offset, or a Date
|
| 6 |
channel(name, { … }) with no policy
|
a declaration omits it. It is a type error and throws X_CHANNEL_DECLARATION_INVALID at load. A public channel says so: policy: allow('public'). ChannelDescription.policy is string, no longer string | null — delete a null branch |
| 7 | a custom QueueStore or LocalStore
|
you implement one. QueueStore.save(state) → write(change: QueueChange), and LocalStore.saveQueue(scope, state) → writeQueue(scope, change): one change per mutation, by key. Whole-queue records a 21.x page wrote are converted on first read, so queued writes survive the upgrade. The shipped stores need nothing |
| 8 | the page's SharedWorker | nothing to edit. The worker is named per build now (workerName(scope, buildId), internal), so tabs of two deploys never share a socket host |
| 9 | ChangeOp |
you switch over it exhaustively. Add case 'truncate': — a rowless change: a TRUNCATE empties the affected live windows and starts a new epoch on every open channel topic |
| 10 |
Result, Ok, Err, ok, err, map, mapErr, isOk, isErr, tryCatch, unwrap, unwrapOr from @ultimat3/core
|
you import any of them. Gone: throw an UltimateError and try/catch it |
| 11 |
X_USERS_TABLE, X_SESSIONS_TABLE, X_ACCOUNTS_TABLE, X_VERIFICATIONS_TABLE, X_API_KEYS_TABLE, X_USERS_MIGRATION_1_3 from @ultimat3/auth
|
you import them, or pasted them into a migration. Delete both: every boot applies AUTH_TABLES (the 1.3 upgrade included, as add column if not exists) |
| 12 | internals removed from package barrels | you import a runtime value that no other package used — CHANGELOG.md's 22.0.0 section lists them per package (auth, ui, core, http, entity, query, mcp, ai, mail, notify, pwa, render, scraping, manifest). None is an error class, a code table or a documented API. Copy the constant into your app, or use the documented API it served |
| 13 | the e2e driver: installE2eDriver, e2eFixtures, startE2eApp, e2eApp, e2eBaseUrl, e2eBrowser, openE2eBrowser, cdpConnect, cdpE2eTab, findChrome, launchChrome, the Cdp*Error / E2e*Error classes and their types, from @ultimat3/cli
|
you import any of them. Import them from @ultimat3/testing; the test preload is @ultimat3/testing/e2e-preload. FRAMEWORK_SCRIPTS and FRAMEWORK_INLINE_SCRIPTS stay on @ultimat3/cli. The X_E2E_* / X_CDP_* codes are unchanged |
| 14 |
entityRow, camel from @ultimat3/realtime/server
|
you decode WAL tuples yourself. entityRow(physical) → entityRow(relation, physical, 'before' | 'after'); it decodes through the registered entity (decodeRow, .column() renames and money included) and refuses a table with no registered entity (X_REPLICATION_PROTOCOL). camel is gone |
| 15 | a sync role on a real database |
it has no reachable change feed. It refuses to boot with X_REALTIME_TOPOLOGY; it used to come up healthy and deliver nothing. Set NATS_URL on web, sync and one replicator, or leave sync off. (x dev --role sync,replicator runs both in one process, for development.) The scaffolded chart (roles.sync.enabled: false) and Compose file (replicas: 0) ship sync off, with the enable recipe beside the switch; a chart or compose file you copied earlier keeps whatever it had |
| 16 |
x shot, x shot --island, the ui.* MCP tools |
you installed puppeteer-core for them. They drive Chrome over raw CDP on the e2e step's launcher now: bun remove puppeteer-core if nothing else imports it. X_SHOT_BROWSER_MISSING now means "no Chrome to launch" — export CHROME_PATH=<binary> where Chrome is not at /usr/bin/google-chrome or the other probed paths, or pass --cdp-url. A request to a host off the allow list is refused inside the browser and recorded as refused: "host" in the verdict |
| 17 |
ui.interact step failures |
you match meta.code against X_SCRAPE_*. A failed step carries X_SHOT_ELEMENT_MISSING, X_SHOT_ELEMENT_UNREADY or X_SHOT_KEY_INVALID now |
| 18 |
backoffDelay from @ultimat3/realtime
|
you import it. Import core's: import { backoffDelay } from '@ultimat3/core', and pass attempt: n + 1 — realtime's copy counted from 0, core's counts from 1. A failed channel catch-up now retries after the base wait, not twice it |
| 19 |
realtime.transport, NATS_URL, selectTransport
|
your deploy relied on NATS_URL alone to pick the bus. realtime.transport decides now: 'nats' dials the variable realtime.urlEnv names and refuses the boot (X_CONFIG_INVALID) when it is unset — it used to fall back to in-process in silence — and 'memory' with NATS_URL set refuses too. Set transport: 'nats' where the fleet shares a bus, or unset NATS_URL. realtime.enabled: false now starts no sync node and no replicator. transport: 'redis' (never built) no longer typechecks. selectTransport(env) → selectTransport(env, { transport, urlEnv })
|
| 20 |
startLiveReplicator, LiveReplicator, LiveReplicatorOptions from @ultimat3/testing
|
you import them — a live-query test or a hand-rolled dev boot. Import them from @ultimat3/realtime/server; @ultimat3/testing's subscribe fixture needs nothing |
| 21 |
@ultimat3/cli's barrel |
you import anything from it but newCommand, dbCommand, verifyCommand and the documented API — 236 internals are gone (command objects, scan internals, report helpers, option types). maskLiterals / stripComments → import { maskLiterals, stripComments } from '@ultimat3/core'. The CLI no longer depends on @ultimat3/scraping, so a workspace that reached scraping through it declares it itself |
| 22 |
x db gen's schema hash |
x verify --only drift reports drift right after the upgrade with no entity edited: the hash now uses core's canonicalJson. Run x db gen once. Neither tracked app needed it |
| 23 | a query's sql: () => from(…) over rows its loader selected |
the query filters (where) or sorts (orderBy) on a column the loader's select({ … }) leaves out. That read used to answer [] in silence; it throws X_QUERY_COLUMN_UNSELECTED (QueryColumnUnselectedError) now. Add the column to the loader's select, or drop the filter if the loader already applies it. The reference app's publicPostSlugs was one — the blog prerendered no article until publishedSlugs selected status and publishedAt
|
grep -rnE "from<|from '@ultimat3/cli'|LiveReplicator|puppeteer|X_SCRAPE_|backoffDelay|selectTransport|transport: 'redis'|expectedConfirmation|planDeploy\(|\bResult<|\b(tryCatch|unwrapOr|mapErr)\(|X_(USERS|SESSIONS|ACCOUNTS|VERIFICATIONS|API_KEYS)_TABLE|X_USERS_MIGRATION_1_3|saveQueue|entityRow\(|\bcamel\b|case 'delete':" apps packages --include=*.ts --include=*.tsx
grep -rnE "from '@ultimat3/cli'" apps packages scripts --include=*.ts | grep -E "e2e|cdp|Chrome|E2e|Cdp"
grep -rnE "channel\(" apps packages --include=*.ts | grep -v policyThe typecheck step finds every removed name, planDeploy's arity and a missing policy. It does
not find a non-ISO date string in a fixture, a seed or a client — those fail at validation, so run
your contract and e2e suites — nor a helm release still named app.
Twenty-seven entries. Entries 1–3, 5, 8, 9, 11–17, 20–23 and 27 are compile errors. Entry 24
is a stale file one rebuild replaces, entry 25 is a budgets finding, and entry 26 is capacity
planning, not code. Entry 10
throws when the module loads, and entry 18 when docker compose up starts. Entry 19 is a
number that means something else. Entries 4, 6 and 7 compile
unchanged and answer differently: a wider isSuperseded, a coded error where a bare TypeError
or SyntaxError used to arrive, and a new response body for non-framework HTTP clients. Entry 2
also changes what 'last-write-wins' keeps, and entry 8 leaves two error codes that nothing
throws. 21.0.0 is the client data layer: one record store per tab, one HTTP seam and one socket
per origin. The entries are CHANGELOG.md's 21.0.0 section.
| # | Surface | Costs you an edit if |
|---|---|---|
| 1 | AsyncState |
you import it from @ultimat3/ui. It moved to @ultimat3/core unchanged, and ui does not re-export it. import type { AsyncState } from '@ultimat3/ui' → import type { AsyncState } from '@ultimat3/core', plus @ultimat3/core in that workspace's dependencies if it is missing. AsyncRegion, asyncBranch, AsyncBranch and AsyncFlags stay in ui |
| 2 | a mutator's conflict: custom(merge)
|
you wrote a merge. It now receives the local and server rows of the record, not the mutator's parsed outputs: custom<PostRow>((local, server) => ({ ...server, title: local.title })). The output-shaped merge never ran. Realtime's rebase holds rows, so it dropped the policy without a word and used its default. Also: resolveConflict moved to @ultimat3/core (its answer is Row, so narrow it); Conflict, CustomConflict and strategyOf are gone from @ultimat3/action, and strategyOf(c) is typeof c === 'string' ? c : c.kind. No compile error warns you that 'last-write-wins' now keeps the local row only when its updatedAt is newer than the server's. A row with no numeric clock resolves to the server's |
| 3 |
ConflictLike, custom, CustomMerge, MergeArgs or ConflictStrategy from @ultimat3/realtime
|
you import any of them. Types: import type { ConflictPolicy } from '@ultimat3/core'. Realtime's own custom(({ local, base, server }) => …) becomes action's custom((local, server) => …): no base, and the merge must return a row with a string id, or the rebase throws X_REBASE_CONFLICT. Delete any branch that returned null to accept a delete. The merge is no longer called when the server deleted the row or the client never held one; the server's answer lands as it is |
| 4 | isSuperseded(error) |
you took true to mean X_SUPERSEDED. It is also true for X_CLIENT_SCOPE_CHANGED, a read in flight when the page changed principal. Code that branches on isSuperseded needs nothing; code that needs the specific code reads error.code
|
| 5 |
QueryRequestFailedError, QueryProblem from @ultimat3/query
|
you import either. A query's failure is a plain UltimateError now: if (e instanceof QueryRequestFailedError) → if (isUltimateError(e)), then switch on e.code. A query's non-2xx with no framework code is X_CLIENT_TRANSPORT_FAILED, not X_RPC_FAILED, so update a match on the old code |
| 6 | a typed client's network fault, non-JSON 2xx, or non-2xx with no framework code | you catch TypeError, SyntaxError or RpcFailedError, or match 'X_RPC_FAILED', around rpc(), .client(), queryClient() or @ultimat3/storage's uploadFile() on its no-XHR fallback (fetchSignedPut). All of them are X_CLIENT_TRANSPORT_FAILED now: if (isUltimateError(e) && e.code === 'X_CLIENT_TRANSPORT_FAILED'). RpcFailedError still exports and nothing throws it, so an instanceof check against it compiles and never matches. RemoteActionError (the server's own code) and X_CONTRACT_DRIFT are unchanged |
| 7 | the body and OpenAPI 200 of an action whose output references an entity row |
something other than an @ultimat3/* client reads it: a generated SDK, curl, a test posting with fetch. Under x-ultimate-records: 1 the output is body.data, beside records. Regenerate an SDK from the new openapi.json. rpc() and .client() strip the envelope, so typed callers need nothing |
| 8 |
@ultimat3/pwa's background sync |
you pass backgroundSync: { flushEndpoint } to generateServiceWorker, import DEFAULT_FLUSH_ENDPOINT or BackgroundSyncOptions, call backgroundSyncSource(opts), or mounted /_x/outbox/flush yourself. Delete the option and the imports, call backgroundSyncSource() with no arguments, and delete the route. The worker now posts OUTBOX_DRAIN_MESSAGE to open tabs, and the page's outbox replays. pwa.backgroundSync: true in app.config.ts is unchanged. Also delete any branch matching X_PWA_SYNC_FLUSH_FAILED or X_PWA_SYNC_INCOMPLETE: both stay registered, nothing throws them, and such a branch compiles and never runs |
| 9 | ClientScope.principal |
you narrow it with !== null and use it as a string. It is string | null | undefined now. undefined is an unscoped page, rendered for nobody, for which nothing is persisted. Test typeof principal === 'string', and give a switch an undefined arm |
| 10 | a mutator declaring conflict: 'last-write-wins'
|
its entity has no number updatedAt the server writes (a timestamp() string does not count), or its output carries no entity row. It now throws X_MUTATOR_CLOCK_MISSING when the module loads. Add updatedAt as a number column (epoch ms) that the server writes on every update, or declare conflict: 'server-wins'. Without a clock the server row already won every time, so 'server-wins' changes no behaviour |
| 11 |
setLiveClient, clearLiveClient, hasLiveClient, LiveClient, ClientSocket
|
your island builds a LiveClient. Delete the socket adapter, the sync-URL module, new LiveClient(…), client.connect() and setLiveClient(client). Add installRealtime({ signal: createSignal }) in mount before the first render. hasLiveClient() → hasPageSocket(). client.subscribe(topic, fn) → useChannel(decl, params, { onEvent, onPresence }) or usePresence(decl, params)
|
| 12 |
useLive, LiveRows, LiveInput, liveHookFor
|
you read a live query. useLive<Row>({ name }, input) → useQuery<Row>({ name, live: true }, input). feed.state() === 'live' → feed().status === 'ready', the rows are feed().data, and unsubscribe() → release(). Delete liveHookFor bindings |
| 13 |
IdentityMap, privateScope, rowKey, RowScope, RowKey
|
you import them. RecordStore, recordKey(type, key), RecordKey; read one record with useRecord(type, key)
|
| 14 |
createOpfsLocalStore, RebaseLog, reconcile, rebaseFrame, strategyName, serverRenderLiveClient; the journalling MemoryLocalStore / LocalStore
|
you pass a store, queue or log to the client, or use them in a test. Delete them: the optimistic apply needs nothing now. MemoryLocalStore and LocalStore still export, but name the persistence store now (rows, write, queue, wipe), not 20.x's journal (apply, rollback, commit), so code written against the old shape stops compiling. Durable offline writes ship: an entity declared persist: true is restored from IndexedDB by the page boot (openLocalStore, pageLocalStore), and its queued mutations replay through pageOutbox()
|
| 15 |
useMutation, useMutationQueue, MutatorLike
|
you call drain(), set entity: on a MutatorLike, or re-query after a write. Delete drain() and entity:. The call resolves with the action's output, so read it directly. Writes go over HTTP, so a mutation now persists where it used to answer X_NOT_IMPLEMENTED. With no response at all (meta.failure: 'network') the call resolves undefined and the write is queued; code that needs the output checks for it. A 'status' or 'body' failure still rejects |
| 16 | sync protocol 3; createSyncNode({ onMutate }), MutationHandler
|
you run a sync node. Delete onMutate, and redeploy clients and nodes together: a v2 client and a v3 node refuse each other with X_PROTOCOL_VERSION
|
| 17 |
topic / Topic from @ultimat3/realtime/server
|
you import it there. Import it from @ultimat3/realtime
|
| 18 | Compose deploys: docker-compose.prod.yml, the scaffold, both tracked apps |
you deploy with Compose. web now refuses to start without SYNC_URL. Set SYNC_URL=ws://<host>:3001/_x/sync in .env.production and run Compose with --env-file .env.production (x deploy does), or proxy /_x/sync to sync and set SYNC_URL=wss://<host>/_x/sync. A copied compose file adds the same line to web
|
| 19 |
x verify --json → data.durationMs
|
you sum step times or read the total as a sum. It is wall time now; the static steps overlap the serial suites |
| 20 | the presence frame, PresenceFrame
|
you read rosters. Declare the room channel(name, { …, events: true }) and read readPresence(frame.event) in its events handler; a roster is an events frame { presence: op, members, total? } now |
| 21 |
ChannelHub#guard, subscribe(socket, topic), publish(topic), publishFrame, channelFrame, TopicGuard*, nodeId, { kind: 'topic' }
|
you run a hub. Declare each channel with channel(name, { params, policy, row?, catchUp, records?, events? }). Build the hub as new ChannelHub({ transport, sockets }). guard → the declaration's policy; publish(topic, event) → hub.publishEvent(decl, params, event). Delete nodeId. An undeclared name is X_TOPIC_FORBIDDEN
|
| 22 |
LocalTable, LocalRow, tx.<type>.insert(row) / upsert(row) / update(id, …)
|
you write rows in a local twin, or type one. Tables are addressed by record key: tx.posts.insert(post.id, post), upsert(key, row), update(key, patch), delete(key), and get(key) / all() to read. LocalRow is gone: LocalTable<Post>
|
| 23 |
cdpE2ePage, CdpE2ePageOptions from @ultimat3/cli
|
your e2e suite opens a page itself. Use openE2eBrowser() and its page tab, session.newTab() for a second tab, or cdpE2eTab({ … }) on an existing connection |
| 24 | .x/build-stats.json |
you ran x build before upgrading. Every budgeted route reads X_BUDGET_UNMEASURED until x build --target static runs again, because the stats file carries the measurement rules that wrote it and v1 files are not read |
| 25 |
hasPageSocket() as a guard in page code |
a module no island imports calls it (an offline banner, an update prompt in a layout). The budgets step now reports it as X_LIVE_ROUTE_NO_ISLAND. Move the module into an island: x g island <route-dir> --at <route-dir>, import it from the island's mount(), declare island({ src })
|
| 26 | a browser's reconnect backoff | you sized a sync node on the 30 s spread of reconnects. Browsers now redial within 4 s (equal jitter from 500 ms). Check the node's AcceptBudget sheds a SIGKILL herd arriving in 2–4 s. Nothing to change in app code |
| 27 |
LaunchedBrowser.endpoint from @ultimat3/cli
|
you launch Chrome yourself for tests. cdpConnect(browser.endpoint) → browser.connection, which is already answering over the debugging pipe. cdpConnect(url) remains for a remote browser |
grep -rnE "import type \{[^}]*AsyncState[^}]*\} from '@ultimat3/ui'" apps packages --include=*.ts --include=*.tsx
grep -rnE "ConflictLike|ConflictStrategy|CustomMerge|MergeArgs|CustomConflict|strategyOf|resolveConflict|custom\(|QueryRequestFailedError|QueryProblem|X_RPC_FAILED|instanceof (TypeError|SyntaxError|RpcFailedError)|isSuperseded|flushEndpoint|DEFAULT_FLUSH_ENDPOINT|BackgroundSyncOptions|outbox/flush|X_PWA_SYNC_|\.principal|last-write-wins|LiveClient|useLive|liveHookFor|IdentityMap|rowKey|privateScope|LocalStore|RebaseLog|reconcile|serverRenderLiveClient|drain\(|onMutate|realtime/server|PresenceFrame|\.guard\(|publishFrame|channelFrame|TopicGuard|nodeId" apps packages --include=*.ts --include=*.tsxThe typecheck step finds every removed name for you. It does not find a custom( merge whose
type argument is still the output type: custom<TRow> takes any object type, so that merge
compiles and reads undefined for every output field that is not a column of the row. The second
grep lists every custom(. Read each one.
Both entries are @ultimat3/ui component behaviour, and neither changes a type — so nothing
fails to compile and nothing throws. They change what a screen DOES, which is why they are a major:
a silent rendering change is worse than a build error, and you would have found these by looking.
| # | Surface | Costs you an edit if |
|---|---|---|
| 1 |
DataTable while reloading |
you relied on skeletons appearing on every load. A FIRST load (rows: []) still renders skeletons; a RELOAD now keeps the existing rows, dimmed, under aria-busy. Replacing rendered content with placeholders on every refresh is a second layout change for no news, and it is what makes a fast list feel slow — the reader has already read those rows, and taking them away to say "loading" tells them nothing they can act on. No edit if you pass fresh rows on each load, which is the ordinary case. If you genuinely want the placeholder back on a refresh, pass rows: [] while the fetch is in flight |
| 2 |
Button with loading
|
you style button[disabled], or you assert on the disabled attribute in a test. loading no longer sets the native attribute — it sets aria-disabled + aria-busy and refuses the click in onClick with preventDefault(). The click is still refused; the mechanism moved. A disabled control loses focus the moment it becomes disabled — mid-flow, with no announcement — is exempt from the WCAG contrast minimum precisely because nobody is meant to read it, which is wrong for a control you are asking someone to WAIT on, and it does not actually prevent a double submit, because that race is server-side and always was. The edit is one selector: style button[aria-disabled='true'] beside button[disabled], and assert on aria-disabled
|
defineTheme() can now refuse your brand — X_UI_CONTRAST_INSUFFICIENT. Listed here rather
than as a third entry because it throws at declaration with the measured ratio, the required one and
the role to move, so it cannot ship silently and needs no search. An override whose RESOLVED
channels put a pairing below WCAG 2.2 AA (4.5:1 text, 3:1 focus ring, 1.4:1 border) fails at boot.
Only pairings your brand can have CHANGED are measured — blaming an app for the framework's own
colours is how a rule gets switched off. The usual defect is half a pairing: a new accent against
the shipped white accent-fg. @ultimat3/ui exports contrastRatio and roleContrast so you can
measure a candidate before shipping it. AA and never APCA: APCA is not a standard, and AA is the
operative legal benchmark.
Nothing else in this release costs an edit. The five new app guards ship in x new only; an
existing app gains one with x g guard <name> (since 23.0.0; x doctor --json lists the shipped
ones it lacks in data.guards.missing), one file per rule, and deleting one drops that rule. x shot --all-islands is additive, and
x g island / x g resource now write a .island.states.ts beside what they generate.
Both entries cost an edit only if you had pwa.enabled: true — the same block 18.0.0 grew, for
the same reason one release later: 18.0.0 wired the web manifest and left the service worker with
zero callers, so pwa.offline steered nothing and /offline was a path no build precached.
x dev, the container and x build --target static all emit sw.js and x-sw-register.js now,
and the config had to be able to say what they emit.
| # | Surface | Costs you an edit if |
|---|---|---|
| 1 | AppConfig.pwa.offline |
you set pwa.enabled: true. It was 'precache' | 'runtime' | 'network-only' — an app-wide default for a field defineRoute makes required on every route, so it defaulted nothing and was read by nobody. It is now { fallback, image, font, neverCache }, and fallback is required once enabled is true: an absolute route path, screened at defineConfig rather than at x build. The edit is one line — offline: 'runtime' → offline: { fallback: '/offline' } — plus a route at that path. A relative value is refused because it resolves against whatever document registered the worker, so offline served under /posts/1 is /posts/offline: a 404 cached as the answer to every offline navigation. Or set pwa.enabled: false. Per-route offline: is unchanged, and is still where the strategy is declared |
| 2 |
x new's scaffold |
you regenerate an app, or you copied the old scaffold. The offline fallback is apps/web/site/offline/page.tsx, not apps/web/app/offline.tsx: the directory is the URL and <name>.tsx is not a route file, so the old path shipped a component nothing imported and left /offline a URL that did not exist. site/ and render: 'static' deliberately — the document that answers a lost network must render with no network, no session and no database, which app/ (ssr | stream) cannot promise. An existing app moves the file and adds offline: 'precache', hydrate: 'never' and robots: { index: false } to its defineRoute
|
x build --json renames one field: precacheWarnings → serviceWorkerWarnings, and the
terminal row is labelled service-worker. It is listed here rather than as a third entry because
no released build ever wrote the old key — PrecacheManifest.warnings had no reader at all until
this release. The list carries the precache byte ceiling and pwa.push: true with no VAPID key
to sign a subscription with, which is the one PWA capability still unwired.
Two of the five cost an edit only if you had pwa.enabled: true, which until this release did
nothing at all — no Ultimate app had ever served a web manifest, so the block was a switch with no
reader. One is bun upgrade. The other two are types.
| # | Surface | Costs you an edit if |
|---|---|---|
| 1 | the Bun floor, >=1.4.0 in engines.bun and in x's own check |
you run Bun below 1.4.0. The edit is bun upgrade. The floor said >=1.3.0 while x test emitted bun test --isolate, a flag Bun introduced in 1.3.13 — so on 1.3.0..1.3.12 the gate's dominant step died on an unknown flag and x doctor called the runtime fine. 1.4.0 rather than 1.3.13 because a floor is a claim about a runtime somebody tested: CI pins 1.4.x and both images build on oven/bun:1.4-*
|
| 2 | AppConfig.pwa |
you set pwa.enabled: true. It now also requires pwa.name and pwa.colors.light / pwa.colors.dark, each with themeColor and backgroundColor. defineConfig refuses an incomplete block at boot, not at x build, and the fix: carries the whole block. There is nothing to derive them from: app.name is a slug, so an install prompt offering ledger-demo is wrong rather than rough, and a browser paints the install splash before a stylesheet loads. Raw hex is legal here — one of two places in an app it is, beside theme.tokens. Or set pwa.enabled: false, which is what it effectively was |
| 3 |
@ultimat3/pwa's exports |
you import PwaConfig, ThemeTokens or SchemeColors from it. PwaConfig is now WebManifestInput — two exported types of one name with no map between them is axiom 1, and this was the one that lied: its doc said "the pwa block of app.config.ts" and it was the generator's input. ThemeTokens/SchemeColors are PwaColors/PwaSchemeColors from @ultimat3/core, which is where the config lives |
| 4 |
x test --json, and four @ultimat3/cli exports |
you read data.shards[] or data.failed, or import planShards, shardArgs, SHARD_COMMAND_PREFIX or Shard. x test runs one bun test --parallel=N now, so there are no shards to report: read data.ok instead of scanning for a failure and data.reproduce instead of rebuilding the rerun. testArgs(…) builds the argv, filesIn(command) reads the file list back out. X_TEST_SHARD_FAILED still exists and is x test --worker I's alone |
| 5 |
ScrapeTarget / ScrapePage
|
you implement either interface yourself. ScrapeTarget gains setColorScheme and ScrapePage gains colorScheme; a driver of your own stops compiling until it has both. Calling @ultimat3/scraping rather than implementing it costs nothing |
x test live --workers 8 now runs one worker, and that is a fix rather than a regression. The
gate always ran live and e2e serially and the command did not, so the same files ran eight
processes under one entry point and one under the other. A logical replication slot is named at the
Postgres cluster level, so a per-worker database never isolated it. --workers is still
accepted and clamps to 1.
Three breaking entries, and all three are the same sweep: a numeric option that used to accept
NaN, ±Infinity, a fraction or a negative now refuses it. The refusal is at boot or at the call
boundary, never mid-request, so one bun test or one x verify surfaces every one of them at
once and each fix: line carries the edit.
An app passing real numbers is unaffected. An app that was passing NaN was not working: the
bound it declared was not being enforced, and nothing said so. ?? guards nullish, and NaN is
not nullish — so Number(process.env.X) on an unset variable walked past the default and landed on
the bound intact. Math.max, Math.min and Math.floor all propagate NaN, and this repo was
relying on all three as guards.
| # | Surface | Costs you an edit if |
|---|---|---|
| 1 | Every numeric option in the table under 17.0.0's Changed heading — across http, query, jobs, realtime, auth, ai, render, pwa, mail, manifest, mcp, notify, ui, scraping, testing, cli, admin, core, time
|
you pass one a value that is not finite, or is fractional or negative where the option counts things. Read the row for the floor: 0 stays legal wherever it means something — port: 0 asks the OS for a free port, timeout: 0 is one look, seed: 0 is a seed, maxAgeSeconds: 0 is "revalidate every time", concurrency: 0 is one worker |
| 2 | http.trustedProxyHops |
you declare 0. That was the failure state, not a setting: forwarded.ts returns undefined for hops < 1, so a declared 0 silently trusted nothing while reading as configured. The domain is 1…64. It is a boot-owned key, so no app can write it — the blast radius is embedders calling defineHttpConfig directly |
| 3 | search().page(input, { first }) |
your page would cut rows. A window narrower than the read is now served when nothing is dropped, and refused — naming both edits — when rows would be lost. This is a widening against 16.x for the common case: limit defaults to 20 and first has no default, so search({…}) + .page(input, { first: 10 }) used to mint a cursor that page two then threw on |
TRUSTED_PROXY_HOPS widened from 1–16 to 1–64 and is listed here only so the change is not a
surprise: it accepts strictly more than 16.x did, so no configuration that worked stops working.
One breaking entry, and it fires at entity() — before a migration exists, before anything
reaches a database. Nothing migrates, and an app whose patterns are already portable emits exactly
what it emitted at 15.x.
| # | Surface | Costs you an edit if |
|---|---|---|
| 1 |
c.<col>.matches(/re/) refuses an unportable construct |
your pattern uses \b, ., \w, \s, a backreference, a named group, an inline flag, a POSIX class, \A/\Z, \x, a leading ] in a class, or a non-ASCII range endpoint |
At 15.x this accepted any RegExp and emitted a maybe-equivalent POSIX pattern. That is the
defect, not the refusal: matches(/\bfoo/) shipped a CHECK that compiled cleanly, errored nowhere,
and enforced a BACKSPACE — measured on 18.4, 'foo' ~ '\bfoo' is FALSE while /\bfoo/.test('foo')
is true. Two rules under one name, with nothing anywhere to report it.
Nothing is translated now either, and that is the mechanism: pattern.source is the string
.test() runs and the string spliced into the constraint. That is only safe because every
construct where the engines disagree is refused at declaration.
How to find them: run your entities — bun test, or x db gen. The refusal is at entity()
time, so one run surfaces every one, and each names the construct, its index, both readings, and
the repair.
| Construct | Measured on 18.4 | Write instead |
|---|---|---|
\b |
'foo' ~ '\bfoo' false — ARE reads BACKSPACE |
a predicate: matches((v) => /\bfoo/.test(v)), app-only, sql: null
|
. |
'a\nb' ~ 'a.b' true, JS false |
[^\n\r] |
\w \W
|
'é' ~ '^\w$' true — locale alnum class |
[A-Za-z0-9_] |
\s \S
|
'\u00a0' ~ '^\s$' false, JS true |
[ \t\n\r\f\v] |
\A \Z
|
anchor in ARE, a letter in JS |
^ / $
|
\x |
'Д' ~ '^\x414$' true — ARE takes 3 hex, JS 2 |
a predicate |
leading ] in a class |
[]a] differs |
\] |
\1–\9, (?<name>…), (?i), [[:alpha:]], non-ASCII range endpoint |
backreference, named group, inline flag, POSIX class, collation-ordered range | a predicate |
Kept, and each measured to AGREE: literals, ^ $ | ( ) * + ?, lazy quantifiers, {n}/{n,}/{n,m},
(?: (?= (?! (?<= (?<!, \d \D \n \r \t \f \v, punctuation escapes, bracket expressions
with ASCII ranges, non-ASCII members, and \uwxyz at exactly four hex digits.
Two of those earn a note. \d is in by measurement, not by reading — POSIX pins [[:digit:]]
at the ten ASCII digits, and '٣'/'5' are false on both sides. \uwxyz is in because Bun
escapes a regex literal's non-ASCII characters (/^é$/.source is ^\u00E9$), so refusing it
would have refused every i18n pattern written the ordinary way.
The subset is falsifiable, not asserted. One live test runs every refused construct against a
real server and asserts the two engines still disagree; another runs the kept subset and asserts
they agree. A future Postgres that grows JavaScript's \b turns those red rather than leaving a
stale exclusion in place.
x db gen now emits drop index / drop constraint for a recorded index no entity declares.
At 15.x it emitted nothing, so those objects stayed on the database while the next sidecar stopped
recording them — and drift was green over it. Your first generation after upgrading may carry
drops you did not expect; read them, they are objects your entities genuinely no longer declare.
A recorded unique CONSTRAINT gets a drop constraint … if exists before the drop index,
because drop index on a constraint-backed index is 2BP01 and if exists does not suppress it.
One breaking entry, and it is a compile error only for code that exhaustively switches over a type it does not own. Nothing changes at runtime and no data migrates.
| # | Surface | Costs you an edit if |
|---|---|---|
| 1 |
DriftKind gains missing-check
|
you switch over DriftKind with no default
|
The same shape 4.0.0 recorded when it gained changed-foreign-key. Add a default, or handle the
new member:
case 'missing-check':
// the snapshot names a CHECK constraint the catalog does not hold
break;It compares conname and never the definition, and that is not an optimisation — it is the
only comparison that can work. pg_get_constraintdef answers Postgres' own rewriting:
status in ('draft','published') comes back as
CHECK ((status = ANY (ARRAY['draft'::text, 'published'::text]))). Comparing that text against a
generated predicate would report a correct database as drifted, forever.
Only the declared side is judged, so a NOT NULL (contype='n' on PG17+), an enumerated()
column's old anonymous constraint and an extension's own constraints are all silent.
Eight breaking entries. Four are compile errors the moment you upgrade (3, 4, 5, 8). Two are security fixes that change behaviour with nothing failing to compile (1, 2). One changes the text of a validation issue path (6), and one is a driver interface (7).
No app.config.ts key moves. No codemod: each entry names its own manual edit.
| # | Surface | Costs you an edit if |
|---|---|---|
| 1 | a frame verb acts on the frame, not the parent document | never — a fix. frame(…).fill() used to clear the parent's same-id field and append to the frame's |
| 2 | a session key hashes each segment | never an edit — but every stored session logs in once more, see below |
| 3 |
$migration(), toSql, invariantsToSql, constraintName removed |
you called one — nothing in this repo did, and $migration() rendered ALTER TABLE "<entity name>", a relation that does not exist |
| 4 |
t.number.int() demands a safe integer |
you relied on 2^53 passing a boundary that the row write then refused |
| 5 |
and() / or() refuse an empty clause list |
you build a policy from a list that can filter to empty — and() used to answer allowed
|
| 6 | a t.record issue path names the entry by position
|
you parse issue paths and expect the caller's key. The key is caller data and reached the log line |
| 7 |
ScrapeTarget.setOfflineMode required; CdpTargetInit.ringCapacity deleted |
you implement ScrapeTarget yourself |
| 8 |
Repo.insert/insertAll/upsertAll take RowWrite<T>
|
you implement Repo — otherwise this accepts more than before |
Segments used to be sanitised by collapsing every run of non-[a-zA-Z0-9._-] to a single -, so
alice@corp.com and alice-corp.com produced one key. The browser then loaded account A's
cookies, auth.validate() answered true — the session is valid, for the wrong account — and
A's rows were stored under B's tenant.
Nothing to run. A stored record is not found under the new spelling, a miss reads as "no session", so the run logs in again and writes the new key: one extra login per stored session, no error, and the old objects are orphaned until your bucket's lifecycle rule collects them.
// before — answered ALLOWED, and admitsAnonymous() agreed, so http did not 401 first
policy: and(...requiredCaps.map(can)) // requiredCaps filtered to empty
// after — refused where it is written
policy: requiredCaps.length === 0 ? allow('public') : and(...requiredCaps.map(can))allow('public') is the explicit spelling for "no clauses required"; deny('<reason>') is the one
that carries a reason. Both are refused because a combinator with no clauses states nothing, and
and() stated the opposite of what its author meant.
They took the row type where money's write type belongs, so Repo.insert demanded a
MoneyValue while narrowMoney exists precisely to narrow a bigint handed to a driver — a
compile error on public API for a value the framework documents, implements and stores correctly.
RowWrite<T> accepts both spellings. If you implement Repo, widen those three signatures; if you
only call them, this accepts strictly more than before and costs you nothing.
Two breaking entries, and both are compile errors the moment you upgrade. Nothing changes at runtime, no data migrates, no cursor or protocol moves. This major is wide in what it ADDS — notifications, full-text search, webhooks, exports, state machines, form binding — and narrow in what it breaks.
No app.config.ts key moves. No codemod: each entry names its own manual edit.
| # | Surface | Costs you an edit if |
|---|---|---|
| 1 |
ServiceFactory receives CtxFacts, not Ctx
|
your defineService factory annotates its parameter (ctx: Ctx), or reads a sibling service off it |
| 2 |
PageLike.content() is deleted |
you called it in an e2e test, or implement PageLike yourself |
defineService's factory is handed the framework's own facts — actor, now(), clock, tz,
locale, requestId — and not the app's augmented services. It always worked this way; the
type said otherwise.
// before — compiles, and is circular: the factory that BUILDS ctx.posts
// declares that ctx.posts must already exist
export const posts = defineService('posts', (ctx: Ctx) => ({ … }));
// after — the documented form, which cannot drift from the signature
export const posts = defineService('posts', (ctx) => ({ … }));Drop the annotation. If you genuinely need to name the type, CtxFacts is now exported from
@ultimat3/core — it was the parameter type all along and the barrel never re-exported it, so no
app could name what its own factory was handed.
Reading a sibling service inside a factory no longer typechecks. That was already documented as
unsupported — factories run in registration order and a sibling may not exist yet — but the type
permitted it. Move the read into the method that needs it, where useService() resolves at call
time.
PageLike's comment claimed "every member is one the reference app's e2e suite already calls".
An audit found that false for three of eleven: content() had zero call sites anywhere in the
repository, and title() / reload() are named only by x g route's generated template, which
nothing executes.
content() is gone. title() and reload() stay, with the caveat recorded on each. If you drive
a browser yourself, delete content from your PageLike implementation; if you called it, read the
DOM through evaluate() instead.
Sixteen breaking entries, in three groups. Eight are compile errors the moment you upgrade
(4, 5, 6, 8, 9, 13, 14, 16). Four need an action before or at the deploy and nothing fails to
compile (1, 2, 3, 11) — every persisted pagination cursor stops working, rows sharing a sort value
change order, one index migration, and sync nodes and browser clients must ship together. The
last four are visible only to a caller at runtime (7, 10, 12, 15).
No app.config.ts key moves, because the surface this major opens never had one: AppConfig has
never carried an http member, so configureHttp() is an addition and not a migration — see the
last table. No codemod: every entry names its own manual edit.
| # | Surface | Costs you an edit if |
|---|---|---|
| 1 | every cursor minted before 12.0.0 is X_CURSOR_INVALID
|
you persisted a cursor — in a URL, a job payload, a client store. Nothing fails to compile |
| 2 | the primary-key tiebreak takes the last declared key's direction | you depended on the order of rows sharing a sort value. Nothing fails to compile |
| 3 | an index declaring where or order is renamed <table>_<cols>_<hash8>_idx
|
you declared one — a migration, before the deploy |
| 4 | a physical column or table name must be [a-z_][a-z0-9_$]*, at most 63 bytes |
you wrote .column('createdAt') or an entity(name) / table that is not lower-snake |
| 5 |
Repo gains aggregate and approximateCount; ReadBuilder gains five terminals |
you implement Repo or Driver yourself |
| 6 |
Operator gains contains, contained-by, overlaps, has-key
|
you switch exhaustively over Operator
|
| 7 |
introspect() returns app tables only |
you read its output, or assert how many catalog queries it issues |
| 8 |
rateLimitKey is deleted; RateLimitConfig.tenantBucket is required |
you called rateLimitKey, or built a full RateLimitConfig
|
| 9 |
Ctx gains a required deadlineAt
|
you hand-build a Ctx — a test fixture, a custom host |
| 10 |
traceHeaders() sends the remaining request budget |
never for the caller; a downstream service now receives x-request-timeout-ms
|
| 11 |
PROTOCOL_VERSION 1 → 2, and five realtime exports are deleted |
you imported one — and every deployment redeploys clients and sync nodes together |
| 12 | MCP rate limits are enforced, 120 read / 20 write per minute per actor | an agent exceeded them; it was previously unmetered |
| 13 |
memoryAuditSink() is bounded at 1,000 and discards oldest-first |
you used it as a system of record, or implement MemoryAuditSink
|
| 14 |
RouteBudget.css, .cls and .tbt are deleted |
you declared one — it was ignored, and now it does not compile |
| 15 |
claim({ queues: [] }) is refused; the memory driver's claim is async
|
you call a JobDriver directly |
| 16 |
DoctorProbe gains a required database()
|
you implement DoctorProbe
|
4. Spell every physical name lower-snake. X_INVARIANT_VIOLATED at entity(), before any
statement runs.
- createdAt: timestamp().column('createdAt'),
+ createdAt: timestamp().column('created_at'),A derived name is unaffected: columnName is meta.name ?? snake(property) and snake()
lower-cases, so createdAt: timestamp() still writes created_at. What changed is that the derived
branch is now checked too — for three majors only meta.name was, so a property named
n" , "x" text); drop table t; -- put a real drop table inside a generated create table.
Quoting is not a defence against a value that can close the quote. entity(name) and table go
through the same assertion.
5. Implement the two new Repo members, or stop hand-rolling one. TS2739.
const repo: Repo<Post> = {
findById, findMany, insert, update, delete: remove, count, countBy,
+ aggregate: (fn, column, args) => driverAggregate(fn, column, args),
+ approximateCount: async () => null, // `null` is "never analysed", and always legal
};approximateCount may answer null unconditionally — that is the documented value for a table
nobody has ANALYZEd, and every caller already handles it. aggregate cannot be stubbed the same
way: a wrong number is worse than no number, so raise X_AGGREGATE_UNSUPPORTED if you will not
implement it.
6. Widen the switch. TS2366, or a silent fallthrough if it had a default.
case 'is-not-null': return sql`${col} is not null`;
+ case 'contains': return sql`${col} @> ${bind}`;
+ case 'contained-by': return sql`${col} <@ ${bind}`;
+ case 'overlaps': return sql`${col} && ${bind}`;
+ case 'has-key': return sql`${col} ? ${bind}`;The four exist so a declared json() or arrayOf() column is no longer write-only from the query
language.
8. rateLimitSpends answers a LIST, not a key. TS2305.
- const key = rateLimitKey(route, ctx);
- await limiter.assert(key, bucket);
+ for (const spend of rateLimitSpends(route, ctx, config)) {
+ await limiter.assert(spend.key, spend.bucket);
+ }One request spends the caller's bucket and then the tenant's, stopping at the first refusal. The old
builder answered actor else org else ip, exclusively — and the anonymous actor answers
null for both of the first two — so no HTTP request ever spent an org bucket. RateLimitConfig
gains a required tenantBucket: string | null; null is "this app has no per-tenant allowance",
which is the default and the previous behaviour.
9. Add deadlineAt to a hand-built Ctx. TS2741.
const ctx: Ctx = {
requestId, traceId, locale, tz, buildId, role, actor, now,
+ deadlineAt: null, // null is "no deadline", which is what a job or a test has
};createContext({ … }) already defaults it, so only a literal pays. It is what
remainingBudgetMs(ctx) reads and therefore what entry 10 propagates.
13. memoryAuditSink() is not a system of record. The interface break is TS2739 on
size/dropped; the behaviour break is silent.
- setAuditSink(memoryAuditSink());
+ setAuditSink(postgresAuditSink({
+ executor: { query: (text, values) => db().query({ text, values }) },
+ }));Past 1,000 records the memory sink drops the oldest on every write, so an audited action can run,
succeed, be recorded and leave nothing behind. { maxRecords } raises the bound and does not remove
it. dropped is non-zero exactly when the sink is telling you it is the wrong one. x_audit is
applied at boot beside the jobs, idempotency and rate-limit tables, so there is no migration to write.
14. Delete the budget key. TS2353.
budget: {
js: '40kb',
- css: '12kb',
- cls: 0.1,
},There is nothing to replace them with. All three were declared on the route contract, flattened away
by registerRoute — which projects a budget to budgetJs + budgetLcp and nothing else — and read
by no consumer anywhere, so a declared CSS budget was ignored while the budgets step reported
green. A new budget key is now a build error until the descriptor projects it
(_EveryBudgetKeyIsProjected, packages/render/src/type-pins.tsx). budget.lcp survives and is
published, not enforced: nothing in the build observes a paint.
16. Implement DoctorProbe.database(). TS2741. Only a hand-written probe pays — x doctor's
own is unchanged.
const probe: DoctorProbe = {
bunVersion, root, port, production, devCursorSecret, devStorageSecret,
+ database: () => probeDatabase(process.env['DATABASE_URL']),
};x doctor answered "shippable" while probing the web port alone: a wrong password or a database that
does not exist accepts the socket and refuses the session, which a port check cannot see. It now
probes both ports and the database.
1. Drop every persisted cursor. Nothing fails to compile; a stored cursor is refused at decode
with X_CURSOR_INVALID.
| Where a cursor lives | Do this |
|---|---|
| a URL a client holds | nothing — request the first page (after: null) and re-mint |
a job payload, a resumable inBatches() position |
re-enqueue from the start, or from a business key of your own |
| a client store, a saved view | clear it on the version bump |
Two changes make an old cursor unreadable, and both were forced by one defect. A timestamp() sort
key is now carried as a microsecond epoch rather than an ISO string, because ORDER BY evaluates
timestamptz at microsecond precision while the seek treated a whole millisecond as one equality
class — two different equality classes over one page boundary. Reproduced against Postgres 16: three
rows inside one millisecond, uuid-v7 ids, orderBy('createdAt','desc').limit(1) returned 1 of 3
rows and stopped, every time. And every entry is tagged — ~ for an absent value, ! before a
present one — so an absence can be told from the text that spells it, which is what nullable sort
keys need.
The seek is now a plain < / > / = against $n::timestamptz; nextMillisecond and the
>= v and < v + 1ms window are gone. A read ordered by a timestamp() column carries one extra
output column on the wire, "<col>$US" — a name no entity can declare, stripped by decodeRow, so
it reaches no caller's row. Only a test that asserts SQL text sees it, which is exactly the suite
that hid this defect for three majors.
2. The default total order is uniform-direction.
- ORDER BY created_at DESC, id ASC
+ ORDER BY created_at DESC, id DESCtotalOrder appends the primary key in the last declared key's direction rather than always
ascending, so orderBy('createdAt','desc') runs created_at desc, id desc. Rows sharing a sort
value come back in the opposite order to before, and no page is lost either way — the seek matches
the order it is built from.
Two things follow. A mixed-direction order was un-indexable by this framework's own index DSL, so the
default one could never be served by a declared index. And a uniform order is now emitted as a row
comparison (a, b) < ($1, $2), measured on PG16 as an Index Only Scan against BitmapOr + Sort
for the or-chain. The or-chain remains for an order you wrote as mixed yourself, and for one whose
keys are not all NOT NULL: a row comparison has no null ordering, so a NULL on either side makes
the whole comparison unknown.
3. Rename the indexes that declare where or order. A declared index is matched by name, so
the old one is not dropped and the new one is not created until you say so.
x db gen "rename partial and ordered indexes" # then read the emitted up/down before applying-- what the generated migration looks like, one pair per affected index
alter index posts_author_id_idx rename to posts_author_id_9f2c1ab4_idx;The discriminator is sha256("<order>|<where>"), first 8 hex. Plain and unique() names are
unchanged, deliberately: Postgres names a column-level unique() index <table>_<column>_key
itself, so a discriminator there would make the generator emit a second create unique index for an
index that already exists (42P07), and a foreign key's own index is deduped against a hand-declared
one by the plain name.
Without it, two different partial indexes on one column were one name — posts_author_id_idx for
both where status = 'published' and where status = 'draft' — and the second was dropped with no
error, no warning and no drift finding. So this migration may create an index you declared years ago
and never had. A name over 63 bytes is now refused at declaration rather than truncated by the server
in silence.
11. Redeploy clients and sync nodes together. PROTOCOL_VERSION moves 1 → 2 and a skewed peer
is refused with X_PROTOCOL_VERSION, in both directions — a cursor rides the client's subscribe
and the node's snapshot, and the deleted fields were decoded through str() / num(), which throw
on absence. There is no rolling window in which the two versions interoperate.
- import { digestOf, DIGEST_UNVERIFIED, fnv1a } from '@ultimat3/realtime';
+ // nothing replaces themLiveCursor.digest and LiveCursor.count are gone with them. Every snapshot ran canonicalJson
over every row and hashed it for a value no code path read — a full serialize-and-hash of every
result set, per live query, per reconnecting socket, in the restart storm this package is benchmarked
on. count would have been wrong had it ever gained a reader: advance seeds its set from the
already-truncated ids, so a delete past CURSOR_ID_LIMIT never decremented it. @ultimat3/flags
and @ultimat3/ai keep their own fnv1a and are untouched.
7. introspect() returns app tables only.
| Relation | Before | Now |
|---|---|---|
| an ordinary or partitioned table | returned | returned |
| a view, a materialised view, a foreign table | returned | excluded |
anything Postgres records as extension-owned (pg_depend, deptype = 'e') |
returned | excluded |
| a table someone created by hand | returned | returned, and still unexpected-table
|
IntrospectOptions.exclude no longer decides the set on its own — it narrows what survives the rule
above, and cannot bring an excluded relation back. This is what makes a stock managed Postgres
deployable: create extension pg_stat_statements in public is the CNPG, RDS, Supabase and Neon
default, and its view read as unexpected-table with x db gen "add pg_stat_statements" as the
printed fix — so every deploy failed terminally and following the fix would have written an
extension's internal view into the app's migration set. Ownership rather than a name prefix, because
that rule covers the view and misses PostGIS's spatial_ref_sys.
Edit only if a test asserts the statement count: it issues four catalog queries where it issued three.
10. A downstream service now receives x-request-timeout-ms. No edit on the calling side —
traceHeaders() is spread by both typed clients before your own headers, so an explicit value still
wins.
| Situation | Header sent |
|---|---|
| in a request with 12s left of its budget | x-request-timeout-ms: 12000 |
| in a request whose budget is spent | none — never 0, which the far side reads as "the caller asked for nothing" |
| in a job, a test, a browser | none; there is no ambient deadline |
The receiving end may only be shortened by it: resolveTimeoutMs takes the minimum of its own
configured budget and the header. Before this, a 30s gateway budget already spent to t=29 handed the
next service a fresh 30s, so work ran for another half minute holding a pool slot and a vendor
connection after the caller's socket had already been answered X_TIMEOUT.
12. MCP callers are metered. 120 read and 20 write per minute, per actor, per class —
X_MCP_RATE_LIMITED, 429, with Retry-After.
mcpHttpRoute({ server, resolveToken, rateLimits: { read: 600, write: 60 } });
// or defineAppMcp({ …, rateLimits: { read: 600, write: 60 } })A tools/call naming a destructive: true tool spends write; so does any call this server
cannot resolve, fail-closed, so a probing client never gets the cheap bucket. Everything else,
initialize included, spends read — a coarse per-route rule would have thrown an agent off on its
handshake. The key names the actor and never reaches the caller.
Behind more than one replica, pass the store too — the default counts per process, which is
honest for x mcp serve and a lie for N replicas behind one URL, each enforcing the full allowance
on its own:
mcpHttpRoute({ server, resolveToken, rateLimitStore: postgresRateLimitStore({ executor }) });X_MCP_RATE_LIMITED is its own code and not X_RATE_LIMITED because the knob differs: that one's
fix: names the HTTP pipeline's buckets, which do not govern this route, so raising them would run
and change nothing.
15. Name the queue. X_JOB_CLAIM_QUEUES_EMPTY from both drivers.
- await driver.claim({ queues: [], limit, visibilityTimeoutMs, workerId });
+ await driver.claim({ queues: ['default'], limit, visibilityTimeoutMs, workerId });An empty list named no queue and meant two different things: every queue on the memory driver,
the default queue on Postgres, with ClaimOptions.queues documenting neither. Each meaning is
silently wrong in the other's deployment — one takes work this worker was never configured for, the
other drains nothing and reads as an idle queue. There is no third meaning to pick.
createWorker passes exactly one queue per pass, so only an embedder calling a driver directly is
affected. The memory driver's claim is now async to raise the refusal, so a caller that read its
return synchronously gets a Promise.
Read these if you built a workaround for one.
| Change | What it means |
|---|---|
| read replicas |
DATABASE_REPLICA_URL plus a withReplicaReads(fn) scope. Opt in twice and byte-identical when unconfigured. Read-your-writes is the rule, not an option: one write at any depth pins the rest of the scope to the primary, and a transaction is always the primary's. Three consecutive replica failures park it for ten seconds. The URL must name a read-only standby
|
configureHttp() |
the entire HTTP tuning surface — CORS origins, body limit, request timeout, max in-flight, rate-limit buckets — was reachable from no app config key that existed. AppConfig has never had an http member, so every fix: line naming http.<key> in app.config.ts resolved against nothing. Call it at module scope in a file under apps/*/. rateLimit.scope stays boot-owned |
| a durable audit sink |
postgresAuditSink({ executor }), append-only, no purge — retention is a legal question with a different answer per app. The row is a fixed allow-list and never a walk of the Ctx, which on an HTTP surface carries the caller's Authorization and Cookie
|
| aggregates and containment |
sum / avg / min / max / approximateCount, and four containment operators. min/max on text is refused (Postgres orders by collation, JS by code unit); avg over money is refused, naming sum() + count()
|
| nullable sort keys order |
asc nulls last / desc nulls first, with the null position carried in the cursor. Only a nullable primary-key column is still refused — null = null is unknown, so the tiebreak cannot break a tie. The refusal also moved to plan time: it used to fire only when a next page existed, so it was green on 15 seeded rows and X_INVARIANT_VIOLATED on the first real read |
a policy gate step, twentieth |
every permission an app grants or requires must be one it declares. the scaffold shipped an app that answered X_PERMISSION_UNKNOWN on two of its three routes — status 500 — under a green gate. It skips in the framework monorepo, which declares no roles |
X_MANIFEST_MISSING |
an app root with no x.manifest.json fails the manifest step. Nothing ever ran x manifest, so the file did not exist in any app x new produced while the step reported green. Run x manifest once and commit it |
scripts/declaration-readers.ts |
every leaf key of every primitive declaration needs a reader in shipped source. 173 leaves across 18 roots, ratchet at zero |
MCP minLength / maxLength count code points |
the validator counted UTF-16 code units while the schema that publishes those numbers counts code points, so an astral-character argument was passed and then refused by the action's own parse, or refused outright on a bound the agent had obeyed |
x i18n add <locale>, x dev --port N
|
a locale file that turned the gate red printing a fix that repaired nothing; and a dev server dying on port N+1 with a caught Error rendered into the cause and X_CLI_UNEXPECTED rather than a stable code |
Seven breaking entries, from a shutdown, cache and disclosure sweep. Four are compile errors the
moment you upgrade. Three are not — and one of those changes what a CDN is allowed to store for a
signed-in visitor, so read entries 3, 4 and 5 even if nothing here fails to compile. No
app.config.ts key moves in this major, so there is no config edit to start with. No codemod.
| # | Surface | Costs you an edit if |
|---|---|---|
| 1 |
isrKey(url, locale) takes the negotiated locale, and it is part of the store key |
you call isrKey — the document a multi-locale isr route serves changes either way |
| 2 |
IsrStore gains a required markStale(path)
|
you implement IsrStore yourself |
| 3 |
HttpConfig.drainTimeoutMs is number | null, default null
|
you read the resolved field — and the drain budget moves 15s → 25s for an app that declared neither |
| 4 | an unclassified 5xx problem document carries no exception text | a client reads title / detail / cause off a 500 |
| 5 | a request carrying an identity gets private, max-age=0; every shared response varies on cookie and x-timezone
|
never — a CDN leak, closed. Personalised pages stop being shared-cacheable, which is the fix |
| 6 |
initialsOf(name, locale) takes a required locale |
you call initialsOf directly; <Avatar> is unchanged |
| 7 |
@ultimat3/pwa deletes RetryPolicy, DEFAULT_RETRY, retryDelayMs, shouldRetry and BackgroundSyncOptions.retry
|
you imported one from @ultimat3/pwa. @ultimat3/jobs exports two of those names and is untouched |
1. Pass the negotiated locale to isrKey. TS2554, one argument short.
- const served = await isr.serve(isrKey(url), () => renderPage(url));
+ const served = await isr.serve(isrKey(url, ctx.locale), () => renderPage(url));isrKey is on @ultimat3/render/server, where 9.0.0 put it. The locale rides in a reserved query
parameter — __x_locale, exported as ISR_LOCALE_PARAM — and not as a prefix, because routePathOf
splits a key at its ?: an es:/blog key matches no route, so descriptorFor answers undefined
and a declared revalidate: { ttl } silently becomes tag-only.
One entry per path served visitor 2 the document negotiated for visitor 1 — <html lang>, every
t() — for the whole TTL, and s-maxage told the CDN to do the same. toResult emits
vary: accept-language now for the CDN half; the rest of the shared key comes from @ultimat3/http's
cache-headers stage, which sees the actor this function cannot.
The time zone is deliberately not a dimension: a locale set is declared and bounded, a zone list
is not. A date on an isr page belongs in a zone the page itself names, or the page belongs in ssr.
2. Implement markStale in place. Only a custom IsrStore pays this — memoryIsrStore() has it.
const store: IsrStore = {
get: (path) => map.get(path),
set: (entry) => { map.delete(entry.path); map.set(entry.path, entry); },
+ markStale: (path) => {
+ const entry = map.get(path);
+ if (entry === undefined) return false;
+ map.set(path, { ...entry, stale: true }); // in place: the position IS the eviction order
+ return true;
+ },
delete: (path) => { map.delete(path); },
paths: () => [...map.keys()].sort(),
};Never set({ ...entry, stale: true }) — that read-modify-write is the defect the member exists to
end. set means "this page was just generated" and a store is entitled to order eviction by exactly
that, so marking through it made the stalest page the newest: a tag bust protected the pages that
most needed regenerating.
Second half, and it costs nothing: regenerate samples a cache fence before the render and does
not store an entry the fence invalidated. A bust landing mid-render was previously erased by
store.set({ stale: false }), and for a tag-only route isFresh is true forever — so the process
served pre-write HTML for the rest of its life.
6. Pass the locale to initialsOf. TS2554.
- initialsOf(member.displayName)
+ initialsOf(member.displayName, useUi().locale)<Avatar> reads useUi().locale itself, so a component tree pays nothing. A bare
toLocaleUpperCase() reads the runtime's default locale — a server's LANG, a browser's UI
language, never the request's — so one Turkish name uppercased to İ on the server and I in the
browser, out of identical props. @ultimat3/ui has no ambient locale to fall back on, by rule.
7. Delete the import; there is nothing to replace it with. TS2305.
- import { backgroundSyncSource, DEFAULT_RETRY, type RetryPolicy } from '@ultimat3/pwa';
+ import { backgroundSyncSource } from '@ultimat3/pwa';
- backgroundSyncSource({ flushEndpoint, retry: { ...DEFAULT_RETRY, maxAttempts: 5 } });
+ backgroundSyncSource({ flushEndpoint });BackgroundSyncOptions is { flushEndpoint?: string } and nothing else — in this release. 21.0.0
removes it as well, together with the flush route; see entry 8 of 20.x → 21.0.0. This package schedules no
retry and never did: the one-shot sync handler rejects and the platform decides when to wake it
again. Of the policy only maxAttempts reached the emitted worker, as a SYNC_MAX_ATTEMPTS constant
nothing read, and X_PWA_SYNC_INCOMPLETE's fix: told the reader to raise
pwa.backgroundSync.retry.maxAttempts — a key PwaConfig has never carried, because
backgroundSync is a boolean. Error codes already says so.
@ultimat3/jobs is a different package carrying two of those names. RetryPolicy and
DEFAULT_RETRY are still exported from it, still read by the worker, unchanged. Only pwa's copies are
gone, and a RetryPolicy on a job() is not one of them.
3. drainTimeoutMs is number | null, and null means "this app did not say".
| What the app declared | Drain deadline before | Now |
|---|---|---|
| nothing | 15,000ms |
25,000ms — core's own DEFAULT_DEADLINE_MS
|
configureLifecycle({ deadlineMs: 600_000 }) |
15,000ms — reverted by the next line of boot | 600,000ms |
defineHttpConfig({ drainTimeoutMs: 5_000 }) |
5,000ms | 5,000ms |
createServer calls configureLifecycle({ deadlineMs }) only when the app declared one
(packages/http/src/server.ts:101).
Unconditional, with defineHttpConfig defaulting the number, that line reverted the exact edit
X_SHUTDOWN_TIMEOUT's own fix: prints — silently, in every process that serves web.
Edit only if you relied on the 15s default — write it down:
- defineHttpConfig({ rateLimit: { scope: 'process' } })
+ defineHttpConfig({ rateLimit: { scope: 'process' }, drainTimeoutMs: 15_000 })The INPUT field is still number | undefined, so a declaration compiles unchanged. The RESOLVED
field is number | null, so const ms: number = config.drainTimeoutMs is TS2322 — that is the
compile half, and it reaches only a caller that reads the merged config back.
4. An unclassified 5xx says nothing about the exception that caused it.
| Member | On a 5xx nobody classified | On a coded refusal |
|---|---|---|
type, status, code, fix, docs, requestId
|
unchanged | unchanged |
title |
unhandled server error |
the code's own title |
detail, cause
|
one fixed sentence pointing at this process's logs, under the request id | the authored cause |
"Unclassified" is X_INTERNAL, or a code with no row in @ultimat3/http's table and no
registerErrorStatus row — deliberately not status >= 500, which would have blanked X_DRAINING's
one instruction. dev: true is unchanged, and the text is not lost: the error-map stage logs it as
a redactable field and reports every 5xx to the error monitor, both keyed by requestId.
A pg message quoting the rejected row, a driver message quoting the DSN, went to any non-HTML client
in production. error-page.ts had locked the browser out of exactly this, so the two audiences
disagreed about one condition.
Edit only if a client parsed those members. Match on code, correlate on requestId. A 5xx of
your own that should keep its authored cause needs a status of its own — that is what makes it
classified:
registerErrorStatus({ X_PAYMENTS_UNREACHABLE: 502 }); // from @ultimat3/http, once at boot5. A request carrying an identity is private, max-age=0, whatever the handler declared.
- cache-control: public, max-age=0, s-maxage=30, stale-while-revalidate=300
+ cache-control: private, max-age=0The cache-headers stage reviews a cache-control the handler wrote instead of standing down: a
declaration offering the response to a shared cache (public, or an s-maxage) plus a non-anonymous
actor is replaced. immutable is the one exception — it asserts the body is a function of the URL
alone, which a content-addressed island chunk or image is, and demoting those re-downloads every
chunk on every navigation for every signed-in user.
An anonymous shared response is unchanged except that it now carries
vary: accept-language, cookie, x-timezone. Both halves are needed: private for the identified
request, vary: cookie for the shared one.
What was happening: ssrHeaders offers any route without a policy to a CDN for 30 seconds, and
meta.auth is 'public' | 'required' — so the commonest page in any app, public but greeting you by
name when you are signed in, is a 'public' route whose own header said s-maxage. That is the shape
x g route --surface app scaffolds.
No edit, and expect the shared-cache hit rate on personalised pages to go to zero — that is the
fix, not a regression. A route that really is a function of the URL alone says so:
cache-control: public, max-age=31536000, immutable.
Read these if you built a workaround for one.
| Fix | What stops happening |
|---|---|
| the framework's CSP admits its own hydration runtime in production |
no island booted after deploy, anywhere the policy is enforced. script-src was 'self' 'wasm-unsafe-eval' with no hash while the runtime is an inline module — report-only under x dev, enforced in a container. startWeb now hashes the seven HYDRATE_RUNTIME_BODIES into script-src, as it already did for styles |
a browser gets an error page, not problem+json
|
a 404 or a 500 rendering the internal cause and the author-facing fix: into a visitor's window. Copy is the catalog's errors.* keys; override per status with apps/web/site/errors/<status>.html, and x dev keeps the overlay |
worker, scheduler and sync drain in two phases, and holdUntilShutdown reaches the exit |
one accept hook spending the whole budget before "stop listening" and "stop upgrading" had been invoked at all — 4 hooks started, none finished — and an overrun wedging the process until the kubelet's SIGKILL, where the job lease lapsed and another worker re-ran it. Behaviour change: a job outrunning configureLifecycle({ deadlineMs }) is abandoned (jobs.worker.drain-abandoned) and the queue redelivers it, where the teardown used to hang forever with the driver open. Raise the budget past your slowest job |
a worker's fleet slot is released before the driver closes, and a renewal interval is unrefed |
a concurrency: 1 job unclaimable by the replacement pod for a full visibility window after every deploy, and a refed interval holding a drained process open until SIGKILL |
| the scheduler re-asserts leadership before every task, not once per round | the tail of a round dispatching under a lease another node already took. The occurrence key does not absorb it: SQL_ENQUEUE's conflict target is partial over the live states, so a duplicate landing after that job finished inserts a new row and the handler runs twice |
| a replayed backfill batch writes no ledger row | 4,800 x_backfills UPDATEs before a resumed 5M-row sweep read a single new row, on every attempt, inside the visibility lease |
| a server render gets a live client instead of a 500 | a page whose body reads a live query failing on the server; it renders its loading branch and the browser takes over on hydrate. mutate() / drain() there are X_LIVE_SERVER_RENDER
|
createLogger({ level: 'verbose' }) is refused at construction |
an unknown level failing open — every level emitted |
one documented first run, and it is bin/setup
|
cd myapp && x dev failing on X_BUILD_FAILED because x new installs nothing — which eight doc pages, and x new's own closing line, told the reader to do. wiki/Installation.md also listed six x new flags that do not exist |
Nineteen breaking entries, from a twelve-audit correctness sweep. Every one deletes or corrects
a declaration that promised something the code did not do. One app.config.ts edit. Six compile
errors. Five that no compiler will find — read those even if nothing else here applies. Four refuse
a declaration that was already broken, and three are corrected underneath you at no cost. No
codemod.
| # | Surface | Costs you an edit if |
|---|---|---|
| 1 |
ERROR_DOCS_BASE and errorDocsUrl(code) are deleted |
you construct an UltimateError with an explicit docs:
|
| 2 | a problem document's type is urn:ultimate:error:<CODE>
|
a client matches on that string |
| 3 |
@ultimat3/time refuses a malformed locale with X_LOCALE_INVALID
|
you catch RangeError around a formatter |
| 4 |
realtime.tier and RealtimeTier are deleted |
your app.config.ts sets realtime.tier — the one config edit in this major
|
| 5 | the WAL decoder returns parsed values, not Postgres' own text | you name PgOutputMessage, entityRow or PhysicalRow
|
| 6 | a delta resume no longer seats a pre-policy cursor | never — a cross-tenant leak, closed |
| 7 |
verifyDigest() is deleted from @ultimat3/realtime
|
you called it, which nothing could have |
| 8 |
defineAuth({ providers }) defaults to []
|
you serve an OAuth route and name no provider |
| 9 |
@ultimat3/auth writes x_accounts.access_token / refresh_token as null
|
your own SQL reads either column |
| 10 | a multi-audience id token needs a matching azp; a future nbf is refused |
your OAuth provider issues multi-audience id tokens, or a host clock is ahead |
| 11 |
MemoryAdapter.createUser enforces x_users' two UNIQUE constraints |
a test registers one address twice |
| 12 | two admin resources may not claim one path:
|
your defineAdmin already had four screens unreachable |
| 13 |
registerLayout(name, layout) refuses a name already taken |
two modules register one layout name |
| 14 |
assertReadOnly returns a ReadOnlyVerdict
|
you call it from @ultimat3/admin/dev
|
| 15 |
generate() no longer collects a LOCAL refusal |
you catch X_AI_PROVIDER_UNAVAILABLE to mean "the model call failed" |
| 16 |
@ultimat3/render's graph-based island budget API is removed |
you imported routeJsBytes, graphFor, checkBudget, checkBudgets or assertBudget
|
| 17 |
@ultimat3/pwa's routeRules orders by specificity, wildcards last |
you ship a generated sw.js
|
| 18 |
subscriptionState takes a Clock
|
you passed epoch milliseconds |
| 19 |
StaticReport gained a required unmeasured
|
you construct one by hand |
realtime: {
enabled: true,
- tier: 'live-queries',
transport: 'nats',
urlEnv: 'NATS_URL',
},TS2353, and nothing else. RealtimeConfig is { enabled, transport, urlEnv } —
packages/core/src/config.ts:132.
tier accepted 'channels' | 'live-queries' | 'local-first', defaulted, was documented with
per-value semantics, and was set by both tracked apps and every scaffolded app — and nothing read
it. No comparison, no branch, no dereference. tier: 'local-first' bought exactly what
'channels' bought, and the durable local store it advertised does not exist. Which tier an app is
on is decided by what it declares: a channel() topic, a live: true query, a local store.
Leaving the line in also works, and that is the hazard. section() copies an unknown key
through, so a stale tier: still boots and still does nothing; an app that builds its config into a
variable before passing it to defineConfig loses excess-property checking and sees no error at
all. Same shape as jobs.driver in 5.0.0 and realtime.heartbeatMs in 4.0.0 — the thirteenth
instance of that class. bun run scripts/config-readers.ts is what keeps the fourteenth out.
1. Omit docs:; do not substitute the new constant.
- import { errorDocsUrl, UltimateError } from '@ultimat3/core';
+ import { UltimateError } from '@ultimat3/core';
export class BillingDeclinedError extends UltimateError {
constructor(cause: string) {
- super({ code: 'X_BILLING_DECLINED', cause, fix: 'retry with another card', docs: errorDocsUrl('X_BILLING_DECLINED') });
+ super({ code: 'X_BILLING_DECLINED', cause, fix: 'retry with another card' });
}
}The constructor already resolves docs from the registry, so passing it by hand is a second
declaration of one fact. ERROR_DOCS_URL is exported from @ultimat3/core for a caller rendering
the link outside an error — not as a drop-in for the deleted function.
Why: https://ultimate.dev/errors/<code> answered HTTP 404, host included, on every error this
framework has ever thrown, including the first line a new agent reads. One URL rather than one per
code, because codes live on Error codes in table rows and a row has no anchor —
a #X_DB_DRIFT fragment would be a second dead declaration, not a fix for the first.
7. Delete the verifyDigest() call. That is the whole migration, and nobody had one to delete:
a delta-resumed cursor carries DIGEST_UNVERIFIED, so the check answered false for every cursor
drift can occur in, and identity-map.ts merges columns across queries by design — any app with two
reads over one entity would have reported permanent drift. Drift is the server's desynced mark.
14. assertReadOnly returns a verdict, and the verdict carries the string to run.
- const refusal = assertReadOnly(sql);
- if (refusal !== null) return { refused: refusal };
- const rows = await client.query(sql);
+ const verdict = assertReadOnly(sql);
+ if (verdict.kind === 'refused') return { refused: verdict.refused };
+ const rows = await client.query(verdict.sql);ReadOnlyVerdict is { kind: 'runnable'; sql } | { kind: 'refused'; refused }. Execute
verdict.sql, never the string you passed in: every check ran on a stripped form and the verdict
is the reconciled one. The /_x panel discarded it and ran the textarea's own bytes, so two callers
of one guard disagreed about which string runs.
16. Delete the import. Removed from @ultimat3/render: routeJsBytes, graphFor,
checkBudget, checkBudgets, assertBudget and their types. Every one was exported from the
barrel and called by nothing; the budget gate that actually runs is @ultimat3/cli's and it
measures the emitted document. parseByteBudget, defaultIslandBudget and islandModuleIds are
unchanged.
18. subscriptionState takes a Clock where it took epoch milliseconds — TS2345 on a
number. The parameter is optional and defaults to systemClock, so most callers delete an
argument:
- subscriptionState(record, lastStatus, Date.now())
+ subscriptionState(record, lastStatus)
+ subscriptionState(record, lastStatus, frozenClock(NOW)) // a test, from @ultimat3/core19. StaticReport gained a required unmeasured — every budgeted route a build could not
weigh, with the reason, which is the list X_BUDGET_UNMEASURED's fix: cites by name and which
until now reached no x command's output at all.
const report: StaticReport = {
target: 'static', out, buildId, emitted, skipped,
+ unmeasured: [],
};Reading one costs nothing: parseStaticReport takes the field as optional and answers [] when
it is absent, so a .x/static-report.json written by an older build still parses. Only
hand-construction moves.
2. A problem document's type is a URN, per code.
- if (problem.type === 'https://ultimate.dev/errors/X_RATE_LIMITED') …
+ if (problem.type === problemTypeFor('X_RATE_LIMITED')) …problemTypeFor(code) is urn:ultimate:error:${code}, exported from @ultimat3/http. code is
unchanged and is the simpler match — problem.code === 'X_RATE_LIMITED' needs no import. type
and docs used to carry the same dead link on every 4xx and 5xx; they are two values now because
they answer two questions — type is RFC 9457's identifier for the problem kind, a URN so it has
no host left to rot, and docs is the one wiki page.
3. A malformed locale is refused with a code instead of dying as a bare RangeError.
| Tag | Before | Now |
|---|---|---|
en, en-GB, de-DE
|
formats | formats |
zz — well-formed, unknown |
Intl falls back |
Intl falls back, still not refused
|
en_US, '', a raw Accept-Language value |
bare RangeError out of Intl, several frames from the header it came from |
X_LOCALE_INVALID, with a runnable fix:
|
Every @ultimat3/time entry point taking a locale passed the caller's raw tag to an Intl
constructor. assertLocale is the single gate now, and the list is one command:
grep -rn 'assertLocale(' packages/time/srcEdit only if you catch RangeError around a formatter; screen header input with
Intl.DateTimeFormat.supportedLocalesOf([tag]). The code's row is on Error codes.
8. defineAuth({ providers }) defaults to [], not the live OAuth registry.
export const auth = defineAuth({
adapter,
+ providers: ['github', 'google'],
});An app already passing providers: needs nothing. An app that passed none now serves no
/auth/oauth/<id> route — name the ones you mean. The default was every provider any dependency had
registered, so the uniform 404 the option exists for could never fire, and an import decided the
app's login surface. With the credentials fix in the same release, that closed an enumeration
oracle: 500 meant registered, 404 meant not, and the 500 published the app's own *_CLIENT_ID and
*_CLIENT_SECRET names.
15. X_AI_PROVIDER_UNAVAILABLE now means one thing: the transport failed, on every provider
tried. A local refusal reaches the caller with its own code and its own runnable fix:.
| Code | Raised when | Its fix:
|
|---|---|---|
X_AI_KEY_MISSING |
no key configured and none passed |
export ANTHROPIC_API_KEY=<key>, or pass { apiKey } to the provider |
X_AI_REQUEST_INVALID |
a reasoning control the chosen model does not have | set model: on the llm() request |
X_AI_PROVIDER_UNAVAILABLE |
a non-2xx, an in-band error event, or a stream cut before message_stop
|
retry, or configure a second provider |
A catch treating X_AI_PROVIDER_UNAVAILABLE as "the model call failed" stops seeing the two
misconfigurations, which is the point: collecting one discarded its instruction and made
generate() and stream() answer one misconfiguration two ways.
17. Regenerate sw.js — with the call, because no command writes it. x build emits no service
worker and nothing in the framework calls generateServiceWorker; the generated file's own
regenerate: header names the call for that reason.
generateServiceWorker(routes, config, buildId); // from @ultimat3/pwaThe emitted file changes for any app with a dynamic route above a static sibling. ruleFor returns
the first pattern that matches and the order was alphabetical: : (0x3A) and * (0x2A) sort
before every letter, so /posts/:id shadowed /posts/new, and a single /* shadowed the whole
table — every PRECACHE_MANIFEST entry downloaded at install and then never looked up. Path is
still the tie-break, so identical input still emits an identical file.
If one of these fires, your app was half-broken before the upgrade — each produced a declaration that was silently unreachable, not a rule the framework tightened for its own sake. The refusal names the finding.
| # | Refuses | Code | What had been happening |
|---|---|---|---|
| 10 | an id token naming several audiences whose azp is not this client, and an nbf in the future |
X_OAUTH_TOKEN_INVALID |
both only narrow, on the ID_TOKEN_CLOCK_SKEW_MS id-token.ts already exported to workload.ts and did not itself enforce — an nbf ten years out verified, and a token an OP minted for another client that also lists yours verified with it (OIDC Core 3.1.3.7) |
| 11 | a second x_users row with one email, or with one external_id
|
X_AUTH_WRITE_FAILED |
MemoryAdapter is what x new scaffolds and what every test runs against, so the duplicate path was exercised only against the permissive half of the seam: two register() calls at one address made two rows, and the second was unreachable forever |
| 12 | two defineAdmin resources claiming one path:
|
X_ADMIN_PAGE_PATH_INVALID |
eight routes over four paths, with the second resource's four screens silently unreachable. The fix: hands you a path: for one of them |
| 13 |
registerLayout(name, …) on a name already registered |
X_MAIL_DUPLICATE |
layouts.set answered whichever module ran last, base included, so a dependency could re-shell every framework mail in silence |
| # | What changed | What you do |
|---|---|---|
| 5 | the WAL decoder returns the values a repository row holds. PgOutputDecoder decoded a timestamptz as '2026-08-09 12:00:00+00', a text[] as '{a,b}' and a bytea as '\x0102', while the shared live window holds rows @ultimat3/entity parsed — and compareValues normalises a Date to its epoch, so an edit to any column of any row jumped that row to the top of every orderBy('createdAt', 'desc') feed for every subscriber, carrying the raw string into the window. post.tags.map(…) threw on the first patch |
nothing, unless you name PgOutputMessage, entityRow or PhysicalRow from @ultimat3/realtime/server — after/before and entityRow's return widen to PhysicalRow. It is a wire convergence: only a real walsender diverged, because setRowObserver emits already-parsed rows and the parity test handed the same object to both sides |
| 6 | a delta resume no longer seats a pre-policy cursor. resumeFrom advanced across the retained patch list, which is pre-policy by design, so a subscriber reconnecting inside the retain window gained the id of every row inserted for every other actor while it was away — and then received a delete frame carrying another tenant's row id |
nothing. The leak subscriber-gate exists to close, re-opened one layer up and closed again |
| 9 |
@ultimat3/auth no longer persists provider access or refresh tokens. x_accounts.access_token and refresh_token held live third-party credentials in the clear under a tables.ts header promising "no column holds a plaintext secret", and nothing ever read either one back |
nothing, unless your own SQL selected either column — both are written null now. The type and the DDL are unchanged; keep a token you actually call out with in your own table, encrypted |
Read these if you built a workaround for one.
| Fix | What stops happening |
|---|---|
Gateway.stream() resolved the provider between reserve() and the try/finally that releases it |
a registered model no configured provider serves debited the estimate and never credited it back — on MemoryBudgetStore, which is per process and never expires, five refused streams spent an org's whole ceiling with nothing ever sent, and every later call was X_AI_BUDGET_EXCEEDED for the life of the process (#319) |
| a successful login cleared the per-IP failure bucket | one credential the attacker owns bought unlimited stuffing — 4 guesses, 1 login, repeat: 160 guesses from one address against a 5-attempt limit, never locked (#317) |
| the OAuth callback published uncoded internal exception text to an unauthenticated caller | connection strings and bind passwords reaching the browser, on two independent paths. The token-endpoint body is no longer reflected either — that request carries client_secret (#318) |
readonly-sql ended a -- comment at \n only |
a CR terminated the comment for Postgres and not for the scanner, hiding the payload from all four layer-3 checks — including select pg_advisory_lock(42), whose session lock survives ROLLBACK and outlives the read on a pooled connection (#316) |
diffRows threw on a money() column |
every adminUpdate on a money-bearing entity failed with an uncoded TypeError after repo.update() committed, landing zero audit entries (#321) |
x db gen --allow-destructive emitted a migration Postgres refuses |
drop table with no preceding FK drop, tables ordered alphabetically rather than by dependency — SQLSTATE 2BP01 during ROLE=migrate, with a down that cannot restore |
Five breaking entries, from closing the ten findings the 8.0.0 sweep filed rather than absorbed. Four are compile errors the moment you upgrade. The fifth changes what your cache ladder is, at runtime, and it is the one to read even if nothing else here applies. No codemod.
| # | Surface | Costs you an edit if |
|---|---|---|
| 1 |
@ultimat3/render splits into . and ./server
|
you import a build-time name, or relied on importing the barrel to install the .tsx loader |
| 2 |
cache.tiers names the ladder's own rungs, and is now read |
your app.config.ts sets cache.tiers — runtime behaviour changes even if it compiles
|
| 3 |
@ultimat3/storage renames IMAGE_FORMATS / ImageFormat
|
you imported either |
| 4 |
@ultimat3/pwa drops the forced-reload half of version-skew
|
you called updateSignal or updatePolicy
|
| 5 |
@ultimat3/core replaces CacheTier with CacheTierName
|
you named the type |
- import { defineRoute, renderToHtml } from '@ultimat3/render';
+ import { defineRoute } from '@ultimat3/render';
+ import { renderToHtml } from '@ultimat3/render/server';55 names moved: the render pipeline (renderToHtml, renderSsr, renderStatic, renderStreamHtml,
the ISR controller) and the loaders (installRenderLoader, compileStylesheet, stylesFor,
transformTsx). . keeps the authoring vocabulary — defineRoute, h, Fragment, island,
hydrate*, the registry, the mode tables.
Second, easily-missed half: importing @ultimat3/render no longer installs the .tsx/.scss
loader. @ultimat3/render/server does. A test that did await import('@ultimat3/render') before
loading a page module must now import /server.
Why: bun build --target=browser on the barrel failed outright — "Browser polyfill for module
node:url doesn't have a matching export named fileURLToPath", out of css-modules.ts. The
island this framework tells you to write could not be bundled.
- cache: { tiers: ['memo', 'lru', 'shared', 'isr'] }
+ cache: { tiers: ['request-memo', 'lru', 'redis'] }memo → request-memo, shared → redis, and delete isr — it is a RenderMode, and the
routes that want it declare render: 'isr'. It named a cache rung that never existed.
Read this even if your config already compiles. The key was previously read by nothing:
startCacheTiers registered memo + lru unconditionally, redis on REDIS_URL, cdn on a purge
credential. An app declaring tiers: ['request-memo'] measurably got
['request-memo', 'lru', 'redis', 'cdn']. Now the ladder is the declaration, which means:
- naming a rung the environment cannot supply refuses the boot —
rediswithoutREDIS_URL,cdnwithout a purge credential — rather than quietly building a shorter ladder - an environment offering a rung the config does not name logs
cache.tier.unnamedand builds nothing
If you relied on the old always-on lru, or on REDIS_URL adding a tier your config never
mentioned, name it.
- import { IMAGE_FORMATS, type ImageFormat } from '@ultimat3/storage';
+ import { VARIANT_FORMATS, type VariantFormat } from '@ultimat3/storage';Both packages exported those two names over different sets, so a storage caller narrowing on
storage's type had a type saying gif cannot occur and a value from core's probe that was one. If
you were probing rather than minting variants, the six-format set is IMAGE_FORMATS from
@ultimat3/core — which is what you actually had.
variantKey() also now refuses a format outside VARIANT_FORMATS instead of returning a key ending
.undefined.
Removed: updateSignal, updatePolicy, DEFAULT_GRACE_MS, and the types ForceReason,
UpdatePolicy, UpdatePolicyInput, UpdateSignalInput. AppUpdateAvailable narrows to
{ type, to }, losing from, forced and deadlineAt.
Nothing performed the reload they described, and no runtime could have called them: @ultimat3/http
(tier 2) and @ultimat3/realtime (tier 3) both sit below pwa (tier 4). Forcing a reload is not
a capability this framework has. Notification is, and is complete — read
useConnection().updateAvailable, or compare the worker's posted to with detectSkew, and render
your own affordance.
The type behind entry 2. @ultimat3/cache still exports a CacheTier — it is the tier interface,
a different thing, and it is unchanged. The two sharing one name is what made a type error about
CacheTier unreadable.
Six breaking entries, from one whole-repo bug sweep. Five are compile errors the moment you upgrade. The sixth is a silent behaviour change, and it is the one to read even if nothing else here applies to you. No codemod.
| # | Surface | Costs you an edit if |
|---|---|---|
| 1 |
@ultimat3/realtime has two entries |
you import a server name — NATS, pg replication, the sync node, the channel hub |
| 2 |
IdempotencyStore.settle / fail take a reservation id |
you call either, or implement the interface |
| 3 |
pwa.installPrompt, auth.afterSignInPath, ai.modelEnv deleted |
your app.config.ts sets one |
| 4 |
@ultimat3/manifest drops canonical
|
you imported it |
| 5 |
@ultimat3/render drops matchRoute / RouteMatch
|
you imported either |
| 6 |
SQL_CANCEL projects its columns |
you asserted on that constant's text |
- import { ChannelHub, createSyncNode, LiveQueryRegistry } from '@ultimat3/realtime';
+ import { ChannelHub, createSyncNode, LiveQueryRegistry } from '@ultimat3/realtime/server';Client names — useLive, liveHookFor, LiveClient, the offline queue, rebase, the wire protocol,
cursors — are unchanged on .. A file importing both halves now writes both imports.
Why: the single barrel carried useLive beside openNatsClient, so bun build --target=browser on
an entry importing only the hook failed with "Browser build cannot require() Node.js builtin:
stream/web", out of nats. The island this framework tells you to write could not be bundled.
The two barrels are disjoint — ./server re-exports no client name — so which half a symbol
lives in is checkable rather than conventional. If an import stops resolving, the name moved to
./server; nothing was deleted.
- await store.settle(key, value);
+ await store.settle(key, value, reservation.record.id);reservation is what store.reserve(key, hash) answered. Same shape for fail.
Read this if you implement the interface — it is the one silent entry in this major. A store with
the old two-parameter method still compiles, because a shorter function is assignable to a longer
signature, and it silently loses the fence. Both statements now match on id and state, so a
straggler from a slow first attempt can no longer overwrite a replacement reservation still in
flight. The fail half was the worse one: a straggler's failure marked a live replacement
failed, and the replacement's own settle was then fenced out.
- pwa: { enabled: true, offline: 'runtime', installPrompt: true },
+ pwa: { enabled: true, offline: 'runtime' },
- auth: { signInPath: '/signin', afterSignInPath: '/dashboard' },
+ auth: { signInPath: '/signin' },
- ai: { mcp: { expose: true, path: '/mcp' }, modelEnv: 'ANTHROPIC_MODEL' },
+ ai: { mcp: { expose: true, path: '/mcp' } },There is no replacement key, because there was never a behaviour. Each was declared, defaulted,
merged, and read by nothing. Use createInstallController from @ultimat3/pwa, send the visitor
from your own sign-in route, and pass model on the llm() request.
ai.modelEnv's own doc comment argued for its deletion: "an intention, not a behaviour… nothing
consumes the merged value… So the exact thing this key exists to prevent — a model string baked into
the image — is what actually happens."
Same precedent as JobsConfig.driver in 5.0.0 and realtime.heartbeatMs in 4.0.0. All three fail at
typecheck only — and an app that builds its config into a variable before passing it loses
excess-property checking and sees no error at all. scripts/config-readers.ts now keeps the class out.
Use canonicalJson from @ultimat3/core. It was the third of five copies of one serialiser;
manifest's fed buildId and the contract-diff equality, so a -0/NaN/Date fold could make
a breaking API change diff as "no change" and ship silently.
Two exported route matchers existed with different precedence. @ultimat3/http's trie is the live
one; render's had zero consumers repo-wide.
It fed toJobRecord, which does Number(row.run_at) — so against a text-decoding PgExecutor every
timestamp came back NaN. Only an edit if you asserted on the constant's SQL text.
Four breaking entries, and only one of them can reach you at runtime. Three are compile errors the moment you upgrade; the fourth is a type you may never have named. None ships a codemod.
| # | Surface | Costs you an edit if |
|---|---|---|
| 1 | ScrapeTarget.pageErrors |
you implement ScrapeDriver/ScrapeTarget yourself |
| 2 | PwaRenderMode |
you import that type name |
| 3 | PwaOfflineStrategy |
you import that type name |
| 4 | PrerenderReport.skipped |
you read x build --target static --json, or the report in code |
Only a third-party driver author pays this, and nothing in an ordinary app implements
ScrapeTarget. If you build one — the shape packages/scraping/README.md's driver-author example
builds — construct the ring and, if your transport can observe uncaught page exceptions, push to it.
+ import { createRing, type PageErrorRing } from '@ultimat3/scraping';
const target: ScrapeTarget = {
// …
+ pageErrors: createRing(200),
};A driver that cannot observe them builds the ring and never pushes — which is exactly what the offline targets do. That is the whole migration.
Required rather than optional, deliberately. An optional ring lets a driver stay silent about
errors it can see, which is the gap this closed: nothing in @ultimat3/scraping subscribed to
pageerror at all, so an island that threw was invisible. A throw calls no console method, so
console() answered [] and a page whose script had died read as clean.
New on ScrapePage, and additive — no edit needed to consume them: pageErrors() and
pageErrorsDropped(). The dropped count makes the list a floor, not a total.
Two type-only renames. No member changed — only the name the type is declared under.
| Was | Is | Members, unchanged |
|---|---|---|
PwaRenderMode |
RenderMode |
'static' | 'isr' | 'ssr' | 'stream' |
PwaOfflineStrategy |
OfflineStrategy |
'precache' | 'runtime' | 'network-only' |
- import type { PwaRenderMode, PwaOfflineStrategy } from '@ultimat3/pwa';
+ import type { RenderMode, OfflineStrategy } from '@ultimat3/pwa';@ultimat3/pwa re-exports both under the canonical name, so the import path does not have to move —
@ultimat3/core is where they are declared and is equally correct.
Why the alias existed and why it could not stay. Tier 4 may not import tier 4, so @ultimat3/pwa
wrote its own copy of a set @ultimat3/render already had. That copy is what kept spa mapped to
cache-first after spa was deleted in 6.0.0 — the one strategy that gives an app/ route a
shared cache entry, i.e. one signed-in member's HTML served to the next. The vocabulary is now
declared once at tier 0, and bun run scripts/render-modes.ts --json refuses a second declaration
anywhere in packages/*/src.
readonly string[] → readonly SkippedRoute[], where a SkippedRoute is
{ route, surface, render, reason, why }. PrerenderedPage also gains route, the declared path a
concrete URL came from.
- for (const path of report.skipped) console.log(`skipped ${path}`);
+ for (const skipped of report.skipped) console.log(`skipped ${skipped.route}: ${skipped.why}`);x build --target static --json now returns emitted and skipped, and the human path prints the
same rows.
Why it changed. .x/static/ held a partial site and said nothing about the difference: app/
routes exist only through the server, so a tool pointed at the directory filed "the island did not
mount" against a route that was never emitted. A list of paths cannot distinguish "not emitted
because it needs a server" from "not emitted because it is broken", and those are opposite facts.
Installable As of 2026-08-21 — npm view @ultimat3/core version answers 7.0.0, so 6.0.0 is behind latest and every entry below is a step you take on the way to it. Run that command anyway rather than trusting this line; a version written into a page goes stale on the next tag.
Seven breaking entries, and the first is a runtime refusal with no compile error in front of it.
Every single-label timezone name except UTC is refused. isValidTimeZone answers false, canonicalTimeZone answers undefined, assertTimeZone throws X_TIMEZONE_INVALID — and every @ultimat3/time formatter is downstream of that one call. 43 names change answer, tabulated once under the 6.0.0 section of CHANGELOG.md; that table is the source and is deliberately not copied here.
- formatDate(at, { locale, zone: 'CET' })
+ formatDate(at, { locale, zone: 'Europe/Paris' })| Class | Names | Replacement |
|---|---|---|
| geographic link — 24 of the 43 |
Japan, GB, Hongkong, NZ, … |
the Area/Location spelling: Asia/Tokyo, Europe/London, Asia/Hong_Kong, Pacific/Auckland. Textual — identical wall clock, identical offset |
| UTC alias |
UCT, Universal, Zulu
|
UTC |
the GMT family |
GMT, GMT0, GMT+0, GMT-0, Greenwich
|
Etc/GMT, which still renders the label GMT. Not UTC, which renders UTC — same instant, different text on any surface that prints the zone name |
| abbreviation |
CET, EET, MET, WET, EST, MST, HST, EST5EDT, CST6CDT, MST7MDT, PST8PDT
|
none, and that is the defect. An abbreviation names no jurisdiction and carries no DST rule, so only the author knows which city's clock was meant: Europe/Paris for CET, America/New_York for EST5EDT, America/Phoenix for MST
|
Etc/GMT+2 is unaffected — only a leading sign is a bare offset, and that + sits inside a real zone name. US/Eastern and Asia/Calcutta are unaffected too: a deprecated two-label alias is still Area/Location.
TimeZone is string in @ultimat3/time, so 'CET' compiles. Nothing fails until the call runs.
| Site | Spelling | At 6.0.0 |
|---|---|---|
| a formatter, or zone arithmetic |
zone: on formatDate, formatDateTime, formatRange, zonePartsAt, … |
throws X_TIMEZONE_INVALID on the first call |
| a scheduled task |
tz: on task()
|
refused where the task is declared — task() validates through isValidTimeZone, so this one is caught at boot |
app.config.ts |
defaultTimeZone |
refused at boot — defineConfig validates through core's own statement of the structural rule, so a stale key is X_CONFIG_INVALID naming the field, with the swap in its fix:
|
a client's x-timezone header |
any of the 43 | no error — resolveTimeZone falls through to the configured default, so a hand-written client sending CET silently renders in your default zone. Browsers are unaffected: Intl.DateTimeFormat().resolvedOptions().timeZone is always Area/Location
|
Find every candidate:
grep -rnE "(zone|tz|defaultTimeZone): *'[^/']+'" --include='*.ts' --include='*.tsx' .Run it from the app root. Every hit is a single-label zone; 'UTC' is the only one already correct.
Intl answers "can I format this", never "is this an IANA zone", and at ICU 78 the two stopped agreeing: Bun 1.4 resolves CET, EST, GMT and MST where ICU 75 threw. A runtime upgrade alone therefore reopened the "no date without an explicit IANA zone" rule — silently, and in the direction that fails dangerous, because an abbreviation carries no DST rule. The judgement is now structural instead of delegated: an identifier is Area/Location, and UTC is the one legal exception. That refuses the single-label backward links along with the abbreviations, and is meant to — no structural rule keeps CET out while letting Japan in, both being one label, and the alternative is a denylist that grows with every tzdata release. #251, and Timezones and dates for the rule it restores.
| Fix | What changes for you |
|---|---|
island JSX compiles through babel-preset-solid
|
client-side Solid reactivity inside an island works at all. An island containing JSX compiled to React.createElement and threw ReferenceError: React is not defined on first interaction, with the gate green. Two build-time dependencies join @ultimat3/cli; zero bytes reach your client bundle (#243) |
@ultimat3/core loads in a browser bundle |
core's three module-scope AsyncLocalStorage constructions — the request context, the active span, the impersonation reason — moved onto one lazy seam, so @ultimat3/ui no longer throws TypeError: undefined is not a constructor at module evaluation (#244). Six more constructions outside core were untouched at 6.0.0 and carry the same defect — @ultimat3/db, @ultimat3/entity, @ultimat3/ai; they are [Unreleased], along with the guard that makes the rule a build error (#255) |
Rebuild to pick either up.
Two breaking entries over six surfaces, one of which needs an edit. There is no codemod, and there does not need to be: the whole migration is deleting one line, and only if you wrote it.
jobs: {
- driver: 'postgres',
queues: ['app-default'],
concurrency: 8,
},jobs.driver accepted 'postgres' | 'redis' | 'nats' and had no reader anywhere. Boot always
built createPgDriver, so setting it to redis did not throw, did not warn and did not boot Redis
— it changed nothing and you silently got Postgres. If you were relying on it doing something, it
was not: you were on Postgres the whole time.
Which driver runs is setJobDriver(driver), and only that:
setJobDriver(createPgDriver({ executor })) // production
setJobDriver(createMemoryDriver()) // a testJobsDriver (the type) goes with it. JobsConfig.driver was its only use.
Leaving the line in also works. A spread carries a key no type names, so a stale
app.config.ts still boots and the field still does nothing — packages/core/src/config.test.ts
pins exactly that. TypeScript will flag it; the runtime will not.
They are @ultimat3/testing's subscribe fixture, which was declared and had no driver — so
nothing could have been implementing these types. They changed because they described an API that
could not work: LiveTarget was { name, queryHash }, and a node keys a subscription by
(name, input); a hash is the input already thrown away.
| Was | Is |
|---|---|
Subscribe = (target) => Promise<LiveFeed> |
(target, input, actor?) => Promise<LiveFeed> |
LiveTarget = { name, queryHash } |
{ name } — the query itself |
LiveFeed had no reconnect()
|
it has one |
DRIVER_FIXTURE_NAMES held subscribe
|
FRAMEWORK_FIXTURE_NAMES does; the framework builds it |
A test that destructured subscribe and called it now reads:
-const feed = await subscribe(liveFeed.as(actorFor(ada), { orgId: acme.id }));
+const feed = await subscribe(liveFeed, { orgId: acme.id }, actorFor(ada));The actor is the third argument rather than baked into the target because that is where the framework puts it: the shared window is built with no subject, and every decision about an actor is per subscriber.
Error fields are escaped where they are built. UltimateError and SchemaError run
singleLine over code, title, cause, fix and docs in their constructors, so .message,
.cause, format(), toJSON() and any renderer you write are one line by construction. Measured
over every shipped cause:/fix: literal: none contains a newline, so no framework message
changed. If you build error text from a value a CALLER controls, you no longer have to remember —
and if you were already escaping, singleLine is idempotent, so nothing doubles.
One fix: line changed text. X_REPLICATION_FAILED on SQLSTATE 42704 said
x db replication init, which is not a command — x db takes gen, migrate, reset, seed,
studio, branch, backfill. It now names the CREATE PUBLICATION an operator can paste.
Not a change in this release — a defect it made visible. If a query({ live: true }) declares an
orderBy on a column its rows do not carry (a projection that omits it), every change to a row
reads as a move, and the re-delivered row is the raw entity row rather than the projection. Columns
you left out of the projection reach the subscriber. #230,
and Known gaps carries it. Until it is fixed, order a live query by a column its rows
carry.
Twenty-five BREAKING — entries. Most are one of two shapes: a declaration nothing read, deleted rather than implemented, and a surface that answered the wrong thing, corrected. Full rationale per row in CHANGELOG.md's 4.0.0 section.
Start here — these three change behaviour whether or not you edit anything:
| Surface | The edit |
|---|---|
on delete now reaches the generated SQL. Any app that ever declared references(…, { onDelete }) generates different DDL
|
run x db gen and read the diff before migrating. Every add constraint this framework had ever emitted dropped the rule, so the database has been refusing deletes under a declared cascade. Drift also gains changed-foreign-key, whose fix: hands over a drop constraint / add constraint pair — add constraint alone is 42710 on a name already taken |
llm()'s cache.semantic.scope receives { input, ctx } and defaults to the calling actor, not 'global'
|
scope: (input) => input.orgId → scope: ({ ctx }) => ctx.actor.orgId ?? 'none', or delete scope and take the default. A semantic lookup is a cosine nearest-neighbour with no tenant predicate, so the old shared store answered one tenant with another tenant's completion — reproduced at similarity 1.0. A deliberately shared cache must now say so |
reapBranches() skips branches whose base is not current_database()
|
none, and re-read it if you run two Ultimate apps on one Postgres: listBranches() walks pg_database for the whole server, so one nightly sweep was dropping the other app's branches. A pre-4.0 marker records no base and is now skipped rather than dropped; the next createBranch writes it down, so it self-heals with no migration |
Deleted because nothing read them — in every case the edit is "delete the option":
| Surface | The edit |
|---|---|
CaptureOptions.timeoutMs and CaptureRequest.timeout (@ultimat3/scraping) |
delete them. The port required a timeout, page-over-target.ts threaded it, and no driver honoured it
|
ScrapeTarget.click's index parameter |
delete it. It was unreachable from the public vocabulary — ScrapeFrame.click takes (selector, options?) and has no index — and the two drivers disagreed on it |
PrecacheAsset.critical (@ultimat3/pwa) |
delete it. buildPrecacheManifest never copied it, and the documented promise ("critical assets are precached even if large") was vacuous — there is no size filter at all |
PERIODIC_SYNC_TAG, BackgroundSyncOptions.periodicMinIntervalMs (@ultimat3/pwa) |
delete them. Periodic Background Sync was never implemented in any sense: no listener, no registration, no capability flag |
realtime.heartbeatMs (RealtimeConfig) |
delete the key — RealtimeConfig is now { enabled, tier, transport, urlEnv }. The socket beat is new LiveClient({ heartbeatMs }) (browser code, which cannot read server config) and the presence beat is derived. There is no runtime refusal: section() copies unknown keys through, so a stale key is silently inert |
@ultimat3/seo no longer exports extensionOf
|
delete the import; parseImageQuery reads the format off the query |
@ultimat3/realtime no longer exports qidOf or canonicalJson
|
change the import: queryHash from @ultimat3/query, canonicalJson/fingerprint from @ultimat3/core. No live subscription re-keys — the two spellings differed only on values JSON cannot carry |
Corrected, because they answered the wrong thing:
| Surface | The edit |
|---|---|
adminResource no longer pluralises an entity name |
set path: explicitly if you relied on the doubled URL. Every entity in both tracked apps is already named plural, so entity('orgs') was served at /admin/orgses. Which plural a name takes is an app's convention, not a mechanism the framework can own (axiom 8) |
| A local disk's signed URLs carry the registered disk name, not the driver kind | none, if you use defineStorage — it calls registerAs(diskName) at boot. A disk registered as uploads used to 404 every signature it had just written |
ordinal(value) takes no locale |
delete the second argument. It picked the plural category with your locale and appended the English suffix regardless, so ordinal(1, 'de') was '1th'
|
registerFrameworkCatalog() and registerMailCatalog() take no locale
|
delete the argument. defineCatalogs called them once per locale, seating the English-only catalog under every locale an app declared — an app shipping only es served English chrome with isMiss reading false, which is a fallback locale chain the i18n package forbids by name |
t.date refuses a date-time with no offset and no Z
|
send 2026-08-19T10:00:00Z. 2026-08-19T10:00 resolved against the host process's zone, so one wire value meant a different instant on each pod — reachable from a request through coerceQuery, and published as format: 'date-time', which RFC 3339 requires an offset for |
in with a non-array operand matches no rows on both drivers |
pass an array. It matched one row in Postgres (the scalar was wrapped) and none in memory; in with a NULL in the list disagreed in the other direction, and the SQL now emits (col in (…) or col is null)
|
isValidCron / parseCron refuse an unsatisfiable day/month pair ('0 0 30 2 *') |
fix the expression; the refusal names the pair. It used to parse clean and then burn ~184ms of blocking CPU per tick in the scheduler's leader loop before throwing |
createRateLimiter({ now }) → createRateLimiter({ clock })
|
{ config, now: () => t } → { config, clock: { now: () => new Date(t) } }. Callers that passed neither are unaffected |
requiresApp is enforced by the dispatcher |
none, unless a script matched on the old message. Outside an app, x secrets set and its siblings now answer X_NOT_IN_APP
|
NackOptions.countsAsAttempt: false no longer files a job suspended
|
none. "Do not burn an attempt" and "this is a step.sleep suspension" were one flag, so the worker's limiter and job.concurrency sheds pushed rows out of ready — and queue_depth / queue_oldest_ready_seconds under-reported because of it |
A read whose input carries a Date, Map or Set gets a new cache key and cursor scope, once
|
none. Object.keys(date) is [], so every date rendered {} and one key answered for every date window a read ever served. Affected cursors answer X_CURSOR_INVALID once with "request the first page again"; ordinary inputs are byte-identical |
Type-level, for hand-built literals and exhaustive switches:
| Surface | The edit |
|---|---|
ColumnDescription / ReferenceDescription gain onDelete: OnDelete | null
|
add the field to hand-built description literals (a test fixture, a custom generator). null is Postgres' no action and is the old behaviour |
DriftKind gains changed-foreign-key
|
a switch over DriftKind with no default no longer compiles |
BranchInfo gains base: string | null
|
re-type if you built the shape by hand |
| Five generators write typed test filenames | re-run the generator, or rename by hand. x verify selects a suite by filename, so a generated contractTest(…) inside a plain *.test.ts ran under unit while x test contract answered X_TEST_NO_FILES — a step that passed by having nothing to run. x g action/x g mutator now also write <name>.contract.test.ts, x g query --live writes <name>.live.test.ts, and x g job/x g task/x g backfill write <name>.job.test.ts
|
One migration to run: the x_jobs idempotency index gains the tenant. It was (name, idempotency_key) while the row already carried tenant_id. x db migrate applies it.
Ten BREAKING — entries, all from one bug sweep. Each was a documented surface that did nothing, or did the wrong thing; the fix is the edit named beside it. Full rationale per row in CHANGELOG.md's 3.0.0 section.
| Surface | The edit |
|---|---|
defineAuth({ mfa: { required: true } }) — refused at boot (X_CONFIG_INVALID), and AuthMfaPolicy.required narrowed to the literal false
|
delete mfa.required; enforce the requirement in your own enrolment flow. Nothing ever read the flag, so a user who never enrolled got a fully-privileged session under it |
enrolTotp(input) → enrolTotp(auth, input); input.issuer is now optional |
pass the auth you built with defineAuth. The configured issuer never reached the otpauth:// URI before |
@ultimat3/http no longer exports appErrorStatus()
|
read your own registration module. registerErrorStatus() and statusFor() are unchanged |
SyncSocket.lastSeenAt → lastSeenMonotonicMs, on Clock.monotonic()
|
rename the read. If you were formatting it as a date you were already wrong — the rename makes new Date(...) a compile error |
SQL_OUTBOX_RELEASE and SQL_OUTBOX_MARK_PUBLISHED take one more parameter each (1 → 2, 2 → 3): the claimant |
pass the claimant. OutboxStore.release/markPublished take it as an optional trailing argument, so an unfenced store still compiles |
SocketRegistry.sweepIdle() → idle(), which returns the over-budget sockets and removes nothing |
call idle() and evict through the node, or set the budget with createSyncNode({ idleTimeoutMs })
|
DESCRIPTION_MIN_LENGTH deleted from @ultimat3/seo
|
delete the import. There is no replacement and no minimum description length is checked — the constant was documented as enforced and was read by no validator |
A metric redeclared with different bounds or a different observe is refused (X_METRIC_NAME_INVALID) |
make the second declaration state the same bounds/observe, or fetch the handle without options — gauge(name) is unchanged |
Seed.run() resolves with SeedRun instead of void
|
re-type the result if you typed it void. Awaiting it for the side effect alone is unaffected |
SeedContext.insert skips a stored row instead of overwriting it |
expect skipped, not an overwrite. upsert is the verb for a row the table keys |
cachedFormatter and canonicalLocale moved from @ultimat3/time to @ultimat3/core and are re-exported from time, so no import breaks — it is listed here because the move is real, not because it costs an edit.
Thirty-three BREAKING — entries — the largest major this project has shipped, and the first one semver covered. Written up here As of 2026-08; the page carried a row pointing at this section for six releases and never carried the section. Full rationale per entry in CHANGELOG.md's 2.0.0 section — the numbers below are that section's own order. No codemod.
Two things are not compile errors and are the ones to read first: the seven behaviour changes under Start here, and the one migration every app with a money() column owes.
| # | Surface | Costs you an edit if |
|---|---|---|
| 1 |
x db branch takes a verb |
you ever ran the bare-name form, which created a database |
| 2 |
x new writes no migration |
you scaffold a new app, or your app carries a hand-written 0000_initial.sql
|
| 3 | an MCP tool is named by its export name, verbatim | you read a tool name off openapi.json, describe().mcp.tool or .tool().name
|
| 4 |
selectMailDriver refuses with no mail credential |
you send mail from staging or production
|
| 5 | a lapsed fleet slot cannot be renewed by its holder | a job run outlives its slot lease |
| 6 |
@ultimat3/query ships no read-cache seam of its own |
you called setReadCache, invalidateQueryTags, or imported ReadCache
|
| 7 |
@ultimat3/auth drops requireRole / requireScope
|
you gate a route with either |
| 8 |
@ultimat3/db drops readOnly() and its four companions |
you imported any of them |
| 9 |
@ultimat3/seo drops the performance-budget surface |
you imported checkBudgets, parseBytes, a Budget* type, or set RouteRecord.budget
|
| 10 |
@ultimat3/seo drops renderLd
|
you called it |
| 11 | seven zone and locale helpers are gone | you imported attachTimeZone, timeZoneOf, attachLocale, localeOf, negotiateLocale, isValidTimeZone or resolveTimeZone
|
| 12 |
@ultimat3/seo drops renderHeadTags
|
you called it |
| 13 | a derived BudgetLedger bills its parent |
you already sit at an llm() budget ceiling — no signature changed |
| 14 |
idempotencyKeyFor takes the actor, required and third |
you call it, or you hold idempotency records written before the deploy |
| 15 |
Idempotency-Key is enforced at 255 characters |
a client sends a longer key |
| 16 |
@ultimat3/action's fingerprint is SHA-256/16 |
you enqueue an action job across the deploy boundary |
| 17 |
markReady() throws X_LIFECYCLE_DRAINED after a drain |
a test or a process drains and then starts a role |
| 18 | a drain is bounded at 25s, and a hook that outruns it is abandoned | your drain legitimately takes longer |
| 19 |
cacheKeyFor takes a fourth, required authority
|
you call it directly |
| 20 | the query fingerprint is SHA-256/16 hex | you hold cursors minted before the deploy |
| 21 |
semantic.remember refuses a TTL the tiers refuse |
you passed a non-finite or negative lease |
| 22 |
OutboxRelay.stop() returns Promise<void>
|
you await teardown, or implement the interface |
| 23 |
TierFailure.tier is TierLabel
|
you switch over it with no default
|
| 24 |
hello carries no cursors |
you build a hello frame by hand, or read FRAME_LIMITS.resume
|
| 25 | three more @ultimat3/realtime surfaces move |
you call qidOf, read a mutation's status after a drain, or implement SyncNode
|
| 26 | four projection changes — what a value becomes when it leaves the process | you have a money() column (a migration), read schema.nullable, pass an unclonable .default, or take a nested object as query({ input })
|
| 27 |
EPOCH is gone; call epoch()
|
you imported it, or declare a 6-field cron |
| 28 |
job() and backfill() require a tenant
|
you declare any job or backfill |
| 29 | one resolveEnvironment, and it is @ultimat3/core's |
you imported seo's, or wrote 'preview'
|
| 30 | the NATS wire client is nats@2.29.3, behind the same transport seam |
you imported a hand-rolled NATS name, or faked a byte stream in a test |
| 31 |
@ultimat3/cli exports checkSourceDrift, not checkDrift
|
you imported the CLI's |
| 32 |
invariants is a function, and invariant() takes a built expression |
you declare an entity with invariants |
| 33 | the framework's version is a call, not a constant | you imported FRAMEWORK_VERSION, DEFAULT_SERVER_INFO or CLI_VERSION
|
| # | What changes | What you do |
|---|---|---|
| 4 | with neither SMTP_URL nor RESEND_API_KEY, staging and production install a driver that rejects every send with X_MAIL_CREDENTIAL_MISSING. development and test are unchanged, and an app that sends no mail still boots — the refusal is on the send, not at boot |
set one of the two env keys in every environment that sends. The SMTP Message-ID, and so SendResult.id, is now content-derived and stable across attempts of one send |
| 5 |
SQL_LEASE_RENEW fences on expires_at > now() as well as holder, matching the memory store. A run whose slot lapsed is cancelled with X_JOB_SLOT_LOST instead of running on uncapped past job.concurrency
|
nothing, unless a handler holds a slot longer than its lease — raise the lease, or shorten the run. This is what the documented contract already said and what x dev already did |
| 13 | a derived ledger bills its parent, so a call that used to slip past a request ceiling can throw X_AI_BUDGET_EXCEEDED, and gateway.spent() returns a larger — correct — number |
raise the ceiling, or accept the refusal. Listed as breaking because it is observable to an app already at its limit, even though it makes "derive can only tighten" true for the first time |
| 15 |
Idempotency-Key is enforced at 255 characters. The OpenAPI operation published maxLength: 255 all along and nothing checked it |
shorten the key. A client sending longer keys worked by accident and now gets a 400 |
| 17 |
markReady() throws X_LIFECYCLE_DRAINED on a drained lifecycle instead of declining in silence |
call resetLifecycle() between a drain and the next start — which is what three test files were already doing by hand. A process that drains and then starts a role now fails at the mistake rather than binding a socket that answers 503 forever |
| 18 | a drain is bounded at 25s by default and a hook that outruns it is abandoned, not stopped — it is still running when the process exits. drainDeadlineMs() returns a number always, and remainingBudget() is a number rather than number | undefined
|
if your drain legitimately takes longer, say so — and move the pair together, or you have only relocated the kill |
| 21 |
semantic.remember puts its TTL through assertTtl like every other write, with jitterFraction: 0
|
pass a finite, non-negative lease. It used to compute ttlMs itself and hand a tier a value no other write path can produce |
Entry 18's pair, both sides or neither:
configureLifecycle({ deadlineMs: 600_000 }); // and terminationGracePeriodSeconds >= 600jobs and realtime are the two roles that most need a bound and declared none, so before 2.0.0 they drained unbounded — a worker pod holding a long job past terminationGracePeriodSeconds is SIGKILLed by the kubelet mid-statement, which is the failure the deadline exists to prevent.
A money() property is three physical columns, not two: <p>_minor, <p>_currency and the new <p>_scale. Every existing app needs a migration — without the column, every read of that table names a column it does not have.
alter table "<t>" add column "<p>_scale" integer check (<p>_scale is null or (<p>_scale >= 0 and <p>_scale <= 15));Byte-for-byte what generateMigration's columnClause emits. NULL is the right value for every existing row: it means the currency's own minor unit, which is what those rows always meant, where 0 would mean whole units. examples/dummy/packages/db/migrations/0002_money_scale.sql is the worked example, hand-written because x db gen answers X_MIGRATION_SNAPSHOT_MISSING in an app whose 0001 records no snapshot.
The other three projections in the same entry:
| Was | Now |
|---|---|
t.nullable(x) emitted { …converted, nullable: true }
|
{ anyOf: [<converted>, { type: 'null' }], …annotations }. nullable is an OpenAPI 3.0 keyword no later draft defines, so every validating consumer rejected null. A hand-written consumer reading schema.nullable reads schema.anyOf instead |
.default(value) accepted any value |
a default structuredClone refuses — a function, a class instance, a Proxy — throws X_SCHEMA_DEFAULT_UNSHAREABLE at the first import of the file that declares it. Pass a plain value, or a factory the handler calls |
query({ input }) accepted any schema |
an input that cannot survive a query string is refused at query() with X_QUERY_INPUT_UNENCODABLE, in the declaring file. A read is GET /_x/query/<name>, so its input is characters: flatten the nested object, or make it an action
|
No edit for most apps, and each is a one-time cost worth knowing before it is a support ticket.
| # | What goes cold, or re-runs | Why, and what to do |
|---|---|---|
| 6 | a cached query is cold once |
@ultimat3/query no longer ships its own read-cache seam. Removed: setReadCache, getReadCache, invalidateQueryTags, MemoryReadCache, DEFAULT_READ_CACHE_MAX_BYTES, and the types ReadCache and ReadCacheEntry; DEFAULT_READ_CACHE_TTL_MS stays. A Redis deployment's read path changes in both directions — it was the Redis tier alone, so every cached read was a network round trip; it is now read-down/promote-up across request-memo → lru → redis, and concurrent misses of one key share a single load |
| 14 | an in-flight idempotency record is unreachable | the stored key's shape changed with the signature, so on the shared Postgres store a retry crossing the deploy boundary finds no record and re-runs the handler, inside the 24h window. truncate x_idempotency after deploying makes that state honest rather than half-reachable. The memory store dies with its process and is unaffected |
| 16 | an action job does not dedupe against its pre-deploy row |
@ultimat3/action's fingerprint is SHA-256/16, so job-handle.ts's dedupe key action:<name>:<fingerprint> changed. Action idempotency itself is unaffected in practice, because the key changed too |
| 20 | a cursor minted before the deploy is rejected once | the query fingerprint is SHA-256/16 hex where it was FNV-1a/32 — 4×10⁹ values, brute-forceable offline in seconds, and a fingerprint here is a sharing key over client-chosen input. The canonical form is unchanged, so only the hash moved; X_CURSOR_INVALID's fix: is already request the first page again
|
An app that installed its own read cache registers it where every other cached surface already took one:
- setReadCache(myCache);
- invalidateQueryTags(tags);
+ registerTier(myTier); // from @ultimat3/cache
+ invalidateTags(tags); // literally the same callA process that registers no tier reads uncached rather than filling a store no fan-out can see.
x db branch takes a verb. The argument was the branch name and the dispatcher fell through to it, so x db branch ls — the fix: line the planned x branch command hands out — cloned the database into one called ls. A stray database is not a typo an agent can see: it is a copy of production-shaped data with a name nobody will recognise a week later.
- x db branch feat-new-billing
+ x db branch create feat-new-billing| Verb | What it does |
|---|---|
x db branch create <name> |
the old bare-name form, said out loud |
x db branch ls |
name, location, created-at, size |
x db branch drop <name> |
what only dropBranch('<name>', { force: true }) could do before |
Every verb is itself a legal branch name, so verb-first is the only shape where a name cannot be read as a subcommand. A word outside that set is X_CLI_UNKNOWN_COMMAND, and its fix: hands your own word back inside the command that still creates it. drop takes no confirmation flag deliberately — it may only remove what ls shows. branchSql is removed with the psql shell-out it was the text for; an external clone now runs through @ultimat3/db's createBranch(), which is what makes ls work at all — the old path wrote the database and no marker comment, so every branch the CLI made was invisible to the only lister the framework has. Branches created by the old path carry no marker and are listed and dropped by neither.
x new writes no migration: packages/db/migrations/0000_initial.sql and its .hash are gone from the scaffold, and x db gen is that directory's single writer (axiom 1). A hand-written first migration could not carry the .snapshot.json only the generator produces. A scaffold that declares an entity is therefore red on x verify's drift step until the first generate runs, and that is correct behaviour:
x db gen "initial"
x db migratebin/setup runs both for you, generating only when the directory holds no .sql.
snake_case tool names are gone, and so is toToolName. One primitive was reachable under one name and published under another — the served name has only ever been the export name, while three publishers spelled the same tool publish_post. So an agent handed openapi.json called tools/call { name: "publish_post" } and got ToolNotFound: the catalog it was given was the wrong one.
| Was | Now |
|---|---|
publishPost.tool().name → 'publish_post'
|
'publishPost' |
openapi.json → "x-ultimate": { "mcpTool": "publish_post" }
|
"mcpTool": "publishPost" |
publishPost.describe().mcp.tool → 'publish_post'
|
'publishPost' |
import { toToolName } from '@ultimat3/action' / '@ultimat3/query'
|
removed from both — there is no derivation left to call |
Nothing that worked moves: a tools/call, a scopes: entry and a visibleTo list were already spelled verbatim, and a snake_case scopes: entry was already X_MCP_SCOPE_UNKNOWN at boot. What moves is everything read off the published contract — run x manifest to regenerate openapi.json, then re-point any agent prompt, saved tool allowlist, generated client or test that took its tool name from x-ultimate.mcpTool, describe().mcp.tool or .tool().name. x.manifest.json is unaffected: its mcp fact never carried a tool name.
- import { EPOCH } from '@ultimat3/time';
+ import { epoch } from '@ultimat3/time'; // 27 — call it: epoch()
- import { resolveEnvironment } from '@ultimat3/seo';
+ import { resolveEnvironment } from '@ultimat3/core'; // 29
- import { checkDrift } from '@ultimat3/cli';
+ import { checkSourceDrift } from '@ultimat3/cli'; // 31 — same signature, same findings
- import { FRAMEWORK_VERSION } from '@ultimat3/core';
+ import { frameworkVersion } from '@ultimat3/core'; // 33 — call it: frameworkVersion()| # | Why the spelling had to move |
|---|---|
| 27 |
EPOCH was one shared mutable Date exported from a tier-1 package, so any consumer calling EPOCH.setUTCFullYear(...) corrupted it for every other consumer in the process, permanently and silently. A Date cannot be frozen — Object.freeze does not close setTime — so it could not be fixed in place. instant() also returned the caller's own object and now does not, and describeCron refuses a 6-field expression with X_CRON_NOT_DESCRIBABLE where it used to return a wrong sentence |
| 29 | the name existed in @ultimat3/core and @ultimat3/seo with different parameters and different return unions — the axiom-1 violation the 1.1.0 notes named and deferred. Core's takes an options object, resolveEnvironment({ env }), and throws X_ENVIRONMENT_INVALID on a typo'd ULTIMATE_ENV; tryResolveEnvironment() is the caller that must answer rather than fail |
| 31 | two functions named checkDrift answered two different questions. @ultimat3/db's keeps its name and its meaning — the live database against the ledger. The CLI's is the entity source hashed against what x db gen recorded, no database. Nothing an app writes calls either |
| 33 | read at module scope, the version resolved before main in every process that imported core, so x build --target binary produced an executable that threw at import. @ultimat3/mcp's DEFAULT_SERVER_INFO becomes defaultServerInfo() and @ultimat3/cli's CLI_VERSION becomes cliVersion() for the same reason — a constant holding the result is the module-scope read again, one import away |
Entry 29 also renames one environment across seo's surface. isIndexable() and RobotsConfig.environment take core's Environment, so 'staging' is accepted and 'preview' is a compile error; no robots.txt body changes, because neither spelling was ever indexable and only the # environment: comment line moves.
- buildRobots({ environment: 'preview' })
+ buildRobots({ environment: 'staging' })
- import type { SeoEnvironment } from '@ultimat3/seo';
+ import type { Environment } from '@ultimat3/core';@ultimat3/realtime hand-rolled the protocol — framing, parser, PING/PONG, TLS upgrade, inbox muxing and reconnect, 1,019 LOC plus a 431-line fake nats-server to test it. All of it is deleted, on docs/idea/18-build-vs-wrap.md's criterion: own what must join the transaction, context and error machinery; wrap a wire protocol with a dominant maintained client, because an agent knows that client's semantics from training and can never know a reimplementation. nats is the first external runtime dependency any @ultimat3/* package has taken, pinned exact, importable from exactly one file.
Transport, NatsTransport, NatsTransportOptions and selectTransport are the same seam and cost no edit. The test seam moved up one level, from an injected byte stream to an injected client:
- new NatsTransport({ url, bucket, open: (target) => Promise.resolve(stream) });
+ new NatsTransport({ url, bucket, connect: fakeNatsConnect(broker) });| Direction | Names |
|---|---|
| removed |
NatsConnection, NatsConnectionOptions, NatsConnectOptions, NatsProtocolParser, NatsOperation, NatsServerInfo, NatsStream, natsStreamOver, bunNatsStream, FakeNatsServer, fakeNatsStream
|
| added |
NatsClient, NatsConnect, NatsClientOptions, NatsRequestOptions, NatsRequestManyOptions, openNatsClient, FakeNatsBroker, fakeNatsConnect
|
unchanged, moved to nats-client.ts
|
NatsHeaders, NatsMessage, NatsMessageHandler, NatsSubscription, NatsTarget, parseNatsUrl
|
The JetStream KV layer stays ours: this client's KV abstraction expresses neither per-message TTL nor a batch multi_last direct get.
Every one had zero callers in the framework and in both tracked apps. In each case the edit is delete the import, and the replacement — where there is one — is named beside it.
| # | Gone | Instead |
|---|---|---|
| 7 |
requireRole / requireScope (@ultimat3/auth) |
declare the rule as a Policy — can('admin:access'). They decided a 403 outside @ultimat3/policy, so a route gated that way reported policy: null in x routes, in framework.manifest.json and in openapi.json, and x policy list reported its permission unenforced. requireActor / currentActor stay — those assert authentication
|
| 8 |
readOnly(), assertReadOnly(), inspectStatement(), MutationVerdict, ReadOnlyOptions, readonlyViolation() (@ultimat3/db); X_READONLY_VIOLATION is retired |
readOnly(db()).query(f) → readOnlyQuery(text, { role: await ensureReadOnlyRole() }), which reports which defences engaged. The deleted lexer judged statement keywords and nothing else, so select pg_sleep(60) and select pg_read_file('/etc/passwd') both read as reads |
| 9 |
checkBudgets, assertBudgets, parseBytes, DEFAULT_BUDGET, BUDGET_UNITS, the four Budget* types, budgetExceeded(), RouteBudget, RouteRecord.budget (@ultimat3/seo); X_SEO_BUDGET_EXCEEDED is retired |
nothing to call — the gate that runs is @ultimat3/cli's, raising @ultimat3/render's X_BUDGET_EXCEEDED. seo is tier 1 and cannot see a build's bytes, so it was never the package that could answer. The retired code's row moves under Reserved codes so an old log line still resolves |
| 10 |
renderLd (@ultimat3/seo) |
ld.* and meta.ld — renderMeta already emits one <script type="application/ld+json"> per node, and an app calling both emitted its graph twice |
| 11 |
attachTimeZone, timeZoneOf (@ultimat3/time), attachLocale, localeOf (@ultimat3/i18n), negotiateLocale, isValidTimeZone, resolveTimeZone (@ultimat3/http) |
write the zone with createContext({ tz }) or withChildContext({ tz }), read it with currentTimeZone(), and take the other three from the packages that own them. HttpConfig.locale and HttpConfig.tz hold header and cookie names only |
| 12 |
renderHeadTags (@ultimat3/seo) |
renderHead(headFromMeta(meta, seoRenderers())). It escaped </ and nothing else and had no caller, while renderHead — the path every x dev and every build takes — escaped nothing at all: two serializers, the unused one weaker and the used one vulnerable. It could not borrow render's escapers, because xml.ts escapes into entities, which is right for XML and exactly wrong inside a raw-text element |
| 24 |
HelloFrame.resume and FRAME_LIMITS.resume
|
drop the key. A cursor rides its own subscribe frame, which is where resume was always decided — the node replied resume: [] and read the field from nobody, so every reconnect shipped each cursor twice, up to 512 ids per subscription, during the exact restart storm the herd bound exists to flatten |
PROTOCOL_VERSION was deliberately not bumped for entry 24: decode builds a whitelist, so a new node drops an old client's resume and an old node reads a new client's omission as the empty list it always received. Both skews are readable, and bumping would refuse every in-flight client on a rolling deploy to buy nothing — the version guards incompatibility, not novelty.
Entry 11 also brings a stricter zone rule with it: CET, EST5EDT, +01:00 and '' are refused, and a resolved zone comes back canonically spelled, so one zone is one formatter-cache key. The supported locale set and fallback are defineCatalogs({ locales, default }); the fallback zone is configureTime({ defaultZone }). TimeZoneSources gains cookie, and the default order is user, cookie, query, header — explicit before inferred.
- cacheKeyFor(name, input, tags)
+ cacheKeyFor(name, input, tags, readAuthority(ctx.actor, 'actor'))readAuthority(actor, scope) is the only thing that produces the value, and 'actor' keeps 1.2.0 behaviour for a per-caller read. The forgotten authority is a cross-tenant read, which is why it is positional and required rather than an option with a default. Entry 14 is the same argument on idempotencyKeyFor(name, input, actor), where the forgotten one is a cross-actor replay.
Entry 28 puts one new line on every job() and every backfill():
export const notifySubscribers = job({
input: t.object({ postId: t.uuid, orgId: t.uuid }),
idempotencyKey: ({ postId }) => `notify:${postId}`,
+ tenant: ({ orgId }) => orgId,
retry: { attempts: 5, backoff: 'exponential' },
async run({ input, ctx }) { /* … */ },
});A definition with no tenant is X_JOB_TENANT_REQUIRED at declaration. tenant: 'none' is the other legal answer and means the opposite thing on each side of the factory: on a job() it declares the body touches no tenant-scoped table, because every scoped read then fails closed with X_TENANCY_ACTOR_ORG_REQUIRED; on a backfill() — which forwards tenant verbatim — it is how a sweep declares it spans every tenant, and backfillPass opens the bounded crossTenant scope for it, never the author.
In the same slice, on the read primitive, a bare boolean policy bypass gains a reason:
- sourceFor(target, input, { ctx, enforce: false })
+ sourceFor(target, input, { ctx, unenforced: 'explain returns no rows' })The reason is required, a blank one is refused before the source is built, and one query.policy.unenforced audit line is written at debug.
| # | Was | Now |
|---|---|---|
| 22 |
OutboxRelay.stop() returned void
|
Promise<void>. It cleared the timer and returned underneath the pass in flight, so a role shutdown that awaited it resumed while a publish and its markPublished were still running — a torn write against a closing pool. Callers ignoring the return value keep compiling and keep the old race |
| 23 |
TierFailure.tier was TierName
|
TierLabel = TierName | 'query-read', because @ultimat3/query's read tier degrades through the same bestEffort wrapper and had nowhere to report as. A switch over it needs a 'query-read' arm |
| 25 |
qidOf(name, input) was <name>:<fnv1a 32-bit>
|
<name>:<first 16 hex of SHA-256>. A qid is a sharing key — a hit hands back the seated window, carrying the first subscriber's input and rows — and input is client-chosen, so 32 bits is a collision found offline in seconds and one client served out of another's window. A rolling deploy costs one bounded snapshot per subscription |
| 25 |
queue.drain(send) marked each mutation acked when send resolved |
a drained mutation stays inflight until the server settles it with ack/fail or requeueInflight() returns it. DrainReport.remaining is now what is still sendable; a UI rendering unsynced should read pending(), which is unchanged and still counts both |
| 25 |
SyncNode had one teardown, stop()
|
it also declares stopAccepting(), called by the SIGTERM accept phase — additive for a createSyncNode caller, breaking for anything implementing the interface structurally. SyncNode.websocket no longer carries publishToSelf
|
| 32 | invariants: [ invariant(name, (c) => …) ] |
invariants: (c) => [ invariant(name, …) ] — see the diff below |
SyncSocket.subscribeTopic / unsubscribeTopic no longer call Bun's ws.subscribe / ws.unsubscribe either: every channel message is one filtered send per socket through SocketRegistry.deliver, because a native publish cannot be refused per socket, cannot report the frame it dropped and cannot mark a subscriber desynced.
Entry 32 is mechanical — move the [ to after (c) => , drop each (c) => inside invariant(), drop every !:
- invariants: [
- invariant('post_title_not_blank', (c) => c.title!.trimmed().minLength(1)),
- invariant('post_price_non_negative', (c) => c.price!.minor.atLeast(0)),
- ],
+ invariants: (c) => [
+ invariant('post_title_not_blank', c.title.trimmed().minLength(1)),
+ invariant('post_price_non_negative', c.price.minor.atLeast(0)),
+ ],The defect it fixes is why every generated entity needed a !: InvariantColumns was an index-signature type, so under noUncheckedIndexedAccess every c.title was ColumnExpr | undefined. It is now a mapped type over the declared columns, so c.title is a ColumnExpr and c.titel is TS2551: Property 'titel' does not exist … Did you mean 'title'?. unique() and satisfies() take keyof C & string, so a typo in a column list is caught too. indexes[].where is unchanged — it was already a callback, and its c is now typed too.
| Surface | From |
|---|---|
X_* error codes |
already stable forever — a shipped code never changes meaning and is never reused |
| The eight primitive shapes |
entity, policy, action, mutator, query, job, route, task and their declared fields — 1.0.0 |
The x CLI surface |
commands, flags, exit codes, and --json output shape — 1.0.0 |
| The import tier table | which package may import which — 1.0.0 |
app.config.ts field names |
renaming or removing a field is a major — 1.0.0 |
| Bump | Means | Examples |
|---|---|---|
| major | a covered surface changed incompatibly | a removed config field, a renamed CLI flag, a changed primitive field, a narrowed tier |
| minor | additive, old code still compiles and still passes x verify
|
a new optional field, a new command, a new driver behind an existing interface |
| patch | no surface change | a bug fix, a perf change, a corrected fix: line |
1.0.0 means a stable API under semver. It is not a claim about your infrastructure.
| Rule | Detail |
|---|---|
| Pinned exact versions | no ^, no ~, in the framework or in a generated app. A range is a silent upgrade |
| 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 and x upgrade is not implemented, so every BREAKING — entry names the manual edit itself. A section of this page walks it |
| 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.0, 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, multi-region replication, and the Redis/NATS job drivers — the last throw X_NOT_IMPLEMENTED with a runnable fix: rather than pretending to work |
Do not upgrade a transitive dependency of a @ultimat3/* package by hand. Open an issue instead — the pin is deliberate.
As of 2026-08 this command exits X_NOT_IMPLEMENTED (packages/cli/src/cmd-planned.ts). Its own fix: line names what to run instead, and that is the upgrade path today:
bun update --latest && x verify # the shipped path
Everything the manual path skips, you do yourself: bump every @ultimat3/* pin to one exact version, then x manifest and x verify. There are no codemods to run, because no release has shipped one yet.
The design below is what the command will do, kept here because the classes of breakage it automates are real today and the next section is how each one is detected with or without it.
| # | Step | Detail |
|---|---|---|
| 1 | Resolve the target release | all @ultimat3/* at one version; refuses a partial set |
| 2 | Bump package.json pins |
every workspace, exact versions |
| 3 | Run codemods | per-release, idempotent, AST-based. Each prints the files it touched |
| 4 | Regenerate x.manifest.json
|
routes, entities, actions, jobs, policies, tags, budgets |
| 5 | Regenerate openapi.json
|
HTTP surface from action/query declarations |
| 6 | Run x verify
|
the gate. Not green = the upgrade is not done |
--dry-run, once it exists, performs 1, 3 (in-memory), and reports the diff without writing. Output carries every changed file, every codemod name, and every check that would fail.
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.
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.
There is no jobs.driver field. 5.0.0 deleted it, because it selected nothing: boot always built createPgDriver, so jobs: { driver: 'redis' } gave you Postgres in silence. Which driver runs is setJobDriver(driver) at boot, and only that.
x jobs drain --to takes redis | nats, and neither lands a job: both are interface-complete stubs that throw X_NOT_IMPLEMENTED on the first enqueue, having moved nothing. So there is no driver migration to perform As of 2026-09. Postgres is the source, never a --to value.
memory is refused by name (X_CLI_BAD_FLAG), As of 2026-09. It was a target until then, and it was the one that appeared to work: a Map inside the command's own process, so the drain acked every durable row off the source, reported ok: true, and lost the copy when the command exited. A target that dies with the command is not a migration.
Nothing rehearses the procedure below today. It is written against the interface that already ships and applies unchanged the moment a driver does:
| Order | Step |
|---|---|
| 1 | deploy with the old driver still installed |
| 2 |
x jobs drain --to <driver> --dry-run --json — read the plan; a skipped candidate is a job whose runAt has not arrived, not an error |
| 3 |
x jobs drain --to <driver> — leases the batch off the old queue, copies steps, enqueues, then acks |
| 4 | change the setJobDriver(…) call at boot, x verify, deploy |
| 5 | confirm with x jobs ls --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).
| 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 |
realtime.transport: 'nats', and realtime.urlEnv — the env key name, not a URL (default NATS_URL). There is no realtime.url field |
roll sync and replicator; clients reconnect with server-directed backoff. realtime.transport decides the bus (selectTransport(env, { transport, urlEnv })), As of 2026-09-23 (in the tree, not yet tagged): 'nats' with its variable unset refuses the boot, and so does 'memory' with NATS_URL set (X_CONFIG_INVALID). On 21.x the presence of NATS_URL decided it and the config field was never read (Configuration) |
| 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.
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.
Ultimate — v23.0.0 As of 2026-09. Stable API, semver from here. MIT licensed. What npm serves is npm view @ultimat3/core version, never this line.
This footer is the only page that stamps a version. It renders under every wiki page, so one release bumps one line; a stamp on a second page is 46 hand-copies of one fact, and every one of them goes stale on the next tag.
Repository · Issues · Changelog · llms.txt
Edits to these pages are synced from wiki/ in the repository — change the file there, not the wiki, or the next sync overwrites it.
Start
Tutorials
- 1 · First app
- 2 · First feature
- 3 · Auth and admin
- 4 · Jobs and realtime
- 5 · Deploy free
- 6 · Growing up
Primitives
- The eight primitives
- Building your own base
- Actions
- Entities and migrations
- Policies and authz
- Queries and live queries
- Client data
- Jobs and workflows
- Scheduled tasks
- Routes and render modes
Capabilities
- Realtime
- Caching and invalidation
- Batching and preloading
- N+1 detection
- PWA and offline
- Client navigation
- MCP and AI
- Agents
- Admin dashboard
- Scraping
- Auth
- Notify
- Storage and uploads
- Feature flags
- SEO
- Static assets
Cross-cutting
- I18n
- Theming
- UI components
- Interface rules
- Timezones and dates
- Money
- Resource management
- Migrations and backfills
- Testing
- CI: the gate across parallel jobs
Reference