9.x → 10.0.0, entry by entry
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 |
Start here — the one config edit
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.
Entries 1, 7, 14, 16, 18 and 19 — a compile error the moment you upgrade
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.
Entries 2, 3, 8, 15 and 17 — nothing fails to compile, and a caller can see the difference
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.
Entries 10, 11, 12 and 13 — a refusal of something that was already broken
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 |
Entries 5, 6 and 9 — corrected underneath you
| # | 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 |
Fixed in the same release, and none of it costs an edit
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 |