Skip to content

Known Gaps

github-actions[bot] edited this page Sep 12, 2026 · 39 revisions

Known gaps

Defects and unfinished seams named here rather than left to be discovered. As of 2026-08-23.

A reference manual that hides these is lying to the reader.

Read the section headings first. Open is what is wrong with the code you can install today. Open by decision will not be fixed and says why. Not built yet is unfinished work, not a defect. Closed is history — every row there carries the release that fixed it and the workaround for anyone still pinned below it.

Only the footer stamps the installable version. Resolve it yourself rather than reading it here:

npm view @ultimat3/core version

A row that says fixed on main is fixed in the repository and in no published release; if you are on an earlier version, treat those rows as open and take the workaround. [Unreleased] in CHANGELOG.md is not empty and is the source of truth for what the next release carries — As of 2026-08-23 it holds a served /favicon.ico, X_ERROR_FIX_PATH_MISSING, the server-render live-client fallback with X_LIVE_SERVER_RENDER, and two fixes; none of them was ever a row here. Read that section rather than this sentence: grep -n '^## ' CHANGELOG.md finds its bounds, and it changes on any commit.

Publication is not a gap. All 31 workspaces are on the registry As of 2026-08-20, checked by scripts/registry-audit.ts daily; @ultimat3/scraping was the last never-published package and was bootstrapped by hand at 2.0.0 (PUBLISHING.md step 1). bun add @ultimat3/scraping resolves — check it with npm view @ultimat3/scraping version.

Open

Gap Symptom Work around it by
A deployed fresh scaffold has no session: every policy-protected route is anonymous x new writes apps/web/app/auth/dev-actor.ts, a cookie-named viewer installed in development only, so x dev boots with no warning and /dashboard opens. A container deploy still logs X_CONFIG_INVALID: 7 route(s) declare auth: 'required' and no authenticator is configured until the app issues real sessions — by design: a dev actor in production would be an open door. As of 2026-08-23 run the call the warning prints, at module scope in a file under apps/*/: configureAuthenticator((request) => viewerFor(request.header('cookie'))), resolving to the Actor in apps/web/shared/actor.ts. The sync half is createSyncNode({ authenticate })Policies and authz
SessionInit.proxy is declared on the scrape seam and read by nothing packages/scraping/src/driver.ts:30 declares proxy?: string with the comment "BOTH transports dial through it". Nothing dials through it: runScrape builds the whole SessionInit at scrape-run.ts:103 and passes no proxy key, the CDP driver reads options.proxy — its own launch option, never init.proxy — and the fake and fixture drivers read neither. A seam field that changes no behaviour is the same shape as the robots-read bug closed one file over pass the exit as a driver option, which is the one that works: localBrowser({ proxy }) / remoteBrowser({ proxy }). Read ScrapeSession.proxy to see which exit a session actually took — see Scraping
An offline scrape reads /robots.txt over the real network every fakeBrowser / fixtureBrowser run takes the default robots: 'obey', and the gate reads https://<host>/robots.txt with the platform fetch before the first navigation. Measured As of 2026-08-19 on runScrape against fakeBrowser: one fetch to https://shop.test/robots.txt leaves the process, and the run still reports refused: 0. Under bun test the sealed network does refuse it — and robotsFetcher's catch { return undefined } swallows the refusal, which the gate reads as "no robots.txt", which is allow-everything. So the suite is green either way and neither the egress nor the seal is visible in it. Contradicts SECURITY.md's egress row and packages/scraping/CLAUDE.md's "an offline driver that fell through to the network would make a green suite secretly live" declare the policy in the definition under test — robots: { ignore: 'fixture host, no live origin' } — which returns a gate that reads nothing at all. There is no boolean and no ambient off switch, by design — see Scraping
bun run error-render cannot see a catch (error) binding the gate matches a parameter annotated unknown/any (UNKNOWN_BINDING in scripts/error-render.ts:158). A catch (error) binding is unknown by inference under useUnknownInCatchVariables and carries no annotation, so it matches nothing — which is how String(error) echoing a caller's own request body into a 422 cause: and into the log store stayed green in packages/http/src/request.ts through 3.0.0. That instance is fixed; the blind spot is not review every catch by hand: a caught value reaches a cause:/fix: only through renderThrowable(error) from @ultimat3/core. bun run error-render --json is a floor, never a proof — its own file header lists what it cannot see
The ISR cache key is the pathname, so a query string is ignored packages/cli/src/dev-render.ts:188 calls isr.serve(url.pathname, …) while the render it hands over is given the whole request — data.url is request.url.href. A route whose load reads ?q= therefore renders ?q=bob's document and stores it under /search, and the next caller of ?q=alice is served it. The cross-actor half is closed: render: 'isr' with a policy is refused at definition (X_ROUTE_MODE_INVALID) and RouteContext is { params, url } with no actor on it, so one cached document cannot answer two actors put the varying part in the path — /search/[q] — or take render: 'ssr', which caches nothing. A query string on an isr route is safe only when load ignores it
acceptSignedUpload is reachable from no route the function ships, is exported from @ultimat3/storage and is tested — and nothing mounts it. The only /_storage route the framework serves is GET (packages/cli/src/dev-storage.ts:227), so a signed PUT has no server. Open since #145 mount the PUT yourself, in your app's api/, around the one call — docs/architecture/17-uploads.md carries the handler. Or presign against S3, which never touches this route
x g writes hand-wrapped source instead of formatting what it writes every template is a string literal with its line breaks typed by hand, and no generator runs a formatter. A template edited past 100 columns lands in an app whose lint step is biome check .. This is about x g output, not the scaffold: a fresh x new app lints clean, zero diagnostics across every source file it writes, measured As of 2026-09-11. The import-order half is closed As of 2026-08-23: sortedImports in packages/cli/src/templates/imports.ts orders every emitted import block the way Biome's organizeImports wants, and generate-format.test.ts runs the real Biome over a scaffold named zebra-demo (sorts after ultimat3) so the order cannot regress. Open since #127 for the remaining line-width half run bunx biome check --write over the files the generator just listed. Feasible to close with no new dependency — ExecOptions.stdin already exists on packages/cli/src/exec.ts:25
Nothing pins the documented generator file counts the counts on CLI reference, 1 · First app and docs/architecture/15-adding-a-feature.md are all correct As of 2026-08-19 and no gate reads them — a template added tomorrow makes six documents wrong and nothing red. They were wrong by 11 for one release, which is what this row is. Open since #128 derive rather than quote: x new myapp --dry-run --json | jq '.data.files | length', and x g <kind> <name> --dry-run --json for a generator
The cache fill fence is weaker across nodes than inside one a read-through fill re-checks a fence before it writes, so an invalidation landing mid-load can no longer be overwritten. Two halves of it do not cross a process boundary. Tag busts do cross, at bus speed: receiveInvalidationBroadcast fans out through the same markInvalidated a local bust takes (packages/cache/src/invalidate.ts:189), and startCacheTiers wires the publish and the subscribe over the app's transport — so the hole is the propagation window, not the absence of one. Key busts never cross at all: CacheStack.write and .drop mark { key } locally and the wire form is serializeTags, which carries tags only. Closing the window properly is a shared epoch, a read of it before every load() — an extra round trip on every miss — a CAS, and a wire-format change give a cache: query a TTL you can afford to be wrong for on one pod, or make the shared tier the only cached tier for reads that must not go stale across nodes. Prefer a tag bust over cache.drop(key) on a multi-pod deploy: only the first one is broadcast
invoke()'s cache bust is not transaction-aware an app that wraps invoke() in db.transaction(…) busts cache.invalidates before the commit lands, so a concurrent read repopulates the entry from the pre-commit row and the stale value survives to its TTL. The blocker is not the tier tableaction is tier 3 and db is tier 1, so action → db is an ordinary downward edge. It is that there is no hook to hang the bust on: DbTx (packages/db/src/transaction.ts:13) has no onCommit, so nothing can defer work to the commit boundary. No tracked app does this: neither examples/dummy nor dummy/social-media-clone calls transaction( in apps/ do the transactional work inside the handler, which is already inside the one commit boundary the framework knows about — or drop cache.invalidates from the declaration and call invalidateTags yourself after the transaction returns, so there is one bust and it is after the commit
Live queries need REPLICA IDENTITY FULL, and nothing refuses a database without it narrowed As of 2026-08-19. The code now exists and fires: X_LIVE_REPLICA_IDENTITY is registered, preflight asks the catalog which replicated tables are not on identity f and warns with the exact ALTER TABLE per table (packages/realtime/src/pg-preflight.ts), and ReplicationStreamStats.partialBefore counts every partial-row update and delete at runtime. What is still open is the refusal: it warns rather than throws, deliberately — an app on the default identity would otherwise stop booting — and no x verify step and no generator reads it, so a table added tomorrow gets a log line and no build error. Without FULL, "did this row leave the result set" is decided from a key-only tuple set it per table, in your own migration: ALTER TABLE <t> REPLICA IDENTITY FULL; — the warning names every table that needs it. Watch partialBefore in the stream stats; a non-zero value is deletes already being judged on partial rows
Nothing checks .env.example against the schema assertEnvExample(schema, text) ships and throws X_ENV_EXAMPLE_DRIFT for a declared key the file is missing — and has no shipped caller: no x verify step, no CLI command and no boot hook runs it, As of 2026-08-19 call it from a test of your own — three lines, and it is then a gate step: assertEnvExample(schema, await Bun.file(ENV_EXAMPLE_PATH).text())Configuration
HPAs read <unknown> without a metrics adapter the chart half is done — values.yaml declares metricsPort: 9090, every role but migrate emits the container port, service.yaml publishes it and templates/servicemonitor.yaml ships the scrape target. Two things still stand and neither is the chart's to fix: serviceMonitor.enabled defaults false, because a cluster without the Prometheus operator has no such CRD and helm install fails on an unknown kind; and turning scraped series into the Pods metrics an HPA reads needs a custom-metrics adapter, which is the cluster's set serviceMonitor.enabled: true and install a metrics adapter; until then disable the HPAs and pin replicas. Do not hand-add a metrics container port — the chart emits one and a duplicate is rejected by the API server → Observability
--target binary has never been served from a bare VM the target compiles and boots — x build --target binary passes --define ULTIMATE_FRAMEWORK_VERSION, docker/Dockerfile passes it too, and the image build ends in /out/app --version so a binary that cannot answer fails the build. What is unmeasured is the rest: no scaffolded app has been compiled, copied to a VM with no Bun on it, and served under systemd prove it for your own app before you depend on it — build, scp, run ./app --version, then ROLE=web PORT=3000 ./app and hit /readyz. Report what breaks → Deployment
The documented tier 2 → tier 3 climb, persist: true, is a field nothing accepts — and framework source still calls it a shipped flag query() has never taken a persist key, so pasting the documented climb is a TS2353 excess property at the typecheck step. The only persist in @ultimat3/realtime is a private #persist() inside the offline queue. What makes it a defect rather than an unwritten feature is that the promise is asserted in code an agent reads as authority: packages/realtime/src/sync-protocol.ts:3 says moving a route from tier 2 to tier 3 "is a config flag (persist: true), never a new protocol — that promise is enforced here", and no field, no reader and no test enforces the half about the flag. Same shape as jobs.driver: declared, never wired, and it does not throw. As of 2026-09 build the client with the store instead: store on LiveClientOptions is what gates the optimistic apply (client-mutations.ts, if (store && local && !collapsed)), and new MemoryLocalStore() is the one LocalStore that exists — createOpfsLocalStore() throws X_NOT_IMPLEMENTED. Tab lifetime, not across a reload, so treat durability as absent → Realtime
X_ROUTE_MODE_INVALID's fix line offers two edits and one does not work it reads "wrap the data-dependent part of <file> in <Suspense fallback={…}> or change render to 'ssr'" (packages/render/src/modes.ts:193). Only the second half works: Solid's <Suspense> throws getContextId cannot be used under non-hydrating context under this renderer at any Solid version — the server JSX factory is inert by design and is not a Solid renderer take the second branch, render: 'ssr'. Async data needs no boundary at all: renderToHtml awaits async components and promise children

Open by decision

Each of these is a defect somebody has already argued about, and the reasoning is why it stays.

Gap Why it stays
Drift never compares an index's where text the catalog answers its own rewriting of the expression ((deleted_at IS NULL)) where the snapshot holds the author's spelling (deleted_at is null), so comparing the strings reports drift on a database that is exactly right. Normalising them means shipping an expression parser to compete with the server's. Three of the four parts of an index are compared now — columns, uniqueness, direction, and the predicate's presence — so a partial index recreated as a total one and a desc index rebuilt ascending are both caught (packages/db/src/drift.ts, compareIndexes). x db gen compares the text, where both sides are generated. A predicate rewritten by hand into a different predicate of the same presence is the residue, and it is invisible: regenerate and read the diff
MemoryAdapter does not enforce the email uniqueness Postgres does it is a Map keyed by id, so register() twice at one address silently makes two users under x dev, where the x_users.email unique index raises a driver-level violation in production. Every available fix mints a refusal Postgres does not throw, swapping one divergence for another — and the defensible home, a coded check inside register(), is an account-enumeration oracle, which @ultimat3/auth's whole loginFailed() doctrine exists to avoid. The database is the enforcement and it is the right one. Develop signup flows against Postgres. Email normalisation itself is fixed — all four doors go through one normaliseEmail
A long backfill() retains one step name per batch createStepRunner's claimed Set is the authority for X_STEP_DUPLICATE and is deliberately unbounded: a 5M-row sweep at batch: 250 holds 20,000 short strings (~1–2 MB) for the life of the attempt, released when it ends. MAX_TRACE_NAMES bounds the reported trace, not the membership set. Bounding the set would let a duplicate step name through after 200 batches, and silently replaying a step is the worse failure. Raise batch if the attempt is long enough for the retention to matter

Not built yet

Open Where it stands
Two-platform deploy proof milestone 11, still 🚧. All three build targets, both compose files and the Helm chart ship — and x new now writes the chart too. What is not demonstrated is the demo app on Compose and on Kubernetes from one image with a rolling restart invisible to connected clients
Multi-node realtime the forced-restart benchmarks are measured, on one sync node over InProcessTransport — neither run crossed NATS and neither subscribes to a live query, so no cursor, snapshot or gap-repair path is under test. Fanout, throughput and per-node socket capacity across nodes remain targets → Realtime
x db studio planned. X_DB_STUDIO_FAILED is reserved and not thrown; the command exits X_NOT_IMPLEMENTED pointing at x dev → the /_x db panel. Keeping a bunx drizzle-kit studio shell-out would have meant a second schema engine for one subcommand
Deferred, As of 2026-08 realtime tier 3 local-first, the plugin API, multi-region replication, and the Redis/NATS job drivers. Each sits behind an interface that ships today and throws X_NOT_IMPLEMENTED with a runnable fix: rather than pretending to work

Closed

Historical. Every row is a defect that shipped; the third column is for readers still pinned below the release that fixed it. Upgrading is the fix, and the breaking entries are the cost (Upgrading).

Rows marked main are fixed in the repository and in no published release yet.

Gap Fixed in If you are pinned below that
No test file was typechecked main All 30 package tsconfig.jsons carry "exclude": ["src/**/*.test.ts"] — they still do — so bun run typecheck, a tsc -b, reads none of the 984 test files under packages/*/src and the gate's typecheck step was green over every one. Re-derive both: grep -l 'src/\*\*/\*.test.ts' packages/*/tsconfig.json | wc -l and find packages -path '*/src/*' -name '*.test.ts*' -not -path '*/dist/*' | wc -l. Closed by a second program, not by editing the 30: tsconfig.tests.json compiles the tests with noEmit, scripts/test-typecheck-gate.ts runs it on a per-package ratchet that may only fall, and it rides the gate's manifest step (scripts/verify.ts:247) rather than typecheck, which takes no host findings. It landed at 446 errors over 161 files in 27 packages (measured 2026-08-19) and scripts/lib/test-typecheck-pins.ts now pins 2, with 29 of 30 packages at zero — so a new test compiles the day it is written. The residue is one named defect, both errors in packages/entity/src/pg-driver.test.ts: Repo's full-row write members take the ROW type where money's WRITE type belongs, and two attempted fixes were reverted with evidence rather than silenced — that pins file carries the argument. On any published release: nothing to work around at runtime, the tests run either way. Typecheck one package's tests yourself with bunx tsc --noEmit over a copy of its config with the exclude dropped
A live query re-delivered the raw table row, and mis-ordered a projected window 5.0.1 Two defects with one cause: a ChangeEvent carries the whole TABLE row, and a live query's result set is whatever its sql returned. The leak — every patch forwarded the change row unnarrowed, so a column the projection dropped went out on the socket the moment it CHANGED; examples/dummy's feed projects ten columns and one publish delivered updatedAt, and a column like a salary or a private note would have gone the same way. The per-subscriber gate could not help: it decides whether a ROW is delivered, never which of its columns. The mis-orderingmatch() decided position by comparing the change row against the rows the WINDOW holds, so an orderBy on a column the projection omits measured a real value against nothing: every update read as a move, and an arriving row landed wherever undefined sorted. Now a patch row is narrowed to the columns the query actually returned, and a position the window cannot answer for is a refill — one re-read and a re-snapshot — rather than a guess. A DELETE still patches incrementally, because it decides no position. #230. On 5.0.0 and below: give a live query's rows the key they are ordered by, and do not rely on a projection to withhold a column from a live subscriber
jobs.driver selected no driver 5.0.0 JobsConfig.driver accepted 'postgres' | 'redis' | 'nats' and had no reader anywhere — boot always built createPgDriver, and packages/jobs/src/driver.ts's own header already said so. jobs: { driver: 'redis' } therefore did not boot-and-then-throw as this wiki once claimed: it changed nothing and you silently got Postgres — the same shape as realtime.heartbeatMs, and worse, because it failed silently in the dangerous direction. Five shipped fix: lines named it as the repair for X_NOT_IMPLEMENTED, which is a fix: that is a no-op; those were corrected in 4.1.0 and the field itself is deleted in 5.0.0, along with the JobsDriver type nothing else used. On 4.1.0 and below the field still typechecks and still does nothing — swap the driver with setJobDriver(createPgDriver({ executor })), or setJobDriver(createMemoryDriver()) in a test, and never through app.config.ts
A caller-controlled string could add a line to the 3-line error format 5.0.0 bun run error-render refuses a parameter typed unknown/any; a value already typed string renders without throwing, so nothing objected — while a newline in one writes a second line an operator, a CI log or the dev overlay's <pre> reads as a genuine framework message. Three holes shipped in @ultimat3/auth under a green check, the worst reachable by an unauthenticated stranger with one crafted OIDC token. The first fix escaped at each of the six RENDERERS, which could not hold: six is a number that only goes up, and it covered none of the renderers an app writes. Now UltimateError's and SchemaError's constructors escape code, title, cause, fix and docs — so .message, .cause, format(), toJSON() and any renderer anyone writes are one line by construction, and singleLine() is idempotent so a call site that already escaped is unharmed. On 4.1.0 and below, pass error.cause through singleLine() from @ultimat3/core before you render it yourself
on delete was declared and reached no SQL 4.0.0 references(() => orgs.id, { onDelete: 'cascade' }) type-checked and the rule was dropped one layer below the declaration, so no generated add constraint ever spelled one and a drift check had nothing truthful to compare. Now: ColumnDescription/ReferenceDescription carry onDelete, addForeignKey writes the clause, and a rule changed on either side is changed-foreign-key drift whose fix: is the drop/add pair. On 3.0.0, add the clause to the add constraint the generator emitted, before applying the migration: … references "orgs" ("id") on delete cascade;. Editing it after it applies moves the checksum → X_MIGRATION_CONFLICT
A references() removed from a column emitted nothing 4.0.0 the key stayed on the database, the snapshot beside it recorded foreignKeys: [] — actively denying a constraint the catalog held — and compareForeignKeys judges the declared side, so no check could see it. Now foreignKeyPlan emits the drop constraint in up and the add in down, naming the constraint the previous snapshot recorded rather than the name the generator would have picked. On 3.0.0, write the drop into the next generated migration by hand before applying it: alter table "posts" drop constraint "posts_org_id_fkey";
Drift ignored an index's direction and its predicate's presence 4.0.0 a desc index rebuilt ascending served a feed's newest page off the wrong end, and a partial index recreated as a total one silently widened the constraint — both read ok: true. Both are compared now; asc is normalised to null first, since Postgres stores an ascending index as not-descending. The predicate text is still uncompared and always will be — see Open by decision. On 3.0.0, x db gen compares all five fields, so regenerate and inspect the diff
An unknown flag was refused before a planned command could answer honestly 4.0.0 the parser read flags against the spec first, so x logs tail --follow reported X_CLI_BAD_FLAG instead of X_NOT_IMPLEMENTED. A planned command now answers with its own status whatever flags it was given; a shipped command still refuses an unknown flag, which is correct — x env is shipped and declares --json, --help, --cwd and --verbose, so anything else is X_CLI_BAD_FLAG for the ordinary reason that no such flag exists. On 3.0.0, run the flagless form of a planned command to see the real message
x deploy --method helm threw X_NOT_IMPLEMENTED in a scaffolded app 4.0.0 x new wrote no docker/helm, and the command carried a "does this build implement helm?" branch over a build that implemented it completely. x new now writes the chart — 8 files, Chart.yaml + values.yaml + 6 templates — and the lying branch is deleted, so a missing chart is helm's own error. The framework repo's own docker/helm carries two more templates the scaffold does not (pdb.yaml, servicemonitor.yaml). On 3.0.0, copy docker/helm from the framework repo, or use --method compose
.env.development and .env.production shipped in the image 4.0.0 the scaffold's .dockerignore excluded .env and .env.*.local — neither pattern matches .env.production, which is the file docker-compose.prod.yml's env_file: tells the operator to create. This page said the leak was "harmless as generated"; that was false, and a real docker build proved both files land in a layer. Now **/.env + **/.env.* + !**/.env.example, in the framework's file, both tracked apps' and the one x new writes. On 3.0.0, add those three lines to docker/Dockerfile.dockerignore yourself — and rebuild, because an image already built still carries them
realtime.heartbeatMs was read by nothing 4.0.0, and BREAKING the key sat in RealtimeConfig with a default of 15 000 and no reader anywhere. It is deleted: RealtimeConfig is { enabled, tier, transport, urlEnv }, the socket beat is the client's new LiveClient({ heartbeatMs }) and the presence beat is derived (PresenceRegistry.heartbeatMs is max(1000, floor(ttlMs / 3))). There is no runtime refusalsection() copies every own key of the patch and validate() checks only named fields, so an app that keeps the key keeps it silently. The failure is at typecheck: TS2353, excess property on Input<RealtimeConfig> — and an app that builds its config object into a variable first loses excess-property checking and gets no error at all. On 3.0.0, delete the key; setting it changes no behaviour either way
@ultimat3/action's stableStringify folded -0, NaN and Infinity together 3.0.0 this page asked for work that was already done. The hash canonicalizer was split out and moved down: canonicalJson and fingerprint live in @ultimat3/core (tier 0), are injective, and give NaN, ±Infinity and -0 bare tokens of their own plus tagged forms for Date, Map and Set. requestHash is fingerprint(input), the job dedupe key is action:<name>:<fingerprint>, and @ultimat3/query's copy was deleted rather than fixed. stableStringify survives as the OpenAPI document serializer alone, where emitting valid JSON is the requirement
.job() produced a handle nothing could enqueue 3.0.0 agentJob() in @ultimat3/ai is the shipped bridge — an agent as durable, resumable, budgeted background work, with the queue accepting the handle. Pinned by packages/ai/src/agent-job.test.ts, whose test is named for this gap: "agentJob() produces a handle the queue accepts, where .job() never could"
A branch reaper could drop another app's databases 4.0.0 listBranches() walks pg_database for the whole server, so two Ultimate apps on one Postgres plus one nightly sweep was the other app's branches dropped by a DROP DATABASE nobody asked for. The marker is now <base>:<iso>BranchInfo carries base — and reapBranches skips any branch whose base is not this database. A pre-3.x marker records no base, so it is skipped too, never dropped: self-healing with no migration, because the next createBranch writes the base down. On 3.0.0, run the reaper only against a server this app owns
x db branch ls could not see a branch made before the psql shell-out was removed 2.0.0 the old path issued CREATE DATABASE … TEMPLATE through psql and wrote no comment, so a branch made by a 1.2.0 or earlier CLI is absent from ls — and drop may only remove what ls shows. Nothing back-fills the comment. Drop it by hand from the string the refusal names: psql "$DATABASE_URL" -c 'DROP DATABASE "<source>_branch_<slug>"', where <slug> is the branch name with every character outside [A-Za-z0-9_] replaced by _
MCP tool names were published in snake_case and served verbatim 2.0.0, and BREAKING openapi.json carried "mcpTool": "publish_post" while the only name tools/call accepts is the export name, publishPost — 15 of the 17 mcpTool values in the two tracked apps' committed specs named no served tool. toToolName is deleted from @ultimat3/action and @ultimat3/query, all three publishers spell the export name, and packages/mcp/src/cross-surface.test.ts drives a tools/call with the name OpenAPI published. On 1.2.0, ignore the published value and call the export namex actions list --json prints it as nameMCP and AI
Shared cache tier invalidation DELed keys it never declared in KEYS 2.0.0 it failed on Dragonfly and on Redis Cluster — a cluster cannot route a key it was not told about. The script returns the member list and the tier deletes value keys client-side, one key per DEL, so every delete is slot-local. On 1.2.0, single-node Redis, or a cache tier that is not the shared one → Caching and invalidation
resolveEnvironment existed twice, with different return types 2.0.0, and BREAKING @ultimat3/core and @ultimat3/seo both exported it. seo's is deleted along with SeoEnvironment; RobotsConfig.environment and isIndexable() take core's Environment, 'preview' is spelled 'staging', and core gained tryResolveEnvironment() for callers that must answer rather than throw — ULTIMATE_ENV is in no env schema, so a robots.txt render is routinely its first reader. On 1.2.0, import one with an alias → Configuration
Two head serializers, one of them weaker 2.0.0, and BREAKING @ultimat3/seo's renderHeadTags escaped </ only — not <!--<script>, which moves the tokenizer into script-data-escaped state — and applied that code rule to a JSON body; nothing called it. @ultimat3/render's renderHead, the path every x dev and every build takes, emitted <script> content entirely raw. renderHeadTags is gone and render escapes raw-text content and JSON-LD by each element's own rule. On 1.2.0, render the head through renderHead(headFromMeta(meta, seoRenderers())) and treat any string interpolated into meta.ld as untrusted
docker-compose.prod.yml paired a published host port with replicas: 3 2.0.0 one host port has exactly one binder, so the second container died on Bind for 0.0.0.0:3000 failed: port is already allocated. web and sync declare replicas: 1 in all four files. sync's PORT was wrong in the same file and fixed with it — the role binds PORT + 1, so PORT: 3001 opened 3002 while publishing 3001. On 1.2.0, set replicas: 1; to scale anyway, drop ports: and put your own reverse proxy on the compose network, or climb to the chart's per-role HPA → docs/idea/17-scale-ladder.md
X_MIGRATE_CONCURRENT was reserved and never thrown 2.0.0 the lock was a blocking pg_advisory_lock, so an overlapping migrator waited forever — no timeout, no exit code, and a wedged predecessor held helm upgrade --wait inside one statement with nothing in the logs. acquireLock now polls pg_try_advisory_lock once per 500ms against a 60s budget (MIGRATION_LOCK_WAIT_MS). On 1.2.0, kill the wedged migrator by hand
x db gen / x db migrate shelled out to bunx drizzle-kit 2.0.0 which x new neither installed nor configured, so bin/setup — the scaffold's own documented first command — failed on "drizzle.config.json file does not exist". gen calls generateMigration() and migrate/reset call migrate(), the engine ROLE=migrate already ran. On 1.1.0, generate with generateMigration + writeSchemaHash from @ultimat3/db directly, then apply with ROLE=migrate bun apps/web/server.ts
x new wrote 0000_initial.sql with no snapshot sidecar 2.0.0 so a scaffolded app started on the one state x db gen refuses: x db migrate applied it and then answered X_DB_DRIFT naming x db gen, whose own X_MIGRATION_SNAPSHOT_MISSING said "restore from version control" for a file version control never had. x new writes no migration at all now — x db gen is the one writer of packages/db/migrations. On 1.2.0, before the first migrate: rm packages/db/migrations/0000_initial.sql packages/db/migrations/0000_initial.hash && x db gen "initial"
A generated migration's foreign keys could not apply 2.0.0 every key was a references clause inside create table, walked in the app's import order — which says nothing about which table a key points at. Measured against PGlite: create table "comments" (… references "posts" …) ran before create table "posts". Every key is its own alter table … add constraint after all tables now, and down drops constraints before tables. On 1.2.0, move each clause out by hand before the migration is applied
The generated .snapshot.json failed the app's own lint 2.0.0 JSON.stringify(value, null, 2) never collapses a one-element array and Biome always does, so x verify answered X_LINT_FAILED on a file no author typed. The serialiser is a fixed point of Biome 2.5.5 at lineWidth: 100, and x new's biome.json excludes **/migrations. On 1.2.0, add "!**/migrations" to files.includes
Composite indexes emitted one mangled column name 2.0.0 indexes: [{ on: ['orgId','createdAt'] }] emitted on "todos" ("org_id_created_at"), which will not apply. The description carries the column list, the predicate and the direction, and the generator spells all three. On 1.2.0, write the index by hand
A migration with two statements failed to apply 2.0.0 cannot insert multiple commands into a prepared statement on the embedded database, and against a server the moment the text carried a bound value. migrate() and rollback() split with statementsOf() and send one statement at a time inside the same transaction. On 1.2.0, one statement per migration file
Every generator but x g resource imported files only x g resource wrote 2.0.0 the templates opened with import * as repo from '../repo', so the generated file did not load: X_CLI_UNEXPECTED from every registry command, --feature <slice> included. Each generator composes the slice modules its own source imports now, and a module the slice already has is skipped rather than overwritten, --force included. On 1.2.0: x g resource <slice> first, then the narrower generator with --feature <slice>
Jobs and tasks registered anonymously 4.0.0 a fresh scaffold had no apps/web/api/index.ts, so they registered as anonymous-job-2 / anonymous-task-1. x new writes it. On 3.0.0, add defineApi yourself → 4 · Jobs and realtime
Generated tests landed in the wrong x verify step 4.0.0 contractTest / liveTest / jobTest inside a plain *.test.ts ran under unit, and x test contract answered X_TEST_NO_FILES. The generators emit the typed filename now — <name>.contract.test.ts for x g action/x g mutator, <name>.live.test.ts for x g query --live, <name>.job.test.ts for x g job, x g task and x g backfill. On 3.0.0, rename by hand: the filename is the type
A disk registered under a name that is not its driver's 404'd its own signed URLs 4.0.0 localDriver minted /_storage/<driver>/<key> while the mounted route resolves the segment through the registry, so a disk registered as uploads signed a URL nothing served. StorageDriver.registerAs(diskName) is called by defineStorage at boot and the driver hangs its URLs off that; signedUrlBase is read off the driver by the verifying half, so the minter and the verifier cannot state it twice. On 3.0.0, register the local disk under the driver's own name (local)
The robots.txt read left from the worker's IP while every page load left through the proxy 4.0.0 two client identities presented to one origin — and an origin reachable only through the proxy answered nothing, which the gate reads as "no restrictions". ScrapeSession.proxy reports the exit the driver resolved, and createRobotsGate takes it as a resolver (proxy: () => sessionProxy) because the gate is an argument to driver.open() while the exit is decided inside it. The read also carries the run's deadline, its cancellation and a 500 KiB cap. On 3.0.0, use robots: { ignore: '<reason>' } on a proxied scrape, or accept that an unreadable robots.txt reads as allow
A malformed request body echoed itself into the 422 4.0.0 the parser's own message quotes the bytes it choked on, and it reached the cause: and the log store through String(error). The caller-facing half now names the format alone — could not parse the body as JSON — and the parser's message rides in meta through renderThrowable. On 3.0.0, do not log a 422's cause verbatim into anything a third party reads
createRateLimiter({ now }) 4.0.0, and BREAKING renamed to ({ clock }), the same Clock shape createRequestContext's init.clock takes — a second spelling of "what time is it" is a second way to set one number. The edit: createRateLimiter({ config, now: () => t }) becomes createRateLimiter({ config, clock: { now: () => new Date(t) } })

Reserved-but-unthrown error codes are listed in full under Error codes → Reserved codes. Symptom-first triage is Troubleshooting.

Clone this wiki locally