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).
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); 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: 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.
| 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 |