Ultimate

22.x → 23.0.0, entry by entry

Sixty-six entries, the 23.0.0 section of CHANGELOG.md, in its order — which is the order an existing app meets them.

What changed for an agent

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.

DoInstead ofRead
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 orgEntities and migrations → The repo
call the server from an island through browserClient / browserQueries (apps/web/shared/browser-client.ts)fetch( — the gate refuses itClient data
store a credential in a text().sealed() columna plaintext column, or a vault of your ownEntities and migrations
record a run's ending in onSettleda status column written from the body, or a second job that pollsJobs and workflows
keep the tests x g emits, and raise the floor in x.verify.json when coverage risesdeleting an emitted test, or a floor nobody statesTesting → 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 operationAdmin dashboard

The upgrade, top to bottom

Run each step; the entries it closes are in the last column.

#DoWhat you see until you doEntries
1pin every @ultimat3/* to the one new version, bun installnothing yet — a mixed install is untested—
2add 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=productionevery web pod logs X_IMAGE_NOT_PREBUILT at boot and compiles every island and stylesheet1, 2
3x db gen, then commit packages/db/schema/ and packages/db/.gitattributesthe drift step is X_SCHEMA_DUMP_DRIFT3–6
4x verify --only unit --json, then paste the coverage line its finding carries into x.verify.jsonthe unit step is X_COVERAGE_FLOOR_UNSTATED7–10
5rewrite each raw request the boundaries step names (table under entry 11)X_BROWSER_TRANSPORT_BYPASS / X_BROWSER_SERVER_BARREL, one finding per site11, 12
6git mv a defineAdmin() under apps/*/src/ to apps/admin/app/admin/admin.ts; then x manifest and commit x.manifest.jsonthe manifest step is X_ADMIN_UNSCANNED, then X_MANIFEST_DRIFT13, 14
7x doctor --json, then x g guard <name> for each name in data.guards.missing you adopt, and fix what x verify --only boundaries reportsnothing — upgrading installs no guard—
8drop --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/pagesX_CLI_BAD_FLAG17–22
9bun run typecheck and fix each job, driver, export, scraping, admin and type-union site it namesTS2741 / TS2345 / TS2339 / TS2353 at each hand-built literal, driver, store or option23–43, 55–57, 63, 64
10delete 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 }). A pages: page that keeps your own shell or an island keeps its file: on 27.1.0 or later, claim it insteadX_ADMIN_REPO_UNBOUND at load, X_ROUTE_DUPLICATE for each page file, X_ADMIN_PAGE_PATH_INVALID for a pages: entry under /jobs49–55
11x 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 catalogX_PERMISSION_UNGRANTED, one per permission a mounted admin route asks for; X_CATALOG_MISSING_KEYS per locale15, 16
12x verify --only unit,job and fix the tests it failsa 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 page44–46, 48, 58–61
13x secrets init where no master key exists; correct any credential a site had refused before the first scrapeX_SEAL_KEY_MISSING before the browser opens; every stored session is logged in again62, 65, 66
14x verifygreen, or a finding whose fix: is the edit — X_PACKAGE_SHAPE for a published package's fixture47

Entry by entry

#SurfaceCosts you an edit if
1the app imageyour 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
2ROLE=worker, ROLE=schedulera 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
3packages/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
4an 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
5x db gen's exit codea 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
6x db migrate, DriftKindyour 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':
7x.verify.json coveragealways. 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
8a slow gate stepa 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
9x verify --json in a pipelineyou parse 2>&1 as JSON. Read stdout only; stderr carries one {"step","ok","ms"} line per finished step, and still running: heartbeat lines — keep the lines that start with {
10test-order dependencea unit test relied on which files shared a --parallel worker. The step runs slices of at most 16 files, one process each
11fetch(, new WebSocket(, new XMLHttpRequest(, new EventSource( in browser codeany *.island.tsx, anything that calls clientTransport/pageClient, or anything they import holds one. The finding names the line; rewrite with the table below
12a value import of @ultimat3/entity / @ultimat3/query in browser codeimport @ultimat3/entity/record or @ultimat3/query/client; import type is unaffected
13x.manifest.jsonalways: 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
14defineAdmin() 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
15the policy step and a mounted admina 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
16the i18n step and a mounted admina 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
17x g entity, x g resourceyou 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
18x g resource <name> --feature <other>a script passes it. Drop the flag (X_CLI_BAD_FLAG)
19x g <kind> --live for any kind but querya 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
20a query x g query / x g resource generatesyou 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 }
21x g admin:pagea 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
22x tasksa 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 handa test calls it. Add finalAttempt: isFinalAttempt(<job>.retry, attempt) and progress: () => undefined
24a hand-built job literal or exhaustive switchover JobOutcome ('refused', 'dropped'), QueueStats (failed), WorkerStats (refused, dropped, pollDelayMs), JobHandle, JobDescriptor, JobRecord, JobTrace. Add the member the compiler names
25a hand-written JobDriveryou 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
26a 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
27a hand-written LeaseStore, SchedulerState, LeaderElection, or createFleetSlots calleradd holders(key); fire(driver, { task, occurrenceMs, jobs }); renewEveryMs; read acquire's SlotGrant
28a 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
29a staged enqueue()you read its id or runId: they are now the job's own. A custom OutboxStore persists runId
30retry: { 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
31concurrency: 0, a negative, a fractioncaught as X_INVARIANT. It is X_JOB_DECLARATION_INVALID
32lastErroryou compare it to a rendered message. It ends with — fix: … now; compare a prefix
33the memory job drivera 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
34exportRows({ 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
35rpc({ pathStyle }) in island code, a match on X_ROUTE_NOT_FOUNDdelete the pathStyle — the document carries it. A wrong style is X_CONTRACT_DRIFT (still 404)
36a live query's sourceit names an org by hand. Drop the argument: the source is read as the subscriber's tenant. LiveQueryDefinition.snapshot receives { input, tenant }
37EntityCore<Row>['$schema'], a hand-built RecordProjectionread the type off the entity() result (typeof posts.$schema); add sealed: [] to the projection
38x secrets rotatesomething 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
39interface X extends LinkProps / PaginationProps / DataTableProps<Row>extend one member: TextLinkProps/ButtonLinkProps, PaginationCallbackProps/PaginationLinkProps, DataTableCallbackProps<Row>/DataTableLinkProps<Row>
40guard.check(root) in a testpass guardSources(root) from @ultimat3/cli as the second argument
41checkAppBoundaries, a switch over BuildTargetdelete the import; add case 'prebuilt':
42a hand-written e2e driverread the third argument, { timeoutMs }
43a hand-written E2eApp doubleyou 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: () => ''
44runJobs in a testa 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
45clock.advance() past the visibility timeout in a runJobs testa 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
46the jobs event bus in a testa 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
47a non-private workspace package with a src/**/*-fixture.tsyou 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
48og:* / article:* / twitter:* on a robots: { index: false } pagea test or a crawler expected them. A document that may not be indexed carries none
49defineAdminalways: 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
50guardedPage, 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
51AdminList, pageRequestOf, adminListyou 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
52the tenant column in the admina form posted it. It is stamped from the actor; the posted value is ignored
53describeRoutes(), Stylesheetyou 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 itemalways: 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())
55defineAdmin({ jobs }), AdminApp.jobsdelete both: the dashboard reads the queue itself. TS2353 names the site
56a 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
57InvokeResultyou read decision off a failure. Failures carry kind: 'denied' | 'not-applicable' | 'invalid'; narrow on kind === 'denied' first
58a posted row action's redirecta test asserts its Location. It is the row (303), not the list
59a row-scoped resource's create and updatea 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
60an action on a row the actor cannot seeit ran. A row that is gone or outside rows is refused, when or none: X_ADMIN_ACTION_NOT_APPLICABLE, 409, the same over MCP
61permissionsForOperation('admin', op)you read a second element. It answers one permission (['admin:read']), not the same one twice
62stored scraping sessionsalways. 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
63storageSessionStore(disk), artifacts: { storage }pass a thunk: storageSessionStore(() => disk('sessions'))
64a hand-built PromptRequest, AuthContext, ScrapeSecrets, ScrapeReport, SessionInit, a createPrompt calladd the members TS2741 names; createPrompt({ scrape, handler, page, runId, clock, … })
65meta.cdpUrl of X_SCRAPE_CDP_ATTACH_FAILEDyou read the full URL from it. It is wss://host:port
66localBrowser({ proxy }) with credentialsyour 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 wasWrite
fetch('/api/…', { method: 'POST' }) — an actionawait browserClient.<action>(input)
fetch('/_x/query/…') — a readuseQuery(<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 URLawait uploadFile({ file, grant, onProgress }) from @ultimat3/storage
anything elseawait 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.

Where the sites are

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/null

The 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.

Mounted admin pages that keep your shell (27.1+)

A 22.x console that mounted each pages: entry in its own <path>/page.tsx — the app's frame around adminRouteFor(…).component, islands declared in the mount — does not have to fold into the admin's layout. Upgrade to 27.1.0 or later (an earlier 23–27 has no seam: the island is X_ISLAND_NOT_HYDRATED, the file X_ROUTE_DUPLICATE), keep every file, and make one edit per mount plus one in the shared helper:

In22.x27.1+
each <path>/page.tsxconst mounted = adminRouteFor(admin, path) (or your mountedPage(page)) FIRST, the island() calls after itthe island() calls first, then export const config = claimAdminRoute(admin, path, { hydrate, budget, navigation, load }) LAST — its defineRoute drains them
each <path>/page.tsxdefineRoute({ render, offline, policy: mounted.policy, meta, cache, load: () => …mounted.component({ ctx, params, url })… })nothing: render, offline, policy, meta, private no-store and the guarded body are the claim's. Drop cache: 'no-store' (the default for a gated page) and meta
the page componentprops.data = your load's object, body from route.componentprops.data: AdminClaimData<YourShellData> — body, status, denied (the body is the refusal, with its own <h1>), titleKey, nav (admin.navFor(ctx)), and app = what your load returned
the shared mount helpera load that resolved the actor, called the guarded component, computed denied with decideAll, read navFor, and withStatus(403 | 404, …)only the shell's own data: load: ({ ctx, url, denied }) => ({ staffRole, mfaEnrolled, locale, path }). Return withStatus(404, data) when the body asked for a 404; the 403 is the admin's
/admin/page.tsx (your own home)a route at the base pathclaimAdminRoute(admin, admin.basePath, { load: () => loadHome() }) — the dashboard is claimable; data.app is your home's data

pages: stays the source of the path, title, nav item and permission pair; only a pages: entry or the dashboard can be claimed, and only by the config claimAdminRoute returned for that exact path (Admin dashboard → An admin page in your own shell).

Edit this page on GitHubThis page as markdown