# 30.x → 31.0.0, entry by entry

`As of 2026-11-10`, released: the 74 breaking entries of `CHANGELOG.md`'s `## 31.0.0` section, in their order. No legacy path, no codemod, no shim. v31 is the stable line — the next major waits for its window ([Stability](/stability)).

**Move TypeScript to `^7.0.2` first** — `bun add -d typescript@^7.0.2` in every workspace that declares it, then `bun install`. `@ultimat3/cli` ships its `.ts` source, and your typecheck reads it: under TypeScript 6 with `exactOptionalPropertyTypes` (on in every `x new` tsconfig), `node_modules/@ultimat3/cli/src/cdp-shot-keys.ts` fails TS2769 at 26:43, so the gate is red before any entry below applies. Since 22.0.0 ([the `22.0.0` section](/upgrading/22)); `x upgrade` moves the `@ultimat3/*` pins, never `typescript`. From 31.2.0, `x doctor` raises `X_TYPESCRIPT_VERSION` per lagging manifest, its fix the exact `bun add`, and `x upgrade` reports the same lag in `data.typescript` — reported only, never written. `x new` scaffolds `TYPESCRIPT_VERSION` (`packages/cli/src/templates/scaffold-repo.ts`). `As of 2026-11`, measured: TypeScript 6.0.3 reports TS2769 on that file, 7.0.2 nothing.

### The upgrade, top to bottom

| # | Do | What you see until you do | Entries |
|---|---|---|---|
| 1 | commit, then `bunx @ultimat3/cli@latest upgrade --json`: it pins every `@ultimat3/*`, installs and runs the gate | `X_UPGRADE_DIRTY_TREE` on a dirty tree | — |
| 2 | `x verify --only typecheck` and close every error: a hand-written `AuthServerStore` or `PreferenceStore`, an import of a removed Redis script or of `MAX_WIRE_ISSUES`, a hand-built `ActionDeniedError`/`QueryDeniedError`, a `set*` job/event-bus/mail/idempotency/meter/notify setter (a `runtime.ts` field), a factory's `associate()` or `buildMany`, a `setAuditSink`/`configureKdfGate`/`configureErrorReporting({ reporter })` call, a `runtime.storage` built with `defineStorage`, a `PushSubscriptionRecord` with `actorId: null`, a `configureAi({ embedder, semanticCache })` or `configureMetrics({ exporter })` call, an import of a framework mail or `noopMetricExporter`, an exhaustive `switch` over `AuditPrimitive` or `ScanVerdict`, `grantUpload({ quarantine })` or `releaseQuarantine`, an import of a name that left a package's `.` (entry 74's list), a gateway `retry` or realtime `backoff` in the old field names, a boolean job `retry.jitter`, a `setLogSink` call, realtime's `Row`, `Surface` from seo or `SURFACES` from render, a hand-written `SearchIndex` without `list`, a `registerActions`/`registerQuery` call | TS2741, TS2554, TS2305, TS2322 | 1, 6, 10, 14, 18, 29, 36, 40, 43–45, 48, 50, 51, 54–65, 67, 71, 72, 74 |
| 3 | `x verify --only unit,contract`: shorten a name over 63 bytes, write each `cors.origins` entry as its own `URL.origin`, rename an unsafe storage key or header value, switch a `fromDecimal` branch to `X_MONEY_NOT_DECIMAL`, read a truncated validation through `issuesTruncated`, pass `expose: true` to an `mcpTools()` whose tools your `/mcp` serves, name `localhost` exactly in an `allowHosts` that reaches it through `'*'`, compare a resolved disk by `.name`, advance the version in a custom store's `upsert`, drop `target` from a grant while a scanner is installed | `X_SQL_UNSAFE`, `X_CORS_CONFIG_INVALID` at boot, `X_STORAGE_PATH_UNSAFE`, `X_INVARIANT`, `X_MONEY_NOT_DECIMAL`, a remote tool missing from your `tools/list`, `X_SCRAPE_HOST_BLOCKED` for a `localhost` URL, `X_STORAGE_TARGET_UNSCANNED` | 2, 4, 7, 9, 11, 12, 19, 22, 27, 69, 70 |
| 4 | **before the deploy:** list stored objects whose keys the new rule refuses and copy them; cache a projection where a value passes 1 MiB; keep `redisTier`'s default `buildId` or plan the one-time bust; on `docker/helm`, move a pod that reads the metadata endpoint to a workload identity; name a Postgres `notifyStores.ledger` in `runtime.ts` if a notifier runs; add `https://<origin>/auth/sso/<provider>/callback` as a redirect URI at every OAuth IdP **before the new image rolls**, keep `/auth/oauth/<provider>/callback` registered until the last old pod is gone, and point sign-in links at `/auth/sso/<provider>`; with objects still under `org/*/pending/quarantine/`, install an `uploadScanner` answering `hold` for them | a read of an old key refused; a cache miss logged `X_CACHE_TOO_LARGE`; stale tagged keys until their TTL; a metadata-endpoint timeout; `X_NOTIFY_STORE_MISSING` on the first notification | 5, 6, 11, 16, 49, 52, 57 |
| 5 | **after the deploy:** declare `maxPage` on a read a client pages past 500 rows (`X_INPUT_INVALID` until you do); treat exit `70` as "drained, work abandoned" in alerts; read rate-limit keys through `addressNetwork`; switch on `denial.surface === 'mcp'` for an MCP read; honour `{ etag }` in a hand-written storage driver; give the collector a logs pipeline or set `OTEL_LOGS_EXPORTER=none`; with an external search index, run each `reindex()` once with `x db backfill <name> --write --force --json` so its sweep drops what the table lost | nothing fails: an alert, a dashboard filter or an ETag answers differently | 3, 8, 13, 14, 17, 28, 30, 66, 73 |
| 6 | `x db gen "tenant indexes"`, commit `packages/db/migrations` and `packages/db/schema/`; the new index builds `concurrently`, so a hot table keeps taking writes | `X_DB_DRIFT` from `x verify --only drift` | 20 |

### Entry by entry

Tier 0 — `@ultimat3/core` (3), (23), (25), (31), (33–35), (43), (51), (55), (61), `@ultimat3/core` with `@ultimat3/cli` (24), `@ultimat3/core` with `@ultimat3/scraping` (22), `@ultimat3/schema` with `@ultimat3/core` (4), `@ultimat3/core` with `@ultimat3/jobs` (56). Tier 1 — `@ultimat3/db` (2), (30), `@ultimat3/db` with `@ultimat3/entity`, `@ultimat3/policy` and `@ultimat3/cache` (29), `@ultimat3/cache` (5–6), `@ultimat3/money` (7), `@ultimat3/seo` (63), `@ultimat3/storage` (11–13), (27), (57), (70), `@ultimat3/flags` with `@ultimat3/scraping` (38). Tier 2 — `@ultimat3/auth` (1), (44), (52), `@ultimat3/http` (8–9), `@ultimat3/entity` (20), (47), (65), (69), (71–73), `@ultimat3/http` with `@ultimat3/jobs` and `@ultimat3/ai` (26), `@ultimat3/http` with `@ultimat3/auth` and `@ultimat3/scraping` (37). Tier 3 — `@ultimat3/action` (10), `@ultimat3/action` with `@ultimat3/query` (14), (67), `@ultimat3/realtime` (15), (32), (59), (62), `@ultimat3/query` (17), `@ultimat3/jobs` with `@ultimat3/action`, `@ultimat3/mail` and `@ultimat3/notify` (36), `@ultimat3/jobs` (60), (66). Tier 4 — `@ultimat3/notify` (18), `@ultimat3/mcp` (19), `@ultimat3/render` (21), (64), `@ultimat3/ai` (41), (50), (58), `@ultimat3/pwa` (48), `@ultimat3/notify` (49), `@ultimat3/mail` (54). Tier 5 — `@ultimat3/cli` (28), (42), (45), (68), `@ultimat3/admin` (39), `@ultimat3/testing` (40), (46), `@ultimat3/testing` with `@ultimat3/scraping` (53). The framework chart — `docker/helm` (16). Every package's barrel (74).

| # | Surface | Costs you an edit if |
|---|---|---|
| 1 | `AuthServerStore` (`@ultimat3/auth`) | you wrote your own store: TS2741, TS2554. Add `getCode(codeHash)` — the code row, used or not, spending nothing (`select … where code_hash = $1`) — and take `clientId` as `pollDevice`'s second argument, answering `null` and stamping nothing when the row's `clientId` differs. `memoryAuthServerStore()` and `postgresAuthServerStore()` already do |
| 2 | `identifier()` (`@ultimat3/db`) | you pass it a name over 63 bytes or holding a NUL: `X_SQL_UNSAFE` now, where Postgres truncated the name without an error (two long names sharing their first 63 bytes were one object). Shorten the name to the one the server already holds — `psql "$DATABASE_URL" -c "select relname from pg_class where relname like '<first 20 chars>%'"` — and pass that |
| 3 | the exit code after a drain (`@ultimat3/core`) | you alert on, or script around, a non-zero exit after SIGTERM: `installSignalHandlers({ exit: true })` now exits `70` (`DRAIN_TIMEOUT_EXIT_CODE`) when a hook or in-flight work was abandoned at the deadline (`X_SHUTDOWN_TIMEOUT`), where it exited `0`. The same 70 when the drain itself threw before its last phase, or a served role's own teardown (`apps/<app>/server.ts`, through `runRole`) overran its budget. A clean drain still exits `0`. Raise the budget — `drain: { deadlineMs: 600_000 }` in `app.config.ts` — or treat `70` as "drained, work abandoned"; `drainExitCode()` answers the same number for your own exit |
| 4 | validation issues and error causes are bounded (`@ultimat3/schema`, `@ultimat3/core`) | you read more than 100 entries off a failed validation, or a `cause` past 4 KiB: `validate`/`parse`/`safeParse` stop at `MAX_ISSUES` (100) and set `issuesTruncated: true`, and every `UltimateError`/`SchemaError` cause is cut at `MAX_CAUSE_LENGTH` (4096) with `... (N more characters)`. Pass `validate(schema, value, { maxIssues: 1000 })` for a longer list; read the structured `meta`/`issues`, never a parsed `cause` |
| 5 | `redisTier()` (`@ultimat3/cache`) | you cache a value whose encoded form is over 1 MiB in the shared tier: its `set` is now `X_CACHE_TOO_LARGE` (recorded by `bestEffort`, read as a miss), where Redis took it. Cache a projection, or raise the cap where the tier is built — `redisTier({ maxValueBytes: 4_194_304 })` |
| 6 | `REDIS_INVALIDATE_SCRIPT`, `REDIS_TAG_MEMBER_SCRIPT` (`@ultimat3/cache`) | you imported either: both are gone — tag buckets are sorted sets (`t2:`/`e2:`) read with plain paged commands, and the one script left is internal. Delete the import. With `redisTier({ buildId: null })`, a v30 and a v31 pod mid-deploy do not see each other's buckets, so a bust on one misses keys the other wrote until their TTL: deploy with the default `buildId`, or run `redis-cli -u "$REDIS_URL" --scan --pattern '<prefix>:c:*' \| xargs -r redis-cli -u "$REDIS_URL" UNLINK` once the rollout completes |
| 7 | `fromDecimal()` (`@ultimat3/money`) | you match on the code of a parse failure: a string that is not a plain decimal (`'1,299.00'`, `'1e3'`) — and now a non-string (`12.99`, `null`, `undefined`), which was a bare `TypeError` — is `X_MONEY_NOT_DECIMAL` (HTTP 422), where it was `X_MONEY_NOT_INTEGER`. `grep -rn X_MONEY_NOT_INTEGER src/` and switch the branches that guard a `fromDecimal` parse; excess precision and fractional minors keep `X_MONEY_NOT_INTEGER` |
| 8 | anonymous rate-limit keys (`@ultimat3/http`) | you read, seed or alert on an `ip:` key, or count on two IPv6 addresses having two allowances: an anonymous caller's key, and the failed-credential `unauthenticated\|ip:` key, is now its `/64` — `@ultimat3/core`'s `addressNetwork(ip)`: IPv4 whole, an IPv4-mapped IPv6 its IPv4, any other IPv6 `<first four hextets>::/64`. A deploy starts every IPv6 bucket full once. Read keys through `addressNetwork`; a dashboard filtering `ip:2001:db8::1` filters `ip:2001:db8:0:0::/64` |
| 9 | `cors.origins` (`@ultimat3/http`) | an entry is not its own `URL.origin` — a trailing slash, a capital, the scheme's default port, no scheme: `X_CORS_CONFIG_INVALID` at boot, where it was accepted and matched nothing. Write the form the `fix:` names: `configureHttp({ cors: { origins: ['https://app.com'] } })` |
| 10 | `MAX_WIRE_ISSUES` (`@ultimat3/action`) | you import it: TS2305. Import `MAX_ISSUES` from `@ultimat3/schema` — the same 100, now the cap validation itself stops at, so the server and the typed client cannot disagree |
| 11 | object keys (`@ultimat3/storage`) | a key has a segment ending in `.` or a Win32 device name (`CON`, `nul.txt`, `com1.log`): `X_STORAGE_PATH_UNSAFE` on every disk, where s3 took it (Windows drops the trailing dot, so `.meta.` IS the reserved `.meta`). A `:` is refused only by a local disk on Windows (an NTFS stream); the fix names the key with `-`. Find them with `aws s3 ls s3://<bucket> --recursive \| grep -iE '\.(/\|$)\|(^\|/)(con\|prn\|aux\|nul\|com[0-9]\|lpt[0-9])(\.\|/\|$)'`, copy each to a key without them, and build keys with `scopedKey`/`joinKey` |
| 12 | `put()`/`putStream()` `contentType`, `cacheControl` (`@ultimat3/storage`) | either holds a byte outside printable ASCII (a line break, a non-ASCII character): `X_INVARIANT` on every disk, where local stored it and s3 answered `X_STORAGE_WRITE_OUTCOME_UNKNOWN`. Pass a plain header value — `put(key, body, { contentType: 'text/plain' })` |
| 13 | `StorageDriver.stream` / `streamRange` (`@ultimat3/storage`) | you implement a driver: both take an optional last argument `{ etag }`, and `/_storage` passes it. Honour it — another revision is `X_STORAGE_OBJECT_CHANGED` (409) — or the route serves an overwrite under the old ETag. A driver that ignores it still compiles |
| 14 | an MCP read's denial, and the `ActionDeniedError` / `QueryDeniedError` constructors (`@ultimat3/query`, `@ultimat3/action`) | you read `QueryDeniedError.denial` from an MCP tool call, or filter `PolicyDecisionEvent.surface` for agent reads: an MCP read is judged on policy's `mcp` surface, as an MCP action always was, so `.denial` is `{ surface: 'mcp', isError, content }` and the event says `'mcp'`, where both said `'job'`. Read the code and reason off the error (`error.code`, `error.cause`), or switch on `denial.surface`. If you construct either error yourself: TS2554 — pass `guardPrimitive(kind, policy, subject, surface)`'s answer from `@ultimat3/policy`, the one argument both now take |
| 15 | `channel()` with a `catchUp` query (`@ultimat3/realtime`) | the query's `policy` is not the very object the `channel()` call passes: `X_CHANNEL_CATCHUP_POLICY_MISMATCH` at load. The catch-up read is served at `GET /_x/query/<name>` with the channel params as input, so a wider rule (a bare `can('<resource>:read')` beside a channel rule comparing a param to the actor) answered any caller what the subscribe refuses. Export the channel's rule from the feature's `policy.ts` and pass it as both `policy:`s. A name-only `catchUp: { name }` (a browser `channelRef`) is not checked |
| 16 | `networkPolicy.egress` in the framework chart and the one `x new` writes (`docker/helm`) | a pod dials a link-local address — `169.254.0.0/16`, `fe80::/10`, `fd00:ec2::254`, `64:ff9b::a9fe:a9fe` — or a port-53 resolver outside the cluster: egress is filtered by default, where `egress: []` left it open. The cloud metadata endpoint is the point; a pod reading node credentials from it now times out. Private ranges, every cluster pod and every public address stay reachable. Give the pod a workload identity (IRSA, Workload Identity) instead of the node's, or set `networkPolicy: { egress: [] }` to keep v30's open egress. An existing app's `docker/helm/values.yaml` is its own and keeps what it has until you paste the default in |
| 17 | `?_first=` over HTTP (`@ultimat3/query`) | a client pages a read past 500 rows: `_first` above `MAX_WIRE_PAGE_SIZE` (500) is `400 X_INPUT_INVALID`, where the route took up to 10,000. Declare the read's own bound — `query({ maxPage: 2000, ... })`, up to 10,000 (`X_QUERY_MAX_PAGE_INVALID` outside it) — or page with a smaller `first`. A server `read.page()` is unchanged; `openapi.json`'s `maximum` follows `maxPage` |
| 18 | `PreferenceStore` (`@ultimat3/notify`) | you wrote your own store: TS2741 / TS2353. `allows(query)` is replaced by `allowsMany(query)`, asked once per chunk of at most `NOTIFY_CHUNK_SIZE` (500) recipients per channel and answering the ids that may be reached (an id it omits is suppressed on that channel only). Turn the per-recipient read into one: `allowsMany: ({ recipients, notifier, channel, ctx }) => prefs.allowed(recipients.map((r) => r.id), notifier, channel, ctx.now())`. `allowAllPreferences()` and `memoryPreferenceStore()` already do. A fan-out's delivery steps are now `deliver:<channel>:chunk-<n>`; a run mid-flight across the deploy re-runs its chunks and the delivery ledger answers `sent` for whoever it already reached |
| 19 | `mcpTools()` (`@ultimat3/mcp`) | you serve a remote server's tools on your own `/mcp` through `include: 'exposed'`: the remote actions are built `expose: false`, so they leave your `tools/list` (an in-app `agent()` is still offered them — they are declared `agents: true`). Opt that server in — `mcpTools({ name, url, allow, policy, lock, expose: true })` — knowing your `/mcp` then answers for the third party's tools. A remote whose tool list moves without a `list_changed` notification is now refused `X_MCP_REMOTE_TOOL_CHANGED` within `reverifyMs` (default 3,600,000) rather than at the next restart: run `x mcp lock <name>` after reviewing the diff |
| 20 | the index a tenant table carries (`@ultimat3/entity`) | an entity has a tenant column — `.tenant()`, `entity(name, { tenant })` or an inferred `orgId`: `$describe().indexes` gains `<table>_<tenant>_<pk>_idx` on `(tenant, pk)`, built `concurrently`, unless a key or index already starts with `(tenant, pk)`, and `.tenant()`'s own `<table>_<tenant>_idx` is gone, so `x verify`'s `drift` step is red until `x db gen "tenant indexes"` writes the migration (create the composite concurrently, drop the single-column one). An unindexed tenant page was a sequential scan: 43.7 ms at 500k rows, 1.5 ms on the composite. A table name long enough that the new name passes 63 bytes is `X_INVARIANT_VIOLATED` at `entity()` — rename with `entity(name, { table })` |
| 21 | `IsrStore`, `IsrController.revalidateByTags` / `markStale` (`@ultimat3/render`) | you wrote your own store, or call either controller method: a store method may now answer a promise (`IsrAnswer<T>`) and the controller awaits each one; `revalidateByTags` answers `Promise<readonly string[]>` and `markStale` `Promise<boolean>` — TS2339 on `.length`/`.includes` of the result, or a test asserting on the promise. `await` both. A synchronous store keeps compiling unchanged. Shared across replicas: `runtime.isrStore = storageIsrStore({ disk: disk('isr') })` |
| 22 | a wildcard `allowHosts` rule and `localhost` (`@ultimat3/core`'s `hostDecision`, so `@ultimat3/scraping` and `x shot`) | a scrape or shot under `'*'` or `'*.localhost'` loads `localhost`, `*.localhost` or `localhost.`: refused (`X_SCRAPE_HOST_BLOCKED`, or a `refused: 'host'` ring entry), as `127.0.0.1` already was — the name is loopback by definition, and Chrome sends it around an egress proxy. Name it exactly: `allowHosts: ['*', 'localhost']`. A launched browser with an exit also gets `--proxy-bypass-list=<-loopback>`, so loopback goes through the proxy too; a local target behind a proxy session must be reachable from the proxy |
| 23 | `configureTelemetry({ exporter })`, `noopExporter`, `ReadableSpan` (`@ultimat3/core`) | you installed a span exporter: TS2353 on `exporter` and TS2305 on `noopExporter` — spans are the `span.end` event on the instrumentation bus. Write `const off = subscribe('span.end', (event) => exporter.export(event.payload))` and call `off()` where you used to hand back `noopExporter`; a test does the same with `memoryExporter()`. You build a `ReadableSpan` by hand: add `droppedEventsCount: 0` (TS2741). A span now keeps 128 events (`MAX_SPAN_EVENTS`) and counts the rest. A `switch` over `OtlpSignal` gains `'logs'` |
| 24 | `SENTRY_DSN` beside reporters an app installed itself (the boot in `@ultimat3/cli`; `sentryTransport`, `localErrorSink` in `@ultimat3/core`) | you set `SENTRY_DSN` AND install an error reporter of your own. Your `sentryErrorReporter` is now the only transport — each error is sent once, through your hooks, where the boot used to send it a second time unscrubbed; a reporter that WRAPS it (`{ report: (r) => inner.report(…) }`) is marked with `sentryTransport(wrapper)`. Local sinks keep the DSN transport beside them: `errorGroupsSink` and `memoryErrorReporter` are marked already, and a sink you wrote that keeps reports on the box is marked with `localErrorSink(sink)`. A reporter marked neither way logs `X_ERROR_REPORTER_DSN_UNCLAIMED` once and the DSN goes unused until you mark it |
| 25 | the `jobs.queues` default (`@ultimat3/core`) and `x new`'s `app.config.ts` | `jobs.queues` unset now serves `['default']`, not `['<name>-default']` — the queue a `job()` naming none lands on. Rows still queued on `<name>-default` (`x jobs list --json`), an `enqueue({ queue: '<name>-default' })`, or `WORKER_QUEUES=<name>-default` (`X_WORKER_QUEUE_UNKNOWN` now): list the queue in `jobs.queues`. A config written by `x new` before 31.0.0 says `['<app>-default']`: change it to `['default']` once that queue is empty, or every boot keeps logging the worker's `queue-unregistered` line |
| 26 | the span attributes `@ultimat3/http`, `@ultimat3/jobs` and `@ultimat3/ai` write | a dashboard, alert or trace query reading the old names: the request span says `http.request.method`, `http.response.status_code`, `url.scheme`, `server.address` and `ultimate.request_id` (was `http.method`, `http.status_code`, `http.request_id`); a job's run span is kind `consumer` with `messaging.system`, `messaging.operation.type`, `messaging.destination.name` (the queue), `messaging.message.id`, `ultimate.job.name`, `ultimate.job.attempt`, `ultimate.job.enqueued_by` (was `job.name`, `job.id`, `job.attempt`, `job.enqueued_by`), and a CHILD of the new `job.<name>.enqueue` producer span, not of the request — the row's `traceparent` names the producer, and an enqueue outside any trace now starts one; `ai.llm` is kind `client` with `gen_ai.request.model`, `gen_ai.provider.name`, `gen_ai.response.model`, `gen_ai.usage.input_tokens` / `output_tokens` (was `llm.model`, `llm.provider`, `llm.tokens`), and every other `llm.*` attribute — on `ai.agent` and its `agent.turn` events too — is `ultimate.llm.*`. Rename the keys in the query |
| 27 | `disk()` / `storage().disk()` (`@ultimat3/storage`) | you compare a resolved disk to the driver you declared — `expect(storage.disk('uploads')).toBe(uploads)` fails: `defineStorage` hands out an instrumented view of each driver (every call is a `storage.<verb>` event and client span), the same object on every resolution. Compare `.name`, or keep and call the driver you constructed — a driver held directly reports nothing. A driver's own members (`multipart`, a memory disk's `objects()`) still answer through the view |
| 28 | the container's telemetry boot (`@ultimat3/cli`) | `OTEL_EXPORTER_OTLP_ENDPOINT` is set and the collector has no logs pipeline: the generic endpoint now names logs too, so every process-logger line is posted to `/v1/logs` and a collector without a logs receiver answers each batch with a 404. Add a logs pipeline, or switch the signal off with `OTEL_LOGS_EXPORTER=none` (also `OTEL_TRACES_EXPORTER` / `OTEL_METRICS_EXPORTER`). Every boot also declares `db_client_operation_duration_seconds`, `job_duration_seconds` and `gen_ai_client_token_usage` on `/metrics`: a scrape with a series allowlist drops them until listed |
| 29 | `setStatementObserver`, `statementObserver`, `StatementObserver`, `StatementEvent`, `STATEMENT_ATTRIBUTE` (`@ultimat3/db`); `setRowObserver`, `rowObserver`, `RowObserver`, `RowChange`, `RowBulkChange`, `setCrossTenantObserver` (`@ultimat3/entity`); `setDecisionSink` and its siblings (`@ultimat3/policy`); `recentInvalidations`, `recentTierFailures`, `InvalidationEvent`, `TierFailure` (`@ultimat3/cache`) | you install or read any of them: TS2305. Subscribe on the bus instead — `const off = subscribe('db.statement', (e) => observer.onStatement(e.payload))`, `subscribe('entity.row' \| 'entity.bulk' \| 'entity.cross_tenant', …)`, `subscribe('policy.decision', (e) => sink.record(e.payload))`, `subscribe('cache.invalidate' \| 'cache.tier_failure', …)` from `@ultimat3/core` — and call `off()` where you restored the previous observer; keep your own bounded buffer where you read a `recent*()` list (a cache event carries no `at`: read the clock in the handler). Types: `DbStatementEvent`, `EntityRowEvent`, `EntityBulkEvent`, `PolicyDecisionEvent`, `CacheInvalidateEvent`, `CacheTierFailureEvent`. Every subscriber hears every event — nothing is displaced — and a throwing subscriber is contained by the bus (logged once as `instrument.subscriber_failed`), never the statement's, evaluation's or read's failure; `@ultimat3/testing`'s `statements` fixture now raises its N+1 verdict when it is disposed, not at the looping statement (`statements.verdicts()` reads it earlier) |
| 30 | database spans (`@ultimat3/db`) | a trace backend, dashboard or sampling budget: every statement is now a `db.<verb>` client span in production, not only under `x dev`, with `db.system.name`, `db.query.text` and `db.operation.name` — the old `db.statement` attribute is gone. Rename the key in a trace query; raise the span budget or lower the sample ratio (`OTEL_TRACES_SAMPLER_ARG`) if a statement-heavy request now exceeds it |
| 31 | `addLogSink` (`@ultimat3/core`) | you tee the process logger's lines: TS2305 on `addLogSink` — the tee is the `log.record` event on the instrumentation bus. Write `const off = subscribe('log.record', (event) => store(event.payload))`: the payload is the line's `at`, `level`, `message` and redacted `fields` (not the rendered JSON string — `JSON.stringify` it if you stored lines), and `off()` is the unsubscribe `addLogSink` returned. A throwing subscriber is still swallowed, and is no longer reported as `log.sink_failed`. The test seam that replaces the streams is `installLogSink` (it was `setLogSink`) |
| 32 | `setChannelTransport`, `resetChannelTransport`, `selectTransport` (`@ultimat3/realtime`) | you called a setter or the selector yourself: TS2305 on `setChannelTransport`/`resetChannelTransport` — an app's bus is `transport` in `export const runtime = { … }` (`apps/<app>/runtime.ts`); a driver or a boot imports `installChannelTransport` from `@ultimat3/realtime/adapter`, whose release is the test reset. TS2559 on `selectTransport(env, config.realtime, opts)`: drop the second argument. `NATS_URL` set now selects NATS with no config key, and any other variable selects nothing — an app that named `urlEnv: 'BUS_URL'` renames its variable to `NATS_URL` or hands a `NatsTransport` to `runtime.ts` |
| 33 | `setFailureStore` (`@ultimat3/core`) | you installed a failure store yourself: TS2305 — set `failureStore` in `export const runtime = { … }` (`apps/<app>/runtime.ts`); a boot or a test harness imports `installFailureStore` from `@ultimat3/core/adapter`, and calls the function it returns to restore (it no longer returns the previous store) |
| 34 | `MasterKeySource`, `MasterKeyRef.source`, `SecretsInstallReport.keySource` (`@ultimat3/core`) | you named the type or switched on `'env' \| 'file'`: TS2305 on `MasterKeySource` — the field is the source's `name`, a `string`; compare it as before or widen your switch. A KMS/Vault key is a `SecretSource` (`{ name, masterKey({ root, env }) }`, from `@ultimat3/core/adapter`) set as `secretSource` in `runtime.ts`, never an exported `ULTIMATE_SECRETS_KEY` written by a wrapper script |
| 35 | `realtime.transport`, `realtime.urlEnv`, `REALTIME_TRANSPORTS`, `RealtimeTransport` (`@ultimat3/core`), all removed | your `app.config.ts` writes either key: `X_CONFIG_INVALID` naming its replacement — delete the line; set `NATS_URL` for every realtime role (rename a variable `urlEnv` named), or pass any other `Transport` as `transport` in `runtime.ts`. TS2305 on the two type names |
| 36 | `setJobDriver`, `setEventBus` (`@ultimat3/jobs`), `setMailDriver` (`@ultimat3/mail`), `setIdempotencyStore`, `setMeterStore` (`@ultimat3/action`), `setNotifyStores` (`@ultimat3/notify`), and their resets | you installed a backend or reset one yourself: TS2305 — set it in `export const runtime = { … }` (`apps/<app>/runtime.ts`): `jobs`, `eventBus`, `mail`, `idempotencyStore`, `meterStore`, `notifyStores`. A driver's author, a boot or a test harness imports `installJobDriver`, `installEventBus`, `installMailDriver`, `installIdempotencyStore`, `installMeterStore`, `installNotifyStores` and `resetJobDriver`, `resetEventBus`, `resetMailDriver`, `resetIdempotency`, `resetMeterStore`, `resetNotifyStores` from `@ultimat3/<pkg>/adapter` — never the `.` barrel. Each `install*` returns a release that removes that install alone, in any order: call it where you called the old setter a second time to put one back |
| 37 | `httpServer({ rateLimitStore })`, `installRateLimitStore`, `adoptRateLimitStore` (`@ultimat3/http`), `configureAuthLimiters`, `configureTotpReplay` (`@ultimat3/auth`), `setScrapeDriver` (`@ultimat3/scraping`), and their resets | you passed a store to `httpServer` or called an installer yourself: TS2353/TS2305 — set `rateLimitStore`, `authLimiter`, `totpReplay` or `scrapeDriver` in `export const runtime = { … }` (`apps/<app>/runtime.ts`); every server's limiter counts in that one store. A boot or test harness imports `installRateLimitStore` from `@ultimat3/http/adapter`, `installAuthLimiters`/`installTotpReplay` (renamed from `configure*`) from `@ultimat3/auth/adapter`, `installScrapeDriver` from `@ultimat3/scraping/adapter` — each answers its release — and `resetRateLimitStore`, `resetAuthLimiters`, `resetTotpReplay`, `resetScrapeDriver` from the same subpaths, never `.` |
| 38 | `ScrapeClock`, `TestScrapeClock`, `systemScrapeClock`, `noWaitClock`, `testScrapeClock`, `scrapeClock`, `setScrapeClock`, `resetScrapeClock` (`@ultimat3/scraping`), `configureFlags({ clock })` (`@ultimat3/flags`) | TS2305 / TS2353 — import `SleepClock`, `FrozenClock`, `systemClock`, `noWaitClock`, `frozenClock`, `ambientClock` from `@ultimat3/core`; replace `setScrapeClock(c)` … `resetScrapeClock()` with `const release = installClock(c)` (`@ultimat3/core/adapter`) … `release()`; drop `clock` from `configureFlags` and set `clock` in `apps/<app>/runtime.ts`. A test that asserted a frozen clock advanced on an already-aborted sleep now reads 0 |
| 39 | `AuditSink` (`@ultimat3/admin`) | TS2305 on the import — `import type { AuditSink } from '@ultimat3/core/adapter'` and type the sink `AuditSink<AuditEntry>` |
| 40 | `associate`, `associations`, `Association`, `AssociationMap`, `buildMany`, `createMany`, `Persister` (`@ultimat3/testing`) | TS2305/TS2353/TS2339 — `associations: { orgId: associate(orgs, (o) => o.id) }` becomes `belongsTo: { org: belongsTo(orgs, 'orgId') }` (keep `orgId` in `defaults`); rename `buildMany`/`createMany` to `buildList`/`createList`; a hand-written persister is `insert(entity, row)` answering the stored row — or delete it and `usePersister(entityPersister({ driver }))`. A test that asserted `create()` returned the BUILT row now sees the stored one (defaults filled); a test that expected `X_TEST_FACTORY_NOT_PERSISTED` for a real `entity()` now writes to `defaultDriver()`. 31.0.0–31.1.0 widened a string literal in `defaults` to `string` (TS2322 on an `enumerated` column); 31.2.0 keeps it, so an `as const` added for that can go |
| 41 | `configureAi({ gateway })` (`@ultimat3/ai`) | TS2353 on `gateway` — set `aiGateway` in `export const runtime = { … }` (`apps/<app>/runtime.ts`); keep `configureAi` for `embedder`, `semanticCache`, `redact` only. A test installs one with `const release = installAiGateway(gateway)` from `@ultimat3/ai/adapter` and calls `release()` after |
| 42 | `RuntimeOverrides.syncAuthenticate` (`@ultimat3/cli`) | TS2353 on `syncAuthenticate` in `runtime.ts` — delete it; state a token's lifetime as `configureAuthenticator(resolve, { credentialTtlMs })`. A `<app>/packages/db/factories.ts` exporting `factories` is now linted by `unit`: `X_TEST_FACTORY_INVALID` names the factory and trait to fix |
| 43 | `setAuditSink`, `resetAuditSink`, `configureErrorReporting({ reporter })` (`@ultimat3/core`) | TS2305 / TS2322 (`reporter` is `never`) — set `auditSink` / `errorReporter` in `export const runtime = { … }` (`apps/<app>/runtime.ts`), not at module scope. A test: `const release = installAuditSink(sink)` / `installErrorReporter(reporter)` from `@ultimat3/core/adapter`, `release()` after — or `swapAdapter('auditSink', sink)` from `@ultimat3/testing`. `configureErrorReporting` keeps `clock`, `release`, `environment`, `enabled`. Through 31.1.0, `x exercise` and `x build`'s measurement did not read `runtime.ts`, so the sink declared there was missing in both ("Not breaking, but you will see it", below); 31.2.0 reads it, and a memory sink installed in `apps/web/exercise.ts` or `prerender.ts` as the workaround can be deleted |
| 44 | `configureKdfGate`, `resetKdfGate` (`@ultimat3/auth`) | TS2305 — `const release = installKdfGate({ maxConcurrent, maxQueued })` from `@ultimat3/auth/adapter`, `release()` (or `resetKdfGate()`, same subpath) after |
| 45 | `RuntimeOverrides.storage` (`@ultimat3/cli`) | TS2322 when `runtime.ts` passes `defineStorage({ … })` — pass the driver (`storage: s3Driver({ … })`): it is the default disk under the driver's `name`. Several disks: `defineStorage()` in `app.config.ts`, which wins |
| 46 | `jobDriverConformance`, `budgetStoreConformance`, `BudgetStoreLike` (`@ultimat3/testing`) | TS2305 — import them from `@ultimat3/testing/conformance`, where every port's suite lives; `behavesLike` stays on `@ultimat3/testing` |
| 47 | a malformed uuid in a repository write (`@ultimat3/entity`) | a test that expected `X_DB_STATEMENT_FAILED` (Postgres 22P02) for `repo.insert({ id: 'not-a-uuid' })` now sees `X_INVARIANT_VIOLATED`, raised before the statement in both drivers — and memory no longer stores the row: expect the new code |
| 48 | `PushSubscriptionRecord.actorId` (`@ultimat3/pwa`) | TS2322 where code builds a record with `actorId: null` — store it for `ctx.actor.id`, as `pushSubscribe()` does; an anonymous visitor has no subscription. At runtime both stores now refuse a missing or empty `actorId` with `X_PWA_PUSH_SUBSCRIPTION_INVALID` before writing (Postgres answered a not-null violation; memory stored it). A custom `PushSubscriptionStore` proves the same with `pushSubscriptionStoreConformance` |
| 49 | the default delivery ledger (`@ultimat3/notify`) | you deploy (staging or production) with no `notifyStores.ledger` in `runtime.ts` and a notifier runs: its first claim is `X_NOTIFY_STORE_MISSING`, where a memory ledger answered — one per process, so a replay on another replica or after a restart sent twice. Set `notifyStores: { ledger: postgresDeliveryLedger({ executor: dbExecutor() }) }` in `export const runtime = { … }` (`apps/<app>/runtime.ts`); the boot already applies `x_notify_deliveries`. Name every store you keep in that one object — it replaces, never merges. Development and test keep the memory default |
| 50 | `configureAi({ embedder, semanticCache })` (`@ultimat3/ai`) | TS2353 on `embedder` / `semanticCache` — set them in `export const runtime = { embedder, semanticCache }` (`apps/<app>/runtime.ts`); `semanticCache` is the same `(scope) => SemanticCache` factory. `configureAi` keeps `redact` only. A test: `const release = installAiEmbedder(embedder)` / `installSemanticCache(factory)` from `@ultimat3/ai/adapter`, `release()` after — or `swapAdapter('embedder', …)` from `@ultimat3/testing`. Prove one with `embedderConformance` / `semanticCacheFactoryConformance` from `@ultimat3/testing/conformance` |
| 51 | `configureMetrics({ exporter })` (`@ultimat3/core`) | TS2353 on `exporter` — subscribe it: `const release = subscribe('metric.export', (event) => exporter.export(event.payload))`, `release()` to uninstall; `exportMetrics()` / `startMetricExport(ms)` publish the tick as before. `configureMetrics` keeps `clock` and `enabled`. With `OTEL_EXPORTER_OTLP_ENDPOINT` set the boot already subscribes `otlpMetricExporter()` |
| 52 | `oauthLogin` (`@ultimat3/auth`) — its paths, cookie and `restartAt` | the routes moved from `/auth/oauth/:provider[/callback]` to `/auth/sso/:provider[/callback]` (`oauthStartPath`/`oauthCallbackPath`/`OAUTH_*_ROUTE_PATH` answer the new values; the call and its options are unchanged). **Before deploying, add `https://<origin>/auth/sso/<provider>/callback` as an allowed redirect URI at each IdP** (GitHub, Google, your OP) — keep the old one until the deploy is out — and point every sign-in link at `/auth/sso/<provider>`. A login started before the deploy fails its callback once (`X_OAUTH_STATE_INVALID`: the state cookie is now `__Host-x_sso_<provider>`); the user starts again. `restartAt(provider)` and every OAuth `fix:` name `/auth/sso/<provider>`. `oauthDenied` and `OAUTH_ROUTE_STATUS` keep their names on `@ultimat3/auth`; a deep import from `./oauth-route` moves to the barrel. The base path is fixed (`OAUTH_BASE_PATH`, `packages/auth/src/oauth-paths.ts`): a mid-roll pod answers on one path only, so both URIs stay registered until the roll completes. **Can't re-register the redirect URI at your IdP?** Keep the old one: `oauthLogin(auth, { callbackPath: '/auth/oauth/:provider/callback' })`, then mount `callback.path` (not `OAUTH_CALLBACK_ROUTE_PATH`); the start link stays `/auth/sso/<provider>`. **Hand-built callback?** From 31.2.0, `readHandshakeCookie` opens the cookie `oauthLogin().start` sets, so a `beginOAuth` + `handshakeCookie` start leg written as a workaround can be deleted; its in-flight cookies (`__Host-x_oauth_<p>`) are still read until 32.0.0 |
| 53 | the raw-CDP launcher (`@ultimat3/testing` → `@ultimat3/scraping/cdp`) | an e2e suite imports `findChrome`, `launchChrome`, `CHROME_PATH_ENV`, a `Cdp*Error` or `E2E_BROWSER_CLOSE_MS` from `@ultimat3/testing` (TS2305, no exported member): `bun add -d @ultimat3/scraping`, then `import { findChrome, LAUNCHED_CLOSE_MS } from '@ultimat3/scraping/cdp';` — `E2E_BROWSER_CLOSE_MS` is `LAUNCHED_CLOSE_MS`, the same value. Everything e2e-shaped (`openE2eBrowser`, `E2E_BROWSER_OPEN_MS`, `installE2eDriver`, …) stays on `@ultimat3/testing` |
| 54 | `welcomeMail`, `verifyEmailMail`, `resetPasswordMail`, `inviteMail`, `mfaEnrolledMail`, `securityAlertMail`, their `*Input` schemas and types, `MFA_METHODS`, `FRAMEWORK_MAILS` (`@ultimat3/mail`) | you import one (TS2305), or send a mail by one of the ids `welcome`, `verify-email`, `reset-password`, `invite`, `mfa-enrolled`, `security-alert` without declaring it — importing the package no longer registers them. Declare your own: `export const welcomeMail = defineMail<{ name: string }>({ id: 'welcome', subject: 'mail.welcome.subject', input: t.object({ name: t.string }), template: ({ data }) => [blocks.heading('mail.welcome.heading', { name: data.name })] })`, and carry every `mail.<id>.*` key it renders in `packages/i18n/catalogs/<locale>.json` — the base catalog keeps `mail.footer.*` only. Email verification and password reset are untouched: `@ultimat3/auth` sends `auth.email-verify` / `auth.password-reset` from your catalog, as before |
| 55 | `noopMetricExporter` (`@ultimat3/core`) | you import it (TS2305): delete the import and what it was handed to — exporting nothing is subscribing nothing to `metric.export` (entry 51). A test that needs an exporter value uses `memoryMetricExporter()` |
| 56 | `AuditPrimitive`, `AuditRecord.primitive` (`@ultimat3/core`); `subjectErase()`'s audit record (`@ultimat3/jobs`) | an exhaustive `switch (record.primitive)` in your audit sink: add `case 'job':`. A report over `x_audit.primitive = 'action'` that meant to include GDPR erases adds `'job'` — an erase now records `{ primitive: 'job', surface: 'job' }`. An action a job enqueued still records `primitive: 'action'` on surface `'job'` |
| 57 | `grantUpload({ quarantine })`, `releaseQuarantine`, `ReleaseQuarantineInput`, `quarantineKey`, `quarantinePrefix`, `isQuarantinedKey`, `QUARANTINE_SEGMENT` (`@ultimat3/storage`) | you use the quarantine flow: TS2353 on `quarantine`, TS2305 on the rest. Delete the option; a review queue or async vendor scan becomes an `uploadScanner` in `apps/<app>/runtime.ts` that answers `{ verdict: 'hold', reason }` for an upload — keyed by the new `ScanInput.pendingKey` — until your own store says cleared, then `{ verdict: 'clean' }`. A hold is `X_STORAGE_QUARANTINED` (409; `meta` is now `{ key, scanner, reason }`), the upload kept pending. That scanner declares `holds: true` — `{ name, holds: true, scan }` — or its `hold` is `X_INVARIANT` and nothing is attached; with it, a scanner that cannot answer is always `X_STORAGE_SCAN_UNAVAILABLE`, even under `onScanUnavailable: 'accept'`. An exhaustive `switch` over `ScanVerdict` gains `'hold'`. **Before deploying, with objects still under `org/*/pending/quarantine/`:** install the holding scanner first, or those keys promote like any pending key — unscanned if no scanner is installed |
| 58 | `RetryPolicy`, `providerGateway({ retry })`, `DEFAULT_GATEWAY_RETRY`, `backoffMs` (`@ultimat3/ai`) | you import the type (TS2305) or pass a gateway `retry` (TS2353 on `baseDelayMs`): `import type { RetryPolicy } from '@ultimat3/core'`, and `retry: { attempts: 3, baseDelayMs: 500, maxDelayMs: 8_000 }` becomes `retry: { attempts: 3, base: 500, max: 8_000, jitter: 'full' }` — `jitter` is required. `curve`, `factor` and `timeBudgetMs` (the whole call, across every provider) are now honoured |
| 59 | `BackoffPolicy` and every `backoff:` option (`@ultimat3/realtime`: `clientTransport`, `socketEngine`, `natsTransport`, the replicator, `deviceAgent`) | you import `BackoffPolicy` (TS2305) or pass a `backoff` (TS2353 on `baseMs`): `backoff: { baseMs: 500, maxMs: 30_000, factor: 2, jitter: 'full' }` becomes `backoff: { base: 500, max: 30_000, factor: 2, jitter: 'full' }`; type it `Pick<RetryPolicy, 'base' \| 'max' \| 'factor' \| 'curve' \| 'jitter'>` with `RetryPolicy` from `@ultimat3/core`. Reading `defaultBackoff.baseMs` / `.maxMs`: `.base` / `.max`. `curve` is now honoured |
| 60 | `retry.jitter` on a `job()` (`@ultimat3/jobs`) | you set it (TS2322 on a boolean): `jitter: false` → `jitter: 'none'`, `jitter: true` → `jitter: 'equal'` — the same delays; `'full'` is new. Omitted is unchanged (`'equal'`). A boolean that reaches `job()` from JS or JSON config is refused at declaration: `X_JOB_DECLARATION_INVALID` |
| 61 | `setLogSink` (`@ultimat3/core`) | you install a log sink in a test or preload (TS2305): `const previous = setLogSink(sink); … setLogSink(previous)` becomes `const release = installLogSink(sink); … release()` — the release removes only this install, in any order. `installLogSink(undefined)` routes lines back to the real streams until released. An app scaffolded by `x new` (or given `x g sign-in`) before 31.0.0 has `apps/web/app/auth/dev-actor.test.ts` calling `setLogSink`: the same edit |
| 62 | `Row` (`@ultimat3/realtime`) | you import realtime's `Row` (TS2305): `import type { LiveRow } from '@ultimat3/realtime'` — same shape, new name. A `Row` imported from `@ultimat3/core` is a different type and unchanged |
| 63 | `Surface` (`@ultimat3/seo`) | you import it from seo (TS2305): `import type { Surface } from '@ultimat3/core'`. `RouteRecord.surface` is `Exclude<Surface, 'shared'>` — still `'site' \| 'app' \| 'api'` |
| 64 | `SURFACES` (`@ultimat3/render`) | you import the value from render (TS2305): `import { SURFACES } from '@ultimat3/core'`. The `Surface` type still comes from either |
| 65 | `SearchIndex` (`@ultimat3/entity`) | you wrote your own adapter (Meilisearch, Typesense) — TS2741: add `list({ entity, tenant?, after, limit }) → { entries: { tenant, id, version }[], next: string \| null }`, every live (non-removed) document of the entity, paged in a stable order, `tenant` omitted = every tenant. Prove it with `behavesLike(searchIndexConformance, () => yours)` from `@ultimat3/testing/conformance`; a Postgres-backed subject may pass `everyTenant: (read) => crossTenant(reason, read)` |
| 66 | `reindex({ source })` (`@ultimat3/jobs`) | your `source` is narrowed by a filter (`db.posts.where({ published: true })`): the new sweep removes every indexed document that `source` does not read, so it now EMPTIES the index of the other rows — pass the whole table (`source: () => db.posts`). The backfill's checksum changed: a pass already recorded completed re-runs only with `{ force: true }` (`x db backfill <name> --write --force --json`) |
| 67 | `registerAction`, `registerActions` (`@ultimat3/action`), `registerQuery`, `registerQueries` (`@ultimat3/query`) | you call one (TS2305): hand the module to `defineApi({ actions, queries })` — the one path — or, if you are writing a loader, import it from `@ultimat3/action/loader` / `@ultimat3/query/loader`. A `<slice>/live/<name>.live.test.ts` that `x g query --live` wrote before 31.0.0 says `const live = registerQuery('feed', feed);`: write `const live = defineApi({ queries: { feed } }).queries.feed;` with `import { defineApi } from '@ultimat3/action';`. An app module or test importing a `./loader` is `X_BOUNDARY_LOADER_IMPORT` on `x verify --only boundaries` |
| 68 | `x g app` / `x g package` (`@ultimat3/cli`) | a script passes a slice flag (`--feature`, `--surface`, `--live`, `--admin`, `--locales`, `--at`, `--app`, `--permission`): `X_CLI_BAD_FLAG` where the flag was ignored. Drop it — a workspace generator reads `--force` and `--dry-run` only |
| 69 | a `defineDriver` store declaring `'upsert'` (`@ultimat3/entity`) | your store merges an upsert collision itself: pass the merged row through `advanceVersion(entity, stored, merged, (p) => plan.set.includes(p))` from `@ultimat3/entity/adapter`, or `entityDriverConformance` fails "a searchable entity's upsert collision in the same ms is a newer version" Every searchable entity's version is now strictly increasing per row: a test asserting `updatedAt` equals a frozen clock after a second write in the same ms expects `+1 ms`, and a `defineDriver` store's `updateWhere` on a searchable entity past `MAX_ASSERTED_ROWS` rows is `X_INVARIANT_VIOLATED` |
| 70 | `grantUpload({ target })`, `grantMultipart({ target })` with an `uploadScanner` installed (`@ultimat3/storage`) | a targeted grant: `X_STORAGE_TARGET_UNSCANNED` (500). Drop `target` and call `promoteAttachment({ disk, key, orgId, target, policy })` once the upload lands, so the scanner sees it. Without a scanner nothing changes |
| 71 | `SearchIndex.index` (`@ultimat3/entity`) | you wrote your own adapter: `index(documents, options?)` takes `{ committed?: boolean }`. `committed: true` (the write path) replaces a TOMBSTONE whatever the versions say — a key deleted and written again is live — and against a held document still keeps only newer; unset (`reindex()`) keeps the tombstone's stale guard. `searchIndexConformance` checks it |
| 72 | `DriverRepo.update` in a `defineDriver` store (`@ultimat3/entity`) | your store's `update` takes a third argument `advance?: { property, stepMs }` (`DriverAdvance`, `@ultimat3/entity/adapter`): write `advance.property` per matched row as the later of the patch's instant and that row's stored one + `stepMs`, atomically with the write — in code, `advanceVersion(entity, row, { ...row, ...patch }, (p) => p === advance.property)`. A store that ignores it fails `entityDriverConformance` |
| 73 | `rowKey` of a key holding a `timestamp()` (`@ultimat3/entity`) | a searchable entity has an instant in its primary key (every partitioned table): its search-index ids are now spelled ISO 8601 UTC, where they were `String(date)` in the process zone. Run its `reindex()` once — `x db backfill <name> --write --force --json` if a pass completed: the sweep removes the old spellings, the rows phase writes the new. Cursors are unaffected |
| 74 | every package's `.` barrel (all `@ultimat3/*` but `create-ultimate`) | you import one of the 1,409 names that left `.` — TS2305, no exported member. Look it up in [`v31-barrel-removals.md`](https://github.com/developerz-ai/ultimate/blob/main/docs/history/v31-barrel-removals.md): listed under "now on `./adapter`" (46 names), import it from `@ultimat3/<pkg>/adapter`; listed under "no longer exported" (1,363), it was internal — an error factory or class is matched by code instead (`import { isUltimateError } from '@ultimat3/core'`, then `isUltimateError(e) && e.code === 'X_…'`), a `*_ERROR_CODES` table by the package's `<Pkg>ErrorCode` type where it has one; otherwise compare `e.code` to the code string — and anything else through the kept factory and its option and result types, which stay on `.` |

### Every removed setter, and the `runtime.ts` field that replaced it

One lookup for entries 32–38, 41, 43 and 50. A backend is swapped in ONE place, `export const runtime = { … }` in `apps/<app>/runtime.ts`; the installer beside each field is what the boot calls, and what a harness of your own imports — a test uses `await swapAdapter(port, impl)` from `@ultimat3/testing`. Each port's interface, reference implementation and conformance suite: [Adapters](/adapters).

| Removed in 31.0.0 | `runtime.ts` field | The installer, on the package's `./adapter` | Entry |
|---|---|---|---|
| `setJobDriver` (`@ultimat3/jobs`) | `jobs` | `installJobDriver` (`@ultimat3/jobs/adapter`) | 36 |
| `setEventBus` (`@ultimat3/jobs`) | `eventBus` | `installEventBus` (`@ultimat3/jobs/adapter`) | 36 |
| `setMailDriver` (`@ultimat3/mail`) | `mail` | `installMailDriver` (`@ultimat3/mail/adapter`) | 36 |
| `setIdempotencyStore` (`@ultimat3/action`) | `idempotencyStore` | `installIdempotencyStore` (`@ultimat3/action/adapter`) | 36 |
| `setMeterStore` (`@ultimat3/action`) | `meterStore` | `installMeterStore` (`@ultimat3/action/adapter`) | 36 |
| `setNotifyStores` (`@ultimat3/notify`) | `notifyStores` | `installNotifyStores` (`@ultimat3/notify/adapter`) | 36 |
| `setFailureStore` (`@ultimat3/core`) | `failureStore` | `installFailureStore` (`@ultimat3/core/adapter`) | 33 |
| `setChannelTransport`, `selectTransport` (`@ultimat3/realtime`); the two deleted realtime transport config keys (entry 35) | `transport` | `installChannelTransport` (`@ultimat3/realtime/adapter`) | 32, 35 |
| `MasterKeySource` (`'env' \| 'file'`, `@ultimat3/core`) | `secretSource` | `installSecretSource` (`@ultimat3/core/adapter`) | 34 |
| `setScrapeDriver` (`@ultimat3/scraping`) | `scrapeDriver` | `installScrapeDriver` (`@ultimat3/scraping/adapter`) | 37 |
| `httpServer({ rateLimitStore })`, `installRateLimitStore` on the barrel (`@ultimat3/http`) | `rateLimitStore` | `installRateLimitStore` (`@ultimat3/http/adapter`) | 37 |
| `configureAuthLimiters` (`@ultimat3/auth`) | `authLimiter` | `installAuthLimiters` (`@ultimat3/auth/adapter`) | 37 |
| `configureTotpReplay` (`@ultimat3/auth`) | `totpReplay` | `installTotpReplay` (`@ultimat3/auth/adapter`) | 37 |
| `setScrapeClock` (`@ultimat3/scraping`), `configureFlags({ clock })` (`@ultimat3/flags`) | `clock` | `installClock` (`@ultimat3/core/adapter`) | 38 |
| `configureAi({ gateway })` (`@ultimat3/ai`) | `aiGateway` | `installAiGateway` (`@ultimat3/ai/adapter`) | 41 |
| `setAuditSink` (`@ultimat3/core`) | `auditSink` | `installAuditSink` (`@ultimat3/core/adapter`) | 43 |
| `configureErrorReporting({ reporter })` (`@ultimat3/core`) | `errorReporter` | `installErrorReporter` (`@ultimat3/core/adapter`) | 43 |
| `configureAi({ embedder })` (`@ultimat3/ai`) | `embedder` | `installAiEmbedder` (`@ultimat3/ai/adapter`) | 50 |
| `configureAi({ semanticCache })` (`@ultimat3/ai`) | `semanticCache` | `installSemanticCache` (`@ultimat3/ai/adapter`) | 50 |

A setter still on a package's `.` barrel is `X_ADAPTER_PORT_INCOMPLETE` on the `boundaries` step unless `scripts/lib/adapter-port-pins.ts` names why it selects no backend.

### Not breaking, but you will see it

| What moved | Do |
|---|---|
| `x verify`'s `manifest` step is red, `X_MCP_APP_UNMOUNTED`, when `ai.mcp.expose` is `true` — **the default**, so an `app.config.ts` that never names it counts — and no `apps/web/mcp.ts` exports `mcp` (`As of 30.4.0`; before, the boot only logged it while `POST /mcp` answered 404). An app scaffolded before `x new` wrote that file hits it on its first 31.0.0 gate | mount it as `x new` does: `apps/web/mcp.ts` exporting `export const mcp = appMcp({ resolveToken: resolveAgentToken })`, `appMcp` from your `@<app>/mcp` package and `resolveAgentToken` from `./app/auth/agent-token` — copy those files (and `packages/mcp/src/index.ts`, if you have no `@<app>/mcp`) from a fresh `bunx create-ultimate`. With no catalog package, the error's own fix is the whole file: `export const mcp = defineAppMcp({ include: 'exposed', resolveToken })` (`@ultimat3/mcp`), `resolveToken` answering an agent or `null`. No agent surface wanted: `ai: { mcp: { expose: false } }` in `app.config.ts` |
| `verifyApiKey` (`@ultimat3/auth`) refuses every key with `X_API_KEY_INVALID` when the stored `prefix` is not the one the bearer parses to: since 31.0.0 it compares `parseApiKey(bearer).prefix` — always `ult_<env>_<id>` — to `record.prefix` (`packages/auth/src/api-keys.ts`, `verifyApiKey`). An app that minted keys itself, or stores a display prefix of its own (`acme_<env>_<id>`, a truncated `ult_prod`), passed on 30.x and fails every key now. A bearer not shaped `ult_<env>_<id>_<secret>` was already refused by `parseApiKey` before 31 | list the rows that cannot match: `psql "$DATABASE_URL" -c "select id, prefix from x_api_keys where prefix !~ ('^ult_[a-z0-9-]+_' \|\| id \|\| '$')"`; for each, `update x_api_keys set prefix = 'ult_<env>_' \|\| id where id = '<id>'`, `<env>` the second segment of the bearer that key's holder sends. A custom `ApiKeyStore` answers `prefix` exactly as `issueApiKey` wrote it |
| a unique collision under the memory driver — what a factory's `create()` writes to in a test since entry 40 — is `X_DB_UNIQUE_VIOLATION` with no SQLSTATE: `sqlState(e)` (`@ultimat3/db`) answers `undefined`, where Postgres answers `23505`, so a race handler matching `sqlState(e) === '23505'` passes in production and fails its test (`packages/entity/src/driver-errors.ts`, `entityUniqueViolation`) | match the code, which both drivers raise: `isUltimateError(e) && e.code === 'X_DB_UNIQUE_VIOLATION'` (`isUltimateError` from `@ultimat3/core`); `meta.constraint` names the constraint on both. Through 31.1.0 only the code matches under memory; fixed in **31.2.0**, from which the memory driver and every `defineDriver` store carry `23505` too and `sqlState(e) === SQLSTATE.uniqueViolation` (`@ultimat3/db`) also matches |
| `x exercise` reports `X_AUDIT_SINK_MISSING` (`X_QUERY_AUDIT_SINK_MISSING` for a query) on every `audit: true` primitive, and `x build` lists each page whose render runs one under `unmeasured`: entry 43 moved `auditSink` into `runtime.ts`, and neither pass installs `runtime.ts` | fixed in **31.2.0**: both read `apps/<app>/runtime.ts` through the boot's own loader, a memory sink standing in for the declared one so neither writes to it. On 31.0.0–31.1.0, install a memory sink at the top of `apps/web/exercise.ts` and `apps/web/prerender.ts` — `installAuditSink(memoryAuditSink())`, `installAuditSink` from `@ultimat3/core/adapter`, `memoryAuditSink` from `@ultimat3/action`; on 31.2.0, delete it |
| `defineFactory` (`@ultimat3/testing`, entry 40) widens a string literal in `defaults` or a trait to `string`, so an `enumerated([…])` column is TS2322 — `Type 'string' is not assignable to type '"closed" \| "open"'` | `'open' as const` on the literal. Fixed in **31.2.0**: from it, an `as const` or per-literal annotation added for this TS2322 can be deleted — kept, it is harmless |
| `disk('<name>')` is `X_STORAGE_DISK_UNKNOWN` for a disk your own `defineStorage({ disks: { … } })` declares, when an app module reached from `runtime.ts` makes that call: the boot installed its one-disk registry over it | fixed in **31.2.0**: the boot keeps a registry the app declared, and the app's `defineStorage()` wins over `runtime.storage`. Upgrade to it; no workaround is verified for 31.0.0–31.1.0 |
