@ultimat3/cli 29.4.0 → 30.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (448) hide show
  1. package/CLAUDE.md +64 -40
  2. package/README.md +61 -11
  3. package/package.json +42 -28
  4. package/src/affected-tests.ts +97 -0
  5. package/src/affected-unowned.ts +43 -0
  6. package/src/affected.ts +25 -6
  7. package/src/app-boundaries.ts +29 -1
  8. package/src/app-evals.ts +12 -4
  9. package/src/app-load-unresolved.ts +62 -0
  10. package/src/app-load.ts +30 -37
  11. package/src/app-mcp.ts +14 -2
  12. package/src/app-module-graph.ts +232 -0
  13. package/src/app-reload-graph.ts +2 -2
  14. package/src/app-root.ts +37 -1
  15. package/src/bin.ts +1 -0
  16. package/src/boundary-findings.ts +6 -0
  17. package/src/budgets-freshness.ts +32 -0
  18. package/src/budgets.ts +20 -1
  19. package/src/build-capture-env.ts +9 -0
  20. package/src/cdp-shot-a11y.ts +2 -39
  21. package/src/ci-tested.ts +243 -0
  22. package/src/client-emit-customer-runtime.ts +139 -0
  23. package/src/client-emit-customer.ts +108 -0
  24. package/src/client-emit-drift.ts +72 -0
  25. package/src/client-emit-header.ts +67 -0
  26. package/src/client-emit-schemas.ts +175 -0
  27. package/src/client-emit-types.ts +121 -0
  28. package/src/client-emit-vendor.ts +146 -0
  29. package/src/client-errors-routes.ts +229 -0
  30. package/src/client-errors-script.ts +74 -0
  31. package/src/client-vitals-route.ts +197 -0
  32. package/src/client-vitals-script.ts +56 -0
  33. package/src/cmd-affected.ts +23 -0
  34. package/src/cmd-build-spec.ts +7 -1
  35. package/src/cmd-build.ts +35 -9
  36. package/src/cmd-ci-spec.ts +42 -6
  37. package/src/cmd-ci.ts +99 -2
  38. package/src/cmd-client-customer.ts +134 -0
  39. package/src/cmd-client-source.ts +81 -0
  40. package/src/cmd-client-spec.ts +48 -0
  41. package/src/cmd-client-write.ts +41 -0
  42. package/src/cmd-client.ts +111 -0
  43. package/src/cmd-db-recount.ts +120 -0
  44. package/src/cmd-db-spec.ts +36 -6
  45. package/src/cmd-db.ts +11 -3
  46. package/src/cmd-deploy-compose.ts +61 -3
  47. package/src/cmd-deploy-drain.ts +10 -2
  48. package/src/cmd-deploy-helm.ts +11 -0
  49. package/src/cmd-deploy-preflight.ts +118 -0
  50. package/src/cmd-deploy-spec.ts +58 -6
  51. package/src/cmd-deploy-verify.ts +380 -0
  52. package/src/cmd-deploy.ts +42 -9
  53. package/src/cmd-docs.ts +34 -4
  54. package/src/cmd-doctor.ts +66 -9
  55. package/src/cmd-drill-spec.ts +25 -0
  56. package/src/cmd-drill.ts +176 -0
  57. package/src/cmd-env-init.ts +102 -0
  58. package/src/cmd-env-spec.ts +10 -3
  59. package/src/cmd-env.ts +15 -10
  60. package/src/cmd-errors-recent.ts +60 -0
  61. package/src/cmd-errors-spec.ts +9 -1
  62. package/src/cmd-errors.ts +4 -0
  63. package/src/cmd-exercise-spec.ts +19 -0
  64. package/src/cmd-exercise.ts +129 -0
  65. package/src/cmd-generate-spec.ts +9 -1
  66. package/src/cmd-generate.ts +32 -27
  67. package/src/cmd-graph-load.ts +97 -0
  68. package/src/cmd-graph-spec.ts +16 -0
  69. package/src/cmd-graph.ts +73 -0
  70. package/src/cmd-i18n.ts +4 -1
  71. package/src/cmd-impact-spec.ts +13 -0
  72. package/src/cmd-impact.ts +64 -0
  73. package/src/cmd-jobs-resolve.ts +76 -0
  74. package/src/cmd-jobs-spec.ts +21 -2
  75. package/src/cmd-jobs.ts +10 -6
  76. package/src/cmd-mail-spec.ts +28 -0
  77. package/src/cmd-mail.ts +70 -0
  78. package/src/cmd-mcp-lock.ts +69 -0
  79. package/src/cmd-mcp-spec.ts +23 -4
  80. package/src/cmd-mcp.ts +17 -3
  81. package/src/cmd-new-spec.ts +8 -1
  82. package/src/cmd-new.ts +61 -9
  83. package/src/cmd-planned.ts +0 -6
  84. package/src/cmd-repro-spec.ts +31 -0
  85. package/src/cmd-repro.ts +174 -0
  86. package/src/cmd-stop-spec.ts +21 -0
  87. package/src/cmd-stop.ts +166 -0
  88. package/src/cmd-test-spec.ts +17 -4
  89. package/src/cmd-test.ts +70 -14
  90. package/src/cmd-upgrade-spec.ts +26 -0
  91. package/src/cmd-upgrade.ts +274 -0
  92. package/src/cmd-verify-spec.ts +3 -3
  93. package/src/db-backfill.ts +6 -0
  94. package/src/db-diagnostics.ts +106 -0
  95. package/src/db-generate.ts +9 -0
  96. package/src/db-reset-template.ts +1 -1
  97. package/src/db-rolling.ts +45 -0
  98. package/src/db-seed-generate.ts +188 -0
  99. package/src/deploy-verify-findings.ts +98 -0
  100. package/src/deploy-verify-flags.ts +109 -0
  101. package/src/deploy-verify-grade.ts +185 -0
  102. package/src/deploy-verify-queues.ts +53 -0
  103. package/src/dev-boot.ts +35 -15
  104. package/src/dev-child-watch.ts +16 -4
  105. package/src/dev-dashboard-guard.ts +8 -1
  106. package/src/dev-dashboard.ts +7 -0
  107. package/src/dev-lock-identity.ts +61 -0
  108. package/src/dev-lock-port-holder.ts +80 -0
  109. package/src/dev-lock-steal.ts +124 -0
  110. package/src/dev-lock.ts +92 -120
  111. package/src/dev-mail-templates.ts +51 -0
  112. package/src/dev-rebuild.ts +26 -2
  113. package/src/dev-route-table.ts +17 -0
  114. package/src/dev-supervisor.ts +5 -2
  115. package/src/dev-watch-tree.ts +11 -10
  116. package/src/dev-watch.ts +1 -1
  117. package/src/dispatch.ts +29 -6
  118. package/src/docs-section.ts +69 -0
  119. package/src/docs-sources.ts +100 -0
  120. package/src/doctor-auth.ts +11 -2
  121. package/src/doctor-chart-alerts.ts +55 -0
  122. package/src/doctor-pooler.ts +107 -0
  123. package/src/doctor-rolling.ts +35 -0
  124. package/src/doctor-row-security.ts +40 -0
  125. package/src/document-options.ts +13 -0
  126. package/src/drill-sync-lane.ts +25 -0
  127. package/src/drill-targets.ts +39 -0
  128. package/src/duplicate-packages.ts +1 -1
  129. package/src/env-production.ts +93 -0
  130. package/src/error-codes-shot.ts +59 -0
  131. package/src/error-codes-tooling.ts +29 -0
  132. package/src/error-codes.ts +100 -54
  133. package/src/error-contract.ts +2 -2
  134. package/src/errors.ts +22 -3
  135. package/src/exercise-database.ts +99 -0
  136. package/src/exercise-findings.ts +159 -0
  137. package/src/exercise-overrides.ts +42 -0
  138. package/src/exercise-registration.ts +58 -0
  139. package/src/exercise-static.ts +88 -0
  140. package/src/exercise-subject.ts +69 -0
  141. package/src/failure-capture-boot.ts +30 -0
  142. package/src/framework-env.ts +7 -1
  143. package/src/framework-schema.ts +72 -5
  144. package/src/generate-catalog-locales.ts +19 -0
  145. package/src/generate-chart-alerts.ts +156 -0
  146. package/src/generate-feature-required.ts +65 -0
  147. package/src/generate-feature.ts +83 -6
  148. package/src/generate-files.ts +24 -0
  149. package/src/generate-format-written.ts +32 -0
  150. package/src/generate-kinds.ts +6 -0
  151. package/src/generate-sign-in-config.ts +100 -0
  152. package/src/generate-sign-in-manifest.ts +28 -0
  153. package/src/generate-sign-in-mode.ts +101 -0
  154. package/src/generate-sign-in.ts +250 -0
  155. package/src/generate-write.ts +44 -7
  156. package/src/hand-rolled-socket.ts +72 -0
  157. package/src/i18n-app-key-shapes.ts +29 -0
  158. package/src/i18n-audit.ts +10 -3
  159. package/src/i18n-registration.ts +18 -3
  160. package/src/index.ts +2 -0
  161. package/src/island-bundle.ts +4 -3
  162. package/src/island-disposer.ts +116 -0
  163. package/src/island-realtime-state.ts +34 -6
  164. package/src/island-realtime.ts +6 -2
  165. package/src/island-routes.ts +3 -2
  166. package/src/isr-query-advice.ts +6 -11
  167. package/src/isr-query-guard.ts +4 -3
  168. package/src/job-budget.ts +95 -0
  169. package/src/jobs-json.ts +11 -0
  170. package/src/lint-findings.ts +104 -0
  171. package/src/local-cli.ts +34 -4
  172. package/src/loopback-host.ts +2 -2
  173. package/src/mail-preview-findings.ts +24 -0
  174. package/src/mcp-errors.ts +82 -2
  175. package/src/mcp-host.ts +11 -1
  176. package/src/mcp-lock-findings.ts +57 -0
  177. package/src/measure-paths.ts +12 -4
  178. package/src/measure-scope.ts +27 -13
  179. package/src/messages-client.ts +23 -0
  180. package/src/messages-db.ts +56 -0
  181. package/src/messages-gate.ts +107 -0
  182. package/src/messages-repro.ts +11 -0
  183. package/src/messages-shot.ts +51 -0
  184. package/src/messages-upgrade.ts +45 -0
  185. package/src/messages.ts +53 -178
  186. package/src/metrics-endpoint.ts +23 -5
  187. package/src/new-shapes-generate.ts +71 -0
  188. package/src/new-shapes.ts +63 -0
  189. package/src/openapi-compile-ir.ts +68 -0
  190. package/src/openapi-compile-schema.ts +284 -0
  191. package/src/openapi-compile.ts +273 -0
  192. package/src/openapi-refuse.ts +45 -0
  193. package/src/otlp-export.ts +2 -1
  194. package/src/output-findings.ts +123 -0
  195. package/src/output.ts +32 -8
  196. package/src/package-boundaries.ts +110 -0
  197. package/src/page-pins.ts +23 -0
  198. package/src/plain-routes.ts +13 -0
  199. package/src/prerender-entries.ts +59 -0
  200. package/src/prerender-heaviest.ts +27 -0
  201. package/src/prerender-preload.ts +6 -0
  202. package/src/prerender-report.ts +12 -0
  203. package/src/prerender-seo-reads.ts +38 -0
  204. package/src/prerender-served.ts +22 -7
  205. package/src/prerender-vitals.ts +42 -0
  206. package/src/prerender.ts +61 -32
  207. package/src/pwa-artifacts.ts +4 -3
  208. package/src/pwa-manifest-locales.ts +1 -1
  209. package/src/registry.ts +18 -0
  210. package/src/repro-bundle.ts +65 -0
  211. package/src/repro-errors.ts +47 -0
  212. package/src/repro-job.ts +72 -0
  213. package/src/repro-literal.ts +41 -0
  214. package/src/repro-request.ts +48 -0
  215. package/src/repro-write.ts +100 -0
  216. package/src/retain-sweep.ts +27 -0
  217. package/src/retained-assets-boot.ts +113 -0
  218. package/src/retained-assets-layout.ts +110 -0
  219. package/src/retained-assets-read.ts +142 -0
  220. package/src/retained-assets-sweep.ts +70 -0
  221. package/src/retained-assets.ts +171 -0
  222. package/src/revalidated-response.ts +8 -5
  223. package/src/role-replicator.ts +7 -1
  224. package/src/role-start-devices.ts +119 -0
  225. package/src/role-start-types.ts +6 -0
  226. package/src/role-start.ts +22 -5
  227. package/src/role-sync.ts +7 -1
  228. package/src/route-islands.ts +42 -0
  229. package/src/route-table.ts +2 -1
  230. package/src/row-security-boot.ts +58 -0
  231. package/src/row-security-wanted.ts +32 -0
  232. package/src/runtime-admin.ts +0 -1
  233. package/src/runtime-assets-site-cache.ts +55 -0
  234. package/src/runtime-assets-site.ts +136 -0
  235. package/src/runtime-assets.ts +99 -55
  236. package/src/runtime-bindings.ts +19 -3
  237. package/src/runtime-cache.ts +23 -14
  238. package/src/runtime-isr-outcome.ts +114 -60
  239. package/src/runtime-purge.ts +14 -2
  240. package/src/runtime-queue.ts +31 -4
  241. package/src/runtime-render-document.ts +204 -0
  242. package/src/runtime-render.ts +48 -198
  243. package/src/runtime-replica-position.ts +62 -0
  244. package/src/runtime-replica.ts +18 -3
  245. package/src/runtime-s3-path-style.ts +17 -0
  246. package/src/runtime-services.ts +19 -6
  247. package/src/runtime-statement-bound.ts +22 -0
  248. package/src/runtime-storage-upload.ts +9 -0
  249. package/src/runtime-storage.ts +41 -81
  250. package/src/schema-diff-views.ts +54 -0
  251. package/src/schema-diff.ts +21 -3
  252. package/src/schema-drift.ts +28 -9
  253. package/src/schema-dump-drift.ts +91 -7
  254. package/src/script-csp.ts +1 -1
  255. package/src/seo-cards.ts +26 -0
  256. package/src/seo-routes.ts +22 -8
  257. package/src/serve-boot.ts +54 -21
  258. package/src/serve-drain.ts +1 -0
  259. package/src/serve-env.ts +26 -8
  260. package/src/serve-retire.ts +12 -6
  261. package/src/serve-web.ts +42 -4
  262. package/src/serve.ts +9 -7
  263. package/src/site-asset-routes.ts +7 -1
  264. package/src/site-config.ts +4 -0
  265. package/src/site-llms-pages.ts +106 -0
  266. package/src/site-llms.ts +64 -0
  267. package/src/site-seo-write.ts +4 -2
  268. package/src/site-seo.ts +55 -6
  269. package/src/source-mtime.ts +50 -0
  270. package/src/static-capture-errors.ts +41 -0
  271. package/src/static-capture.ts +56 -0
  272. package/src/static-cards-serve.ts +39 -0
  273. package/src/static-cards.ts +93 -0
  274. package/src/static-document.ts +14 -13
  275. package/src/static-images.ts +115 -0
  276. package/src/stream-hole-scope.ts +55 -0
  277. package/src/style-bundle.ts +1 -1
  278. package/src/style-routes.ts +3 -2
  279. package/src/sw-precache-plan.ts +2 -2
  280. package/src/sw-routes.ts +2 -2
  281. package/src/templates/action-tests.ts +306 -0
  282. package/src/templates/action.ts +81 -269
  283. package/src/templates/backfill.ts +1 -1
  284. package/src/templates/entity.ts +21 -5
  285. package/src/templates/github/ci.yml.ts +8 -9
  286. package/src/templates/guard-bare-error.ts +2 -2
  287. package/src/templates/guard-error-declared.ts +263 -0
  288. package/src/templates/guard-native-confirm.ts +132 -0
  289. package/src/templates/guard-raw-fetch.ts +151 -0
  290. package/src/templates/guard-raw-interval.ts +121 -0
  291. package/src/templates/guard-repo-raw-sql.ts +71 -2
  292. package/src/templates/job.ts +1 -1
  293. package/src/templates/naming.ts +5 -0
  294. package/src/templates/pinned/docker-compose.yml +6 -0
  295. package/src/templates/pinned-images.ts +8 -0
  296. package/src/templates/policy.ts +85 -2
  297. package/src/templates/query.ts +70 -13
  298. package/src/templates/resource-create.ts +3 -1
  299. package/src/templates/resource-form-island.ts +15 -4
  300. package/src/templates/resource-page-db.ts +83 -0
  301. package/src/templates/resource-page.ts +161 -52
  302. package/src/templates/route.ts +4 -6
  303. package/src/templates/scaffold-app.ts +3 -0
  304. package/src/templates/scaffold-auth.ts +2 -1
  305. package/src/templates/scaffold-bin.ts +5 -52
  306. package/src/templates/scaffold-claude-agents.ts +19 -19
  307. package/src/templates/scaffold-claude-commands.ts +28 -31
  308. package/src/templates/scaffold-claude.ts +26 -23
  309. package/src/templates/scaffold-container-compose.ts +6 -1
  310. package/src/templates/scaffold-container.ts +24 -4
  311. package/src/templates/scaffold-dashboard-bare.ts +3 -3
  312. package/src/templates/scaffold-dashboard-example-tests.ts +49 -9
  313. package/src/templates/scaffold-dashboard-example-view.ts +156 -0
  314. package/src/templates/scaffold-dashboard-example.ts +43 -131
  315. package/src/templates/scaffold-dashboard-shared.ts +3 -7
  316. package/src/templates/scaffold-db-client.ts +20 -6
  317. package/src/templates/scaffold-db-package.ts +5 -14
  318. package/src/templates/scaffold-docs.ts +73 -57
  319. package/src/templates/scaffold-domain-package.ts +18 -14
  320. package/src/templates/scaffold-entries.ts +7 -2
  321. package/src/templates/scaffold-exercise.ts +37 -0
  322. package/src/templates/scaffold-guards.ts +13 -1
  323. package/src/templates/scaffold-helm-alerts.ts +234 -0
  324. package/src/templates/scaffold-helm-templates.ts +7 -5
  325. package/src/templates/scaffold-helm.ts +46 -12
  326. package/src/templates/scaffold-i18n.ts +6 -2
  327. package/src/templates/scaffold-mcp-endpoint.ts +182 -0
  328. package/src/templates/scaffold-mcp-json.ts +31 -0
  329. package/src/templates/scaffold-mcp-package.ts +59 -56
  330. package/src/templates/scaffold-repo.ts +7 -2
  331. package/src/templates/scaffold-shell.ts +45 -79
  332. package/src/templates/scaffold-test-services.ts +13 -5
  333. package/src/templates/shapes/api-keys.ts +170 -0
  334. package/src/templates/shapes/api.ts +101 -0
  335. package/src/templates/shapes/blog-site.ts +340 -0
  336. package/src/templates/shapes/blog-slice.ts +265 -0
  337. package/src/templates/shapes/blog.ts +114 -0
  338. package/src/templates/shapes/dashboard-detail.ts +242 -0
  339. package/src/templates/shapes/dashboard.ts +53 -0
  340. package/src/templates/shapes/index.ts +17 -0
  341. package/src/templates/shapes/overlay.ts +99 -0
  342. package/src/templates/shapes/shared-edits.ts +46 -0
  343. package/src/templates/sign-in-action-tests.ts +237 -0
  344. package/src/templates/sign-in-authenticator.ts +112 -0
  345. package/src/templates/sign-in-oidc.ts +189 -0
  346. package/src/templates/sign-in-pages.ts +148 -0
  347. package/src/templates/sign-in-password.ts +216 -0
  348. package/src/templates/sign-in-shared.ts +268 -0
  349. package/src/templates/slice-foundation.ts +70 -15
  350. package/src/test-batches.ts +1 -1
  351. package/src/test-database.ts +1 -1
  352. package/src/test-shards.ts +7 -1
  353. package/src/test-workers.ts +18 -12
  354. package/src/tool-gate-names.ts +34 -0
  355. package/src/upgrade-agents-link.ts +49 -0
  356. package/src/upgrade-bun-floor.ts +225 -0
  357. package/src/upgrade-gate.ts +228 -0
  358. package/src/upgrade-pins.ts +63 -0
  359. package/src/upgrade-plan.ts +214 -0
  360. package/src/upgrade-registry.ts +53 -0
  361. package/src/upgrade-report.ts +139 -0
  362. package/src/upgrade-root.ts +74 -0
  363. package/src/verify-budgets-build.ts +118 -0
  364. package/src/verify-checks.ts +67 -24
  365. package/src/verify-coverage-run.ts +1 -1
  366. package/src/verify-e2e-lane.ts +41 -0
  367. package/src/verify-exercise.ts +64 -0
  368. package/src/verify-floor.ts +41 -0
  369. package/src/verify-merge.ts +19 -4
  370. package/src/verify-role-load.ts +2 -0
  371. package/src/verify-run.ts +28 -8
  372. package/src/verify-shard.ts +12 -2
  373. package/src/verify-skip-ceiling.ts +87 -0
  374. package/src/verify-step.ts +5 -0
  375. package/src/verify-tests.ts +25 -5
  376. package/src/view-drift.ts +55 -0
  377. package/src/worker-bundle.ts +3 -2
  378. package/src/workspace-graph.ts +38 -19
  379. package/src/write-generated.ts +43 -0
  380. package/src/zone-boot.ts +20 -0
  381. package/wiki/Actions.md +342 -0
  382. package/wiki/Admin-Dashboard.md +432 -0
  383. package/wiki/Agents.md +958 -0
  384. package/wiki/Auth.md +195 -0
  385. package/wiki/Bare-VM.md +111 -0
  386. package/wiki/Batching-And-Preloading.md +136 -0
  387. package/wiki/Building-Your-Own-Base.md +203 -0
  388. package/wiki/CI-Parallel-Gate.md +220 -0
  389. package/wiki/CLI-Reference.md +1894 -0
  390. package/wiki/Caching-And-Invalidation.md +255 -0
  391. package/wiki/Client-Data.md +194 -0
  392. package/wiki/Client-Navigation.md +513 -0
  393. package/wiki/Compared.md +93 -0
  394. package/wiki/Configuration.md +868 -0
  395. package/wiki/Contributing.md +12 -0
  396. package/wiki/Deploy-Recipes.md +122 -0
  397. package/wiki/Deployment.md +508 -0
  398. package/wiki/Entities-And-Migrations.md +1116 -0
  399. package/wiki/FAQ.md +175 -0
  400. package/wiki/Feature-Flags.md +65 -0
  401. package/wiki/Getting-Started.md +221 -0
  402. package/wiki/Home.md +160 -0
  403. package/wiki/I18n.md +239 -0
  404. package/wiki/Installation.md +206 -0
  405. package/wiki/Interface-Rules.md +339 -0
  406. package/wiki/Jobs-And-Workflows.md +783 -0
  407. package/wiki/Known-Gaps.md +185 -0
  408. package/wiki/MCP-And-AI.md +753 -0
  409. package/wiki/Mail.md +138 -0
  410. package/wiki/Migrating-An-Existing-App.md +594 -0
  411. package/wiki/Migrating-From-Django.md +54 -0
  412. package/wiki/Migrating-From-Laravel.md +53 -0
  413. package/wiki/Migrating-From-Node-Drizzle.md +53 -0
  414. package/wiki/Migrating-From-Rails.md +53 -0
  415. package/wiki/Migrations-And-Backfills.md +552 -0
  416. package/wiki/Money.md +338 -0
  417. package/wiki/N-Plus-One-Detection.md +197 -0
  418. package/wiki/Notify.md +208 -0
  419. package/wiki/Observability.md +245 -0
  420. package/wiki/Outbound-HTTP.md +109 -0
  421. package/wiki/PWA-And-Offline.md +343 -0
  422. package/wiki/Policies-And-Authz.md +251 -0
  423. package/wiki/Project-Layout.md +150 -0
  424. package/wiki/Queries-And-Live-Queries.md +429 -0
  425. package/wiki/Realtime.md +734 -0
  426. package/wiki/Resource-Management.md +121 -0
  427. package/wiki/Routes-And-Render-Modes.md +521 -0
  428. package/wiki/SEO.md +288 -0
  429. package/wiki/Scheduled-Tasks.md +175 -0
  430. package/wiki/Scraping.md +493 -0
  431. package/wiki/Shapes.md +63 -0
  432. package/wiki/Stability.md +97 -0
  433. package/wiki/Static-Assets.md +166 -0
  434. package/wiki/Storage-And-Uploads.md +404 -0
  435. package/wiki/Testing.md +1313 -0
  436. package/wiki/The-Eight-Primitives.md +247 -0
  437. package/wiki/Theming.md +362 -0
  438. package/wiki/Timezones-And-Dates.md +194 -0
  439. package/wiki/Troubleshooting.md +163 -0
  440. package/wiki/Tutorial-01-First-App.md +289 -0
  441. package/wiki/Tutorial-02-First-Feature.md +339 -0
  442. package/wiki/Tutorial-03-Auth-And-Admin.md +379 -0
  443. package/wiki/Tutorial-04-Jobs-And-Realtime.md +263 -0
  444. package/wiki/Tutorial-05-Deploy-Free.md +231 -0
  445. package/wiki/Tutorial-06-Growing-Up.md +158 -0
  446. package/wiki/UI-Components.md +156 -0
  447. package/wiki/Upgrading.md +3667 -0
  448. package/src/stored-object-headers.ts +0 -43
@@ -0,0 +1,3667 @@
1
+ # Upgrading
2
+
3
+ **`As of 2026-08`. Semver applies from here.** A breaking change to a documented API needs a major. Every `@ultimat3/*` package moves in lockstep — never mix versions. An app declares them at `^<version>`, as `x new` writes, and `bun.lock` pins the exact build.
4
+
5
+ > **From v30, one major per 90 days.** The window, what semver covers, the support window and the security exception: [Stability](Stability). A removal now ships its `@deprecated` alias for at least one minor before a major takes it, so `x upgrade` across one major names no edit a minor did not already warn about. The 29 sections below stay as they are.
6
+
7
+ **Twenty-nine majors have shipped, and this page walks all twenty-nine** — 2.0.0's 33 entries joined it `As of 2026-08`, and `scripts/changelog-check.ts` now refuses a summary row whose section the page does not carry, which is how they were missing for six releases. [`CHANGELOG.md`](https://github.com/developerz-ai/ultimate/blob/main/CHANGELOG.md) is the source for the majors it still carries, and `git show v<tag>:CHANGELOG.md` for the ones it has archived; none ships a codemod, so every entry is a manual edit the entry itself names. **One section per major**, newest first — read the ones between your pin and your target, oldest first.
8
+
9
+ | From → to | Breaking entries | Read |
10
+ |---|---|---|
11
+ | 29.x → 30.0.0 | **143** — plan 101 (ten waves), issues #990–#999 and two find-and-fix sweeps: a write past its deadline that rolls back, a closed-transaction refusal, a two-release column drop, **same-tenant composite keys that need one `x db gen`**, `upsertAll` overwriting only what it names, every agent and MCP call held by `mcpConfirmations`, `'null'` refused in `cors.origins`, `isr` and `static` that render as nobody, a query-free `isr` key by default, a pool that refuses past `acquireTimeoutMs`, an upload grant bound to its declared size, mail that refuses an unresolved key, a problem document with `retry`, a worker retire on by default, required `StorageDriver`, `JobDriver` and `PoolProfile` members, a `budgets` step that builds before it weighs, no `bin/check` in a new app, `ui`'s Standard Schema copies removed, no vendor link on the default error page, the render brands at `@ultimat3/core/render-node` only, a required `enqueueMany` on every `JobHandle`, `JobsFacade` and `JobDriver`, an outbox relay that claims 500 rows a pass, `'browser'` in `ERROR_SOURCES`, `assets` and `clientErrors` on `AppConfig`, `pwa`'s `retentionPlan` and `retainBuildIds` removed, a `DefineEvalInput` union, `MCP_PROTOCOL_VERSION` deleted, an MCP revision decided per request with `2026-07-28` the newest, a cross-origin `POST /mcp` refused with `'origin-refused'` in `McpAuthRefusal`, `x mcp serve` answering only a loopback Host, a raw `WorkloadToken` payload, a role that refuses a half-loaded app, a query's own MCP tool wording, no `QueryPoll.ms`, an exercise refused outside `bun test`, an accessibility audit on every `e2eTest`, factory values read off each column's declaration, a memory transaction that holds every write inside it, a 21st gate step that runs every primitive with no test written, a finding for an untenanted child of a tenant-owned table, a memory transaction that refuses a write after it ended and runs nested effects at the root's commit, required `failureCapture` and `time` on `AppConfig`, an `x_failures` table in the schema dump, a description on every page `/llms.txt` lists, a required `TimeZoneConfig.detectedCookie`, an MCP resource read handed its caller, a required `DevCapabilities.queryGraph` and `DevSources.mailTemplates`, an exercise step that plans every statement and reds an unindexed read or a loop over rows, an `OpenGraph` that is a hosted `image` or a captured page and never both, a `postgresAuthAdapter()` that reads the ambient client per call, a `refetch()` that returns a promise, 48 more `UI_KEYS` in a module namespace, charts that draw `null` and negative values and refuse a series that does not match its keys, a two-mode `TabsProps`, a command palette that ranks, two more contrast pairs on `bg-soft`, every `mock.module` put back at its file boundary, a static export that writes image variants as files, `/assets` answering only the variants it declares, an island `mount` that must return its disposer, passkey storage on every `AuthAdapter`, a per-call rate limit split from the fixed one, a signed vote in `McpConfirmationStore.decide`, a meter in the manifest and its contract diff, a hand-rolled websocket refused, bad-token counting on every `PushDeviceStore`, exact history images on Postgres, a policy on every multipart part and completion, `LiveHandle.retry`, a Bearer-only CSRF exemption, no `enqueueInTx`, and an event id from `EventLookup.find` | [the `30.0.0` section](#29x--3000-entry-by-entry), in order |
12
+ | 28.x → 29.0.0 | **2** — **Bun 1.4.4 as the floor**; a `WORKER_QUEUES` entry the app does not know refuses the worker's boot | [the `29.0.0` section](#28x--2900-entry-by-entry), in order |
13
+ | 27.x → 28.0.0 | **2** — **Bun 1.4.3 as the floor**; `@ultimat3/ui/jsx-probe` and its `probe`/`unprobe` removed | [the `28.0.0` section](#27x--2800-entry-by-entry), in order |
14
+ | 26.x → 27.0.0 | **2** — `AsyncContext.exit` required; **`/_storage` and `/media` refuse a key outside every `org/<id>/` prefix unless `defineStorage({ shared })` lists its prefix**, and `Storage.isShared` required | [the `27.0.0` section](#26x--2700-entry-by-entry), in order |
15
+ | 25.x → 26.0.0 | **6** — an OAuth login that verifies the id token's signature against the provider's published key set by default; Apple out of the OAuth built-ins; `ServerHandle.describe()` deleted; no `api/**/route.ts` file kind; `recover: 'agent'` deleted and scraping at tier 4; one CLI verb per command (`list`, `show`, `delete`) | [the `26.0.0` section](#25x--2600-entry-by-entry), in order |
16
+ | 24.x → 25.0.0 | **40** — Bun 1.4.2 as the floor; a closed `app.config.ts` that refuses an undeclared key and every deleted one by name, the locales in `defineCatalogs()`; `assertEnvExample`, `resolveSpeculation` and `normalizeAuditRecord` removed, `AuditRecord.name` / `.primitive` required, every value imported from the package that declares it; one spelling per factory, never `create<Thing>`; one `Page` (`entity`'s `Page` and `storage`'s `ListPage` removed), `http.drainTimeoutMs` and `http.buildIdHeader` removed, a `bearerMount` catch-all; one MCP projection (`toolFrom`), one page shape (`nextCursor`, `hasMore`), a per-action rate limit that is the action's own, a redaction-keeping `IdempotencyStore`, `SQL_*` statements off the barrels, the NATS job driver stub deleted, required `WebhookLedger.isDisabled` and `ChangeEvent.write`; one MCP block in core with `McpExposure` and its `name` removed, `contentHash` removed, a scopes map that names every tool, a required `DigestAppend.appender`; **no model chosen for an app — it registers its own and names one**; a widened `Menu` `aria-controls`, a private `FakeElement.listeners`, offline scrapes that state their robots reason, `nearest` removed, a `--cdp-url` capture that fails closed, a page that may not import a repo, ai's `contentHash` renamed `promptHash`, one meaning per value name, every job ack counted; a per-queue `jobs.concurrency`, the framework's own secrets in `.env.example`, an e2e app that inherits no `DATABASE_URL`; a two-argument `resolveGrants`, `WORKER_QUEUES` as the exact set, a `jobWorker` default of 8 slots, and `ChangeEvent.table` | [the `25.0.0` section](#24x--2500-entry-by-entry), in order |
17
+ | 23.x → 24.0.0 | **202** — a calendar check on `t.date`, `t.url` refusing what the parser would cut, plain objects only, a default its own schema must accept, decimal-only coercion, a stricter `defineConfig`, an unknown `LOG_LEVEL` refused, `retry` and `createFlightGate` refusing a bound that is not one, a child context that aborts with its parent, compound credential names redacted, error `meta` under `extra.meta` in the monitor envelope, per-signal OTLP headers, a sampler that ignores a leftover ratio, wildcard host rules that stop at the network edge, an empty cursor secret counted as unset; then tier 1 — `t()` always interpolating, interval crons through both passes of a fall-back hour, exact cron names, `formatRelative` requiring a zone, a transaction that rejects when its body swallowed a failed statement, `X_DB_COMMIT_UNKNOWN`, nested transaction options refused, sibling nested scopes run in turn under a 30 s wait, a `changed-primary-key` drift kind, `introspect()` reporting catalog types, flag expiries that must be ISO; then an `e:<entity>` purge key on every tagged response, WebP-only `responsiveImage()` by default, `promoteAttachment` requiring its policy, a required `StorageDriver.stat()`, an optional `lastModified`, a `get()` ceiling, image variant keys that keep the source extension, `v2` signed URLs that name their disk; then a required `Driver.transactor()`, `dbDrift` leaving entity, a preload ceiling, `assertAllowed` throwing the decision's own code, a 401 for a denial with no actor, a malformed policy decision that denies; then `ctx.peer` behind its own switch, anonymous browser writes held to same-origin, failed sign-ins metered to a 429, health bodies trimmed for strangers, a `max-age` treated as a shared-cache offer, a body refused without a `content-type`, an awaited browser `close()`; then auth — **a sealed MFA secret that needs `x auth seal-mfa` run once**, a retired `x_auth_failures` table, a reservation-shaped `AuthLimiter`, API keys bound to their owner and its grants, `X_MFA_REQUIRED` carrying a challenge, eight more required `AuthAdapter` members, `oauthLogin` requiring `APP_URL`, an e2e `offline()` that rejects when a page refuses the switch; then `compareValues` removed from query, a `.limit()` that bounds every page, a `single` read answering one row over MCP, `admin:*` declared by `defineAdmin()` rather than by import; then `mutator()` requiring `idempotent: true`, a regenerated `openapi.json` and schema dump, idempotent actions and cache busts that settle with the commit, a `manifest` step that fails on a stale `openapi.json`; then jobs — a final-attempt lapse buried instead of re-claimed, `cancel` refusing finished jobs, step writes fenced on the claim, a uuid-only `runId`, an awaited `purgeExpired()`, `x_job_events` swept; then realtime — **three operator steps: `APP_URL` on the `sync` role as the whole origin allow-list, `REPLICA IDENTITY FULL` for channels with params, and a replication connection that refuses a weak password without TLS** — rows revived across the bus, five interfaces with new required members, stricter channel re-authorization, and an offline queue abandoned when the principal changes; then a catch-all route that answers its bare prefix, a zero-length ISR `ttl` refused, `NativeReason` gaining `'unparsable'`, CSS-module classes scoped in selectors only with every scoped name changed once, a positional `CompiledPattern.specificity`, and a notification tap that opens this app only; then ui — two required `ThemeEnv` members, a fixed `theme.defaultMode` that a "System" pick and an OS change no longer override, the deprecated theme inline script removed, a `Popover` trigger whose `aria-controls` can be absent, and file controls that post only the files they accepted; then a required `peek` on every rate-limit store and an address refused on required routes once its failed credentials are spent; then mcp — a 5xx cause hidden from a remote caller, `db.query` refusing SQL run as text, a meta tool without `destructive` listed as a query, list calls held to their whitelist, failed tokens metered per address; then a `send()` that waits for its transaction's commit and one recipient rule on every mail driver; then `x_notify_digests` in the schema dump, queries publishing `input`, a non-finite manifest fact refused, and a dropped NOT NULL default classed as breaking; then ai — an atomic `BudgetStore.take`, a required `Gateway.callLedger(keys)`, **gateway `actor` and `org` ceilings that count every model call's caller**, a 5xx tool cause hidden from the model, `fnv1a` removed and `HashEmbedder` vectors changed, an unknown finish reason read as truncated, an unscoped vector read refused inside an org's request, a required `VectorStore.prune`, `chunk()` owning `source`, and `numericTolerance` refusing a bound that is not one; then testing — a coded `app()` before boot, `assertDeterministic` by canonical form, `frozenClock` announcing its moves, one module instance per island mount; then scraping — a proxy switch in launch args refused, a required browser-level `target()`, a jar-less browser refused, `burnSession` comparing `savedAt`, `maxDrop` held to a fraction, credential-shaped URL redaction, a declared header that replaces the session's, wildcard hosts resolved and pinned, robots redirects screened per hop, and an undated recording read as stale; then admin — a nullable keyset bound, **edit forms that post `_version` and a 409 for a stale edit**, a required `AdminFormProps.version`, a `'stale'` `CrudResult`, creates and updates decided on what they write, tenant-scoped audit screens, actions and batches that commit with their audit entry, `?scope=*` for no scope, and stricter list filters; then a split sitemap's parts under `/sitemaps/`, a permission's declaration site restored with it, then cli — an export directory a build must prove is its own, a static build that fails on a module that does not load, unread build flags refused, a `--dry-run` that is the write plan, generator names and API bindings refused before writing, an unowned path read as root-wide, test discovery anchored at the root, guards and app entry files under `filesize` and `errors`, SEO answers kept an hour, a scaffold pinned to the repository's `solid-js` and typechecking the root program, **a permission only `defineAdmin()` declared refused by the `policy` step**, **an unrecorded `REPLICA IDENTITY FULL` refused by the `drift` step**, and a registry snapshot that carries permission sites; then a handler's own CSP kept beside the app's, `x routes --json` in every route fix, **serving roles that leave the framework schema to `ROLE=migrate`**, decimal-only ports and proxy hops, stored files served as sandboxed downloads, no readiness grace off the listening roles, a replicator ready only while its stream runs, and cli commands that exit 1 on an unknown name or any finding, a `/_x` SQL panel that runs only a same-origin POST from this machine, and stricter `x doctor`, `x jobs`, `x shot`, `x dev`, `x verify merge`, `tests.run`, `x mcp serve` and `x routes`; then `checkErrorCodesThrown` waiving only the codes a list names; then **a chart that renders a NetworkPolicy per role, a bounded `/tmp` and a Secret every role must have**, and a sync listener on its own port at port 0 | [the `24.0.0` section](#23x--2400-entry-by-entry), in order |
18
+ | 22.x → 23.0.0 | **66** — an image line that prebuilds the island store, a worker that imports less of the app, a committed schema dump, a stated coverage floor, step deadlines, raw browser requests refused by the gate, a typed-handle repo with `list(limit)` and a generated query with no `orgId` input, admin label keys the `i18n` step now checks, every hand-written job driver and store fenced on its claim, `runJobs` through a real worker, a framework-served admin that replaces the host's pages and now serves the jobs dashboard, an async `AuditLog`, admin writes held to the row scope, and sealed scraping sessions that discard what was stored before | [the `23.0.0` section](#22x--2300-entry-by-entry), in order |
19
+ | 21.x → 22.0.0 | **23** — two date readers that refuse a non-ISO string instead of reading it in the host's zone, a `helm` release named after the app, `channel()` requiring a policy, a per-mutation outbox, a `sync` role that refuses to boot with nothing to deliver, boot-owned auth tables, `x shot` on raw CDP with no `puppeteer-core`, `realtime.transport` deciding the bus, and removed exports: `Result`, realtime's `backoffDelay`, the e2e driver's move to `@ultimat3/testing`, `startLiveReplicator` leaving it, unreferenced package internals and 236 of the CLI's, a one-time `x db gen` for a re-stamped schema hash, and a query that filters on a column its loader never selected refusing instead of answering `[]` | [the `22.0.0` section](#21x--2200-entry-by-entry), in order |
20
+ | 20.x → 21.0.0 | **27** — `AsyncState`'s import path, `custom(merge)` over rows rather than outputs, realtime's second conflict vocabulary removed, `isSuperseded` widened, one error path for every typed client, the record envelope on actions that return entity rows, the service worker's outbox flush replaced by a message to open tabs, a third client-scope answer, `last-write-wins` refused without a clock, the realtime client rebuilt around one page store and one read hook, Compose requiring `SYNC_URL`, `x verify`'s duration as wall time, and channels served by declaration only. The client data layer, one entry per removed surface | [the `21.0.0` section](#20x--2100-entry-by-entry), in order |
21
+ | 19.x → 20.0.0 | **2**, both `@ultimat3/ui` component behaviour and neither a type change — a `DataTable` that keeps its rows while reloading, and a `Button` whose `loading` no longer sets the native `disabled`. Nothing fails to compile; what changes is what a screen does | [the `20.0.0` section](#19x--2000-entry-by-entry), in order |
22
+ | 18.x → 19.0.0 | **2**, both from the same hole — the service worker had no build behind it, so the config key that steers it and the route it falls back to both had to move | [the `19.0.0` section](#18x--1900-entry-by-entry), in order |
23
+ | 17.x → 18.0.0 | **5** — a runtime floor that was a minor behind what the CLI emits, two PWA config surfaces that had to grow before an app could be installable, a `--json` shape, and one scraping interface | [the `18.0.0` section](#17x--1800-entry-by-entry), in order |
24
+ | 16.x → 17.0.0 | **3**, all one sweep — a numeric option that used to accept `NaN` refuses it, at boot or at the call boundary rather than mid-request | [the `17.0.0` section](#16x--1700-entry-by-entry), in order |
25
+ | 15.x → 16.0.0 | **1** — `matches(/re/)` refuses a construct the two regex engines read differently, at `entity()` time | [the `16.0.0` section](#15x--1600-entry-by-entry), in order |
26
+ | 14.x → 15.0.0 | **1** — `DriftKind` gains a member, so an exhaustive `switch` with no `default` stops compiling | [the `15.0.0` section](#14x--1500-entry-by-entry), in order |
27
+ | 13.x → 14.0.0 | **8** — two are security fixes with a behaviour change (a frame verb, a session key), four are types that refused what the runtime already did, and two remove API nothing called | [the `14.0.0` section](#13x--1400-entry-by-entry), in order |
28
+ | 12.x → 13.0.0 | **2**, both narrow: a service factory's parameter type, and one deleted `PageLike` member with zero call sites anywhere in the repository | [the `13.0.0` section](#12x--1300-entry-by-entry), in order |
29
+ | 11.x → 12.0.0 | **16**, from the widest sweep since 4.0.0 — a keyset defect that dropped rows, a name that reached the DDL unchecked, and eight interfaces that gained a member | [the `12.0.0` section](#11x--1200-entry-by-entry), in order |
30
+ | 10.x → 11.0.0 | **7** | [the `11.0.0` section](#10x--1100-entry-by-entry), in order |
31
+ | 9.x → 10.0.0 | **19** | [the `10.0.0` section](#9x--1000-entry-by-entry), in order |
32
+ | 8.x → 9.0.0 | **5** | [the `9.0.0` section](#8x--900-entry-by-entry), in order |
33
+ | 7.x → 8.0.0 | **6** | [the `8.0.0` section](#7x--800-entry-by-entry), in order |
34
+ | 6.x → 7.0.0 | **4** | [the `7.0.0` section](#6x--700-entry-by-entry), in order |
35
+ | 5.x → 6.0.0 | **7** | [the `6.0.0` section](#5x--600-entry-by-entry), in order |
36
+ | 4.x → 5.0.0 | **2**, over six surfaces, each a declaration that promised what the code did not do | [the `5.0.0` section](#410--500-entry-by-entry), in order |
37
+ | 3.0.0 → 4.0.0 | **25**, from a sweep that closed every known gap | [the `4.0.0` section](#300--400-entry-by-entry), in order |
38
+ | 2.0.0 → 3.0.0 | **10**, all from a five-agent bug sweep | [the `3.0.0` section](#200--300-entry-by-entry), in order |
39
+ | 1.x → 2.0.0 | **33** | [the `2.0.0` section](#1x--200-entry-by-entry), in order |
40
+ | 11.x → 20.0.0 | **47** | every major section `CHANGELOG.md` still carries, oldest first |
41
+
42
+ An entry is a line `CHANGELOG.md` marks `BREAKING —`. The count is derived, never curated:
43
+
44
+ ```sh
45
+ grep -cE '^(- \*\*|### )BREAKING —' <(awk '/^## /{u = ($0 == "## [Unreleased]")} !u' CHANGELOG.md)
46
+ # 560 As of 2026-11-07 — every RELEASED section, which is the sum of every row above whose section
47
+ # the changelog still carries.
48
+ ```
49
+
50
+ How that total is counted, and why `[Unreleased]` is cut: the header of [`scripts/changelog-check.ts`](https://github.com/developerz-ai/ultimate/blob/main/scripts/changelog-check.ts).
51
+
52
+ Each entry changes a surface the table below covers.
53
+
54
+ > **Move to whatever `latest` is** — only the [footer](_Footer) stamps the number, because a version written into a page goes stale on the next tag. All 31 workspaces resolve at one version — 29 `@ultimat3/*` plus the unscoped `create-ultimate`, `@ultimat3/scraping` and `@ultimat3/flags` included — and every tarball since 3.0.0 was published by the release workflow with a provenance attestation. Resolve before you pin, never take it from this page:
55
+
56
+ | Check | Command | Answer that means "go" |
57
+ |---|---|---|
58
+ | what `latest` is | `npm view @ultimat3/core version` | the version you are pinning |
59
+ | that a package resolves at it | `npm view @ultimat3/scraping@<version> version` | that version, not `E404` |
60
+ | that the tarball is attested | `npm view @ultimat3/core dist.attestations` | a `provenance` object |
61
+ | every name that must move together | `bun run scripts/release-workflow.ts --json` | the 30 derived names — check each |
62
+
63
+ ## 29.x → 30.0.0, entry by entry
64
+
65
+ `As of 2026-11-05`, unpublished: `CHANGELOG.md`'s `[Unreleased]` breaking entries in their order, grouped by package, lowest tier first. No legacy path, no codemod, no shim. A later slice appends its rows below the last one and never renumbers; each row's `CHANGELOG.md` line names it as `(#N)` after the dash.
66
+
67
+ ### The upgrade, top to bottom
68
+
69
+ | # | Do | What you see until you do | Entries |
70
+ |---|---|---|---|
71
+ | 1 | commit, then `bunx @ultimat3/cli@latest upgrade --json`: it pins every `@ultimat3/*`, raises the Bun floor, installs and runs the gate | `X_UPGRADE_DIRTY_TREE` on a dirty tree | — |
72
+ | 2 | `x db gen "same-tenant keys"`, commit `packages/db/migrations` and `packages/db/schema/`. A validate failing `X_DB_FOREIGN_KEY_VIOLATION` found rows already pointing across tenants: decide those rows, or state `crossTenant: '<reason>'` on an edge that means it | `X_DB_DRIFT` from `drift`, and `X_SCHEMA_DUMP_DRIFT` for the new framework columns. An entity whose `{ table }` differs from its name moves its keys to the table they always meant in the same migration. `X_DB_DRIFT` from a dump column no snapshot records is an edited migration: restore it | 8, 25, 46, 53 |
73
+ | 3 | `x verify --only typecheck` and close every error: a `Date` off a step result, `step.waitForEvent` with no `schema`, a hand-written `TotpReplayGuard`, `DeliveryLedger`, `JobDriver` or `StorageDriver`, a hand-built `ReferenceDescription` or `PoolProfile`, a `Record<StageName, …>`, `'queue-depth'`, `REPLAYED_HEADER` from `@ultimat3/action`, `requestedUrls()`, `suspenseBoundaries`, `csrfBlocked` with two arguments, a read of `DownloadFile.body`, `ERROR_PAGE_LINKS.homepage`, `FormSchema` or its siblings from `@ultimat3/ui`, `JSX_NODE` from `@ultimat3/render`, a hand-written `JobHandle` or `JobsFacade`, an exhaustive `switch` or `Record` over `ErrorSource`, a hand-built `AppConfig`, `retentionPlan` / `RetentionPlan` / `Deploy` from `@ultimat3/pwa`, `retainBuildIds` passed to `generateServiceWorker`, `input.prompt` off a `DefineEvalInput`, `MCP_PROTOCOL_VERSION`, an exhaustive `switch` over `McpAuthRefusal` | a compile error at each site | 8, 16, 21, 22, 25, 30, 32, 35, 39, 40, 43, 44, 45, 47, 50, 57, 58, 61, 62, 65, 66, 67, 68, 69, 70, 73 |
74
+ | 4 | `x verify --only manifest`: correct a misspelt name in `mcpConfirmations({ tools })`, or expose the action, or build a hand-written server at module scope in `apps/<app>/mcp.ts`. For a role load, add the import its fix names to `apps/web/api/index.ts`, or move the declaration into a module that imports no `.tsx` and no stylesheet | `X_MCP_CONFIRMATION_TOOL_UNKNOWN`, `X_ROLE_LOAD_INCOMPLETE` | 18, 27 |
75
+ | 5 | `x verify --only unit,contract,job`: move each fixture the new refusals name | `X_DB_TRANSACTION_CLOSED`, `X_INVARIANT_VIOLATED`, a memory-driver fixture wiring two tenants refused, `X_N_PLUS_ONE_QUERY` from a memory-backed loop now counted, `X_MAIL_KEY_MISSING` from a mail test, an `enqueue` spy on a spread `memoryJobDriver()` that `enqueueMany` or the relay no longer calls | 3, 4, 8–14, 19, 20, 41, 48, 63 |
76
+ | 6 | `grep -rnE "render: '(isr\|static)'" apps/`: a page varying per visitor becomes `ssr`, and every parameter a `load` or page reads goes in `revalidate: { query: ['<name>'] }` | nothing fails: a signed-in `load` sees nobody, and a query the key dropped serves one document for every value | 23, 24 |
77
+ | 7 | `grep -rnE "upsertAll\|seed\.upsert" apps/ packages/`: name each column a collision must overwrite. A wide `insertAll` that relied on partial progress splits its own batch | nothing fails: a timestamp stops moving on re-upsert, or a failed batch leaves no rows | 11, 12, 13 |
78
+ | 8 | read `mcpConfirmations({ tools })` against every `agent({ tools })` and every MCP server: the agent's `output` reports the pending confirmation id, and a tool that must run unconfirmed leaves `tools` | the write answers `X_MCP_CONFIRMATION_PENDING` and writes nothing | 17, 26 |
79
+ | 9 | **before the next column drop:** declare it `retired`, deploy, then drop | `X_MIGRATION_ROLLING_UNSAFE` from `x db gen` | 5 |
80
+ | 10 | **before the deploy:** move a CDN rule keyed on `public, immutable` for `/media`; re-upload through the server an XML or `+xml` object over 64 KiB that awaits promotion | a CDN miss; `X_STORAGE_TYPE_REJECTED` at promotion | 6, 7, 15 |
81
+ | 11 | `grep -rnE "onShutdown\|retry\(" apps/`: reorder a close hook that depends on running first; wrap a retry or a write that must outlive its caller in `runToCompletion(fn)` | nothing fails: a write or a retry stops with its caller | 1, 2, 3 |
82
+ | 12 | `grep -rn "'null'" apps/ packages/`, and read each list built from `URL.parse(…).origin`: list real origins with their scheme | `X_CORS_CONFIG_INVALID` at boot | 28 |
83
+ | 13 | read each `HeadTag` with a non-http(s) `href`: serve an inline SVG favicon from `site.favicon`, or a same-origin path under `assets/` | nothing fails in production; `head.attribute_dropped` once in dev | 29 |
84
+ | 14 | `x manifest`, commit `openapi*.json` and `x.manifest.json`, once, after every other edit | `X_MANIFEST_STALE` from `manifest` | 38, 42, 49 |
85
+ | 15 | `x verify --only manifest` for `X_JOB_OUTLASTS_DRAIN`: raise `drain.workerRetireMs` or lower the job's `timeout` | `X_JOB_OUTLASTS_DRAIN` naming the job, its limit and the budget | 52 |
86
+ | 16 | `x verify --only i18n`, then `x i18n sync <locale>` per locale it names, and translate what it seeds. Add the framework's `mail.*` keys `meta.keys` lists to `packages/i18n/catalogs/<locale>.json` by hand. A subject interpolates only the payload's top-level scalars: put each `{slot}` there | `X_CATALOG_MISSING_KEYS`; `X_MAIL_KEY_MISSING` from `send()` | 33, 34, 48 |
87
+ | 17 | `x verify --only boundaries`: move each `packages/*` file's runtime import of `apps/*` down. A scaffold from before 30.0.0 has two: `packages/mcp/src/index.ts` (`export const appMcp = () => defineAppMcp({ name, include: 'exposed' })`, no import of `apps/web/api/health`) and `packages/db/src/seed.ts` (`const DEMO_ORG_LABEL = 'org:demo';`) | `X_BOUNDARY_PACKAGE_TO_APP` | 55 |
88
+ | 18 | `x g query` / `x g backfill` take `--feature <slice>`; a new table is `x g resource <name>` | `X_FEATURE_REQUIRED`, zero files written | 54 |
89
+ | 19 | every upload action's input carries `size: file.size`; every `disk.signedUrl(key, { method: 'PUT', maxBytes })` on s3 adds `contentLength: file.size`; `S3_FORCE_PATH_STYLE` is `0`, `1` or blank in every environment | `X_STORAGE_UPLOAD_SIZE_UNDECLARED`; `X_CONFIG_INVALID` at boot; a 403 `SignatureDoesNotMatch` for a client PUTting another length | 36, 37, 56 |
90
+ | 20 | **before the deploy:** the autoscaler adapter gains `max by (queue) (queue_demand)` and an app's own chart its worker HPA metric; set `drain.workerRetireMs` (or `0` for the old stop) and re-deploy with `x deploy --method helm`. A scaffolded chart's `preStop` arrives by editing `docker/helm/templates/_helpers.tpl`: `const end = Date.now() + {{ int $cfg.retireSeconds }} * 1000; for (let n = 1; Date.now() < end; n++)` | the HPA reads a metric nothing publishes; an old worker Terminating up to an hour while it holds a job | 30, 51 |
91
+ | 21 | **before the deploy:** a transaction's slow outbound await moves before `withTransaction`; a pod behind a pooler sets `DATABASE_POOLER` and both bounds with `alter role`, as `X_DB_POOLER_OPTIONS_REFUSED` prints; size `DATABASE_POOL_MAX` from the pool gauges | `X_DB_STATEMENT_FAILED` (`25P03`) on an idle transaction; `X_DB_POOL_EXHAUSTED` where a statement used to queue | 31, 32 |
92
+ | 22 | `x verify --only budgets`: it now builds when `.x/build-stats.json` is missing or older than your source. A route it finds over budget: shrink it, or raise the budget by the measured figure with the reason. Raise `stepTimeoutMs.budgets` in `x.verify.json` if the build outlasts the 5-minute step deadline | `X_BUDGET_EXCEEDED` on a route stale stats hid | 59 |
93
+ | 23 | optional: delete `bin/check.ts` and the `check` script, and run `bun run verify` (`x verify`) locally and in CI. A script that killed `x dev` by pid or `pkill` calls `x stop --json` | nothing fails: `bun run check` still works and does not build twice | 60 |
94
+ | 24 | a compose deploy of an app scaffolded before 30.0.0: in `docker/docker-compose.prod.yml`'s `x-image` block, `tmpfs: [/tmp, '/app/.x:uid=1000,gid=1000']` (1000 is the `USER` of `docker/Dockerfile`), then `x env init --production` if the app has no `.env.production` | `X_STORAGE_UNWRITABLE … EACCES` at boot on every role; `X_DEPLOY_ENV_MISSING` before the first step | — |
95
+ | 25 | `grep -rn "outboxRelay(" apps/ packages/`: a relay that runs the row loop (a driver without `enqueueMany`, such as `redisJobDriver`) against very slow publishes passes `batchSize: 100` | nothing fails: 500 rows must publish inside the 30 s claim lease, or they are claimed again | 64 |
96
+ | 26 | optional, a repository that copies `ci.yml`'s `tested` job ([CI-Parallel-Gate](CI-Parallel-Gate#a-push-a-green-pr-run-already-tested)): give each reused job, the verdict job included, `needs: tested` and `if: ${{ !cancelled() && needs.tested.outputs.tested != 'true' }}` and no other `if` | without `!cancelled()`, a red or cancelled `tested` skips every job instead of running them | — |
97
+ | 27 | an MCP client: a browser page on another origin calling `POST /mcp` lists that origin in `http.cors.origins`; a script reaching `x mcp serve` through a LAN name or a tunnel uses `localhost`; a test pinning `initialize`'s `2025-06-18` answer offers that revision | `403` `X_MCP_ORIGIN_REFUSED`; `421` `X_MCP_HOST_REFUSED`; `protocolVersion: '2025-11-25'` | 71, 72, 74 |
98
+ | 28 | `x verify --only typecheck`: add `createRoot` to each `setSolidRuntime` call, `ui.first` to a `Record<UiKey, string>`, `errors` to a hand-built descriptor, `raw` to a hand-built `WorkloadToken`; drop each read of `QueryPoll.ms`. Add `failureCapture` and `time` to a hand-built `AppConfig`, `detectedCookie` to a full `TimeZoneConfig`, `queryGraph` to a `DevHost`, `mailTemplates` to a hand-built `DevSources`, and the caller to each direct `read` of an MCP resource. Add the 48 keys entry 122 lists to a `Record<UiKey, string>`, a `null` check to each read of `ChartPoint.value` and each `barRects()` rect, `none` to a direct `chartTable()` call, and `async` to a hand-built `refetch`; make an `interface` that extends `OpenGraph`, `BarChartProps` or `TabsProps` a `type … = … & { … }` | TS2345, TS2741, TS2339, TS2554, TS18047, TS2312, TS2322, TS2375 | 75, 77, 82, 83, 86, 111, 114, 115, 116, 117, 119, 121, 122, 123, 125 |
99
+ | 29 | `x verify --only contract`: give each guarded action and query an `exercise`, and fix what its generated input finds. A keyset list that drew a disabled Previous takes `firstHref` | `X_CONTRACT_EXERCISE_UNDECLARED`, `X_CONTRACT_DEFECT`, `X_CONTRACT_EXERCISE_UNREACHED`, `X_UI_INVALID_VALUE` | 76, 78, 80, 81 |
100
+ | 30 | `grep -rn "refresh()" apps/`: a caller that wanted focus on `<main>`, the title read out, or a navigation cancelled says so itself | nothing fails: focus stays where it was | 79 |
101
+ | 31 | `x verify --only e2e`: name what the audit lists — visible text, `aria-label`, `<label for>`, `alt` (`alt=""` for a decorative image), a `<title>`, `<html lang>`, nothing focusable under `aria-hidden` — or give that test `{ a11y: { allow: [{ role, selector, because }] } }`. A raw-tab test (`browser.session.newTab()`) names what it lists too, or calls `allowA11y([{ role, selector, because }])` before the navigation. A hand-written `E2eBrowserPage` implements or forwards `accessibilityTree(request)` | `X_E2E_A11Y_VIOLATION` naming each element; `X_TEST_FIXTURE_UNAVAILABLE` from every `e2eTest` on a page without the method | 88 |
102
+ | 32 | `x verify --only unit,contract`: a test that asserted a `factoriesFor` value by its old name rule asserts the declared one, or builds it with `defineFactory`; a test that relied on a memory write surviving a failed `memoryTransactor().run` writes it outside the transaction | an assertion on an old name-derived value (`<column>-<index>`, `new Date(0)`, `'USD'` in a `…Currency` text column) fails; `X_SEED_GRAPH_UNSATISFIED`; a row gone after a rolled-back transaction | 89, 90 |
103
+ | 33 | `x exercise --json`: paste each printed `fix:` entry into `apps/web/exercise.ts`'s `defineExercise({ … })` — an actor its policy allows, an `input` binding a real row — or give the primitive `{ off: '<why>' }`; then `x exercise <name> --json` for that one. An app with no `apps/web/exercise.ts` writes `import { defineExercise } from '@ultimat3/testing'; export const exercise = defineExercise({});` first | `X_CONTRACT_EXERCISE_UNREACHED` from the `exercise` step, one per primitive | 91 |
104
+ | 34 | for each entity `exercise` names: `orgId: uuid().tenant()` in its columns, `x db gen '<table> tenant'`, and on a table that already holds rows `x g backfill <name> --feature <slice>` to fill it; or `entity('<name>', { shared: '<why it belongs to no tenant>' })` | `X_ENTITY_TENANT_UNDECLARED` from the `exercise` step | 92 |
105
+ | 35 | `x verify --only unit,contract,job`: await every write inside a `memoryTransactor().run`; a test that asserted an `onCommit` effect right after a nested `run` asserts it after the root's | `X_DB_TRANSACTION_CLOSED`; an effect not yet run where the old one had run | 93 |
106
+ | 36 | `x db gen`, commit `packages/db/schema/`; `x verify --only seo` and add each `description` it names (or `seo: { llms: false }`); then `x exercise --json` and close each plan finding with the `indexes:` line and `x db gen` it prints, or a stated `scan`, `expectedSeqScan` or `expectedQueryLoop` reason | `X_SCHEMA_DUMP_DRIFT`; `X_SEO_LLMS_DESCRIPTION_MISSING`; `X_QUERY_UNINDEXED`, `X_QUERY_SORT_UNSERVED`, `X_QUERY_SCALES_WITH_ROWS` | 112, 113, 118 |
107
+ | 37 | `x verify --only unit,contract,e2e`: mock each module in the file that reads it, and move a test pinning a palette order, an empty chart gap cell or `deltaOf(x, 0) === undefined` to the new answer. Then `x build --target static`: put each image a finding names under `apps/web/site/assets/` and pass `asset('assets/<file>')`, or drop `avif` from that route's `formats`. Read each served `responsiveImage()` over `asset()` for its own `widths` or `formats`, and each `defineTheme()` brand for `chart-1` and `chart-5` against `bg-soft`. A site route whose `prerender()` or `load` reads the app's own query passes `entries: report.entries, llmsPages: report.llmsPages` to `siteSeo({ … })` in `apps/web/prerender.ts` | `X_TEST_MODULE_MOCK_UNRESTORABLE`, or a test that reads the real module; `X_UI_INVALID_VALUE`; `X_IMAGE_SOURCE_UNREADABLE`, `X_IMAGE_UNSUPPORTED`; 404 `X_ASSET_VARIANT_UNDECLARED`; `X_UI_CONTRAST_INSUFFICIENT`; `X_CLIENT_TRANSPORT_FAILED` from the export | 120, 124, 126–130 |
108
+ | 38 | `x verify --only boundaries`: end each `.island.tsx`'s `mount` with `return render(() => <Island {...props} />, el)` and type it `(): () => void`; a plain-DOM island returns a function that removes what it added | `X_ISLAND_DISPOSER_MISSING` | 131 |
109
+ | 39 | `x verify --only typecheck,boundaries,contract-diff`: give an out-of-tree `AuthAdapter` the nine `PasskeyStore` methods; read a function `limit` from `descriptor.rateLimitPerCall`; pass `decide` the signature and give a custom `McpConfirmation` its `quorum`, `approvers`, `approvals`; move a hand-rolled websocket to `deviceSocket({ path, resolveToken, onFrame })`; then `x manifest` and commit the new `meter` field | TS2420, TS2339, TS2554; `X_SOCKET_HAND_ROLLED`; `X_MANIFEST_DRIFT` | 132–136 |
110
+ | 40 | give a custom `PushDeviceStore` `noteBadToken` and `clearBadToken` and pass `TokenCache.invalidate` the refused token; with `history: true` on Postgres, `x db gen "history exact images"`, and parse a history reader's `bigint`/`decimal` value as a string | TS2420, TS2554; `X_DB_DRIFT` until the migration exists | 137, 138 |
111
+ | 41 | pass the grant's server-side `policy` to every `signPart` and `completeUpload`; give a hand-written `LiveHandle` `retry()` and a custom event bus's `find` the event `id` and `afterId`; replace `enqueueInTx` with `handle.enqueue`; a client calling with cached Basic credentials across sites sends a Bearer token or comes from a listed origin | TS2345, TS2741, TS2305; `X_INVARIANT`; `X_CSRF_BLOCKED` | 139–143 |
112
+
113
+ ### Entry by entry
114
+
115
+ Tier 0 — `@ultimat3/core` (1–2). Tier 1 — `@ultimat3/db` (3–5), `@ultimat3/storage` (6–7). Tier 2 — `@ultimat3/entity` (8–14), `@ultimat3/http` (15), `@ultimat3/auth` (16). Tier 3 — `@ultimat3/action` (17–20), `@ultimat3/jobs` (21–22). Tier 4 — `@ultimat3/render` (23–24), `@ultimat3/notify` (25). Appended after the tier order: `@ultimat3/mcp` (26–27), `@ultimat3/http` (28), `@ultimat3/render` (29). Appended by the second wave, by tier: `@ultimat3/core` (30), `@ultimat3/db` (31–32), `@ultimat3/i18n` (33–34), `@ultimat3/storage` (35–37), `@ultimat3/http` (38–40), `@ultimat3/entity` (41), `@ultimat3/query` (42–43), `@ultimat3/action` (44), `@ultimat3/jobs` (45–46), `@ultimat3/render` (47), `@ultimat3/mail` (48), `@ultimat3/manifest` (49), `@ultimat3/testing` (50), `@ultimat3/cli` (51–56). Appended by the third wave, by tier: `@ultimat3/http` (57), `@ultimat3/ui` (58), `@ultimat3/cli` (59–60). Appended by the wave 3 gate fix: `@ultimat3/render` (61). Appended by the fourth wave: `@ultimat3/jobs` (62–64), `@ultimat3/core` (65–66), `@ultimat3/pwa` (67–68), `@ultimat3/ai` (69), `@ultimat3/mcp` (70–73), `@ultimat3/cli` (74). Appended by the fifth wave: `@ultimat3/ui` (75–78), `@ultimat3/render` (79), `@ultimat3/action` (80–82). Appended by wave 5b: `@ultimat3/auth` (83), `@ultimat3/cli` (84), `@ultimat3/mcp` (85), `@ultimat3/realtime` (86), `@ultimat3/core` (87). Appended by wave 6a: `@ultimat3/testing` (88–89), `@ultimat3/entity` (90). Appended by wave 6b: `@ultimat3/cli` (91), `@ultimat3/entity` (92–93). Appended by wave 7: `@ultimat3/cli` (94), `@ultimat3/testing` (95), `@ultimat3/auth` (96–97), `@ultimat3/jobs` (98–100), `@ultimat3/scraping` (101), `@ultimat3/db` (102), `@ultimat3/ai` (103). Appended by wave 8: `@ultimat3/cache` (104), `@ultimat3/db` (105–106), `@ultimat3/core` (107), `@ultimat3/db` (108), `@ultimat3/cli` (109–110). Appended by wave 9: `@ultimat3/core` (111), `@ultimat3/db` (112), `@ultimat3/seo` (113), `@ultimat3/http` (114), `@ultimat3/mcp` (115–116), `@ultimat3/admin` (117), `@ultimat3/cli` (118). Appended by wave 10: `@ultimat3/seo` (119), `@ultimat3/auth` (120), `@ultimat3/realtime` (121), `@ultimat3/ui` (122–127), `@ultimat3/testing` (128), `@ultimat3/cli` (129–131). Appended by the issues PR (#990–#999): `@ultimat3/auth` (132), `@ultimat3/action` (133), `@ultimat3/mcp` (134), `@ultimat3/manifest` (135), `@ultimat3/cli` (136). Appended by sweep 1: `@ultimat3/notify` (137), `@ultimat3/db` (138). Appended by sweep 2: `@ultimat3/storage` (139), `@ultimat3/realtime` (140), `@ultimat3/http` (141), `@ultimat3/jobs` (142–143).
116
+
117
+ | # | Surface | Costs you an edit if |
118
+ |---|---|---|
119
+ | 1 | `close`-phase `onShutdown` order | a close hook relies on running before one registered after it: hooks now run last-registered first, so register in the order you want released. `accept` and `inflight` are unchanged |
120
+ | 2 | `retry()` | a retry must outlive its caller: it now stops when the ambient `ctx.signal` aborts and never waits past `ctx.deadlineAt`. Write `runToCompletion(() => retry(work, policy))`. Outside a request context nothing changes |
121
+ | 3 | a write past `requestTimeoutMs` or a job's cancellation | the work must land anyway: it now rolls back. Wrap the whole of it, `runToCompletion(() => withTransaction(fn))`: wrapped inside the transaction, the statement lands and the COMMIT is still refused. It drops the deadline for what runs inside it, so give a raw `fetch` in there its own. A keyed `idempotent` action already runs in that scope, up to `configureIdempotency({ scope, ceilingMs })`, twice `requestTimeoutMs` by default; a handler past it is aborted and its key replays `X_IDEMPOTENCY_RUN_CEILING` |
122
+ | 4 | a statement on a finished `DbTx` | one outlives its scope: it is `X_DB_TRANSACTION_CLOSED`, not an autocommit. `await` it inside `withTransaction(fn)`, or use `outsideTransaction(fn)` for work that must outlive the scope |
123
+ | 5 | dropping a column | you drop in one release: set `retired: ['<c>']` and `x db gen "retire <c>"`, deploy, then remove it and `x db gen "drop <c>" --allow-destructive`. One instance that stops before it starts: `--rolling-unsafe` |
124
+ | 6 | `promoteAttachment` | a pending object's bytes do not match its type: it reads the first 64 KiB and refuses. An XML or `+xml` object over 64 KiB refuses too: upload it through the server. An object overwritten during promotion refuses `X_STORAGE_OBJECT_CHANGED`: promote again once the upload has finished |
125
+ | 7 | the content sniff | a file opens with over 4,096 B of whitespace: refused everywhere. Trim it before upload |
126
+ | 8 | `references()` between two tenant-scoped entities | always once: `x db gen "same-tenant keys"` adds one unique index per referenced table and swaps the key online. A row already crossing tenants fails the validate: decide it, or state `crossTenant: '<reason>'` on an edge that means it, including two tenant columns meaning different things. A memory test wiring a parent and child across tenants moves its fixture. `onDelete: 'set null'` needs Postgres 15+. A null tenant column skips the check. A hand-built `ReferenceDescription` needs `sameTenant: null` and `targetTable`. An entity declared with a `{ table }` that differs from its name, as a target: `X_DB_DRIFT` until `x db gen` |
127
+ | 9 | `update`, `updateWhere`, `transition` | a value `insert` would refuse reached them: it is refused before the statement, not as `X_DB_STATEMENT_FAILED`, and a number written to a text column is an error, not a coercion. Pass the column's type |
128
+ | 10 | `orderBy` on a `json`, `bytes` or array column | you order by one: refused at plan time (it paged wrongly or threw past page 1). Order by a scalar |
129
+ | 11 | `db.x.upsertAll`, `seed.upsert` | you relied on a collision resetting unnamed columns: name the column, `{ ...row, label: 'default', createdAt: now }`. A timestamp expected to move on re-upsert (`reviewedAt`) is named or declared `onUpdateNow()`. Both `X_INVARIANT_VIOLATED`: rows naming different columns (give each row the same columns), and rows naming only the conflict target or primary key, which answer `would write nothing`: `db.blocks.upsertAll([{ blockerId, blockedId }], { onConflict: ['blockerId', 'blockedId'] })` takes `onMatch: 'nothing'` when "already there" is fine, or a named column to overwrite |
130
+ | 12 | `repo.upsertAll` on a hand-held repository | one row holds `label: undefined` and another a value: `X_INVARIANT_VIOLATED`. Give every row a value, or none |
131
+ | 13 | a wide `insertAll` / `upsertAll` | you relied on partial progress when one failed: it now leaves nothing. Split the batch yourself and call once per part |
132
+ | 14 | a soft delete under a JS-only invariant | the stamped row breaks the rule: refused on Postgres too, and `deleteWhere` past `MAX_ASSERTED_ROWS` matched rows is refused. Relax the invariant for the stamped row, or delete in smaller `where` sets |
133
+ | 15 | `/media` on a shared key | a CDN rule keys on `public, immutable`: it misses from now on. Serve a public image from `apps/web/site/assets/` via `asset()`, or from an app route using `objectResponse`. Existing variants under old keys go unread and are re-minted once |
134
+ | 16 | a custom `totpReplay` | you wrote one: replace `isUsed`/`remember` with `claim(subject, step, at): Promise<boolean>`, answering `true` the first time, `false` for a replay, and rejecting when its store is down. A framework-booted app needs no edit: drop the option and the boot installs `postgresTotpReplayGuard`, whose table `x_totp_steps` is applied at boot (`ROLE=migrate` on an external database) |
135
+ | 17 | a tool in both `mcpConfirmations({ tools })` and `agent({ tools })` | an agent calls it: it now waits for a person. Have the agent's `output` report the pending confirmation id, or remove the tool from `tools` to let the call run unconfirmed. With two deciders on one tool, the first registered answers in-app |
136
+ | 18 | a decision action's module that reaches a document | `manifest` goes red with `X_ROLE_LOAD_INCOMPLETE`: add the import its fix names to `apps/web/api/index.ts`, or move the declaration into a module that imports no `.tsx` and no stylesheet |
137
+ | 19 | `asProjectableAction(handBuilt)` | you compare by identity: it returns a copy behind the gate, and projecting that copy, or a spread of it, passes the gate once. Compare a hand-built tool by `name` |
138
+ | 20 | a failed agent tool's text | a test pins the rendered `(fix: …)` of an error that declares `callerFix`: the model now sees `callerFix`. Move the assertion with it |
139
+ | 21 | `step.run`'s answer | you read a value JSON changes: a `Date` is a compile error at the read, so write `new Date(value)`; a miss stored from `undefined` is `null`, so compare `=== null`; a value JSON cannot hold (a `bigint`, a cycle, `NaN`, an infinity, an invalid `Date`, a NUL, a lone surrogate) is `X_STEP_OUTPUT_UNSERIALIZABLE`; a keyed action answering such text is `X_IDEMPOTENCY_OUTPUT_UNSERIALIZABLE`. A step's object keys read in `jsonb`'s order on every attempt. Runs in flight across the upgrade replay as they always did |
140
+ | 22 | `step.waitForEvent` | you wait on an event: `schema` is required and `<T>` is gone. Pass a schema |
141
+ | 23 | `isr` and `static` routes | a `load` reads the actor, a cookie, `Authorization` or any other inbound header: it now sees none, `ctx.ip` and `ctx.peer` are `null`, `ctx.tz` is the app's default, and `ctx.url` is the document's address, with no undeclared query. A page that varies per visitor is `render: 'ssr'`. With no declared origin, a process stores `isr` pages for 16 request origins: set `APP_URL` if you serve more |
142
+ | 24 | an `isr` route with no `revalidate.query` | its `load` or page reads a query parameter: the route now serves one document for every value, and nothing can see that from the key alone. List it in `revalidate: { query: ['<name>'] }` |
143
+ | 25 | `x_notify_deliveries`, `DeliveryLedger` | always, for the column: `x db gen`, commit `packages/db/schema/`. An app with its own ledger also takes the table below. A channel whose send can outlast five minutes sets `reclaimAfterMs` above its slowest send, or a reclaimer overlaps it |
144
+ | 26 | a tool in `mcpConfirmations({ tools })` on a server you handed no `confirmations` | a caller relied on it running at once: it now waits. To let a call run unconfirmed, remove the tool from `tools` |
145
+ | 27 | a name in `mcpConfirmations({ tools })` | no exposed action, exposed query, MCP tool or in-process tool answers to it: `X_MCP_CONFIRMATION_TOOL_UNKNOWN` at `manifest`. Correct the spelling, or expose the action. A hand-written tool counts once its `defineAppMcp` has run when the app loads, so build that server at module scope in `apps/<app>/mcp.ts` |
146
+ | 28 | `cors.origins` containing `'null'` | you list it, or build the list from `URL.parse('localhost:3000').origin` (no scheme): `X_CORS_CONFIG_INVALID` at boot, with or without credentials. List real origins with their scheme: `configureHttp({ cors: { origins: ['http://localhost:3000'] } })`. A public, credential-free API uses `origins: ['*'], credentials: false` |
147
+ | 29 | a `HeadTag` `href` | a `link` carries `data:image/svg+xml`, any other `data:` type, or a scheme outside `http`, `https`, `mailto`, `tel`, `sms`: it prints no `href` (`android-app:` and `ios-app:` stay on `rel="alternate"`; a raster `data:image/` stays). Serve the file: `site.favicon` for an icon, or a same-origin path under `assets/`. `head.attribute_dropped` names the tag and attribute in dev |
148
+ | 30 | the `ScalingSignal` `'queue-depth'` | you name it, or an adapter rule reads `queue_depth` for the worker HPA: it is `'queue-demand'`, `SCALING_METRICS['queue-demand']` is `queue_demand`. Add the adapter rule `max by (queue) (queue_demand)`. `queue_depth` stays, the ready-only gauge for a backlog alert |
149
+ | 31 | a plain statement behind a full pool | you relied on it queueing past `acquireTimeoutMs`: it answers `X_DB_POOL_EXHAUSTED`. Raise `DATABASE_POOL_MAX` or fix the slow statement. A statement unanswered past the role's bound plus 5 s is `X_DB_UNAVAILABLE` and the pool is replaced; an autocommit write may have landed, and a job worker re-runs that attempt, so make such a write idempotent |
150
+ | 32 | a transaction left idle, `PoolProfile` | a transaction awaits slow outbound work: past 30 s (`web`, `sync`) or 120 s (`worker`, `scheduler`) the server closes it, `X_DB_STATEMENT_FAILED` (`25P03`). Do that work before opening it. A hand-built `PoolProfile` adds `idleInTransactionMs`. Behind a pooler, set `DATABASE_POOLER` and both bounds with `alter role` |
151
+ | 33 | keys only a mail or a push names | `x verify`'s `i18n` step reports them: `x i18n sync <locale>`, then translate |
152
+ | 34 | an `en` key another locale lacks | always, for a key reached only through a template literal: `x i18n sync <locale>`. A `⟦…⟧` marker over such a key stays missing until replaced |
153
+ | 35 | a third-party `StorageDriver` | you wrote one: implement `streamRange(key, [first, last])`, reading no byte before `first`, and `putStream(key, stream, { maxBytes, … })`, storing nothing past `maxBytes` |
154
+ | 36 | `grantUpload` | your own action builds the grant: its input carries `size: file.size` (`uploadFile` already sends it), or `X_STORAGE_UPLOAD_SIZE_UNDECLARED`. `grant.maxBytes` is now that size, not the policy's; an s3 URL accepts exactly that many bytes, so another length is 403 `SignatureDoesNotMatch` |
155
+ | 37 | `disk.signedUrl(key, { method: 'PUT', maxBytes })` on s3 | you call it: refused. Add `contentLength: file.size`, or drop `maxBytes` for a URL that binds nothing |
156
+ | 38 | the problem document's `retry` | a golden test pins a whole body, or a middleware adds `retry: errorRetry(error)`: the member is on every document, 401 and scope 404 included. Add it to the test; delete the middleware. `x manifest` regenerates `openapi.json`'s `Problem` |
157
+ | 39 | `PIPELINE_STAGES`, `StageName` | a `Record<StageName, …>`: add `compress`. A handler whose answer must not be coded sets `no-transform` |
158
+ | 40 | `csrfBlocked(pathname, reason, ip, origin)` | you call it: pass the request's `ip` and `origin` |
159
+ | 41 | `memoryRepo` and the `statements` fixture | a memory-backed test that uses `statements` loops: it now fails `X_N_PLUS_ONE_QUERY` / `X_N_PLUS_ONE_WRITE`. `preload()`, or `expectedQueryLoop('<why>', …)` |
160
+ | 42 | a read's published `rows:` | an app declaring `rows:` finds `openapi*.json` stale: `x manifest`, commit. Later, a member removed, retyped or made optional fails `contract-diff` with `X_MANIFEST_BREAKING` until the app's major moves |
161
+ | 43 | `DownloadFile.body` | code reads the field rather than handing it over: narrow the function form, `(range?) => BodyInit`, whose `size` is required |
162
+ | 44 | `REPLAYED_HEADER` | `import { REPLAYED_HEADER } from '@ultimat3/action'`: import it from `@ultimat3/core` |
163
+ | 45 | a custom `JobDriver` | implement `demand(): Promise<readonly QueueDemand[]>`: per queue, the running rows not held by a worker whose live registry row says it drains, plus the due `ready`/`delayed`/`suspended` rows of an unpaused queue; list only queues above 0, sorted by code unit |
164
+ | 46 | `x_job_workers.build` | always, for the dump: `x db gen`, commit `packages/db/schema/` |
165
+ | 47 | `suspenseBoundaries`, `ModeSpec.needsSuspense` | you pass or read them (TS2353 / TS2339): delete the key or the read. To stream, wrap the slow read: `<Hole fallback={<Skeleton lines={3} />}>{readRows}</Hole>` from `@ultimat3/render` |
166
+ | 48 | `send()` | a mail holds a key the locale lacks, a catalog value still `⟦…⟧`, or a `{slot}` the payload's top-level scalars do not carry: `X_MAIL_KEY_MISSING`, a framework `mail.*` mail included. Add the key, translate it, or put the field at the top level |
167
+ | 49 | `QueryFact.rows`, `Manifest.mounts` | an app declaring `rows:` or a mount: `x manifest`, commit. Later, a name dropped from a mount's `scopes` or a changed `prefix` fails `contract-diff` with `X_MANIFEST_BREAKING` until the app's major moves |
168
+ | 50 | `requestedUrls()` | you call it: `requestLog().map((call) => call.url)` answers the same list. `NetworkSnapshot.seen` is `NetworkSnapshot.log` |
169
+ | 51 | the worker retire | always, on a chart: a rollout leaves an old worker Terminating while it holds a job, for up to `retireSeconds`. `drain.workerRetireMs: 0` keeps the old stop. An app's own chart keeps its values until `x deploy` sets the retire |
170
+ | 52 | a job's `timeout` | it outlasts the worker's budget: `X_JOB_OUTLASTS_DRAIN` at `manifest`. Raise `drain.workerRetireMs`, or lower the `timeout` |
171
+ | 53 | `packages/db/schema/04_tables/` against the newest snapshot | a column one holds and the other does not: an edited migration. Restore the file, then `x db gen` |
172
+ | 54 | `x g query`, `x g backfill` | you named no slice: pass `--feature <slice>`, or `x g resource <name>` for a new table |
173
+ | 55 | a `packages/*` runtime import of `apps/*` | you have one: move the code down. `import type` passes, and `packages/db/src/` may read an entity module |
174
+ | 56 | `S3_FORCE_PATH_STYLE` | it holds anything but `0`, `1` or blank: `X_CONFIG_INVALID` at boot. Write `1` |
175
+ | 57 | `ERROR_PAGE_LINKS.homepage` | you read it: it is removed, and the default error page links only the repository. `ERROR_PAGE_LINKS.repository` is unchanged. An app that wants its own links ships `apps/web/site/errors/<status>.html`, as before |
176
+ | 58 | `FormSchema`, `FormValidationResult`, `FormValidationIssue`, `formatFieldPath`, `IssuePathSegment` from `@ultimat3/ui` | you import one: take `StandardSchemaV1`, `StandardResult<unknown>`, `StandardIssue`, `formatPath`, `StandardPathSegment` from `@ultimat3/schema`. A hand-written schema handed to `formBinding({ schema })` adds `version: 1` and a `vendor` beside `validate`. `parseFieldPath`, `fieldSelector` and `MAX_FIELD_INDEX` are unchanged |
177
+ | 59 | `x verify`'s `budgets` step | your stats were stale: it builds first (`x build --target static --no-preflight --out .x/verify-static`) and weighs fresh bytes, so a route stale stats hid is red. Shrink it or raise its budget by the measured figure; `x verify` can be one static build slower after an edit (8.2 s on `dummy/social-media-clone`) |
178
+ | 60 | `bin/check.ts`, `bun run check` | a script or CI step expects a new scaffold to have them: run `bun run verify`. An existing app keeps its own; it still works |
179
+ | 61 | `JSX_NODE` from `@ultimat3/render` | you import it: `import { JSX_NODE } from '@ultimat3/core/render-node'`, the same global symbol. `HOLE_NODE` and `ISLAND_NODE` live there too |
180
+ | 62 | a hand-written `JobHandle`, `JobsFacade` or `JobDriver` | you wrote one: each gains a required member. `JobHandle.enqueueMany(inputs, options?)` (a fake built from `job()` has it already). `JobsFacade.enqueueMany(handle, inputs, options?)`, or build the facade with `outboxJobsFacade()`. `JobDriver.enqueueMany(requests)`: answer each request as `enqueue` would, in order, in one round trip, opening with `assertEnqueueMany('<driver>', requests)`; a driver with no batch primitive loops its own `enqueue`. It refuses a request carrying `onConflict: 'error'` (`X_INVARIANT`): send that one alone through `driver.enqueue`. `OutboxStore.stageMany` / `markPublishedMany` stay optional |
181
+ | 63 | an `enqueue` spy on a spread `memoryJobDriver()` | a test wraps the driver as `{ ...driver, enqueue: spy }` and drives `handle.enqueueMany` or the outbox relay: both go through `driver.enqueueMany`, so the spy is never called. Wrap that member too |
182
+ | 64 | `outboxRelay()`'s default `batchSize` | it claims 500 rows a pass, not 100. Pass `batchSize: 100` to keep the old figure; the one reason to is a relay running the row loop (a driver without `enqueueMany`, such as `redisJobDriver`) against very slow publishes, where 500 rows must still publish inside the 30 s claim lease |
183
+ | 65 | `ERROR_SOURCES`, `ErrorSource` | an exhaustive `switch` on `ErrorSource` with no `default`, or a `Record<ErrorSource, …>`: add a `browser` arm. It is what `/_client-errors` reports with, once `clientErrors.enabled` is set |
184
+ | 66 | `AppConfig` | you build one by hand (a test fake, `{ … } as AppConfig` aside): it gains the required `assets` and `clientErrors` (TS2741). Spread `defineConfig({ name })`'s answer, or add `assets: { retain: undefined }` and `clientErrors: { enabled: false, anonymous: false, perMinute: 30 }` |
185
+ | 67 | `retentionPlan`, `RetentionPlan`, `Deploy` from `@ultimat3/pwa` | you import them (TS2305): delete the call, which retained nothing unless your own code acted on its answer. To keep earlier builds' hashed files served across deploys, declare `assets: { retain: { disk: '<a disk every web pod shares>', builds: <n> } }` in `app.config.ts`, with that disk in `defineStorage({ disks })` |
186
+ | 68 | `ServiceWorkerConfig.retainBuildIds` passed to `generateServiceWorker` | you pass it (TS2353, excess property): delete the key. An activating worker keeps only its own build's caches; a tab open across a deploy gets earlier builds' chunks from the server, through `assets: { retain: { disk, builds } }` |
187
+ | 69 | `DefineEvalInput` | you read `input.prompt` off one (TS2339): it is `PromptEvalInput \| AgentEvalInput`; narrow with `'prompt' in input`. A `defineEval({ prompt, … })` call is unchanged, and a custom `Scorer` typed against `{ output; expected? }` still compiles. An eval of a prompt an `agent()` runs may move to the agent arm, `defineEval({ name, agent, cases: [{ name, input }], tools: { <tool>: (input) => <answer> }, scorers, baseline, tolerance })`, with its baseline re-recorded by `ULTIMATE_EVAL_RECORD=1 x test eval` |
188
+ | 70 | `MCP_PROTOCOL_VERSION` from `@ultimat3/mcp` | you import it (TS2305; it was `'2025-06-18'`): `MCP_MODERN_VERSIONS[0]` for the modern revision (`'2026-07-28'`), `MCP_LEGACY_VERSIONS` for the handshake ones. `grep -rn MCP_PROTOCOL_VERSION apps packages` finds every caller |
189
+ | 71 | the MCP revision a server answers | a client or test pins `initialize`'s answer: it echoes an offered `MCP_LEGACY_VERSIONS` revision, else `2025-11-25` (was always `2025-06-18`). A request whose `_meta` names `2026-07-28` is served statelessly, with no `initialize` or `ping`; an unserved version in `_meta` or `MCP-Protocol-Version` is `400` `-32022`. Legacy clients (`initialize`) keep working unchanged |
190
+ | 72 | `POST /mcp` with an `Origin` | it is not this app and not in `http.cors.origins`: `403` `X_MCP_ORIGIN_REFUSED`, before `resolveToken`. Call from the app's own origin, list a sibling page's origin in `http.cors.origins`, or call from a client that sends no `Origin` |
191
+ | 73 | `McpAuthRefusal` | an exhaustive `switch` over it, or a `Record<McpAuthRefusal, …>`: add an `'origin-refused'` arm (`mcp.auth.origin-refused`, `onAudit`'s `auth-refused` with status 403) |
192
+ | 74 | `x mcp serve --transport http` | you reach it through a name that is not `localhost`, `*.localhost` or a loopback address: `421` `X_MCP_HOST_REFUSED`. Use `http://localhost:<port>/mcp` or `http://127.0.0.1:<port>/mcp`. `GET`/`DELETE` on it answer `405` `X_METHOD_NOT_ALLOWED`, not `404` |
193
+ | 75 | `setSolidRuntime` | every island that registers the runtime: add `createRoot` to the `solid-js` import and to the call, `setSolidRuntime({ createContext, useContext, createSignal, createMemo, createEffect, onCleanup, createRoot })`. A six-name call no longer compiles (TS2345). A whole `solid-js` namespace still registers |
194
+ | 76 | `DataTable`'s `rowKey` | two rows answering one key are `X_UI_INVALID_VALUE`: make the key the row's primary key — unique within `rows`, stable across updates, never an index — `rowKey={(row) => row.id}` |
195
+ | 77 | `UI_KEYS`, `UiKey` | a `Record<UiKey, string>`: add `ui.first` |
196
+ | 78 | a cursor-mode `Pagination` with no `prevCursor` | it no longer draws a disabled Previous. To give a keyset list a way back, pass `firstHref` (link mode, `listUrl().firstHref`) or `onFirst` (callback mode) |
197
+ | 79 | `refresh()` from `@ultimat3/render/client` | code that relied on the old behaviour, [the table below](#entry-79-what-refresh-no-longer-does) |
198
+ | 80 | `.contract()` on a non-public action or query | it calls your handler 16 times with generated legal input: pass `{ exercise: { actor } }` or `{ exercise: { off: '<reason>' } }`, [the rules below](#entry-80-the-exercise-assertion) |
199
+ | 81 | `sampleInput` | you imported action's `sample-input` module by path; it is gone: `import { sampleInput } from '@ultimat3/schema/sample'` |
200
+ | 82 | `ActionDescriptor`, `QueryDescriptor`, `Query` | code that assigned `.method` to a `'POST'`-typed variable widens it to `ActionHttpMethod`. A hand-built descriptor adds `errors: null`; a hand-built `Query` adds `contract` |
201
+ | 83 | `WorkloadToken` (`@ultimat3/auth`) | you build one by hand, usually a test fake: add `raw`, the decoded payload (`raw: {}` where nothing reads it) |
202
+ | 84 | a deployed role's app modules | a module throws on import or registration: the role now exits `X_APP_MODULE_LOAD_FAILED` instead of serving without it. `x manifest --json` names the module and why |
203
+ | 85 | MCP tool descriptions | you snapshot `tools/list`: an undescribed query reads `Read with the "<name>" query.`, an admin action quotes its name. Set `mcp: { description }` to keep your own wording. `exposedPrimitives()` returns opted-in primitives only |
204
+ | 86 | `QueryPoll` | you read `.ms`: keep the number you passed to `pollEvery` |
205
+ | 87 | `.contract()` / `exerciseAction` / `exerciseQuery` | you ran one with `ULTIMATE_ENV` or `NODE_ENV` set to `staging` or `production`: run it under `bun test` |
206
+ | 88 | `e2eTest`, `E2eBrowserPage` (`@ultimat3/testing`) | Every `e2eTest` now audits the browser's accessibility tree. Name what it lists — visible text, `aria-label`, `<label for>`, `alt` (`alt=""` for a decorative image), a `<title>`, `<html lang>`, nothing focusable under `aria-hidden` — or add `{ a11y: { allow: [{ role, selector, because }] } }` to that test. A tab a suite drives itself (`browser.session.newTab()`) is audited too: name what it lists, or call `allowA11y([{ role, selector, because }])` in that test before the navigation. A hand-written `E2eBrowserPage` passed to `installE2eDriver({ page })` must implement or forward `accessibilityTree(request)`. `toAxNode` is imported from `@ultimat3/testing`, not `@ultimat3/cli`'s `cdp-shot-a11y.ts` |
207
+ | 89 | `factoriesFor` (`@ultimat3/testing`) | a test reads a value it never set: it is now the column's declared default, an enumerated value or its kind's value, never derived from the name. Set the value in the call (`build({ column: … })`) or a `defineFactory`. A registry whose `$columns` are not `entity()` columns throws `X_SEED_GRAPH_UNSATISFIED`: pass real entities |
208
+ | 90 | `memoryTransactor().run` (`@ultimat3/entity`) | a memory-store write inside it that names no `tx` must survive the transaction throwing: move it outside `run`. A write that names no `tx` now rolls back with the transaction, as on Postgres |
209
+ | 91 | `x verify` (`@ultimat3/cli`) | a registered action, query or job no generated run reaches as an allowed actor: the new `exercise` step is red with `X_CONTRACT_EXERCISE_UNREACHED`. Paste the entry `x exercise --json` prints into `apps/web/exercise.ts`, or give `{ off: '<why>' }`. A script that pinned the step count reads one more (`VERIFY_STEP_NAMES`) |
210
+ | 92 | `entity()` (`@ultimat3/entity`) | an entity with no tenant column references, directly or through others, one that has one: `X_ENTITY_TENANT_UNDECLARED` from the `exercise` step. Add `orgId: uuid().tenant()` and `x db gen '<table> tenant'` (with `x g backfill` on a table holding rows), or declare `shared: '<why>'` |
211
+ | 93 | `memoryTransactor().run` (`@ultimat3/entity`) | a test writes after the transaction ended — an unawaited promise — and now gets `X_DB_TRANSACTION_CLOSED`; or it relied on a nested `run`'s `onCommit` effect running at that nested commit: it now runs at the root's, and never after the root rolls back |
212
+ | 94 | `x exercise`, the `exercise` step (`@ultimat3/cli`) | a primitive hands tenant B a row of tenant A's, or changes one, when B passes A's ids: `X_TENANT_LEAK` / `X_TENANT_WRITE_CROSSED`. Read and write by input id through the repository scoped to `ctx.actor`; `x exercise <name> --json` reruns it |
213
+ | 95 | `SeedWriter` (`@ultimat3/testing`) | you wrote one by hand: add `snapshot: async (rows) => rows.map(() => null)` at least — TS2741 otherwise. A seeded count per tenant moved: an untenanted child of a tenant-owned table now has rows under each tenant |
214
+ | 96 | the framework schema (`@ultimat3/auth`) | you commit a schema dump: five `x_oauth_*` tables appear. `x db gen`, then commit `packages/db/schema/` |
215
+ | 97 | `VerificationPurpose` (`@ultimat3/auth`) | you `switch` over a purpose or type one as the two-value union: it is `string`. Add a default arm; declare a new purpose with `defineVerificationPurpose` |
216
+ | 98 | `publishEvent` on the Postgres bus (`@ultimat3/jobs`) | you commit a schema dump: `x_job_steps_waiting_idx` appears; `x db gen`, commit. A wait that relied on the 30 s poll to notice an event published by other means than `publishEvent`: `eventPoll: '30s'` |
217
+ | 99 | `StepStatus`, step names (`@ultimat3/jobs`) | you `switch` over `StepStatus`: handle `started` and `cleared`. A step named `unwinding` or `compensate:<x>`: rename it. A hand-written driver keeps compiling: `resolveStep` is optional |
218
+ | 100 | enqueuing a tenant-declared job (`@ultimat3/jobs`) | it names another org than its `tenantId` or the acting user's: `X_JOB_TENANT_MISMATCH`. Enqueue from the job's own org, or inside `crossTenant(reason, fn)` by an actor holding `tenancy:cross` when crossing is meant. A client that read the code as a 500 now gets 403. Rows already queued that way go dead: re-enqueue them, never `x jobs retry` |
219
+ | 101 | `ScrapePage`, the wedge watchdog (`@ultimat3/scraping`) | you wrote a `ScrapePage` fake: add `activity(): void` (the shipped fakes carry it). An attached browser now closes when the watchdog fires, not when the run unwinds: a run that waits on slow work the page cannot see calls `page.activity()` |
220
+ | 102 | the schema dump (`@ultimat3/db`) | the database holds a policy, hand-written or from `rowSecurity`: it is rendered under `10_policies/` and no longer named in `unrendered.sql`. `x db gen`, commit `packages/db/schema/`. A hand-built `CatalogDescription` adds the two row-security fields |
221
+ | 103 | a prompt over the model's window (`@ultimat3/ai`) | you matched `X_AI_PROVIDER_UNAVAILABLE` or the provider's text for it: the code is `X_AI_CONTEXT_OVERFLOW`, terminal, and `agent()` compacts and retries first, ending `X_AGENT_CONTEXT_EXHAUSTED`. A run over dense tool results compacts earlier: raise `compact: { at }` (up to 1) if it summarises too soon |
222
+ | 104 | the Redis client (`@ultimat3/cache`, `@ultimat3/jobs`) | you relied on `Bun.redis` queueing commands while Redis was down: a cache read is now a miss at once and `enqueue` is `X_DRIVER_UNAVAILABLE`. Catch it where a queued retry was implied. A `client` you hand in keeps its behaviour and gets the deadline |
223
+ | 105 | the framework schema (`@ultimat3/db`, `@ultimat3/admin`) | you commit a schema dump: `x_migrations.destructive` and `x_error_groups` appear. `x db gen`, commit `packages/db/schema/` |
224
+ | 106 | a rollback (`@ultimat3/db`) | you roll back across a migration a newer build recorded as destructive: `X_MIGRATION_ROLLBACK_DESTRUCTIVE`. Check the older build on the newer schema, then `ROLLBACK_ALLOW_DESTRUCTIVE=<id>[,<id>]` on the migrate role |
225
+ | 107 | `ClientErrorsConfig` (`@ultimat3/core`) | you build one by hand: TS2741 on `vitals`. `{ ...CLIENT_ERRORS_DEFAULTS, enabled: true }` |
226
+ | 108 | a statement inside a transaction (`@ultimat3/db`) | one runs past the role's `statement_timeout` + 5 s on purpose: it now fails `X_DB_UNAVAILABLE`, where it hung. Raise the bound in that transaction: `setLocal(tx, { statement_timeout: '<ms>' })` |
227
+ | 109 | `x exercise`, the `exercise` step (`@ultimat3/cli`) | a job makes an effect outside `step.run` that a retry repeats: `X_JOB_CRASH_DIVERGED`. Wrap the effect the finding names in `step.run('<name>', …)`; there is no pin |
228
+ | 110 | the chart (`docker/helm`) | your cluster serves the Prometheus operator and you did not want the ServiceMonitor or the alert rules: set `serviceMonitor.enabled: false` and `alerts.enabled: false`. A dashboard grouping on the scrape's labels sees `app_kubernetes_io_instance` |
229
+ | 111 | `AppConfig` (`@ultimat3/core`) | you build one by hand (a test fake, `{ … } as AppConfig` aside): TS2741 on `failureCapture` and `time`. Spread `defineConfig({ name })`'s answer, or add `failureCapture: FAILURE_CAPTURE_DEFAULTS` and `time: TIME_DEFAULTS` |
230
+ | 112 | the framework schema (`@ultimat3/db`) | you commit a schema dump: `x_failures` appears and `drift` is `X_SCHEMA_DUMP_DRIFT`. `x db gen`, commit `packages/db/schema/` |
231
+ | 113 | `/llms.txt`, the `seo` step (`@ultimat3/seo`) | a public, static `site/` page has a `title` and no `description`: `X_SEO_LLMS_DESCRIPTION_MISSING`. Add `description` to its `defineRoute({ meta })`, or set `seo: { llms: false }` in `app.config.ts`. |
232
+ | 114 | `TimeZoneConfig` (`@ultimat3/http`) | you build a full `TimeZoneConfig` or `HttpConfig` literal: TS2741 on `detectedCookie`. Spread `DEFAULT_TZ_CONFIG`; `configureHttp({ tz })` is unaffected |
233
+ | 115 | `McpResource.read`, `ResourceRegistry.read` (`@ultimat3/mcp`) | you call either yourself: TS2554. Pass the admitted caller: `resource.read(caller)`, `registry.read(uri, caller)`. A resource whose `read()` takes no argument is unchanged |
234
+ | 116 | `DevCapabilities` (`@ultimat3/mcp`) | you wrote a `DevHost`: TS2741 on `queryGraph`. Implement `queryGraph(question)` — `x impact <node> --json`'s data for `{ kind: 'impact' }`, `x graph --from --to --json`'s for `{ kind: 'path' }` — or throw for a host with no graph |
235
+ | 117 | `DevSources` (`@ultimat3/admin/dev`) | you build one by hand: TS2741 on `mailTemplates`. Add `mailTemplates: async () => []`, or build it with `defaultDevSources()` |
236
+ | 118 | `x exercise`, the `exercise` step (`@ultimat3/cli`) | a statement no index serves, a limited read that sorts, or a loop over rows: `X_QUERY_UNINDEXED`, `X_QUERY_SORT_UNSERVED`, `X_QUERY_SCALES_WITH_ROWS`. `x exercise --json`; paste the printed `indexes:` line into that `entity()` and run its `x db gen`, or declare the reason: `entity({ scan: '<why>' })`, `expectedSeqScan('<why>', fn)`, `expectedQueryLoop('<why>', fn)`; a loop is usually a missing `preload()` |
237
+ | 119 | `OpenGraph` (`@ultimat3/seo`) | you extend it, or spread one while setting `image`: TS2312, TS2375. Write `type MyOg = OpenGraph & { … }`, and `{ ...og, image, capture: undefined }` |
238
+ | 120 | `postgresAuthAdapter()` (`@ultimat3/auth`) | you build it with no client and rely on it keeping that client past a later `setDbClient`, or outside a `withTransaction` it is now called in: pass the client, `postgresAuthAdapter(client)` |
239
+ | 121 | `QueryAccessor.refetch` (`@ultimat3/realtime`) | you hand-build an accessor (a test fake): TS2322 on `refetch`. Write `refetch: async () => {}`. Where `noFloatingPromises` is on, `void x.refetch();` or `await x.refetch();` |
240
+ | 122 | `UI_KEYS`, `UiKey` (`@ultimat3/ui`) | a `Record<UiKey, string>`: add the 48 keys entry 122 lists, or build it with `uiCatalog(t)`. Code that read `Object.keys(UI_KEYS)` in declaration order reads it alphabetical |
241
+ | 123 | `ChartPoint`, `barRects`, `BarChartProps`, `chartTable` (`@ultimat3/ui`) | you read `point.value` or a `barRects()` rect as always there, extend `BarChartProps`, or call `chartTable()` yourself: TS18047, TS2312, TS2345. Check `value === null` and skip a `null` rect; `type X = BarChartProps & { … }`; pass `none: t(UI_KEYS.chartNone)` |
242
+ | 124 | `BarChart`, `Sparkline`, `LineChart`, `AreaChart`, `deltaOf` (`@ultimat3/ui`) | you pass a negative value you expected drawn at zero, a series whose `values` do not match `keys`, or test an empty gap cell or `deltaOf(x, 0) === undefined`: clamp it yourself (`Math.max(0, v)`); give each series one value per key, `null` for a gap (`X_UI_INVALID_VALUE`); assert `ui.chart.none`'s text and the signed difference |
243
+ | 125 | `TabsProps` (`@ultimat3/ui`) | you extend it or index `TabsProps['onChange']`: TS2312, and `onChange` is `\| undefined`. Name the mode you mean: `TabsButtonProps`, or `TabsLinkProps` for link tabs |
244
+ | 126 | `CommandPalette`, `filterItems` (`@ultimat3/ui`) | a test pins the filtered order, or that an accented label stays out: assert the ranked order, label prefix first |
245
+ | 127 | `CONTRAST_PAIRS`, `defineTheme()` (`@ultimat3/ui`) | your brand sets `chart-1` or `chart-5` under 4.5:1 against `bg-soft`, or `fg-strong`, `accent` or a status role under 3:1 on a surface: `X_UI_CONTRAST_INSUFFICIENT` names the pair and its ratio. Move that role until it passes |
246
+ | 128 | the test preload (`@ultimat3/testing`) | a test passed by reading a module another file mocked: mock it in the file that needs it. `X_TEST_MODULE_MOCK_UNRESTORABLE` on a file's first test: the file before it mocked a module that does not load. Hand that dependency to the code under test as a parameter, or run `x test unit --isolate` |
247
+ | 129 | `x build --target static`, `responsiveImage()` (`@ultimat3/cli`) | a static page names an image the export does not hold, or asks for `avif`: `X_IMAGE_SOURCE_UNREADABLE`, `X_IMAGE_UNSUPPORTED`. Put the file under `apps/web/site/assets/` and pass `asset('assets/<file>')`, or an absolute URL; drop `avif` from that route's `formats`. A CDN rule keyed on `?w=` now sees `/_img/` paths |
248
+ | 130 | `/assets/*` (`@ultimat3/cli`) | a served `responsiveImage()` over `asset()` passes its own `widths` or `formats`, or a link adds `?q=`: 404 `X_ASSET_VARIANT_UNDECLARED`. Drop the override, or store the image and serve it from `/media`, which answers any width |
249
+ | 131 | `.island.tsx` `mount` (`@ultimat3/cli`) | `x verify --only boundaries` is red with `X_ISLAND_DISPOSER_MISSING`: end `mount` with `return render(() => <Island {...props} />, el)` and type it `(): () => void`; a plain-DOM island returns a function that removes what it added (an `AbortController`'s `abort`) |
250
+ | 132 | `AuthAdapter` (`@ultimat3/auth`) | an adapter you wrote is TS2420 until it implements `PasskeyStore`'s nine methods; `passkey-postgres.ts` is the reference. The built-in adapters need nothing |
251
+ | 133 | `ActionDescriptor.rateLimit` (`@ultimat3/action`) | a reader that met a function `limit` there reads `rateLimitPerCall`; `rateLimit` is now the fixed limit or `null` |
252
+ | 134 | `McpConfirmationStore`, `McpConfirmation` (`@ultimat3/mcp`) | a custom store's `decide` takes the signature as its fifth argument and records it; a hand-built confirmation carries `quorum`, `approvers` and `approvals`. The Postgres store's three new columns are added at boot |
253
+ | 135 | `x.manifest.json`, the contract diff (`@ultimat3/manifest`) | `x manifest` adds `actions[].meter`: commit it. Introducing or tightening a meter is now `breaking` in `x verify --only contract-diff` |
254
+ | 136 | a websocket upgrade (`@ultimat3/cli`) | `x verify --only boundaries` is red with `X_SOCKET_HAND_ROLLED` on `server.upgrade` or `Bun.serve({ websocket })`: declare `deviceSocket({ path, resolveToken, onFrame })` instead |
255
+ | 137 | `PushDeviceStore`, `TokenCache` (`@ultimat3/notify`) | a store you wrote is TS2420 until it implements `noteBadToken` and `clearBadToken`; a caller of `invalidate()` passes the token the provider refused. The built-in stores need nothing; `x_push_devices.bad_tokens` is added at boot |
256
+ | 138 | `history: true` images (`@ultimat3/db`) | `x db gen "history exact images"` reinstalls the trigger; a reader that did arithmetic on a `bigint` or `decimal` value in `before`/`after` parses the string; timestamps are UTC `…Z`. Rows already written keep the old spelling |
257
+ | 139 | `signPart`, `completeUpload` (`@ultimat3/storage`) | pass the policy the grant was made under — the app's own, never a value from the request; without it both throw `X_INVARIANT`. The browser's `MultipartUploadClient` is unchanged |
258
+ | 140 | `LiveHandle` (`@ultimat3/realtime`) | a handle you wrote is TS2741 until it implements `retry(): boolean`: re-send the subscribe of a refused registration and answer whether it did |
259
+ | 141 | `CsrfCheckInput`, the `csrf` stage (`@ultimat3/http`) | a caller of `checkCsrf` passes `authorization: request.header('authorization')`; a write a browser sends with only cached Basic, Digest or Negotiate credentials is now judged like a cookie session — send `Authorization: Bearer`, or list the origin in `cors.origins` |
260
+ | 142 | `enqueueInTx` (`@ultimat3/jobs`) | TS2305 on the import: call `handle.enqueue(input)` (or `jobsFacade().enqueue`) inside the transaction — it joins it and runs the refusals `enqueueInTx` skipped |
261
+ | 143 | `EventLookup.find` (`@ultimat3/jobs`) | a custom event bus returns each event's `id` and seeks strictly past `(afterMs, afterId)` when `afterId` is given. A job live across the deploy was queued with no tenant, so a plain enqueue of its key afterwards is a new job |
262
+
263
+ ### Entry 25: a hand-written `DeliveryLedger`
264
+
265
+ A compile error until it does:
266
+
267
+ | Was | Now |
268
+ |---|---|
269
+ | `claim(claim, at): Promise<boolean>` | `claim(claim, at): Promise<DeliveryClaimAnswer>`: `{ outcome: 'owned', claimId }`, `{ outcome: 'sent' }` or `{ outcome: 'held', retryAfterMs }` |
270
+ | `settle(claim, status, at): Promise<void>` | `settle(claim, status, at, claimId): Promise<boolean>`: `false` when `claimId` no longer owns a `sending` row, and nothing changes |
271
+
272
+ A `sending` row is no longer re-claimable at once: it is held for `reclaimAfterMs`, default `DEFAULT_DELIVERY_RECLAIM_MS` (5 minutes). A retry landing inside it raises `X_NOTIFY_DELIVERY_HELD` and is rescheduled past the window with the attempt uncounted.
273
+
274
+ ### Entry 79: what `refresh()` no longer does
275
+
276
+ Only an app that relied on the old behaviour has anything to do:
277
+
278
+ | Relied on | Now | Do |
279
+ |---|---|---|
280
+ | focus landing on `<main>` after `refresh()` | focus stays on the control the visitor was in; `<main>` only when it is gone | call `document.querySelector('main')?.focus()` after `await refresh()` where that is wanted |
281
+ | the title read out after `refresh()` | nothing is announced | announce in the app's own live region |
282
+ | `refresh()` cancelling a navigation in flight | it resolves and does nothing; that navigation lands | `await navigate(location.href, { replace: true })` to replace it |
283
+ | `refresh()` re-fetching the page under an open modal | nothing is fetched | `closeModal()` first, then `refresh()` |
284
+
285
+ ### Entry 80: the exercise assertion
286
+
287
+ `.contract()`'s fourth assertion calls the handler 16 times with input `input:` accepts, inside a rolled-back transaction.
288
+
289
+ | Case | Answer | Do |
290
+ |---|---|---|
291
+ | no `exercise`, on a non-public action or query | `X_CONTRACT_EXERCISE_UNDECLARED` | `{ exercise: { actor } }`, or `{ exercise: { off: '<reason>' } }`. A public one (its policy admits an anonymous caller) runs as the signed-out caller with nothing declared |
292
+ | an input a generated uuid cannot satisfy: a row id, an org the policy compares | every run stops before the handler | a binder: `{ exercise: { actor, input: (drawn) => ({ ...drawn, postId, orgId }) } }` |
293
+ | the handler throws uncoded, fails its output schema, or answers a coded 5xx on a legal input | `X_CONTRACT_DEFECT`; an `errors:`-undeclared code counts as a 5xx | fix the handler. The `fix:` is the `bun test <file> -t '<name>: handler survives generated input'` that replays the run |
294
+ | no run reaches the handler: every run denied, or a sealed network | `X_CONTRACT_EXERCISE_UNREACHED` | a grant the actor holds, or a binder |
295
+ | the memory store (`storeMode` `memory`) | each run's writes roll back, as on Postgres (since entry 90; before it, what a run wrote stayed until the next `seed()`) | nothing |
296
+
297
+ ### Not breaking, but you will see it
298
+
299
+ | What moved | Do |
300
+ |---|---|
301
+ | with `DATABASE_REPLICA_URL` set, a request that wrote sets `__Host-x-pos` and `x-ultimate-position` | nothing. A proxy with a cookie allowlist passes `__Host-x-pos`; stripped, the next read may land on a standby that has not replayed the write |
302
+ | `onSettled`'s `ctx.signal` is live | a hook that treated an aborted signal as "the run ended" reads `settled.outcome` instead. It is aborted at `onSettledTimeout`, 30 s across every try by default: raise it on a hook that takes longer |
303
+ | `publishEvent` inside a transaction commits and rolls back with it | nothing, unless a rolled-back event must still resume a run: publish it in `outsideTransaction(fn)` |
304
+ | the `drift` step's `rollingSince` rail is off until set | optional: set `rollingSince` in `x.verify.json` to your newest applied migration id. `x doctor` reminds you |
305
+ | any other `HeadTag` attribute drops as `attributePair` does: `on*`, `srcdoc`, a non-http(s) `href` or `src` on `base` or `script` | nothing to migrate: these never belonged in a document |
306
+ | `disk.delete` still leaves a variant's bytes, now unreadable once its source is gone | an erasure of one object also calls `deleteVariants({ disk, key })` |
307
+ | a websocket upgrade from an unlisted origin is refused whatever `sec-fetch-site` it carries | add a sibling host to `allowedOrigins` |
308
+ | stored `isr` entries re-render once, for the new key | nothing |
309
+ | an `isr` route's `isr.query.undeclared` log line is gone | remove an alert keyed on it |
310
+ | a public text answer of 1 KiB to 256 KiB with a declared length leaves as gzip with a weak `etag`; a signed-in one only when `immutable` | a handler that must not be coded sets `no-transform` |
311
+ | `html`, `textResponse` and `jsonResponse` set `content-length` | a handler that rewrites such a body drops the header |
312
+ | a mount declaring `rateLimit` lifts the token's per-route cap of 120 a minute, and a mount's 422s and pipeline 403s spend the allowance | check the mount's number is the ceiling you meant |
313
+ | `Link: rel="successor-version"` on a mount names the mounted path, or is absent when this token cannot follow it | nothing |
314
+ | `rpc` or `queryClient` pointed at a mount's prefix never reached it | call a mount from its own OpenAPI document |
315
+ | `DEFAULT_CORS` allows and exposes more headers | an app with its own `allowHeaders` or `exposeHeaders` adds the new names itself |
316
+ | `RemoteActionError.meta` and a `problemError`'s `meta` gain `requestId` | a `toEqual` on a whole `meta` gains the key |
317
+ | a rendered `fix:` prints `bunx x` where the app's own bin exists | an agent parsing the string for `x ` adjusts |
318
+ | a positional `x deploy <word>` is refused | `x deploy --image <ref>`, or `x deploy verify` |
319
+ | `x deploy verify` with no `DATABASE_URL` passes with `graded: false` and reads no queue | give the step the database credentials to grade the workers |
320
+ | `x new`'s `docker/Dockerfile.dockerignore` drops `**/*.test.ts`, `**/*.test.tsx` and `docs` | optional: append the three lines to your own; `!docs/<path>` re-includes a served file |
321
+ | `x jobs show` / `inspectJob(driver, id).input` is `'[redacted]'` for a job name this process has not registered with `job()` | to read such a payload, register the job again (a stub `job({ name, input: t.json(), … })`) in the process that inspects it |
322
+ | a field marked `.sensitive()` (last in the chain) is masked in the audit record and `x jobs show`; one reachable from an action `output` or a query `rows` fails boot with `X_SCHEMA_SENSITIVE_OUTPUT` | optional: mark what must never be written down. An app's own `logger.info('x', input)` is not covered: keep `redactKeys` or a `Secret` there |
323
+ | every builtin schema refusal carries `rule` (`SCHEMA_RULES`) and `params`, on the wire and on `FormIssue` | key a form's `messageFor` on `issue.rule`, not the English `issue.message`; keep a fallback, since a third-party schema library sends no `rule` |
324
+ | app codes declare with `defineError({ code, title, status \| offSocket, cause, fix })` from `@ultimat3/core`, which registers the status in the same call | optional: replace `class X extends UltimateError` with `defineError`; `X.is(error)` replaces `instanceof`, and a base class becomes a code prefix. `x g guard error-declared` adds the guard that keeps it so; `x g guard bare-error --force` refreshes the bare-error guard's fix. An app code with no status still answers 500 |
325
+ | the Sentry envelope carries `fingerprint: [errorFingerprint(report)]` | nothing: each existing issue reopens once under its new group. A reporter of your own groups the same way with `errorFingerprint(report)` |
326
+ | OTLP export retries 429, 502, 503, 504 and a transport failure, inside the exporter's `timeoutMs` (default 10 s) | lower `timeoutMs` if a shorter hold on the in-flight batch matters more than delivery. A collector that accepted a batch then answered 503 receives it twice; span ids dedupe it |
327
+ | `X_DEV_ALREADY_RUNNING`'s fix is `x stop --json` | a script that killed `x dev` by pid calls `x stop --json`, which returns once the next `x dev` can boot |
328
+ | the AI gateway skips a provider for 30 s once it fails five passes in a row | to keep re-spending every attempt on every call: `providerGateway({ …, breaker: 'off' })`; your own per provider: `breaker: (provider) => breaker({ … })` |
329
+ | after the replica breaker's cooldown, one read probes the replica and the rest take the primary until it answers | nothing |
330
+ | `x doctor` lists `raw-fetch` as a shipped guard your app lacks | `bunx x g guard raw-fetch`, then give each server `fetch(` a `signal`, or call `outbound()` from `@ultimat3/core/outbound` |
331
+ | `DeadlineTimer` and `platformDeadlineTimer` left `@ultimat3/scraping`'s `clock.ts` (never on its barrel) | import them from `@ultimat3/core/outbound` |
332
+ | `breaker()`'s `admit()` returns a ticket (or `undefined`) that `succeeded`, `failed` and `released` take; `defaultBreakerClassify` is not exported | pass the ticket back; write your own `classify` |
333
+ | `registerErrorStatus`'s table is core's: the writers are on the internal subpath `@ultimat3/core/internal/error-status`, never a barrel | nothing: call `registerErrorStatus` from `@ultimat3/http`, or declare an app code with `defineError` |
334
+ | `x new` writes `.mcp.json` and mounts a refusing `POST /mcp` | optional: copy `.mcp.json` (`{ "mcpServers": { "<app>": { "command": "bunx", "args": ["@ultimat3/cli", "mcp", "serve", "--transport", "stdio"] } } }`) and `apps/web/mcp.ts` (`defineAppMcp({ include: 'exposed', resolveToken: apiKeyResolver(() => appAuth().adapter) })`) from a fresh `bunx create-ultimate`, or set `ai: { mcp: { expose: false } }` to silence `X_MCP_APP_UNMOUNTED`. Spell commands `bunx x …` in `AGENTS.md`, `CLAUDE.md` and `.claude/` |
335
+ | `x docs` reads the wiki and every installed `@ultimat3/*` package; its hits move | nothing: the `--json` shape is unchanged |
336
+ | an app test file through `@ultimat3/testing/preload` starts on the installed frozen instant: the clock is reset at every file boundary | a file that relied on an EARLIER file's `advanceClock` calls `setFrozenClock()` itself |
337
+ | `@ultimat3/core/render-nodes` ships the node-tree walker for tests | optional: a test that copied a walker (a `Symbol.for('ultimate.render.jsx')` check over `type`/`props`) imports `renderNodes`, `nodesOf`, `byTag`, `byComponent`, `withAttr`, `one`, `attachRef` and `fire` from it; `examples/dummy/apps/web/app/settings/page.test.ts` is the worked example |
338
+ | `x new` writes `AGENTS.md` as a symlink to `CLAUDE.md`; `x upgrade` links an `AGENTS.md` byte-identical to `CLAUDE.md` and reports a different one as `data.agentsMd: "differs"` | optional: merge what only `AGENTS.md` says into `CLAUDE.md`, then `ln -sf CLAUDE.md AGENTS.md`. The `manifest` step then holds `CLAUDE.md` to the 12 kB `AGENTS.md` budget: raise `agentsMdMaxBytes` in `x.verify.json` if it needs more. A Windows clone without `core.symlinks` checks the link out as a file reading `CLAUDE.md` |
339
+ | `x env init --production` writes `.env.production`; `x deploy --method compose` refuses without one | add `.env.production` to `.gitignore` if yours does not list it (`x env init` does this itself) |
340
+ | `x dev` and the `statements` fixture report a loop of `await job.enqueue(input)` as `X_N_PLUS_ONE_WRITE`, naming `handle.enqueueMany` | replace the loop with `await job.enqueueMany(inputs)`: one round trip per 1,000 inputs |
341
+ | a duplicate job enqueue with a different payload, or one still in flight, never threw `X_IDEMPOTENCY_CONFLICT`, whatever the wiki said: it answers `{ deduped: true, id }` of the live row, and the first payload wins | a `catch` for `X_IDEMPOTENCY_CONFLICT` around `<job>.enqueue()` never runs: read `result.deduped`. A job re-enqueuing itself from its own body arms nothing, because its own `running` row holds the key: re-arm from outside the run, for example a `task` on a cron |
342
+ | `webhookReceiver()` from `@ultimat3/jobs` is the one supported door for an inbound webhook; a pinned action (`http: { path }`) or a hand-mounted `runtime.ts` route keeps working | optional: replace it with `webhookReceiver({ name, path, tenant, verify, handle })`. In `verify(inbound)` run the signature check (`verifyBodyHmac(...)`, answering `{ text }`, or `verifyWebhookSignature(inbound.request(), { secret })`) and return `{ eventId, payload }` read **from the verified data**, never an unsigned header; move the work into `handle`. List it in `defineApi({ jobs })`, delete the `runtime.ts` line, and point the vendor at the new path, which must not sit under `/api` or `/_x` (`X_WEBHOOK_RECEIVER_INVALID`). A `verify` that parses the body catches the parser's error and throws its own code: Bun's `JSON.parse` message quotes the token it stopped at. Past the queue's live window `handle` runs at least once per `eventId`; the stored payload stays in `x_jobs` until `jobs.doneRetentionMs` / `jobs.deadRetentionMs` |
343
+ | `importRows()` from `@ultimat3/jobs` reads a CSV or ndjson object page by page into the app's `handle` | optional: replace a hand-rolled import with `importRows({ name, input, tenant, source: () => disk('…'), key, format, columns, schema, handle, errors: { sink, prefix } })`, writing with `upsertAll` in `handle`, since a page can be handled more than once. A cell `exportRows()` prefixed with `'` comes back with the `'` |
344
+ | browser reporting, `clientErrors` in `app.config.ts`, is off by default | optional: `clientErrors: { enabled: true }` mounts `/x-client-errors.js` and `POST /_client-errors` and reports each `app/` document's uncaught failures as `X_CLIENT_ERROR`. Signed-in members only, 30 reports a minute each; `anonymous: true` opens it to visitors with no session, limited per address |
345
+ | `assets: { retain: { disk, builds } }` keeps earlier builds' hashed files reachable across a deploy; off unless declared | optional: name a disk every web pod can read (an S3-compatible bucket, or a volume shared on one box) and size the window, `assets: { retain: { disk: 'deploys', builds: 3 } }`. Each app and environment gets its own prefix (`x-build-assets/<config.name>/<environment>/`). A `disk` the app did not declare refuses the web boot with `X_CONFIG_INVALID`; a file over the disk's `maxPutBytes` is skipped and named in one `assets.retain_skipped` line |
346
+ | `/admin/audit` refuses a parameter it does not read with a 400 (`X_ADMIN_FILTER_INVALID`), not the unfiltered trail | drop the parameter. An app that overrode `admin.audit.empty` for the row's history card moves that text to `admin.audit.empty-row`; `admin.audit.empty` is the trail's "nothing matches". An app's own `AuditLog` honours `outcome`, `since` and `until` in `entries()` with `auditWindow(subject, query)` and `auditEntryMatches(entry, query)`, or the screen drops the entries that do not match and pages draw short |
347
+ | `x ci` gains `tested`; `--branch`, `--run`, `--tail`, `--full` and `--event` belong to `runs` | nothing: `x ci` with no subcommand is `x ci runs`, as before |
348
+ | `X_DB_LOCK_TIMEOUT`'s `fix:` reads `x db diagnostics --json \| jq '.data.blockers.rows[] \| {pid, blockedBy, blockerState}'` | a script that matched the old `psql "$DATABASE_URL" -c "select pid, state, query from pg_stat_activity …"` text matches on the code instead. `--limit` on `x db` is accepted by `diagnostics` as well as `backfill` |
349
+ | `x new` writes `storeMode(env) === 'memory' ? testDriver() : postgresDriver()` in `packages/db/src/client.ts` | optional, one line: `import { type Driver, database, postgresDriver, testDriver } from '@ultimat3/entity'`, then change the memory arm of `selectDriver` to `testDriver()`. Without it nothing changes; with it a test file that runs the [Testing](Testing#database-state-and-what-a-run-leaves-behind) recipe reads and writes its migrated database through `db`, and a file that installs no client behaves as before. A web workspace that runs the recipe lists `@ultimat3/db` (and `@ultimat3/cli`) among its dependencies |
350
+ | hand-rolled polling in an island (`setInterval(…)` beside a `visibilitychange` listener) | optional: `visibleInterval(run, ms)` from `@ultimat3/core/page`, or `useQuery(ref, input, { poll: pollEvery(ms) })` for a read; `x g guard raw-interval` adds the guard that refuses the bare call. A channel event that re-renders the page is `scheduleRefresh()` from `@ultimat3/render/client`, not `refresh()` |
351
+ | `errors:` on `action()`, `mutator()` and `query()` is optional | an operation that does not declare it keeps its behaviour and its OpenAPI bytes. To close a contract, list each 4xx code the handler throws, importing the `defineError` class and listing its `.code` (the import registers the status before `action()` checks the list): `errors: [PostNotFound.code, 'X_FORBIDDEN']`. Then `x verify --only contract`: a 4xx the list does not name is `X_ERROR_UNDECLARED`, whose `fix:` names the code to add |
352
+ | `http.path` takes `:param` templates, and `http.method` is new | nothing for an app that pins no parameterised path: every static route, its OpenAPI bytes and its manifest fact are unchanged. To serve a resource-shaped URL pin it on the primitive and write no route file: `http: { path: '/orders/:id/refund', method: 'POST' }`, each `:param` a required `t.string` key of `input:`. Call it with `action.client()` or `read.client()`; `derivePath('refundOrder')` throws, so use `actionHttpPath('refundOrder', { id })` |
353
+ | a body or search string naming a `:param`'s key | it carries the URL's value or leaves the key out; a different value is 400 `X_HTTP_PATH_INPUT_CONFLICT` |
354
+ | every action pinned before 30.0.0 gains `http: { path, method: 'POST' }` in `x.manifest.json` | `additive` in `contract-diff`, not breaking: regenerate with `x manifest` |
355
+ | an app with no `persist: true` entity: a `useQuery` / `useRecord` island is no longer held | nothing: it paints earlier |
356
+
357
+ ## 28.x → 29.0.0, entry by entry
358
+
359
+ **Two entries** — `CHANGELOG.md`'s 29.0.0 breaking entries in their order, grouped by
360
+ package, lowest tier first. No legacy path, no codemod, no shim. **Entry 1 is the whole upgrade for
361
+ almost every app**: Bun 1.4.4 everywhere your app runs — laptop, CI, image. A later slice appends
362
+ its rows below the last one and never renumbers; each row's `CHANGELOG.md` line names it as `(#N)`
363
+ after the dash.
364
+
365
+ Why 1.4.4 is a floor: the floor names the one patch the release was tested on, and every pin site
366
+ moves with CI's (`scripts/bun-pin.test.ts`). Nothing in the framework needed 1.4.4 to compile; the
367
+ edits are your app's pins — plus two things 1.4.4 itself moves, which step 3 and 4 cover.
368
+
369
+ ### The upgrade, top to bottom
370
+
371
+ | # | Do | What you see until you do | Entries |
372
+ |---|---|---|---|
373
+ | 1 | install Bun 1.4.4 (`bun upgrade`, or `curl -fsSL https://bun.sh/install \| bash -s bun-v1.4.4`) and pin it where your app pins Bun: `.bun-version`, `packageManager`, `engines.bun` (`>=1.4.4`), CI's `setup-bun` `bun-version`, every `oven/bun:` base image (`1.4.4-alpine` / `1.4.4-slim`, by digest) | `X_BUN_VERSION` from `x` and `x doctor` on 1.4.0–1.4.3 | 1 |
374
+ | 2 | pin every `@ultimat3/*` to the one new version, `bun install` | nothing yet — a mixed install is untested | — |
375
+ | 3 | an app that reaches `@ultimat3/*` through `file:` folder dependencies or overrides (a local checkout of the framework) under `linker = "hoisted"`: drop the forced linker and install with Bun's default isolated one. Under the hoisted linker 1.4.4 copies a `file:` folder per dependent instead of symlinking it, so each `@ultimat3/*` package carries its own `@ultimat3/core` and a primitive registers into the wrong one | `X_REGISTRAR_MISSING` from `x` at boot, `bun run setup`, `x db gen` | — |
376
+ | 4 | `x verify` — if `unit` reports `X_COVERAGE_BELOW_FLOOR` on functions with no test or source changed, 1.4.4's `--coverage` counts fewer functions and keeps the uncovered ones: re-measure and set `x.verify.json`'s `coverage.funcs` to the new figure, the reason in its `why` | green, or a finding whose `fix:` is the edit | — |
377
+ | 5 | **before the deploy,** read every worker Deployment that sets `WORKER_QUEUES`: each entry must be in `jobs.queues` or be a registered job's queue (`x jobs list --json`). Write patterns out (`banks-*` is a literal name, never expanded); add a queue only `enqueue({ queue })` reaches to `jobs.queues` | `X_WORKER_QUEUE_UNKNOWN` at the worker's boot, the new pods crashing and the roll stalled on the old ones | 2 |
378
+
379
+ ### Entry by entry
380
+
381
+ Every package (1). Tier 5 — `@ultimat3/cli` (2).
382
+
383
+ | # | Surface | Costs you an edit if |
384
+ |---|---|---|
385
+ | 1 | `engines.bun` `>=1.4.4`, `REQUIRED_BUN`, the scaffold's images | you run any Bun below 1.4.4 anywhere: `x` and `x doctor` refuse it with `X_BUN_VERSION`. Step 1 |
386
+ | 2 | `WORKER_QUEUES` | a worker Deployment names a queue that is neither in `jobs.queues` nor a registered job's queue — a typo, a pattern (`banks-*`, `*`), or a queue only `enqueue({ queue })` reaches. It booted and claimed nothing there; it now refuses with `X_WORKER_QUEUE_UNKNOWN`, whose `fix:` is the `WORKER_QUEUES=` line to set. Step 5 |
387
+
388
+ ## 27.x → 28.0.0, entry by entry
389
+
390
+ **Two entries** — `CHANGELOG.md`'s 28.0.0 breaking entries in their order, grouped by
391
+ package, lowest tier first. No legacy path, no codemod, no shim. **Entry 1 is the whole upgrade for
392
+ almost every app**: Bun 1.4.3 everywhere your app runs — laptop, CI, image. A later slice appends
393
+ its rows below the last one and never renumbers; each row's `CHANGELOG.md` line names it as `(#N)`
394
+ after the dash.
395
+
396
+ Why 1.4.3 is a floor: it changed what Bun's own transform makes of a `.tsx`. Under the scaffold's
397
+ `jsx: "preserve"` + `jsxImportSource: "solid-js"`, 1.4.2 compiled `<div/>` to
398
+ `React.createElement` and 1.4.3 to `import { jsxDEV } from "solid-js/jsx-dev-runtime"`, which
399
+ solid-js does not export — a link-time `SyntaxError: Export named 'jsxDEV' not found` in any file
400
+ that imports a component, before a line of it runs (issue #883). Every framework path installs its
401
+ own transform first; a path your app built itself must too (step 4).
402
+
403
+ ### The upgrade, top to bottom
404
+
405
+ | # | Do | What you see until you do | Entries |
406
+ |---|---|---|---|
407
+ | 1 | install Bun 1.4.3 (`bun upgrade`, or `curl -fsSL https://bun.sh/install \| bash -s bun-v1.4.3`) and pin it where your app pins Bun: `.bun-version`, `packageManager`, `engines.bun` (`>=1.4.3`), CI's `setup-bun` `bun-version`, every `oven/bun:` base image (`1.4.3-alpine` / `1.4.3-slim`, by digest) | `X_BUN_VERSION` from `x` and `x doctor` on 1.4.0–1.4.2 | 1 |
408
+ | 2 | pin every `@ultimat3/*` to the one new version, `bun install` | nothing yet — a mixed install is untested | — |
409
+ | 3 | delete a hand-written test preload whose only job was `import '@ultimat3/render'` / `'@ultimat3/render/server'` — `@ultimat3/testing/preload` now installs the render loader in every test process, an app root or a package below it | nothing — it is redundant, and harmless while it stays | — |
410
+ | 4 | `grep -rnE "Bun\.build\(\|bun build" apps packages scripts` — a bundle YOUR app builds that reaches a `.tsx` (a server `--compile`, a byte measurement) adds `plugins: [serverTsxBuildPlugin]` from `@ultimat3/render/server` (server code) or builds it as an island (`x build`); the CLI's `bun build` takes no plugin, so use `Bun.build` | `No matching export in "…/solid-js/dist/solid.js" for import "jsxDEV"` | 1 |
411
+ | 5 | `x verify` | green, or a finding whose `fix:` is the edit | — |
412
+
413
+ ### Entry by entry
414
+
415
+ Every package (1). Tier 4 — `@ultimat3/ui` (2).
416
+
417
+ | # | Surface | Costs you an edit if |
418
+ |---|---|---|
419
+ | 1 | `engines.bun` `>=1.4.3`, `REQUIRED_BUN`, the scaffold's images | you run any Bun below 1.4.3 anywhere: `x` and `x doctor` refuse it with `X_BUN_VERSION`. Steps 1 and 4 |
420
+ | 2 | `@ultimat3/ui/jsx-probe`, `probe`, `unprobe` | you imported them. They set a `globalThis.React` nothing reads from Bun 1.4.3 on; delete the import and the `beforeAll(probe)` / `afterAll(unprobe)` calls |
421
+
422
+ ## 26.x → 27.0.0, entry by entry
423
+
424
+ **Two entries** — `CHANGELOG.md`'s 27.0.0 breaking entries in their order, grouped by
425
+ package, lowest tier first. No legacy path, no codemod, no shim. **Entry 2 can 404 a read your app
426
+ relies on**: find every un-scoped key a page links before the deploy (upgrade step 2). A later
427
+ slice appends its rows below the last one and never renumbers; each row's `CHANGELOG.md` line names
428
+ it as `(#N)` after the dash.
429
+
430
+ ### The upgrade, top to bottom
431
+
432
+ | # | Do | What you see until you do | Entries |
433
+ |---|---|---|---|
434
+ | 1 | pin every `@ultimat3/*` to the one new version, `bun install` | nothing yet — a mixed install is untested | — |
435
+ | 2 | **before the deploy:** `find apps -type f -exec grep -nE '/_storage/\|/media/\|signedUrl\(' {} +` — for each key that is NOT built with `scopedKey(orgId, …)` (no `org/<id>/` prefix) and that readers must still fetch, add its prefix to `defineStorage({ shared: { <disk>: ['<prefix>/'] } })`; leave everything else unshared | `404 X_STORAGE_KEY_UNSHARED` on that link | 2 |
436
+ | 3 | `bun run typecheck` — a hand-written `AsyncContext` or `Storage` object | TS2741 naming `exit` or `isShared` | 1, 2 |
437
+ | 4 | `x verify` | green, or a finding whose `fix:` is the edit | — |
438
+
439
+ ### Entry by entry
440
+
441
+ Tier 0 — `@ultimat3/core` (1). Tier 1 — `@ultimat3/storage`, with `@ultimat3/cli`'s routes (2).
442
+
443
+ | # | Surface | Costs you an edit if |
444
+ |---|---|---|
445
+ | 1 | `AsyncContext.exit(fn)` | you implement `AsyncContext` yourself rather than calling `asyncContext()`: TS2741. Add `exit: (fn) => fn()` for a context with no store, or your store's own `exit` |
446
+ | 2 | `GET /_storage/:disk/*key`, `GET /media/*key`, `StorageConfig.shared`, `Storage.isShared` | a page, island or client reads a key outside `org/<id>/` through either route — a brand asset on a disk, a shared upload. 26.x served it to every holder of `storage:read`; 27.0.0 answers `404 X_STORAGE_KEY_UNSHARED` unless `defineStorage({ disks, shared: { uploads: ['brand/'] } })` lists its prefix for that disk (whole segments ending in `/`, never `org/`, never the whole disk — `X_CONFIG_INVALID` at boot otherwise). Or move a truly public file to `apps/web/site/assets/` and name it with `asset()`. A hand-built `Storage` adds `isShared(disk, key)` (TS2741). Tenant-scoped reads are unchanged |
447
+
448
+ ## 25.x → 26.0.0, entry by entry
449
+
450
+ **Six entries** — `CHANGELOG.md`'s 26.0.0 breaking entries in their order, grouped by
451
+ package, lowest tier first. No legacy path, no codemod, no shim. **Entry 1 can refuse sign-ins** on
452
+ a host whose egress cannot reach the provider's `jwks_uri` — check it before the deploy (upgrade
453
+ step 2). A later slice appends its rows below the last one and never renumbers; each row's
454
+ `CHANGELOG.md` line names it as `(#N)` after the dash.
455
+
456
+ ### The upgrade, top to bottom
457
+
458
+ | # | Do | What you see until you do | Entries |
459
+ |---|---|---|---|
460
+ | 1 | pin every `@ultimat3/*` to the one new version, `bun install` | nothing yet — a mixed install is untested | — |
461
+ | 2 | **before the deploy, an app with an OIDC sign-in:** from a production pod, `curl -sS -m 5 https://www.googleapis.com/oauth2/v3/certs` (or the `jwks_uri` of each provider you registered); where it cannot be reached, return `'token-endpoint-tls'` for that provider only from `oauthLogin(auth, { idTokenKeys })` (entry 1 shows the function) | `X_OAUTH_EXCHANGE_FAILED` (stage `jwks`) on every sign-in through that provider | 1 |
462
+ | 3 | `bun test` — a test that feeds `exchangeOAuthCode`, `completeOAuthLogin` or `oauthLogin` an unsigned id token: sign it and serve the key set at the provider's `jwksUri` from the injected `fetch`, or pass `keys` / `idTokenKeys: 'token-endpoint-tls'` | `X_OAUTH_TOKEN_INVALID` where the test signed in; an extra key-set URL in a recorded endpoint list | 1 |
463
+ | 4 | `bun run typecheck` for the removed exports: `APPLE_PROVIDER`, `ServerHandle.describe()`, `AgentRecovery`, `Recovery`, `ROUTE_FILENAME.api` | TS2305 / TS2339 at each site | 2, 3, 5 |
464
+ | 5 | `find apps -path '*/api/*' \( -name route.ts -o -name page.tsx \)` — move each into an action, or a plain route in `runtime.routes` | `X_ROUTE_FILE_INVALID` at boot | 4 |
465
+ | 6 | `grep -rnE "x (jobs (ls\|rm)\|(actions\|queries\|entities) describe\|db branch (ls\|drop))\b" .github scripts bin package.json docs` and swap each for `list`, `delete`, `show` | `X_CLI_UNKNOWN_COMMAND` naming the new verb | 6 |
466
+ | 7 | `x verify` | green, or a finding whose `fix:` is the edit | — |
467
+
468
+ ### Entry by entry
469
+
470
+ Tier 2 — `@ultimat3/auth` (1–2), `@ultimat3/http` (3). Tier 4 — `@ultimat3/render` (4),
471
+ `@ultimat3/scraping` (5). Tier 5 — `@ultimat3/cli` (6).
472
+
473
+ | # | Surface | Costs you an edit if |
474
+ |---|---|---|
475
+ | 1 | `exchangeOAuthCode`, `completeOAuthLogin`, `oauthLogin` — the id token's signature | your egress cannot reach a provider's `jwks_uri`, or a test hands the exchange an unsigned id token. 25.x accepted any id token read off the token endpoint over TLS; 26.0.0 checks its signature against the provider's published key set whenever the provider has a `jwksUri` (`providerJwks`, cached per provider). `X_OAUTH_TOKEN_INVALID` for a bad signature, `X_OAUTH_EXCHANGE_FAILED` (stage `jwks`) for an unreachable key set. Opt one provider out with `oauthLogin(auth, { idTokenKeys: (provider) => provider === 'bigco-sso' ? 'token-endpoint-tls' : undefined })` — `undefined` keeps the default; `providerJwks(providerFor(id))` there throws for GitHub, which publishes no `jwks_uri`; `completeOAuthLogin(auth, { idTokenKeys })` and `exchangeOAuthCode(…, { keys })` take the value directly |
476
+ | 2 | `APPLE_PROVIDER`, `BUILTIN_OAUTH_PROVIDERS`, `BUILTIN_OAUTH_PROVIDER_IDS` | you import `APPLE_PROVIDER` (TS2305) or list `'apple'` in `defineAuth({ providers })` (an unknown provider). Apple answers a `form_post` and the callback is a GET, so it never completed a login. Delete it, or `registerOAuthProvider` your own |
477
+ | 3 | `ServerHandle.describe()` | you call it. TS2339; read `x routes --json`, or call `describeRoutes(table)` from `@ultimat3/http` |
478
+ | 4 | `api/**/route.ts`, `ROUTE_FILENAME`, `RouteEntry.surface` | a route file lives under `api/`: `X_ROUTE_FILE_INVALID` (was `X_ROUTE_MODE_INVALID`). Make it an action, or a plain route in `runtime.routes`. `ROUTE_FILENAME` is `Record<RouteSurface, string>`; type a surface with the new `RouteSurface` export of `@ultimat3/render` |
479
+ | 5 | `recover: 'agent'`, `AgentRecovery`, `Recovery` | a scrape passes `'agent'` (refused at `scrape()`; 25.x threw `X_NOT_IMPLEMENTED` on the first moved selector) or imports a removed type. Write a `RecoveryHook` function and call your own `llm()` from it; `import type { Recovery }` becomes `RecoveryHook`. `@ultimat3/scraping` is tier 4 now — nothing to edit in an app |
480
+ | 6 | `x jobs ls`, `x jobs rm`, `x actions\|queries\|entities describe`, `x db branch ls`, `x db branch drop` | a script runs an old verb: `X_CLI_UNKNOWN_COMMAND` with the new one in its fix. `list`, `delete`, `show`, `list`, `delete`; a bare `x jobs` still lists, and `--json` is unchanged |
481
+
482
+ ## 24.x → 25.0.0, entry by entry
483
+
484
+ **Forty entries** — `CHANGELOG.md`'s `25.0.0` breaking entries in their order: Bun
485
+ first, then grouped by package, lowest tier first. No legacy path, no codemod, no compatibility
486
+ shim — every break is a build error or an `X_*` error naming the rewrite. Most of the major is
487
+ deletion: a second spelling, a second projection or a second page shape removed, and the keys and
488
+ re-exports a deprecation promised to remove. **Entries 2 and 8 refuse the boot** of an app that
489
+ still writes a deleted or an undeclared key. **Entry 12 is silent:** a per-action bucket in `configureHttp` stops
490
+ limiting anything, so move it before the deploy — upgrade step 9. **Entries 22–24 refuse every AI
491
+ call** until the app registers its own models and names one — upgrade step 8. Entries 31–40 landed
492
+ after the cut, so they sit below 30 rather than with their packages. **Entry 38 is silent:** a worker
493
+ Deployment that set `WORKER_QUEUES` stops claiming every queue it does not list — upgrade step 12. A later slice
494
+ appends its rows below the last one and never renumbers; each row's `CHANGELOG.md` line names it
495
+ as `(#N)` after the dash.
496
+
497
+ ### The upgrade, top to bottom
498
+
499
+ | # | Do | What you see until you do | Entries |
500
+ |---|---|---|---|
501
+ | 1 | `bun upgrade` to 1.4.2 and move the app's own image to a 1.4.2 base | `X_BUN_VERSION` from `x` and `x doctor` | 1 |
502
+ | 2 | pin every `@ultimat3/*` to the one new version, `bun install` | nothing yet — a mixed install is untested | — |
503
+ | 3 | delete the removed keys from `app.config.ts` and each overlay — the seven of 25.0.0, the ten of earlier majors, and any key the refusal says is not one (fix the typo it suggests); declare the locales in `defineCatalogs` (`packages/i18n/src/index.ts`); move a non-`/mcp` `ai.mcp.path` to the first `defineAppMcp({ path })` in `apps/<app>/mcp.ts`; move `drainTimeoutMs` to `drain: { deadlineMs }` and delete `buildIdHeader` from `configureHttp` | TS2353 in a typed config; `X_CONFIG_INVALID` naming the key and its replacement, or the nearest real key, at the first import | 2, 8 |
504
+ | 4 | `bun run typecheck` for the renames and removed exports: factory spellings (every `create<Thing>` and `new` of a class now a type), every value a second package re-exported, the renamed value names, `checkEnvExample`, `fingerprint`, `nearestName`, `promptHash`, `entity`'s `Page`, `SQL_*`, `createNatsDriver` and `createRedisDriver` imports | TS2305 / TS2724 at each import; TS1485 / TS1362 at a class now exported as a type | 3, 5–7, 14, 15, 20, 28, 31, 32 |
505
+ | 5 | `bun run typecheck` for the MCP projection, the MCP block and the page shape: `.tool()` and `toolFromAction` → `toolFrom`, `QueryMcp` / `McpExposure` / `McpAnnotationHints` → core's `McpExposureDeclaration` family, `mcp.name` → the primitive's `name`, `endCursor` / `hasNextPage` → `nextCursor` / `hasMore`, a storage page's `objects` / `truncated` / `cursor` → `rows` / `hasMore` / `nextCursor` | TS2339 / TS2305 / TS2353 at each site | 7, 10, 11, 18 |
506
+ | 6 | `bun run typecheck` for the members a custom implementation owes: `AuditRecord.name` / `.primitive`, `IdempotencyStore.keepsRedaction` and `settle(…, redacted)`, `WebhookLedger.isDisabled`, `ChangeEvent.write`, `ChangeEvent.table` for `.entity`, `DigestAppend.appender`, a `StorageDriver.list()` that answers `Page<StorageListEntry>`; drop `counted` from every `ack`; widen a `Menu` trigger's `aria-controls`; read `listenerFor` instead of `listeners`; narrow a read of `config.jobs.concurrency` with `typeof … === 'number'`; pass an `OAuthGrantContext` where code calls a `ResolveOAuthGrants` | TS2741 / TS2554 / TS2322 / TS2339 / TS2353 at each site | 4, 7, 13, 16, 17, 21, 25, 26, 33, 34, 37, 40 |
507
+ | 7 | `bun run typecheck` for ai: pass `models` to each `anthropicProvider(…)`, replace `OPENAI_MODEL_IDS` with the app's ids | TS2554 / TS2305 at each site | 23 |
508
+ | 8 | **before the deploy, where the app calls a model:** `registerModel` every model it names (by convention in its own `models.ts`), and name one on each declaration or as `providerGateway({ defaultModel })`; a test registers its rows in `beforeEach` and passes `model` to `echoProvider()`'s requests | `X_AI_MODEL_UNKNOWN` before any provider call; `X_AI_MODEL_UNRESOLVED` at a call that names none | 22–24 |
509
+ | 9 | **before the deploy:** move each `rateLimit.buckets.<actionName>` from `configureHttp` to that action's `rateLimit: { limit, windowMs }` | `X_CONFIG_INVALID` at boot, naming the bucket and the action | 12 |
510
+ | 10 | `x env example` (an app exporting `envSchema`), `x manifest`, commit `.env.example`, `x.manifest.json` and `openapi.json` | `X_ENV_EXAMPLE_DRIFT` / `X_MANIFEST_STALE` from the `manifest` step | 11, 35 |
511
+ | 11 | `x verify --only boundaries,unit,contract,e2e` and fix what it fails: move an app wildcard off a `bearerMount` prefix, list every projected tool under a scope, give each offline scrape a `robots: { ignore }` reason, move a page's repo read into a query, pass an e2e's external `DATABASE_URL` / `S3_ENDPOINT` in `startE2eApp({ env })` | `X_ROUTE_CONFLICT`; `X_MCP_SCOPE_UNCOVERED` at boot; `X_SCRAPE_ROBOTS_DISALLOWED`; `X_BOUNDARY_ROUTE_TO_DB`; an e2e that read the external database's rows fails against the embedded one | 9, 19, 27, 30, 36 |
512
+ | 12 | read each script or CI job that runs `x shot --cdp-url` against a hosted browser, passes `--to` or `--dry-run` to `x jobs drain`, or reads `data.next` from `x jobs list --json`; **before the deploy,** read each worker Deployment that sets `WORKER_QUEUES` and list every queue it must serve; pass `concurrency: 5` to a hand-built `jobWorker` that must keep 5 slots | `X_CDP_CALL_FAILED` where a capture used to proceed; `X_CLI_BAD_FLAG`; `undefined` where a cursor was; `jobs.worker.queue-unserved` at a worker's boot and jobs left `ready` on the queues it no longer claims; 8 jobs at once where 5 ran | 7, 15, 29, 38, 39 |
513
+ | 13 | `x verify` | green, or a finding whose `fix:` is the edit | — |
514
+
515
+ ### Entry by entry
516
+
517
+ Every package (1). Tier 0 — `@ultimat3/core` (2–5). Tier 1 and up — the factory spellings of
518
+ `db`, `cache`, `storage`, `auth`, `action`, `jobs`, `realtime`, `mail`, `notify`, `ai` and
519
+ `testing` (6). Tier 2 — `@ultimat3/entity` (7),
520
+ `@ultimat3/http` (8–9). Tier 3 — `@ultimat3/action` and `@ultimat3/query` (10–14),
521
+ `@ultimat3/jobs` (15–16), `@ultimat3/realtime` (17). Tier 4 — `@ultimat3/mcp` (18–19),
522
+ `@ultimat3/manifest` (20), `@ultimat3/notify` (21), `@ultimat3/ai` (22–24), `@ultimat3/ui` (25).
523
+ Tier 5 — `@ultimat3/testing` (26), `@ultimat3/scraping` (27), `@ultimat3/cli` (28–30). After
524
+ the cut — `@ultimat3/ai` (31), the value names of every package (32), `@ultimat3/jobs` (33).
525
+ Sweep 12c — `@ultimat3/core` (34), `@ultimat3/cli` (35), `@ultimat3/testing` (36); its review round —
526
+ `@ultimat3/auth` (37), `@ultimat3/cli` (38), `@ultimat3/jobs` (39), `@ultimat3/realtime` (40).
527
+
528
+ | # | Surface | Costs you an edit if |
529
+ |---|---|---|
530
+ | 1 | `engines.bun`, `x`, `x doctor` | you run Bun 1.4.0 or 1.4.1. `X_BUN_VERSION`; `bun upgrade`, and a 1.4.2 base image in the app's own Dockerfile |
531
+ | 2 | `app.config.ts`: any key it does not declare, at any depth; keys removed: `locales`, `defaultLocale`, `defaultTimeZone`, `defaultCurrency`, `theme.tokens`, `jobs.driver`, `ai.mcp.path`, and from earlier majors `realtime.heartbeatMs`, `database.urlEnv`, `database.poolSize`, `database.schema`, `pwa.installPrompt`, `auth.afterSignInPath`, `ai.modelEnv`, `cache.driver`, `cache.urlEnv`, `realtime.tier` | a layer writes one. `X_CONFIG_INVALID` naming the path and either its replacement or, for a key that never existed, the nearest real one (`did you mean drain?`); TS2353 in a typed config; TS2339 where code reads `config.locales`. The locales and their default are `defineCatalogs({ default, locales })` in `packages/i18n/src/index.ts`; the zone is passed per call, the currency per `Money`; the theme is `defineTheme` in `apps/web/shared/theme.ts`; the driver is `setJobDriver(postgresJobDriver({ executor }))`. Delete `ai.mcp.path`: every MCP endpoint, #0 included, mounts at its own `defineAppMcp({ path })` (default `/mcp`) — if yours was not `/mcp`, pass it as `path` to the first `defineAppMcp` in `apps/<app>/mcp.ts`. `McpConfig` is `{ expose }`. Of the ten older keys only `cache.driver` was read (→ `cache: { tiers: [...] }`); the rest were read by nothing — delete the line, and the `fix:` names what does the job (`REMOVED_CONFIG_KEYS`, `packages/core/src/config-removed.ts`) |
532
+ | 3 | `assertEnvExample`, `EnvExampleDriftError`, `resolveSpeculation` | you import one. `checkEnvExample(…)` returns findings instead of throwing — or let `x verify --only manifest` run it; speculation is `defineConfig({ navigation: { speculation } })` |
533
+ | 4 | `AuditRecord`, `normalizeAuditRecord`, `NormalizedAuditRecord` | a sink or test reads `record.action` or builds a record without `name` and `primitive`. TS2339 / TS2741. Read and write `name` and `primitive`; no DDL changed |
534
+ | 5 | `Deprecation`, `DeprecationField`, `DeprecationRender`, `recordDeprecatedCall`, `renderDeprecation`; the values `createClientFlight` (now `clientFlight`), `DEFAULT_CLIENT_RETRY`, `isSuperseded`, `isTransientFailure`, `getAuditSink`, `setAuditSink`, `resetAuditSink`; every other value a second package re-exported — the Entry 5 table below, including `formatIssues` (`mcp`), `MAX_MONEY_SCALE` (`money`), `ULTIMATE_ERROR_BRAND` (`core`), `JOB_STATES` and `TEST_TYPES` (`cli`) | you import one from a package that is not its home. TS2305; change the module specifier per the table — `@ultimat3/core` unless the row names another, and under core's own name where the row gives one. `action` and `query` re-export types only; scraping's `throwIfAborted(signal)` is `signal.throwIfAborted()` |
535
+ | 6 | every `createMemory*`, `createPg*`, `pgSchedulerState`, `createPostgresClient`; a `Memory*` / `InMemory*` / `Pg*` / `BuiltinAdapter` class `new`-ed as a value; every `create<Vendor>Driver`; storage's `memoryDriver`; every other `create<Thing>` factory; ai's `new AnthropicProvider` / `EchoProvider` / `HashEmbedder` / `RemoteEmbedder` / `BudgetLedger`, mcp's `new McpServer`, query's `new Builder` | you call one. TS2305 / TS2724; TS1485 / TS1362 where the class is now a type only. Rename per the table below. Arguments are unchanged; a type a factory takes or builds is renamed to the factory's spelling (TS2724 at the old name — the type-renames table below), so `import type { BuiltinAdapter }` is `PostgresAuthAdapter` and `MemoryAdapter` is `MemoryAuthAdapter`. A budget ledger is the gateway's (`gateway.callLedger(keys)`, `gateway.scope(…)`); a query builder is `from(…)`'s. `createBranch`, `createSession` and `createApp` keep their names: they are verbs |
536
+ | 7 | `Page` from `@ultimat3/entity` | you annotate a `findMany` or `.page()` result with it. Import `Page` from `@ultimat3/core` (or `@ultimat3/query`): `findMany` answers it, so the page now carries `hasMore` too. A hand-written `Repo` builds its page with core's `pageOf(rows, nextCursor)` (TS2741 / TS2322 at a `{ rows, nextCursor }` literal). **And** `ListPage` from `@ultimat3/storage`: `list()` answers `Page<StorageListEntry>` — read `rows` / `hasMore` / `nextCursor` for `objects` / `truncated` / `cursor` (`null` on the last page), pass `cursor: page.nextCursor`; a custom driver returns `pageOf(entries, nextCursor)`. **And** a script reading `x jobs list --json`: `data.next` is `data.nextCursor`, beside `data.hasMore` |
537
+ | 8 | `configureHttp({ drainTimeoutMs })`, `defineHttpConfig({ drainTimeoutMs })`; the same two with `buildIdHeader` | you pass one. `X_CONFIG_INVALID`, refused by key so a spread is caught too. `drainTimeoutMs`: declare `drain: { deadlineMs }` in `app.config.ts` — every role, web included, drains on it. `buildIdHeader`: delete it; the header is core's `BUILD_ID_HEADER` (`x-ultimate-build`), the only one any client sends |
538
+ | 9 | `bearerMount` | the app declares a wildcard at the mount's own `<prefix>/*rest`. `X_ROUTE_CONFLICT` at registration; the mount answers every method under its prefix |
539
+ | 10 | `.tool()`, `toMcpTool(s)`, `toQueryTool(s)`, `isExposed`, `toolFromAction`, `toolFromQuery`, `McpToolDescriptor`, `McpInvokeOptions`, `QueryTool*` | you project or call a tool by hand. `toolFrom(x)` for an action or a query (`toolFromAction` renamed, no alias), `toolListEntry(tool)` for the `tools/list` entry, `invoke(x, input, { ctx, surface: 'mcp' })` to call, `(await sourceFor(q, input, { surface: 'mcp' })).execute()` to read, `isMcpExposed(x.mcp)` from `@ultimat3/core` |
540
+ | 11 | query `Page`, the `?_first=` envelope, `openapi.json` | a client reads `endCursor` or `hasNextPage`. Read `nextCursor` and `hasMore`; pass `nextCursor` as `after` (`_after=` on the wire). Run `x manifest`. **And** a client keeps the LAST page's cursor — to poll for rows appended after it, or because it reads `nextCursor` without `hasMore`: the last page's `nextCursor` is now `null` (it is `null` exactly when `hasMore` is false, on every surface), so a `while (page.nextCursor)` loop no longer fetches an empty page past the end. To tail a listing, keep the cursor of the last page that had one, or re-read the first page |
541
+ | 12 | `http.rateLimit.buckets.<actionName>` | you limited an action through a named bucket. It is refused at boot with `X_CONFIG_INVALID` (a bucket keyed by a mounted action's or query's name): delete it from `configureHttp` and declare `rateLimit: { limit, windowMs }` on the action |
542
+ | 13 | `IdempotencyStore` | you wrote your own. Declare `keepsRedaction: true`, keep `settle`'s 4th argument `redacted` and return it as `IdempotencyRecord.redacted` |
543
+ | 14 | `SQL_*` exports of `action`, `jobs`, `notify`, `admin` | you import a statement other than a table DDL (`SQL_AUDIT_TABLE`, `SQL_IDEMPOTENCY_TABLE`, `SQL_JOBS_TABLE`, `SQL_NOTIFY_*_TABLE`, `SQL_ADMIN_AUDIT_TABLE`) — `SQL_CLAIM`, `SQL_OUTBOX_RELEASE` and `SQL_NOTIFY_INBOX_MARK_READ` included. Call the store that runs it, or copy the text into the app |
544
+ | 15 | `createNatsDriver`, `NatsDriverOptions`, `createRedisDriver`, `RedisDriverOptions`; the `--to` and `--dry-run` flags of `x jobs drain` | you import one — every method threw `X_NOT_IMPLEMENTED`. Postgres is the durable driver: `postgresJobDriver({ executor })`. Or a script passes either flag: `X_CLI_BAD_FLAG`; `x jobs drain` is still planned (`X_NOT_IMPLEMENTED`), so delete the call |
545
+ | 16 | `WebhookLedger` | you wrote your own. Add `isDisabled(endpointId): Promise<boolean>` |
546
+ | 17 | `ChangeEvent` | a custom change feed builds one. Pass `write: null` where it has no write name |
547
+ | 18 | `McpExposure` and its `name`; `QueryMcp`, `QueryMcpAnnotations`, `QueryListParams`, `QueryListFilterOp` (`query`); `McpAnnotationHints` (`action`) | a hand-built primitive handed to `toolFrom` sets `mcp: { name }` — rename the primitive instead. Or you name one of the types: import `McpExposureDeclaration`, `McpAnnotationHints`, `McpListParams`, `McpListFilterOp` from `@ultimat3/core`. An action's block is `ActionMcp`, `Omit<McpExposureDeclaration, 'listParams'>` |
548
+ | 19 | `defineAppMcp({ scopes })` | the map leaves a projected tool out — `include: 'exposed'` and hand-written tools such as `whoami` included. `X_MCP_SCOPE_UNCOVERED` at boot; list each tool under a scope |
549
+ | 20 | `contentHash` from `@ultimat3/manifest` | you import it. `fingerprint(body)` from `@ultimat3/core`, the same 16 hex characters |
550
+ | 21 | `DigestAppend`, `DigestStore` | you wrote a store. Key its replay on the required `appender` |
551
+ | 22 | `DEFAULT_MODEL`, a call with no model | a declaration, its prompt and the gateway all leave the model out. `X_AI_MODEL_UNRESOLVED` naming the three places; `echoProvider()` needs `model` too, and so does a request to a provider called directly (`anthropicProvider`, `openAiProvider`), which runs `request.model` and nothing else. `describeAgents()` answers `model: null` there |
552
+ | 23 | `anthropicProvider()` (24.x: `new AnthropicProvider()`), `ANTHROPIC_MODEL_IDS`, `OPENAI_MODEL_IDS` | you construct a provider without `models` or with the built-in list. TS2554 / TS2305; an empty list is `X_AI_REQUEST_INVALID`. Pass the ids the app registered |
553
+ | 24 | the built-in catalogue, `registerOpenAiModels()` | the app names a model it never `registerModel`-ed. `X_AI_MODEL_UNKNOWN` before any provider call. Register each model at boot, with its source and date beside each number |
554
+ | 25 | `Menu` trigger props | you type `aria-controls` as `string`. It is `string \| undefined`; a closed menu carries none |
555
+ | 26 | `FakeElement.listeners` | a test reads it. `element.listenerFor(name)`, or `island.fire(selector, type)` |
556
+ | 27 | an offline `scrape()` test with `fakeBrowser` / `fixtureBrowser` | it declares no `robots: { ignore }`. `X_SCRAPE_ROBOTS_DISALLOWED` under `bun test`; write the reason the run is permitted |
557
+ | 28 | `nearest` from `@ultimat3/cli` | you import it. `nearestName` from `@ultimat3/core` |
558
+ | 29 | `x shot --cdp-url` | the provider refuses the browser-target attach. `X_CDP_CALL_FAILED`; point at a browser that allows it, or drop the flag |
559
+ | 30 | the `boundaries` step | a `page`, `layout` or `route` imports a slice's `repo`. `X_BOUNDARY_ROUTE_TO_DB`; read through a query |
560
+ | 31 | `contentHash` from `@ultimat3/ai` | you import it. `promptHash(input)`, the same argument and the same hash; `contentHash` is `@ultimat3/render/server`'s byte hash only |
561
+ | 32 | a value name two packages declared: `forbidden`, `unauthenticated`, `json`, `text`, `validate`, `money`, `isEnabled`, `describeRoutes`, `channel`, `guard`, `guardBeforeInput`, `resetRegistry`, `explain`, `actorLabel`, `allowed`, `denied`, `relationsFor`, `invariant`, `uuid`, `cacheKeyFor`, `DEFAULT_RETRY`, `NO_TENANT`, `chunk`, `normalize`, `tokenize`, `introspect`, `isDestructive`, `assertNoDrift`, `safeUrl`, `formatBytes` (ui), `resetIdCounter`, `srcsetFor`, `frozenClock`, `testClock` (scraping), `sessionExpired` | you import one. TS2305 / TS2724; rename per the Entry 32 table below — same arguments, same answer, no alias. Scraping's `testClock` is `testScrapeClock`: `@ultimat3/testing`'s `testClock` is the former `createTestClock`, a different clock |
562
+ | 33 | `AckOptions.counted` | a custom `JobDriver.ack` reads it, or a caller passes it. TS2353 at a literal; delete it — every ack counts |
563
+ | 34 | `JobsConfig.concurrency` (`JobsConcurrency`) | code reads `config.jobs.concurrency` as a `number`. It is `number \| Readonly<Record<string, number>>`, slots per queue; narrow with `typeof concurrency === 'number'`, and read a queue's row as `concurrency[queue] ?? JOBS_CONCURRENCY_DEFAULT` (both exported by `@ultimat3/core`). An empty table, a blank queue name or a count below 1 is `X_CONFIG_INVALID` |
564
+ | 35 | `.env.example`, `x env example` | the app exports `envSchema`. The projection now ends in a `Framework` section — `ULTIMATE_CURSOR_SECRET`, `STORAGE_SIGNING_SECRET` — so the committed file is `X_ENV_EXAMPLE_DRIFT` in the `manifest` step; run `x env example` and commit it. A key the schema already declares is not repeated |
565
+ | 36 | `startE2eApp` | an e2e relied on an exported `DATABASE_URL` or `S3_ENDPOINT` reaching the app. Neither is inherited any more; pass it: `startE2eApp({ root, env: { DATABASE_URL: '<the external url>' } })` |
566
+ | 37 | `ResolveOAuthGrants` | code calls a `ResolveOAuthGrants`-typed function with one argument — typically an app's unit test of its grants function. TS2554; pass an `OAuthGrantContext` (`provider`, `tokens`, `idTokenClaims`, `userinfo()`) as the second argument. A one-argument implementation still assigns to the type |
567
+ | 38 | `WORKER_QUEUES` | a worker Deployment sets it and relied on also serving `jobs.queues` and every registered job's queue (24.x read nothing by that name). It is now exactly the listed queues: jobs on any other queue stay unclaimed by that worker, which warns `jobs.worker.queue-unserved` at boot naming them. List every queue the Deployment must serve, or unset the variable for the union. An empty entry (`banks,,mail`) is `X_CONFIG_INVALID` |
568
+ | 39 | `jobWorker({ concurrency })` | you build a `jobWorker` by hand and pass no `concurrency`, or a table that leaves a queue out. That queue runs `JOBS_CONCURRENCY_DEFAULT` (8, `@ultimat3/core`) slots, was 5; pass `concurrency: 5` to keep it. A worker `x` boots is unchanged |
569
+ | 40 | `ChangeEvent.entity` (`@ultimat3/realtime`) | a custom `ChangeFeed`, test or consumer builds or reads a change event. Rename `entity:` to `table:` — it holds the relation (table) name on every producer. TS2353 / TS2339. A sync node still reads a 24.x replicator's `entity` off the bus, so the rolling deploy needs no ordering |
570
+
571
+ Entry 6, the renames:
572
+
573
+ | Package | Was | Is |
574
+ |---|---|---|
575
+ | `db` | `createPostgresClient` | `postgresClient` |
576
+ | `cache` | `createMemorySemanticCache` | `memorySemanticCache` |
577
+ | `jobs` | `createMemoryDriver`, `createPgDriver` | `memoryJobDriver`, `postgresJobDriver` |
578
+ | `jobs` | `createPgLeader`, `createPgLeaseLeader`, `pgSchedulerState` | `postgresLeader`, `postgresLeaseLeader`, `postgresSchedulerState` |
579
+ | `jobs` | `createPgEventBus`, `createPgOutboxStore` | `postgresEventBus`, `postgresOutboxStore` |
580
+ | `jobs` | `createMemoryEventBus`, `createMemoryOutboxStore`, `createMemoryStepStore` | `memoryEventBus`, `memoryOutboxStore`, `memoryStepStore` |
581
+ | `jobs` | `createMemoryLeaseStore`, `createMemorySchedulerState`, `createMemoryBackfillLedger` | `memoryLeaseStore`, `memorySchedulerState`, `memoryBackfillLedger` |
582
+ | `mail` | `createMemoryDriver` | `memoryMailDriver` |
583
+ | `notify` | `createMemoryDeliveryLedger`, `createMemoryDigestStore`, `createMemoryInboxStore`, `createMemoryPreferenceStore` | `memoryDeliveryLedger`, `memoryDigestStore`, `memoryInboxStore`, `memoryPreferenceStore` |
584
+ | `notify` | `createPgDeliveryLedger`, `createPgDigestStore`, `createPgInboxStore` | `postgresDeliveryLedger`, `postgresDigestStore`, `postgresInboxStore` |
585
+ | `storage` | `memoryDriver`, `MemoryDriverOptions` | `memoryStorageDriver`, `MemoryStorageDriverOptions` |
586
+ | `auth` | `new BuiltinAdapter(…)`, `new MemoryAdapter(…)` | `postgresAuthAdapter(…)`, `memoryAuthAdapter(…)` |
587
+ | `action` | `new MemoryIdempotencyStore(…)` | `memoryIdempotencyStore(…)` |
588
+ | `realtime` | `new MemoryLocalStore()`, `new MemoryQueueStore()` | `memoryLocalStore()`, `memoryQueueStore()` |
589
+ | `realtime/server` | `new InMemoryAdvisoryLock(…)`, `new PgAdvisoryLock(…)` | `memoryAdvisoryLock(…)`, `postgresAdvisoryLock(…)` |
590
+ | `realtime/server` | `new InMemoryChangeFeed(…)`, `new PgLogicalReplicationFeed(…)`; `PgOutputDecoder` | `memoryChangeFeed(…)`, `postgresChangeFeed(…)`; not exported — the feed decodes |
591
+ | `mail` | `createSmtpDriver`, `createResendDriver`, `createLogDriver`, `createUnconfiguredDriver` | `smtpMailDriver`, `resendMailDriver`, `logMailDriver`, `unconfiguredMailDriver` |
592
+ | `ai` | `new PgVectorStore(…)`, `new MemoryVectorStore(…)`, `new MemoryBudgetStore()` | `postgresVectorStore(…)`, `memoryVectorStore(…)`, `memoryBudgetStore()` |
593
+ | `testing` | `createSubscribeDriver()` | `subscribeDriver()` |
594
+ | `auth` | `createAuthLimiter`, `createJwksClient`, `createKdfGate`, `createPkce`, `createTotpReplayGuard` | `memoryAuthLimiter`, `jwksClient`, `boundedKdfGate`, `pkcePair`, `memoryTotpReplayGuard` |
595
+ | `cache` | `createCacheStack`, `createCdnTier`, `createLruTier`, `createMemoTier`, `createRedisTier` | `cacheStack`, `cdnTier`, `lruTier`, `memoTier`, `redisTier` |
596
+ | `core` | `createClientFlight`, `createContext`, `createFence`, `createFlightGate` | `clientFlight`, `ctxOf`, `generationFence`, `flightGate` |
597
+ | `core` | `createLogger`, `createRaster`, `createSingleFlight` | `structuredLogger`, `blankRaster`, `singleFlight` |
598
+ | `db` | `createPgliteClient`, `createRecordingClient` | `pgliteClient`, `recordingClient` |
599
+ | `http` | `createPipeline`, `createRateLimiter`, `createRequestContext`, `createRouter`, `createServer` | `httpPipeline`, `rateLimiter`, `requestContext`, `httpRouter`, `httpServer` |
600
+ | `i18n` | `createTranslator` | `catalogTranslator` |
601
+ | `jobs` | `createJobsFacade`, `createLimiter`, `createOutboxRelay`, `createPacer` | `outboxJobsFacade`, `concurrencyLimiter`, `outboxRelay`, `backfillPacer` |
602
+ | `jobs` | `createScheduler`, `createStepRunner`, `createWorker` | `jobScheduler`, `stepRunner`, `jobWorker` |
603
+ | `realtime` | `createOutbox`, `createSyncNode` | `localOutbox`, `syncNode` |
604
+ | `realtime/server` | `createEntry`, `createFrameRouter`, `createReplicator` | `queryEntry`, `frameRouter`, `changeFeedReplicator` |
605
+ | `mcp` | `createDevServer`, `createMcpServer`, `new McpServer(…)` | `devMcpServer`, `mcpServer`, `mcpServer(…)` |
606
+ | `pwa` | `createInstallController` | `installController` |
607
+ | `render` | `createIslandCollector`, `createIsrController` | `islandCollector`, `isrController` |
608
+ | `ai` | `new AnthropicProvider(…)`, `new EchoProvider(…)`, `new HashEmbedder(…)`, `new RemoteEmbedder(…)` | `anthropicProvider(…)`, `echoProvider(…)`, `hashEmbedder(…)`, `remoteEmbedder(…)` |
609
+ | `ai` | `createGateway`, `CreateGatewayInput`; `new BudgetLedger(…)` | `providerGateway`, `ProviderGatewayInput`; `gateway.callLedger(keys)` or `gateway.scope(…)` |
610
+ | `ui` | `createFocusTrap`, `createFormBinding`, `createRovingTabindex`, `createToastStore` | `focusTrap`, `formBinding`, `rovingTabindex`, `toastStore` |
611
+ | `testing` | `createTestClock`, `createTestMail`, `createTestNetwork`, `createTestStatements` | `testClock`, `testMail`, `testNetwork`, `testStatements` |
612
+ | `testing` | `createRunJobs`, `createLiveNode`, `createTemplateSql` | `testJobs`, `liveNode`, `templateSql` |
613
+ | `scraping` | `createArtifactWriter`, `createPacer`, `createPrompt`, `createRing` | `artifactWriter`, `scrapePacer`, `scrapePrompt`, `boundedRing` |
614
+ | `scraping` | `createRobotsGate`, `createSecretBag`, `createUsageMeter` | `robotsGate`, `secretBag`, `usageMeter` |
615
+ | `query` | `new Builder(…)` | `from(…)` |
616
+
617
+ Each class above stays nameable as a type (`import type { MemoryVectorStore }`) — under the
618
+ factory's spelling where the two differed. Entry 6, the type renames (TS2724 at the old name):
619
+
620
+ | Package | Was | Is |
621
+ |---|---|---|
622
+ | `jobs` | `PgDriverOptions`, `MemoryDriverOptions`, `PgEventBusOptions` | `PostgresJobDriverOptions`, `MemoryJobDriverOptions`, `PostgresEventBusOptions` |
623
+ | `jobs` | `PgOutboxOptions`, `MemoryOutboxOptions`, `PgLeaseLeaderOptions` | `PostgresOutboxStoreOptions`, `MemoryOutboxStoreOptions`, `PostgresLeaseLeaderOptions` |
624
+ | `mcp` | `CreateMcpServerInput`, `CreateDevServerInput` | `McpServerInput`, `DevMcpServerInput` |
625
+ | `ai` | `PgVectorStore`, `PgVectorStoreInput`, `CreateGatewayInput` | `PostgresVectorStore`, `PostgresVectorStoreInput`, `ProviderGatewayInput` |
626
+ | `auth` | `BuiltinAdapter`, `MemoryAdapter` | `PostgresAuthAdapter`, `MemoryAuthAdapter` |
627
+ | `notify` | `PgDigestStore`, `PgDigestStoreOptions`, `PgInboxStore`, `PgInboxStoreOptions` | `PostgresDigestStore`, `PostgresDigestStoreOptions`, `PostgresInboxStore`, `PostgresInboxStoreOptions` |
628
+ | `notify` | `PgDeliveryLedger`, `PgDeliveryLedgerOptions`, `MemoryLedgerOptions` | `PostgresDeliveryLedger`, `PostgresDeliveryLedgerOptions`, `MemoryDeliveryLedgerOptions` |
629
+ | `realtime/server` | `InMemoryAdvisoryLock`, `PgAdvisoryLock`, `PgAdvisoryLockOptions` | `MemoryAdvisoryLock`, `PostgresAdvisoryLock`, `PostgresAdvisoryLockOptions` |
630
+ | `realtime/server` | `InMemoryChangeFeed`, `InMemoryChangeFeedOptions`, `PgLogicalReplicationFeed`, `PgLogicalReplicationOptions` | `MemoryChangeFeed`, `MemoryChangeFeedOptions`, `PostgresChangeFeed`, `PostgresChangeFeedOptions` |
631
+ | `storage` | `MemoryDriverOptions` | `MemoryStorageDriverOptions` |
632
+
633
+ Entry 5, each value at its home — `@ultimat3/core` unless the right-hand column names another:
634
+
635
+ | Was imported from | Values | Import from |
636
+ |---|---|---|
637
+ | `action` | `BUILD_ID_HEADER`, `IDEMPOTENCY_HEADER`, `pluralize`, `actorOf` | `@ultimat3/core` |
638
+ | `action` | `toBucket` | `@ultimat3/http` |
639
+ | `action`, `query` | `admitsAnonymous`, `policyPermissions`, `policyCapability` | `@ultimat3/policy` |
640
+ | `query` | `CursorInvalidError`, `actorOf`; `derivePath` | `@ultimat3/core`; as `queryPath` |
641
+ | `query` | `MAX_PAGE_SIZE` | `@ultimat3/entity` |
642
+ | `http` | `escapeHtml`, `readCookie`, `REQUEST_TIMEOUT_HEADER`, `isCanonicalWebhookField`, `WEBHOOK_ID_HEADER`, `WEBHOOK_SIGNATURE_HEADER`, `WEBHOOK_SIGNATURE_VERSION`, `WEBHOOK_TOPIC_HEADER` | `@ultimat3/core` |
643
+ | `jobs` | `WEBHOOK_FIELD_MAX`, `webhookHeaders`, `webhookMac`, `webhookSignature`, `webhookSigningString`, `classifyThrown`; types `WebhookMacInput`, `WebhookSigningInput` | `@ultimat3/core` |
644
+ | `flags` · `time` · `auth`, `storage` | `fnv1a` · `localeInvalid` · `timingSafeEqual` | `@ultimat3/core` |
645
+ | `i18n` | `DEFAULT_LOCALE`, `directionOf`, `isRtl`, `localeSegment`; `FRAMEWORK_CATALOG_LOCALE` | `@ultimat3/core`; as `DEFAULT_LOCALE` |
646
+ | `mail` | `escapeHtml`; `MAIL_CATALOG_LOCALE` | `@ultimat3/core`; as `DEFAULT_LOCALE` |
647
+ | `ai` | `MAX_SEMANTIC_CACHE_SCOPES` | `@ultimat3/core`, as `MAX_CACHED_FORMATTERS` |
648
+ | `cache` | `TIER_ORDER`; `createSingleFlight`, types `FlightJoin`, `SingleFlight` | `@ultimat3/core`, as `CACHE_TIERS`; as `singleFlight` |
649
+ | `render` | `formatBytes`, `RENDER_MODES`, `HYDRATE_STRATEGIES`, `OFFLINE_STRATEGIES`, `THEME_STORAGE_KEY`, `CLIENT_PATH_STYLE_META`, `CLIENT_PERSIST_META`, `CLIENT_SCOPE_META`, `CLIENT_BUILD_META`, `CLIENT_SYNC_META`, `CLIENT_SYNC_WORKER_META` | `@ultimat3/core` |
650
+ | `render` | `NAVIGATION_HEADER`, `NAVIGATION_SURFACE_HEADER`, `NAVIGATION_SCOPE_HEADER`, `NAVIGATION_LOCATION_HEADER` | `@ultimat3/core`, as `CLIENT_NAVIGATION_HEADER`, `CLIENT_NAVIGATION_SURFACE_HEADER`, `CLIENT_NAVIGATION_SCOPE_HEADER`, `CLIENT_NAVIGATION_LOCATION_HEADER` |
651
+ | `render` | `tagKeys` | `@ultimat3/cache` |
652
+ | `pwa` | `formatBytes`, `BUILD_ID_HEADER`; `BUILD_ID_META`; `APP_UPDATE_AVAILABLE` | `@ultimat3/core`; as `CLIENT_BUILD_META`; as `APP_UPDATE_MESSAGE` |
653
+ | `ui` | `THEME_STORAGE_KEY` | `@ultimat3/core` |
654
+ | `admin` | `REDACTED`; `ADMIN_AUDIT_TABLE`, `SQL_ADMIN_AUDIT_TABLE` | `@ultimat3/core`; `@ultimat3/admin/schema` |
655
+ | `realtime` | `isJsonObject` | `@ultimat3/core` |
656
+ | `scraping` | `ANY_HOST`, `hostDecision`, `hostMatches`; types `HostRule`, `HostDecision` | `@ultimat3/core` |
657
+ | `scraping` | `DEFAULT_CONTENT_TYPE` | `@ultimat3/storage` |
658
+ | `scraping` | `throwIfAborted(signal)` | nowhere: `signal.throwIfAborted()` |
659
+ | `mcp` · `money` · `core` | `formatIssues` · `MAX_MONEY_SCALE` · `ULTIMATE_ERROR_BRAND` | `@ultimat3/schema` |
660
+ | `cli` | `JOB_STATES` | `@ultimat3/jobs` |
661
+ | `cli` | `TEST_TYPES` | `@ultimat3/testing` |
662
+
663
+ Two re-exports stay, pinned in `scripts/lib/package-reexports.ts`: schema's `t` through the eight
664
+ primitive barrels (`action`, `ai`, `entity`, `jobs`, `mail`, `mcp`, `notify`, `query`), and core's
665
+ `describeValue` and `isIsoDateTime` over the declared `core → schema` edge. `bun run flight-copies`
666
+ refuses any other (`X_HELPER_COPY`), and a blind `export *` in shipped package source.
667
+
668
+ Entry 32, one name per meaning:
669
+
670
+ | Package | Was | Is |
671
+ |---|---|---|
672
+ | `auth` | `forbidden`, `unauthenticated` | `authForbidden`, `authUnauthenticated` |
673
+ | `policy` | `forbidden` | `policyForbidden` |
674
+ | `http` | `json`, `text`, `validate` | `jsonResponse`, `textResponse`, `validateBody` |
675
+ | `money` · `pwa` · `render` · `notify` | `money()` · `isEnabled` · `describeRoutes` · `channel` | `fromMinor()` · `hasCapability` · `describePages` · `deliveryChannel` |
676
+ | `action` | `guard`, `guardBeforeInput`, `resetRegistry` | `guardAction`, `guardActionBeforeInput`, `resetActions` |
677
+ | `query` | `guard`, `guardBeforeInput`, `resetRegistry`, `explain` | `guardQuery`, `guardQueryBeforeInput`, `resetQueries`, `explainQuery` |
678
+ | `admin` | `actorLabel`, `allowed`, `denied`, `relationsFor` | `adminActorLabel`, `adminAllowed`, `adminDenied`, `relationDataFor` |
679
+ | `core` | `invariant`, `InvariantOptions`, `uuid` | `assertCoded`, `AssertCodedOptions`, `uuidV7` |
680
+ | `ai` | `cacheKeyFor`, `DEFAULT_RETRY`, `NO_TENANT` | `promptCacheKey`, `DEFAULT_GATEWAY_RETRY`, `NO_VECTOR_TENANT` |
681
+ | `ai` | `chunk`, `normalize`, `tokenize` | `chunkDocument`, `normalizeVector`, `wordTokens` |
682
+ | `db` | `introspect`, `isDestructive`, `assertNoDrift` | `introspectSchema`, `isDestructiveMigration`, `assertNoSchemaDrift` |
683
+ | `mail` | `safeUrl` | `safeMailHref` |
684
+ | `ui` | `formatBytes`, `resetIdCounter`, `srcsetFor` | `formatFileSize`, `resetUseIdCounter`, `variantSrcset` |
685
+ | `testing` | `frozenClock(now, body)` | `withFrozenClock(now, body)` |
686
+ | `scraping` | `testClock`, `sessionExpired` | `testScrapeClock`, `scrapeSessionExpired` |
687
+
688
+ `@ultimat3/i18n`'s `t('key')` and `@ultimat3/schema`'s `t` keep one name on purpose: no file imports
689
+ both (`scripts/factory-names-pins.ts` holds the measurement).
690
+
691
+ ### Before → after
692
+
693
+ Entry 2 — `app.config.ts`, and where each key went:
694
+
695
+ ```diff
696
+ export const config = defineConfig({
697
+ name: 'my-app',
698
+ - locales: ['en', 'es'],
699
+ - defaultLocale: 'en',
700
+ - defaultTimeZone: 'UTC',
701
+ - defaultCurrency: 'USD',
702
+ - theme: { defaultMode: 'system', tokens: { brand: '#0a84ff' } },
703
+ - jobs: { driver: 'postgres' },
704
+ - ai: { mcp: { expose: true, path: '/agents' } },
705
+ + theme: { defaultMode: 'system' },
706
+ + ai: { mcp: { expose: true } },
707
+ + drain: { deadlineMs: 25_000 }, // entry 8: was configureHttp({ drainTimeoutMs })
708
+ });
709
+
710
+ // apps/web/mcp.ts — an endpoint's path is its own; /mcp needs no line
711
+ +export const mcp = defineAppMcp({ path: '/agents', /* name, tools, resolveToken … */ });
712
+
713
+ // packages/i18n/src/index.ts — the one declaration of the locales and their default
714
+ +export const catalogs = defineCatalogs({ default: 'en', locales: { en, es } });
715
+
716
+ // at each format call — there is no ambient zone
717
+ -formatDate(at, { locale });
718
+ +formatDate(at, { locale, zone: 'Europe/Paris' });
719
+ ```
720
+
721
+ Entry 10 — the one MCP projection:
722
+
723
+ ```diff
724
+ -import { toMcpTool, isExposed } from '@ultimat3/action';
725
+ -import { toQueryTool } from '@ultimat3/query';
726
+ +import { invoke } from '@ultimat3/action';
727
+ +import { isMcpExposed } from '@ultimat3/core';
728
+ +import { toolFrom, toolListEntry } from '@ultimat3/mcp';
729
+ +import { sourceFor } from '@ultimat3/query';
730
+
731
+ -const entry = publishPost.tool(); // or toMcpTool(publishPost)
732
+ +const entry = toolListEntry(toolFrom(publishPost)); // an action or a query
733
+ -await toMcpTool(publishPost).invoke(input, { ctx });
734
+ +await invoke(publishPost, input, { ctx, surface: 'mcp' });
735
+ -await toQueryTool(postList).read(input);
736
+ +await (await sourceFor(postList, input, { surface: 'mcp' })).execute();
737
+ -if (isExposed(publishPost)) …
738
+ +if (isMcpExposed(publishPost.mcp)) …
739
+ ```
740
+
741
+ Entry 11 — a page:
742
+
743
+ ```diff
744
+ const page = await client.postList({ _first: 20 });
745
+ -if (page.hasNextPage) next = page.endCursor;
746
+ +next = page.nextCursor; // null exactly when !page.hasMore — the last page carries no cursor
747
+ ```
748
+
749
+ Entry 12 — a per-action limit:
750
+
751
+ ```diff
752
+ -configureHttp({ rateLimit: { buckets: { publishPost: { capacity: 10, refillPerSecond: 0.1 } } } });
753
+ export const publishPost = action({
754
+ input: t.object({ id: t.uuid }),
755
+ + rateLimit: { limit: 10, windowMs: 60_000 },
756
+ …
757
+ });
758
+ ```
759
+
760
+ Entries 4, 13, 16, 17 and 21 — the members a custom implementation owes:
761
+
762
+ ```diff
763
+ -const record = { action: 'publishPost', /* … */ };
764
+ +const record = { name: 'publishPost', primitive: 'action', /* … */ };
765
+
766
+ const store: IdempotencyStore = {
767
+ + keepsRedaction: true,
768
+ - settle: (key, value, reservationId) => …,
769
+ + settle: (key, value, reservationId, redacted) => …, // keep it; return it as record.redacted
770
+ };
771
+
772
+ const ledger: WebhookLedger = {
773
+ + isDisabled: (endpointId) => Promise.resolve(disabled.has(endpointId)),
774
+ };
775
+
776
+ -await feed.emit({ entity, op, before, after, lsn, txid, orgId, at });
777
+ +await feed.emit({ entity, op, before, after, lsn, txid, orgId, at, write: null });
778
+ ```
779
+
780
+ Entries 22–24 — the app brings its models. Every number below is a placeholder: copy the vendor's
781
+ published limits and prices into the app's own `models.ts`, with the source and the date beside
782
+ each, as `examples/dummy/apps/web/app/models.ts` does.
783
+
784
+ ```ts
785
+ import { anthropicProvider, configureAi, providerGateway, registerModel } from '@ultimat3/ai';
786
+
787
+ const usd = (minor: number) => ({ minor, currency: 'USD' }) as const;
788
+
789
+ export const appModel = registerModel({
790
+ id: 'your-model-id',
791
+ family: 'your-vendor',
792
+ contextWindow: 200_000,
793
+ maxOutput: 32_000,
794
+ inputPerMillion: usd(300),
795
+ outputPerMillion: usd(1_500),
796
+ cacheMinimumTokens: 1_024,
797
+ reasoning: { effort: true, adaptive: true, disableThinkingUpTo: undefined },
798
+ });
799
+
800
+ configureAi({
801
+ gateway: providerGateway({
802
+ providers: [anthropicProvider({ models: [appModel.id] })],
803
+ defaultModel: appModel.id,
804
+ }),
805
+ });
806
+ ```
807
+
808
+ ```diff
809
+ -new AnthropicProvider()
810
+ +anthropicProvider({ models: [appModel.id] }) // entry 6: a factory, not a class
811
+ -openAiProvider({ apiKey, models: [...OPENAI_MODEL_IDS] })
812
+ +openAiProvider({ apiKey, models: [appModel.id] })
813
+ -beforeEach(() => registerOpenAiModels());
814
+ +beforeEach(() => registerModel(testModelRow));
815
+ -new EchoProvider().generate({ prompt })
816
+ +echoProvider().generate({ prompt, model: appModel.id })
817
+ -createGateway({ providers, defaultModel })
818
+ +providerGateway({ providers, defaultModel })
819
+ ```
820
+
821
+ Entries 6 and 32 — a factory name and three value names, after the edit:
822
+
823
+ ```ts
824
+ import { structuredLogger, uuidV7 } from '@ultimat3/core';
825
+ import { jsonResponse } from '@ultimat3/http';
826
+ import { fromMinor } from '@ultimat3/money';
827
+
828
+ const log = structuredLogger(); // was createLogger()
829
+
830
+ export function quote(): Response {
831
+ const id = uuidV7(); // was uuid()
832
+ const price = fromMinor(1_999, 'USD'); // was money(1_999, 'USD')
833
+ log.info('quoted', { id, minor: price.minor });
834
+ return jsonResponse({ id, price }); // was json({ id, price })
835
+ }
836
+ ```
837
+
838
+ Entry 7 — a storage page and the `x jobs list --json` page:
839
+
840
+ ```diff
841
+ let cursor: string | null = null;
842
+ do {
843
+ const page = await disk.list({ prefix: 'avatars/', cursor });
844
+ - for (const entry of page.objects) seen.push(entry.key);
845
+ - cursor = page.truncated ? page.cursor : null;
846
+ + for (const entry of page.rows) seen.push(entry.key);
847
+ + cursor = page.nextCursor; // null exactly when !page.hasMore
848
+ } while (cursor !== null);
849
+
850
+ -x jobs list --json | jq -r '.data.next'
851
+ +x jobs list --json | jq -r 'select(.data.hasMore) | .data.nextCursor'
852
+ ```
853
+
854
+ ### Not breaking, but you will see it
855
+
856
+ | Surface | What changed |
857
+ |---|---|
858
+ | `x shot`, the PWA manifests, the service worker, the prerender, the speculation rules, the sitemap, `x g --locales`, `x i18n`, the e2e default locale | read the locales from the app's `defineCatalogs()` through one reader, `appLocaleSet(root)` (`@ultimat3/i18n/app-catalogs`), never `app.config.ts` — an app with no `packages/i18n/src/index.ts` is `en` alone on every one of them (`UNDECLARED_LOCALES`) |
859
+ | `x jobs list` | a full last page no longer prints `--after <cursor>` to an empty page |
860
+ | Helm grace periods | sized from `drain.deadlineMs` (the worker's from `drain.workerDeadlineSeconds` when > 0, since 26.0.0), which `x deploy --method helm` passes on every upgrade |
861
+ | `x jobs drain` | still planned: `X_NOT_IMPLEMENTED` before it boots the queue or leases a job; it takes no `--to` or `--dry-run` (entry 15), since no durable second driver ships |
862
+ | `describeAgents()` | `model` and `modelFrom` are `null` where nothing names a model; `'built-in-default'` is gone |
863
+ | fix lines | no `fix:` names a vendor's model as the default |
864
+ | `pwa.offline.fallback`, `.image`, `.font` | must be paths on this origin: `//host`, `/\host` and absolute URLs are `X_CONFIG_INVALID` at config load |
865
+ | a raw MCP tool with no `destructive` | billed to the write bucket and listed as `(action)`; set `destructive: false` on a hand-registered read |
866
+ | `BarChart` | renders a `<figure>` around `<svg role="img">`; `class` lands on the figure |
867
+ | `x shot --matrix` | directories are `<theme>-<w>x<h>`, were `<theme>-<w>` |
868
+ | `/admin` home | counts a resource only when it declares `count: true` |
869
+ | `x env check`, `x doctor` | report `ULTIMATE_CURSOR_SECRET` and `STORAGE_SIGNING_SECRET` off one list, the published development cursor key counted as unset; `x env check` judges a table as the boot does (a table naming no environment is deployed), `x doctor` only where this machine's environment is named `staging` or `production`; neither reports the storage key when `S3_ENDPOINT` selects object storage. `X_CURSOR_SECRET_DEV` at boot prints the same fix, `export ULTIMATE_CURSOR_SECRET="$(openssl rand -hex 32)"` |
870
+ | `ROLE=worker` on SIGUSR2 | retires: stops claiming, finishes every held job, aborts nothing, logs `jobs.worker.retiring` / `jobs.worker.retired`, drains, exits 0. Both Helm charts send it from a worker `preStop` when `roles.worker.retireSeconds` > 0 (default `0`, off) and add those seconds to the worker's grace period. A no-op on Windows |
871
+ | a table two producer kinds publish | a sync node keeps the kind it read first for that table and drops the other's changes, logged once as `X_REALTIME_PRODUCER_CONFLICT` |
872
+ | `x dev` restart | retires the worker first and waits, on stderr, for its running jobs to finish |
873
+
874
+ ### Where the sites are
875
+
876
+ ```sh
877
+ grep -rnwE "locales|defaultLocale|defaultTimeZone|defaultCurrency|tokens|driver|drainTimeoutMs|buildIdHeader|heartbeatMs|urlEnv|poolSize|schema|installPrompt|afterSignInPath|modelEnv|tier" app.config.ts apps packages --include=*.ts
878
+ grep -n "mcp" app.config.ts
879
+ grep -rnwE "createMemory[A-Z]\w*|createPg[A-Z]\w*|pgSchedulerState|createPostgresClient|create[A-Z]\w*Driver|(Nats|Redis|Memory)DriverOptions|memoryDriver|MemoryIdempotencyStore|(Pg|Memory)VectorStore|MemoryBudgetStore|(Builtin|Memory)Adapter|Memory(Local|Queue)Store|(InMemory|Pg)AdvisoryLock|InMemoryChangeFeed|PgLogicalReplicationFeed|PgOutputDecoder" apps packages --include=*.ts --include=*.tsx
880
+ grep -rnwE "assertEnvExample|EnvExampleDriftError|resolveSpeculation|normalizeAuditRecord|NormalizedAuditRecord|contentHash|nearest" apps packages scripts --include=*.ts --include=*.tsx
881
+ grep -rnwE "create(AuthLimiter|JwksClient|KdfGate|Pkce|TotpReplayGuard|CacheStack|CdnTier|LruTier|MemoTier|RedisTier|ClientFlight|Context|Fence|FlightGate|Logger|Raster|SingleFlight|Pipeline|RateLimiter|RequestContext|Router|Server|Translator|JobsFacade|Limiter|OutboxRelay|Pacer|Scheduler|StepRunner|Worker|Outbox|SyncNode|Entry|FrameRouter|Replicator|DevServer|McpServer|InstallController|IslandCollector|IsrController|Gateway|PgliteClient|RecordingClient|FocusTrap|FormBinding|RovingTabindex|ToastStore|TestClock|TestMail|TestNetwork|TestStatements|RunJobs|LiveNode|TemplateSql|ArtifactWriter|Prompt|Ring|RobotsGate|SecretBag|UsageMeter)|CreateGatewayInput|AnthropicProvider|EchoProvider|HashEmbedder|RemoteEmbedder|BudgetLedger|McpServer|Builder|authLimiter|totpReplayGuard" apps packages --include=*.ts --include=*.tsx
882
+ grep -rnwE "PgDriverOptions|MemoryDriverOptions|PgEventBusOptions|PgOutboxOptions|MemoryOutboxOptions|PgLeaseLeaderOptions|CreateMcpServerInput|CreateDevServerInput|PgVectorStore(Input)?|BuiltinAdapter|MemoryAdapter|Pg(DigestStore|InboxStore|DeliveryLedger)(Options)?|MemoryLedgerOptions|InMemoryAdvisoryLock|InMemoryChangeFeed(Options)?|PgAdvisoryLock(Options)?|PgLogicalReplication(Feed|Options)" apps packages --include=*.ts --include=*.tsx
883
+ grep -rnwE "BUILD_ID_HEADER|IDEMPOTENCY_HEADER|pluralize|toBucket|admitsAnonymous|policyPermissions|policyCapability|actorOf|CursorInvalidError|MAX_PAGE_SIZE|derivePath|escapeHtml|readCookie|REQUEST_TIMEOUT_HEADER|isCanonicalWebhookField|WEBHOOK_[A-Z_]+|webhook(Headers|Mac|Signature|SigningString)|Webhook(Mac|Signing)Input|classifyThrown|fnv1a|localeInvalid|timingSafeEqual|DEFAULT_LOCALE|directionOf|isRtl|localeSegment|FRAMEWORK_CATALOG_LOCALE|MAIL_CATALOG_LOCALE|MAX_SEMANTIC_CACHE_SCOPES|FlightJoin|SingleFlight|TIER_ORDER|formatBytes|RENDER_MODES|HYDRATE_STRATEGIES|OFFLINE_STRATEGIES|THEME_STORAGE_KEY|CLIENT_[A-Z_]+_META|NAVIGATION_(SURFACE_|SCOPE_|LOCATION_)?HEADER|tagKeys|BUILD_ID_META|APP_UPDATE_AVAILABLE|REDACTED|(SQL_)?ADMIN_AUDIT_TABLE|isJsonObject|ANY_HOST|host(Decision|Matches)|Host(Rule|Decision)|throwIfAborted|DEFAULT_CONTENT_TYPE|formatIssues|MAX_MONEY_SCALE|ULTIMATE_ERROR_BRAND|JOB_STATES|TEST_TYPES" apps packages --include=*.ts --include=*.tsx
884
+ grep -rnwE "forbidden|unauthenticated|json|text|validate|money|isEnabled|describeRoutes|channel|guard|guardBeforeInput|resetRegistry|explain|actorLabel|allowed|denied|relationsFor|invariant|InvariantOptions|uuid|cacheKeyFor|DEFAULT_RETRY|NO_TENANT|chunk|normalize|tokenize|introspect|isDestructive|assertNoDrift|safeUrl|resetIdCounter|srcsetFor|frozenClock|testClock|sessionExpired" apps packages --include=*.ts --include=*.tsx | grep "@ultimat3/"
885
+ grep -rnwE "McpExposure|QueryMcp|QueryMcpAnnotations|QueryListParams|QueryListFilterOp|McpAnnotationHints|ListPage|truncated|counted" apps packages --include=*.ts --include=*.tsx
886
+ grep -rnE "(Deprecation\w*|recordDeprecatedCall|renderDeprecation|createClientFlight|DEFAULT_CLIENT_RETRY|isSuperseded|isTransientFailure|(get|set|reset)AuditSink).*from '@ultimat3/(action|query)'" apps packages --include=*.ts --include=*.tsx
887
+ grep -rnE "\.tool\(\)|toMcpTools?\b|toQueryTools?\b|isExposed\b|toolFromAction|toolFromQuery|McpToolDescriptor|McpInvokeOptions|QueryTool(Descriptor|ReadOptions|Answer)" apps packages --include=*.ts --include=*.tsx
888
+ grep -rnwE "endCursor|hasNextPage|SQL_[A-Z_]+|AuditRecord|IdempotencyStore|WebhookLedger|ChangeEvent|DigestAppend|DigestStore|FakeElement" apps packages --include=*.ts --include=*.tsx
889
+ grep -rnE "Page\b.*from '@ultimat3/entity'|bearerMount\(|scopes:|buckets:|aria-controls|robots:|fakeBrowser\(|fixtureBrowser\(" apps packages --include=*.ts --include=*.tsx
890
+ grep -rnwE "DEFAULT_MODEL|ANTHROPIC_MODEL_IDS|OPENAI_MODEL_IDS|registerOpenAiModels|AnthropicProvider|EchoProvider|defaultModel|registerModel" apps packages --include=*.ts --include=*.tsx
891
+ grep -rnE "cdp-url|x shot|jobs drain|jobs (ls|list)|data\.next\b" .github scripts package.json bin
892
+ grep -rnE "jobs\.concurrency|startE2eApp\(|jobWorker\(|ResolveOAuthGrants|resolveGrants" apps packages --include=*.ts --include=*.tsx
893
+ grep -rn "WORKER_QUEUES" . --include=*.yaml --include=*.yml --include=*.env --include=Dockerfile* --exclude-dir=node_modules
894
+ ```
895
+
896
+ The `typecheck` step finds 3–7, 10, 11, 13–18, 20, 21, 23, 25–28, 31–34, 37 and 40 at the import or
897
+ the member, and 2 in a typed `app.config.ts`. Entries 2 and 8 also refuse at the first import of the
898
+ config (`X_CONFIG_INVALID`), 19 at boot, 9 at route registration and 22 and 24 at the first model
899
+ call. The `manifest` step finds 11's stale `openapi.json` and 35's stale `.env.example`, `boundaries` finds 30, and the unit and
900
+ e2e suites find 27. Nothing finds 12 — a bucket keyed by an action's name is read by nobody — or 29
901
+ until a capture runs; read each `configureHttp` for 12 and each `x shot --cdp-url` caller for 29.
902
+ A shell script that parses `x jobs list --json` (7) or passes `--to` to `x jobs drain` (15) fails only
903
+ when it runs: the last grep above finds it. Nothing finds 36 but an e2e that reads the external
904
+ database's rows; the `startE2eApp` grep finds each caller. Nothing finds 38 or 39 either — a worker
905
+ leaves an unlisted queue's jobs `ready` with one boot warning, and a hand-built `jobWorker` simply
906
+ runs more at once — so the last two greps list every `WORKER_QUEUES` and every `jobWorker(` to read.
907
+ Entry 1 is `x doctor`'s first line.
908
+
909
+ ## 23.x → 24.0.0, entry by entry
910
+
911
+ **Two hundred and two entries** — `CHANGELOG.md`'s 24.0.0 entries in their order: grouped by package, lowest tier first. No legacy path, no
912
+ codemod, no compatibility shim — every break is a build error or an `X_*` error naming the rewrite.
913
+ `As of 2026-10` slices 01–10 have landed: `@ultimat3/schema` and `@ultimat3/core`; tier 1 —
914
+ `i18n`, `time`, `db`, `flags`; then `cache`, `seo`, `storage` and one `render` entry; then slice 04, complete — tier 2's `entity`, `policy` and `http`, with one `testing` entry; then slice 05, `auth`; then slice 06, complete — `query`, `mcp`, `admin`, then `action` and the `manifest` step; then slice 07, `jobs`; then slice 08, `realtime`; then slice 09, complete — `render` and `pwa`, with one `http` entry, then `ui`; then slice 10, complete — `http` again, `mcp`, `mail`, `notify` and `manifest`, then `ai`; then slice 11, complete — `testing`,
915
+ `scraping` and `admin`; then slice 12a — `cli`'s build, generator, test-selection and policy half, with
916
+ `seo`, `policy` and `testing`; then slice 12b — `cli`'s runtime and commands, with `http` and the
917
+ chart; then slice 13a, the repository's guards, with one `cli` entry; then slice 13b, CI and the
918
+ chart; then slice 14, one home per helper — `render`, `cli`, `schema` and `core`. **Realtime has three operator steps: entries
919
+ 88–90, upgrade steps 32–34.** **A deployment with MFA-enrolled users has an
920
+ operator step: entry 58, upgrade steps 18–20.** **A gateway that declares `actor` or `org`
921
+ ceilings: entry 123, upgrade step 49.** **The `policy` and `drift` steps go red on an
922
+ existing app: entries 169–170, upgrade step 61.** **Every deploy runs `ROLE=migrate` before its
923
+ serving roles: entry 174, upgrade step 65.** **A chart upgrade needs its values read first:
924
+ entries 192–194, upgrade step 72.** A later slice appends
925
+ its rows below the last one and never renumbers; each row's `CHANGELOG.md` line names it as
926
+ `(#N)` after the dash.
927
+
928
+ ### The upgrade, top to bottom
929
+
930
+ | # | Do | What you see until you do | Entries |
931
+ |---|---|---|---|
932
+ | 1 | pin every `@ultimat3/*` to the one new version, `bun install` | nothing yet — a mixed install is untested | — |
933
+ | 2 | `bun run typecheck`, then `x verify --only typecheck,unit` | `X_SCHEMA_DEFAULT_INVALID` or `X_CONFIG_INVALID` at the first import of the file that declares it | 4, 6 |
934
+ | 3 | read the deploy environment: `LOG_LEVEL`, `ULTIMATE_CURSOR_SECRET`, `OTEL_EXPORTER_OTLP_TRACES_HEADERS`, `OTEL_EXPORTER_OTLP_METRICS_HEADERS`, `OTEL_TRACES_SAMPLER` | a boot that exits on `X_INVARIANT` or `X_CURSOR_SECRET_DEV`; a collector that rejects one signal; every root trace sampled where a leftover ratio thinned them | 7, 13, 14, 16 |
935
+ | 4 | `x verify --only unit,contract,e2e` and fix the tests it fails | a date, URL, object or query number that validated and is now refused; a redacted field a test read | 1–3, 5, 8–11 |
936
+ | 5 | repoint error-monitor rules from `extra.<key>` to `extra.meta.<key>`; add an exact host rule for each internal address a wildcard used to admit | a saved search that matches nothing; a refused request to `127.0.0.1` | 12, 15 |
937
+ | 6 | `bun run typecheck` again for the tier-1 types: add `zone` to each `formatRelative` call, `wildcardTime` to a hand-built `CronExpression`, `case 'changed-primary-key':` to a `DriftKind` switch, `.expression` to a `CatalogColumn.generated` read | TS2741 / TS2339 at each site | 18, 20, 26, 28 |
938
+ | 7 | load the app once (`x verify --only unit`) and fix each cron and flag declaration it refuses | `X_CRON_INVALID`, `X_FLAG_EXPIRY_INVALID` at the first import of the declaring file | 19, 30 |
939
+ | 8 | `x db gen` where the database holds an object entry 29 lists; commit `packages/db/schema/` | a dump that no longer matches the one the gate regenerates | 29 |
940
+ | 9 | `x verify --only unit,contract,job,live` and fix the tests it fails; read every `catch` and every `Promise.all` inside a `withTransaction` body | `X_DB_TRANSACTION_ABORTED` where a call used to resolve; `X_DB_SIBLING_SCOPE_TIMEOUT` after a 30 s wait; `⟦name⟧` in a rendered string; a relative date counted in calendar days | 17, 20–25, 27, 31 |
941
+ | 10 | `bun run typecheck` for the storage types: add `policy` to each `promoteAttachment` call, `stat()` to a hand-written `StorageDriver`, a guard to each `lastModified` read, a base path to `canonicalRequest` / `signConstraints` | TS2741 / TS18048 / TS2554 at each site | 35–37, 41 |
942
+ | 11 | load the app once (`x verify --only unit`) and rename each cache tag and `revalidate.tags` entry it refuses | `X_ROUTE_MODE_INVALID` at registration; `X_CACHE_PURGE_FAILED` where a tagged response is built | 33, 43 |
943
+ | 12 | `x verify --only unit,contract,e2e` and fix the tests it fails; add the real media types to each upload policy; pass `{ formats: FORMAT_ORDER }` where your driver encodes AVIF | a header or `<picture>` snapshot that changed; `X_STORAGE_TOO_LARGE` from a `get()`; an upload refused for its sniffed type | 32, 34, 38, 40, 42 |
944
+ | 13 | after the deploy: list and delete the old-shape image variants; expect signed URLs minted before it to fail for 15 minutes; purge the CDN once if a collection bust must reach older edge copies | orphaned variant files; an outstanding signed URL that no longer verifies | 32, 39, 41 |
945
+ | 14 | `bun run typecheck` for tier 2: add `transactor()` to a hand-built or wrapping `Driver`, import `dbDrift` from `@ultimat3/db`, retype a `403` denial status as `DenialStatus` | TS2741 / TS2305 / TS2322 at each site | 44, 45, 48 |
946
+ | 15 | `x verify --only unit,contract,policy` and fix the tests it fails; read every `catch` around `assertAllowed` and every policy predicate's return | `X_UNAUTHENTICATED` and a 401 where a test expected `X_FORBIDDEN` and 403; a denial where a malformed decision allowed; `X_INVARIANT_VIOLATED` from a preload over 10,000 rows | 46–49 |
947
+ | 16 | set the http config the deploy needs: `trustClientCertHeader: true` where `ctx.peer` is read behind a proxy that strips the header, `healthDetailPeers` for an off-box health reader, `cors.origins` for a cross-origin browser form, `hostname` for an embedder | `ctx.peer` is `null`; a health body of three fields; `X_CSRF_BLOCKED` on an anonymous form post | 50, 51, 53, 56 |
948
+ | 17 | `x verify --only unit,contract,e2e` and fix the tests it fails: `await` every browser `close()`, send `content-type` with every body, write `private, max-age=N` where a handler meant a per-user lifetime | `X_BODY_INVALID`; a rewritten `cache-control`; 429 after repeated 401s; a leaked Chrome | 52, 54, 55, 57 |
949
+ | 18 | **before the deploy, where any user has MFA:** `x secrets init`, or set `ULTIMATE_SECRETS_KEY` in the deploy | `X_SEAL_KEY_MISSING` at `login()` for an enrolled user | 58 |
950
+ | 19 | `bun run typecheck` for auth: a custom `AuthLimiter` implements `reserve` / `refund`; a custom `AuthAdapter` implements the eight members; callers of `verifyApiKey` read `.record`; delete imports of `redeemRecoveryCode`, `mfaRequired`, `authNotImplemented`; enrolment writes through `saveTotpSecret`; set `APP_URL` | TS2741 / TS2305 / TS2339 at each site; `X_ENV_MISSING` from `oauthLogin` | 60, 61, 64, 65, 67 |
951
+ | 20 | **deploy, then `x auth seal-mfa --json` once** | `X_MFA_SECRET_UNSEALED` (500) at the second factor for every enrolled user | 58 |
952
+ | 21 | `x verify --only unit,contract,e2e`; move the second-factor client to `meta.challenge` and `completeMfa`; list each API key's scopes and pass `grantsOf` where users hold roles | a key that resolves with fewer scopes or 401; a client still reading `meta.userId`; `X_AUTH_WRITE_FAILED` where a test matched `X_DB_UNIQUE_VIOLATION`; `X_CDP_CALL_FAILED` from an `offline()` a page refused | 61–63, 66, 68 |
953
+ | 22 | once every replica runs this release: `x doctor --json`, then `psql "$DATABASE_URL" -c 'drop table if exists x_auth_failures'` | `X_FRAMEWORK_TABLE_ORPHANED` from `x doctor` | 59 |
954
+ | 23 | `bun run typecheck` for slice 06: delete `compareValues`, pass `kindsOf(shape.entity)` to `compareRows` / `matchesFilter` / `isAfterKey`, replace `adminPermissions` with `...ADMIN_PERMISSIONS` in `definePermissions([...])` | TS2305 / TS2554 at each site | 69, 72 |
955
+ | 24 | `x verify --only unit,contract,e2e`; drop `.limit()` from each read a client pages to the end; read the row, not `.rows`, from a `single: true` MCP tool | a listing that ends at its limit; `X_CURSOR_INVALID` once for a cursor minted before the deploy; an MCP answer of a different shape | 70, 71 |
956
+ | 25 | add `idempotent: true` to every `mutator({ … })`; on more than one replica also `configureIdempotency({ scope: 'shared' })`. A hand-built `postgresIdempotencyStore` adds `origin` and `reclaimAfterMs` | TS2741 at each site; `X_MUTATOR_NOT_IDEMPOTENT` at declaration from untyped code | 73, 77 |
957
+ | 26 | `x manifest`, commit `x.manifest.json` and `openapi.json` | the `manifest` step is `X_MANIFEST_STALE` | 74, 79 |
958
+ | 27 | `x db gen`, commit `packages/db/schema/` | the `drift` step is `X_SCHEMA_DUMP_DRIFT` for `x_idempotency` | 75 |
959
+ | 28 | `x verify --only unit,contract,job`; read each test that rolls back an idempotent action or asserts a cache bust inside `withTransaction` | a record left `in-flight` after a rollback; a bust that fires at commit; `X_IDEMPOTENCY_RESERVATION_LOST` (409) on a slow attempt | 76, 78 |
960
+ | 29 | `bun run typecheck` for jobs: a custom `EventBus` returns a promise from `purgeExpired()`, a custom `EventLookup` adds `now()`, a hand-built `Lease` adds `abandon()`, a hand-built `RetentionStores` adds `events`; a caller of `SQL_CLAIM` / `SQL_SCHEDULER_FIRE` binds the extra parameter | TS2741 / TS2322 at each site; a statement that fails to bind | 81, 85, 87 |
961
+ | 30 | read every job with `retry.attempts: 1`, every script that cancels a finished job, and every `runId` passed to `enqueue` | a job buried `dead` after its worker died; `X_JOB_NOT_CANCELLABLE`; `X_ID_INVALID` | 80, 82, 84, 86 |
962
+ | 31 | `x verify --only unit,job` and fix the tests it fails | `X_JOB_LEASE_LOST` from a step write after a lapsed lease; a dead letter where a test expected a re-run | 80, 83 |
963
+ | 32 | **before the deploy:** make `APP_URL` on the `sync` role the origin the pages are served on — a declared origin is the whole allow-list; add it to `.env.production` for an older compose file | every websocket upgrade is `403 X_SOCKET_ORIGIN_REFUSED` | 88 |
964
+ | 33 | **where a channel is declared with params:** `x db gen "replica identity full"`, then `x db migrate` | `replication.channel_identity_partial` in the replicator log; deletes on those channels not announced | 89 |
965
+ | 34 | **before the deploy:** check the replication role's password method and the connection's `sslmode`; set `?sslmode=verify-full`, move the role to scram-sha-256, or state `?sslmode=disable` | `X_REPLICATION_FAILED` when the replicator dials | 90 |
966
+ | 35 | roll `replicator` and `sync` together — they must run the same major; point an off-box health reader of the sync role at `healthDetailPeers` | rows that do not parse across a mixed fleet; a health body of three fields | 91, 93 |
967
+ | 36 | `bun run typecheck` for realtime: a custom `Transport` adds `onReconnect`, a custom `AdvisoryLock` adds `onLost` and `abandon`, a custom `ChangeFeed` adds `abandon`, an `UpgradeTarget` adds `requestIP`, `UpgradeDeps` adds `healthDetailPeers`; replace `.hasOwnProperty(` on decoded records with `Object.hasOwn(` | TS2741 at each site; a `TypeError` on a null-prototype object | 92, 96 |
968
+ | 37 | `x verify --only unit,live,e2e` and fix the tests it fails | `X_TOPIC_FORBIDDEN` where a channel kept delivering; `X_OFFLINE_QUEUE_ABANDONED` for a write queued across a sign-out | 94, 95 |
969
+ | 38 | `bun run typecheck` for render: add `case 'unparsable':` to an exhaustive `switch` over `NativeReason` | TS2322 / TS2366 at the switch | 99 |
970
+ | 39 | load the app once (`x verify --only unit`) and give each `isr` route whose only trigger is a zero-length `ttl` a positive `ttl` or `tags` | `X_ROUTE_MODE_INVALID` at registration, naming the file | 98 |
971
+ | 40 | `x build`, then `x verify --only unit,contract,e2e`; handle `path === ''` in each catch-all handler, read an escaped CSS-module class by its unescaped name, replace a stored `specificity` with a comparison, point each push `url` at this origin | a 200 where a test expected a 404 at a catch-all's bare prefix; a snapshot holding the old scoped class name; `styles['w-1']` undefined; a tap that opens the app root | 97, 100–102 |
972
+ | 41 | `bun run typecheck` for ui: add `current()` and `appDefault()` to a hand-built `ThemeEnv`; delete each import of `THEME_INLINE_SCRIPT` / `themeInlineScript*`, its hand-inlined `<script>` and its `sha256-…` in your CSP; widen a `Popover` trigger's `aria-controls` to `string \| undefined` | TS2741 / TS2305 / TS2322 at each site | 103, 105, 106 |
973
+ | 42 | `x build --target static`, then `x verify --only unit,e2e,budgets`; set `theme: { defaultMode: 'system' }` where the page must follow the OS; read each upload test that posts a file the control refuses | `X_BUDGET_UNMEASURED` on every budgeted route until the build; a "System" pick that stays dark; a refused file missing from the request | 104, 107 |
974
+ | 43 | `bun run typecheck` for http: a hand-written `RateLimitStore` adds `peek(key, bucket, nowMs)`, a hand-written `RateLimiter` adds `peek(key, bucketName)` | TS2741 at each site | 108 |
975
+ | 44 | **before the deploy:** list every probe, monitor, script or agent that sends a credential that can fail — to an `auth: 'required'` route or to the MCP endpoint — from an address real users or agents share; raise `rateLimit.defaultBucket` or `rateLimits.unauthenticated`, or move it | `429 X_RATE_LIMITED` / `429 X_MCP_RATE_LIMITED` for a right credential after a run of wrong ones | 109, 114 |
976
+ | 45 | `x manifest`, then `x db gen`; commit `x.manifest.json`, `openapi.json` and `packages/db/schema/`. Where `x manifest` refuses `X_MANIFEST_FACT_INVALID`, make the declaration at `meta.path` finite and run it again | `X_MANIFEST_STALE` from the `manifest` step; `X_MANIFEST_BREAKING` for `jobs.mail.send.input` from `contract-diff`; `X_SCHEMA_DUMP_DRIFT` for `x_notify_digests` from `drift` | 116–119 |
977
+ | 46 | `x verify --only unit,contract,job,e2e` and fix the tests it fails; read each MCP client that parses a 5xx `cause`, each `db.query` an agent runs, each hand-registered tool's `destructive`, each `manage_resource` list call, each `send()` inside `withTransaction` and each recipient a mail is sent to | a fixed cause where the server's was; `X_MCP_QUERY_REJECTED`; a writing tool listed as `query`; `X_INPUT_INVALID` for a list key; a mail sent only after the commit; `X_MAIL_ADDRESS_INVALID` or `X_VALIDATION_FAILED` at `send()` | 110–113, 115, 116 |
978
+ | 47 | before dropping a default from a NOT NULL column: bump the app's major in `package.json`, or keep the default | `X_MANIFEST_BREAKING` from `contract-diff` naming `…hasDefault` | 120 |
979
+ | 48 | `bun run typecheck` for ai: a hand-written `BudgetStore` adds an atomic `take(key, tokens, limit)`; a hand-written or wrapping `Gateway` implements `callLedger(keys)`; a hand-written `VectorStore` adds `prune(filter, keep)`; replace each `fnv1a` import with core's `fingerprint` | TS2741 / TS2554 / TS2305 at each site | 121, 122, 125, 129 |
980
+ | 49 | **before the deploy, where `createGateway({ budget })` declares `actor` or `org`:** size each ceiling for every `llm()`, `agent()` and hive member a caller makes — every anonymous caller shares one counter — and key each `gateway.scope()` with `budgetKeysFor(actor)` | `X_AI_BUDGET_EXCEEDED` where the call ran | 123 |
981
+ | 50 | load the app once (`x verify --only unit,eval`); give each `numericTolerance` a finite, non-negative bound; bind a tenant on each vector store read inside an org's request, or open the backfill store with `scope: UNSCOPED` | `X_INVARIANT` at the eval file's import; `X_VECTOR_UNSCOPED` (500) at the read | 128, 131 |
982
+ | 51 | `x verify --only unit,eval,e2e` and fix what it fails: re-index every store `HashEmbedder` filled; give each app code an agent tool throws for the model to read a 4xx `registerErrorStatus` row or a public cause; move a `source` passed to `chunk()` under another key; make a fixture's invented `finish_reason` `stop` | a ranking or vector snapshot that changed; a tool result reading `CODE: ` and a fixed sentence; `X_LLM_TRUNCATED` where an answer was repaired | 124, 126, 127, 130 |
983
+ | 52 | `bun run typecheck` for scraping: a hand-written launcher or test double's browser adds `target()` answering `createCDPSession()`, whose session has `send` and `on`; a `burnSession` call passes the version of the record the run used | TS2741 / TS2554 at each site | 137, 139 |
984
+ | 53 | load the app once (`x verify --only unit`) and write each `expect.maxDrop` as a fraction | `X_INVARIANT` at `scrape()`, naming the scrape | 140 |
985
+ | 54 | `x verify --only unit,e2e` and fix the tests it fails: read `app()` inside a `test` body, set up per mount the state a test carried between island mounts | `X_TEST_APP_NOT_BOOTED` where a test expected `ReferenceError`; a mount that no longer sees the last one's module state; an assertion on key order or a count of clock announcements that changed | 132–135 |
986
+ | 55 | `x verify --only unit,job` and fix the scraping runs it fails: move a proxy switch out of `localBrowser({ options: { args } })` into `proxy` or `egress`; add an exact `allowHosts` rule for each internal host a wildcard reached and for each host a `robots.txt` redirects to; re-record each fixture with no readable `recordedAt` | `X_SCRAPE_LAUNCH_ARGS_INVALID`; `X_SCRAPE_HOST_BLOCKED`; `X_NOT_IMPLEMENTED` from a jar-less browser; `X_SCRAPE_FIXTURE_STALE`; a redaction snapshot or a header that changed; robots rules that no longer apply | 136, 138, 141–145 |
987
+ | 56 | `bun run typecheck` for admin: a custom `AdminRepo#list` handles a `null` `keyset.value`; an `AdminForm` rendered by hand passes `version`; an exhaustive `switch` over `CrudResult` handles `'stale'` | TS2345 / TS18047 / TS2741 at each site; a switch that no longer compiles | 146, 148, 149 |
988
+ | 57 | load the app once (`x verify --only unit`) and rename a list scope declared as `*` | `X_ADMIN_FILTER_INVALID` where the resource is declared | 153 |
989
+ | 58 | `x verify --only unit,contract,e2e` and fix the tests it fails: post the rendered `_version` with each hand-posted edit form; read each policy rule over `input` for what an admin create or update writes; read each test that expects an action's or a batch's write to outlive a failed audit entry, or another org's audit rows; fix each link that sends a blank, hex, exponent or off-calendar filter | a 409 where an edit used to save; a denial on a create or update; a rolled-back write with a `failed` entry; an audit list without another org's rows; `X_ADMIN_FILTER_INVALID` | 147, 150–152, 154 |
990
+ | 59 | `bun run typecheck` for slice 12a: a hand-built `ProcessRegistrySnapshot` adds `permissionSites`; a one-argument `restorePermissions(names)` call passes the `permissionDeclarationSites()` captured with the names | TS2741 at each snapshot; TS2554 at each `restorePermissions` call | 156, 171 |
991
+ | 60 | **where a sitemap is split** (past `maxUrls`, 50,000 by default): `x build --target static`, upload the new `sitemaps/` folder, delete or redirect each old `sitemap-<n>.xml`, re-submit `/sitemap.xml`. A running web role answers SEO from a one-hour memo — restart it to publish a change sooner | a search console reporting the old parts as 404; a sitemap change that shows an hour late | 155, 166 |
992
+ | 61 | `x verify --only policy,drift`: add each permission it names to the app's own `definePermissions([...])`; `x db gen "record replica identity full"`, then `x db migrate` | `X_PERMISSION_BORROWED` naming the file; `X_DB_SCHEMA_UNMIGRATED` for `replica identity full` | 169, 170 |
993
+ | 62 | `x verify --only filesize,errors,i18n,unit,live,e2e` and fix what it fails | a guard or an `apps/*/server.ts` / `prerender.ts` over 500 lines or with an unrunnable `fix:`; a test under a slice named `build`, `examples` or `dummy` that never ran and now fails | 164, 165 |
994
+ | 63 | read each build and CI script: empty a custom `--out` once or build to `.x/static`; drop `--tag` / `--out` / `--no-preflight` where the target never reads it; fix each module `x verify --only manifest --json` lists; read `ok` and the findings of `x g --dry-run`; rename a generator name it refuses; expect more work from `--affected` after a change under `scripts/` or `guards/` | `X_BUILD_OUT_UNSAFE`; `X_BUILD_FAILED`; `X_CLI_BAD_FLAG`; `X_GENERATE_CONFLICT`; a CI job that runs every workspace | 157–163 |
995
+ | 64 | optional, to match a fresh scaffold: pin `solid-js` `1.9.15`; set `"typecheck": "tsc --noEmit -p ../../tsconfig.json"` in `apps/web/package.json` and `apps/admin/package.json` | an app typecheck that now reports what the gate already refused | 167, 168 |
996
+ | 65 | **before the deploy:** make `ROLE=migrate` run to completion before every serving role. The shipped chart's pre-install/pre-upgrade hook and `docker-compose.prod.yml` already do; a deploy of your own adds the step | every `web`, `sync`, `worker`, `scheduler` and `replicator` pod refuses to boot: `X_FRAMEWORK_SCHEMA_UNAPPLIED` | 174 |
997
+ | 66 | read the deploy environment and the monitors: `TRUSTED_PROXY_HOPS` decimal 1–64, `PORT` and `METRICS_PORT` decimal; repoint an alert on `X_PORT_INVALID` for hops to `X_TRUSTED_PROXY_HOPS_INVALID`, and a match on `x routes list --json` to `x routes --json` | a boot that exits on `X_PORT_INVALID` or `X_TRUSTED_PROXY_HOPS_INVALID`; an alert that never fires | 173, 175, 176 |
998
+ | 67 | roll the chart: `docker/helm` carries the replicator probe; a chart `x new` wrote copies the two edits from `docker/helm/templates/_helpers.tpl` and `service.yaml`; read each alert on a not-ready replicator | a replicator at 0/1 while its stream is down; a worker that stops claiming at SIGTERM | 178, 179 |
999
+ | 68 | `bun run typecheck`: a hand-built `DoctorProbe` adds `appUrl` | TS2741 at each site | 182 |
1000
+ | 69 | `x verify --only unit,contract,e2e` and fix what it fails: read each handler that sets its own CSP, each link to an uploaded non-media file, each script or test of `/_x/db`, `x routes list`, `x verify merge` parts and MCP `tests.run` | a page under two policies; a download where a tab opened; `405`, `421` or `X_CSRF_BLOCKED` from `/_x`; `X_CLI_UNKNOWN_COMMAND`; `X_VERIFY_MERGE_INPUT`; `failed ≥ 1` | 172, 177, 183, 187, 188, 190 |
1001
+ | 70 | read each script and CI job that calls `x`: `x help`, `x i18n add\|sync`, `x db seed`, `x db backfill`, `x doctor` behind a `PORT`, `x jobs` with an id, `x shot --out`, the `x dev` and `x mcp serve` port fixes | exit 1 where 0 was; a probe of `PORT`; `X_APP_URL_PORT_MISMATCH`; `X_JOB_UNKNOWN`; a screenshot in the cwd; `X_PORT_IN_USE` | 180–182, 184–186, 189 |
1002
+ | 71 | where a repository of your own calls `checkErrorCodesThrown`: pass the codes it retired as the third argument | `X_ERROR_CODE_UNTHROWN` for a code whose row says "not thrown"; `X_ERROR_CODE_UNTHROWN_STALE` for a listed code that is constructed again | 191 |
1003
+ | 72 | **before the chart upgrade:** read the values — a scraper outside the cluster in `networkPolicy.metricsFrom` (or `networkPolicy.enabled: false`); `tmp.sizeLimit` for a role that writes more than 512Mi; `existingSecret` or `roles.<role>.existingSecret` for every role. A chart `x new` wrote copies `templates/_volumes.tpl`, `templates/networkpolicy.yaml` and the two values blocks from a fresh scaffold | `helm template` failing on `tmp.sizeLimit` or on a role with no Secret; a scraper that reads nothing; a pod evicted for `/tmp` | 192–194 |
1004
+ | 73 | read each test or tool that runs `x dev --port 0` or `startRoles({ port: 0 })` and dials the sync node at the web port + 1: read `syncUrl` from the boot's answer | a refused connection to web + 1 | 195 |
1005
+ | 74 | `x verify` | green, or a finding whose `fix:` is the edit | — |
1006
+
1007
+ ### Entry by entry
1008
+
1009
+ Tier 0 — `@ultimat3/schema` (1–5), `@ultimat3/core` (6–16). Tier 1 — `@ultimat3/i18n` (17),
1010
+ `@ultimat3/time` (18–21), `@ultimat3/db` (22–29), `@ultimat3/flags` (30–31), `@ultimat3/cache`
1011
+ (32–33), `@ultimat3/seo` (34), `@ultimat3/storage` (35–42). Tier 4 — `@ultimat3/render` (43).
1012
+ Tier 2 — `@ultimat3/entity` (44–46), `@ultimat3/policy` (47–49), `@ultimat3/http` (50–56). Tier 5 —
1013
+ `@ultimat3/testing` (57). Tier 2 — `@ultimat3/auth` (58–67). Tier 5 —
1014
+ `@ultimat3/testing` again (68). Tier 3 — `@ultimat3/query` (69–70), with `@ultimat3/mcp` (71). Tier 5 —
1015
+ `@ultimat3/admin` (72). Tier 3 — `@ultimat3/action` (73–78). Tier 5 — `@ultimat3/cli` (79).
1016
+ Tier 3 — `@ultimat3/jobs` (80–86). Tier 5 — `@ultimat3/cli` again (87). Tier 3 —
1017
+ `@ultimat3/realtime` (88–96). Tier 2 — `@ultimat3/http` again (97). Tier 4 — `@ultimat3/render`
1018
+ (98–101), `@ultimat3/pwa` (102), `@ultimat3/ui` (103–107). Tier 2 — `@ultimat3/http` again
1019
+ (108–109). Tier 4 — `@ultimat3/mcp` (110–114), `@ultimat3/mail` (115–116), `@ultimat3/notify`
1020
+ (117), `@ultimat3/manifest` (118–120), `@ultimat3/ai` (121–131). Tier 5 — `@ultimat3/testing`
1021
+ (132–135), `@ultimat3/scraping` (136–145), `@ultimat3/admin` (146–154). Tier 1 —
1022
+ `@ultimat3/seo` (155). Tier 2 — `@ultimat3/policy` (156). Tier 5 — `@ultimat3/cli` (157–170),
1023
+ `@ultimat3/testing` (171). Tier 2 — `@ultimat3/http` (172–173). Tier 5 — `@ultimat3/cli`
1024
+ (174–190), and again (191). Deploy — the chart, with `@ultimat3/cli`'s scaffold (192–195).
1025
+ Tier 4 — `@ultimat3/render` (196). Tier 5 — `@ultimat3/cli` (197). Tier 0 — `@ultimat3/schema`
1026
+ (198–199), `@ultimat3/core` (200–202).
1027
+
1028
+ | # | Surface | Costs you an edit if |
1029
+ |---|---|---|
1030
+ | 1 | `t.date`, `isIsoDateTime`, `fromIso`, `timestamp()` columns, feed dates, `DateTime` | a fixture, seed, import or client sends a day its month does not have (`'2026-02-30'`, `'2026-04-31'`), month `00`/`13`, day `00`/`32`. Refused at validation; it used to roll over into the next month. Correct the date at its source |
1031
+ | 2 | `t.url` | a value has a leading or trailing space or control character, or a tab, CR or LF anywhere. `value.trim()` before validating; an interior tab, CR or LF survives `.trim()` — strip or percent-encode it at the source. A stored row that already holds one fails the next time it is validated |
1032
+ | 3 | `t.object`, `t.record`, `t.money` | you pass a `Map`, a `Date` or a class instance. Pass a plain object: `{ ...instance }`, `Object.fromEntries(map)`. Null-prototype objects are still accepted |
1033
+ | 4 | `.default(v)` | `v` fails the schema it is declared on (`t.number.min(5).default(1)`). `X_SCHEMA_DEFAULT_INVALID` at the first import of the file; the cause quotes the rule. Edit the default, or relax the rule |
1034
+ | 5 | HTTP query and form coercion | a client sends `0x10`, `0b11` or `0o17` for a number. It stays a string and fails as `expected a number`. Send decimal |
1035
+ | 6 | `defineConfig` | `app.config.ts` or an overlay holds: an unknown `roles` entry, `jobs.backoff`, `database.driver` or `theme.defaultMode`; a non-boolean `database.ssl`, `realtime.enabled` or `ai.mcp.expose` (`'false'` from an env variable read as on — write `process.env.X === 'true'`); `auth.signInPath` with no leading `/` (`ai.mcp.path`, then also checked here, is deleted in 25.0.0); `cache.tiers: []`; an empty or non-string `jobs.queues` entry; one locale twice (`['EN', 'en']`); a section set to `null`. `X_CONFIG_INVALID` names each key |
1036
+ | 7 | `LOG_LEVEL` | a deploy sets a value that is not `trace`, `debug`, `info`, `warn`, `error`, `fatal` or `silent`, lower-case — `DEBUG` and `verbose` included. The process exits at import (`X_INVARIANT`); it used to log at `info`. Unset and empty are unchanged |
1037
+ | 8 | `retry()`, `retryDecision()` | `attempts` can be `NaN`, infinite, negative or a fraction, or `timeBudgetMs` `NaN` or infinite — typically `Number(process.env.X)` on an unset variable. `X_INVARIANT` before the first try. Parse and default the value before passing it. `attempts: 0` still runs once |
1038
+ | 9 | `createFlightGate` | `maxConcurrent` or `maxQueued` is `NaN`, infinite, negative or a fraction (`X_INVARIANT` at construction), or `maxConcurrent` is `0` and you expected callers to wait: each is refused with `X_FLIGHT_GATE_OVERLOADED` |
1039
+ | 10 | `withChildContext({ signal })` | the child's work was meant to survive the request. The child's signal now aborts when the parent's does. Move that work to a job |
1040
+ | 11 | redaction: logs, audit rows, the error monitor | a test, a log query or an audit reader expects the value of a field named like a credential — `currentPassword`, `mfaSecret`, `resetToken`, `recoveryCode`, `webhookSecret`, `passwordHash`, `tokenHash`, `keyHash`. It reads `[redacted]`. A name ending in `token` (`NPM_TOKEN`, `confirmationToken`), qualified key material (`privateKey`, `AWS_ACCESS_KEY_ID`) and a value embedding a credential (`connectionString`, `databaseUrl`) read `[redacted]` too. Ask `isRedactedKey('<name>')` for any other name. `idempotencyToken`, `continuationToken`, `maxTokens`, `cacheKey` and `code` are unchanged |
1041
+ | 12 | the Sentry envelope | a monitor rule, alert or saved search reads an error's `meta` key at `extra.<key>`. It is `extra.meta.<key>`. `scope.extra` keys stay at `extra.<key>`, except `fix`, `docs`, `stack`, `requestId` and `actorId`, which the framework's own values now win |
1042
+ | 13 | `OTEL_EXPORTER_OTLP_TRACES_HEADERS`, `OTEL_EXPORTER_OTLP_METRICS_HEADERS` | a deploy sets either one and also `OTEL_EXPORTER_OTLP_HEADERS`. The per-signal variable was ignored; it now replaces the generic one for that signal. Put every header the signal needs in it, or unset it |
1043
+ | 14 | `OTEL_TRACES_SAMPLER=parentbased_always_on` | `OTEL_TRACES_SAMPLER_ARG` is also set. The ratio is ignored and every root is sampled. For a ratio: `OTEL_TRACES_SAMPLER=parentbased_traceidratio` |
1044
+ | 15 | `hostDecision`, every `allowHosts` list | a `'*'` or `'*.suffix'` rule was how a request reached a loopback, private, link-local or metadata address literal. Add the exact rule: `allowHosts: ['*', '127.0.0.1']`. Hostnames are unaffected, including one that resolves inward |
1045
+ | 16 | `ULTIMATE_CURSOR_SECRET` | a compose file or chart sets it to the empty string. Outside local development the boot is `X_CURSOR_SECRET_DEV`: `x secrets set ULTIMATE_CURSOR_SECRET`. Cursors issued under the empty key stop verifying — clients restart from page one |
1046
+ | 17 | `t(key)` with no vars | a caller relied on the template coming back raw: a key whose message holds `{name}` or `{{`. It renders `⟦name⟧` and `{`. Pass the vars, or read the template with `t.raw(key)` |
1047
+ | 18 | interval crons, `CronExpression` | a task with `*` or `*/n` in its minute or hour field must not run twice in a fall-back hour — make the body idempotent for that hour, or give it a fixed time. A hand-built `CronExpression` adds `wildcardTime` (TS2741) |
1048
+ | 19 | cron expressions | one spells a name loosely (`mond`, `thurs`, `sept`) or has an extra `-` or `/` part (`1-5-7`, `1/2/3`). `X_CRON_INVALID` where the task is declared. Write `mon` or `monday`; write one range, one step |
1049
+ | 20 | `formatRelative` | always: add `zone` (TS2741). A test that asserted on "tomorrow" / "in N days" re-reads its expectation — the count is midnights crossed in that zone |
1050
+ | 21 | `addDaysInZone`, `formatDuration`, `formatDurationIso`, `plainDateUtc`, `addPlainDays`, `plainDateIn` | a computed argument can be a fraction, `NaN`, infinite, or a date outside years 0000–9999. `X_SCHEDULE_INVALID` or `X_INVARIANT` at the call. Round or default the number before passing it |
1051
+ | 22 | `withTransaction` | a body catches a statement error and carries on. `X_DB_TRANSACTION_ABORTED`; it used to resolve with nothing stored. `await withTransaction(() => fallible()).catch(fallback)` around the statement, or rethrow |
1052
+ | 23 | `X_DB_COMMIT_UNKNOWN`, `onRollback` | you retry on `X_DB_UNAVAILABLE` from a commit, or an `onRollback` undo had to run when the socket died mid-`COMMIT`. Neither list runs now. In a `psql "$DATABASE_URL"` session, select a row the transaction wrote; re-run only when it is absent |
1053
+ | 24 | a nested `withTransaction(fn, options)` | it passes `isolation`, `readOnly: true`, `deferrable: true` or another `client` (`X_INVARIANT`). Move the first three to the outermost call; run the other client's work after the outer scope returns |
1054
+ | 25 | sibling nested `withTransaction` scopes | a nested body awaits another scope under the same parent (`X_DB_SIBLING_SCOPE_TIMEOUT` after 30 s — it is a cycle), code relied on two siblings interleaving under `Promise.all`, or the first sibling runs longer than 30 s. Await them in sequence: `await withTransaction(first); await withTransaction(second)`. For a long first sibling pass `{ siblingWaitMs }`; `0` removes the deadline |
1055
+ | 26 | `DriftKind`, the drift check | you `switch` over `DriftKind` exhaustively: add `case 'changed-primary-key':`. A table re-keyed by hand now fails the check; the finding's `fix:` is one `psql "$DATABASE_URL" -c '…'` command to run against that database, then `x db migrate` re-checks |
1056
+ | 27 | `introspect()` | you compare `ColumnDescription.dataType` to `'numeric'`, `'ARRAY'` or `'USER-DEFINED'`, or assume every `IndexDescription.columns` entry is a column name. Compare to `numeric(12,2)`, `text[]`, the enum's name; skip entries in parentheses |
1057
+ | 28 | `CatalogColumn.generated` from `@ultimat3/db/schema-dump` | you read it as a string. Read `generated.expression`; `generated.storage` says `stored` or `virtual` |
1058
+ | 29 | the schema dump's `unrendered.sql` | the database holds extended statistics, forced row security, non-default column storage, an unpopulated materialized view, or a trigger on a partitioned table. `x db gen`, commit the directory |
1059
+ | 30 | a temporary flag's `expiresAt` | it is not ISO-8601 (`'December 1, 2026'`, `'12/01/2026'`) or names a day its month lacks. `X_FLAG_EXPIRY_INVALID` at declaration. Write `'2026-12-01'` |
1060
+ | 31 | `configureFlags({ reportEveryMs })` | the value can be `NaN`, infinite, negative or a fraction — `Number(process.env.X)` unset. `X_INVARIANT`. Parse and default it first; `0` is legal |
1061
+ | 32 | `Surrogate-Key` / `Cache-Tag` from `cacheHeaders()` | a test asserts the header value, or a CDN rule parses it: each tagged response adds `e:<entity>`. After the deploy a collection bust misses edge copies cached before it until their `s-maxage` passes — purge the CDN once if that window matters |
1062
+ | 33 | cache tags | a tag's entity or id holds whitespace or a comma. `X_CACHE_PURGE_FAILED` from `cacheHeaders()`, `surrogateKeys()` and the CDN tier's bust. Rename it in its `declareTags(...)` call |
1063
+ | 34 | `responsiveImage()`, `usableWidths` | your image driver encodes AVIF and you relied on the default offering it: pass `{ formats: FORMAT_ORDER }`. A snapshot of `<picture>` markup loses its AVIF `<source>`. A source wider than 8192 gets `?w=8192` as its widest candidate. An intrinsic width that is `NaN`, zero or negative is `X_INVARIANT` |
1064
+ | 35 | `promoteAttachment` | always: add `policy` (TS2741; `X_INVARIANT` from untyped code) — the `uploadPolicy()` the upload was granted under. An upload over `policy.maxBytes` is `X_STORAGE_TOO_LARGE` and stays under `pending/` |
1065
+ | 36 | a hand-written `StorageDriver` | you ship one: add `stat(key): Promise<StorageObject \| undefined>` (TS2741) |
1066
+ | 37 | `StorageListEntry.lastModified`, `StorageObject.lastModified` | you read it without a guard (TS18048), or relied on `sweepOrphans` deleting objects with no reported date. Handle `undefined` |
1067
+ | 38 | `disk.get(key)` | an object can exceed the disk's `maxPutBytes` (10 MB unless set) — a presigned upload, a file written by another tool. `X_STORAGE_TOO_LARGE`. Use `disk.stream(key)`, or set `maxGetBytes` on the driver |
1068
+ | 39 | stored image variants | always, if variants exist: keys are `<source key>@<transform>.<ext>` with the source's extension kept. Old-shape variants are orphaned; delete them with the commands below. A stored reference to a variant key is recomputed with `variantKey()` |
1069
+ | 40 | `variantKey`, `fitDimensions` | a width or height can be `NaN`, zero, negative or a fraction (`X_INVARIANT`): round it first. A caller that relied on `contain` upscaling a small source gets the source's size |
1070
+ | 41 | signed URLs on the local and memory disks | always: URLs minted before the deploy stop verifying (15 minutes by default) — a client retries with a fresh grant. A caller of `canonicalRequest` / `signConstraints` adds the base path: `signedUrlBasePath(baseUrl)` |
1071
+ | 42 | upload policies | a policy allowed `video/mp4` and took AVIF, HEIC, MOV, M4A or 3GP under it. Add `image/avif`, `image/heic`, `video/quicktime`, `audio/mp4`, `video/3gpp` as needed |
1072
+ | 43 | a route's `revalidate.tags` | a tag holds whitespace or a comma. `X_ROUTE_MODE_INVALID` at registration, naming the file. Rename the tag |
1073
+ | 44 | a hand-built `Driver`, a hand-built `SealedMeta` | you ship or wrap an entity driver: add `transactor()` — a wrapper writes `transactor: () => inner.transactor()` (TS2741). A hand-built `SealedMeta` adds `plaintext`. A hand-built driver kept in a file under the app's own `packages/db/` moves the schema hash when it gains `transactor()`: `x verify` answers `X_DB_DRIFT`, and `x db gen "<name>"` re-records the hash and writes no migration |
1074
+ | 45 | `dbDrift`, `ENTITY_ERROR_CODES` from `@ultimat3/entity` | you import `dbDrift` from entity: `import { dbDrift } from '@ultimat3/db'` (TS2305). A list built from `ENTITY_ERROR_CODES` no longer holds `X_DB_DRIFT` |
1075
+ | 46 | `preload(relation)` | one page attaches more than 10,000 related rows. `X_INVARIANT_VIOLATED`; pass the bound the relation really has: `.preload('comments', { max: 50000 })`, or page the relation itself. The last `{ max }` stated on a chain wins. The refusal's `fix:` is `x entities show <entity> --json`; the edit is in its cause |
1076
+ | 47 | `assertAllowed` | a `catch` or a test expects `X_FORBIDDEN` for a caller with no actor, or for a `denied(reason, code)` with the app's own code. It is `X_UNAUTHENTICATED`, or that code, as a `PolicyDenialError` |
1077
+ | 48 | `HttpDenial.status`, `problem.status`, `problem.title` | you type either status as `403`, or assert on them: no actor is 401, and the title is the code's registered title. Type them `DenialStatus` |
1078
+ | 49 | policy predicates, `definePolicy({ check })` | one returns something other than a boolean or a `PolicyDecision` — `{ allowed: 'yes' }`, an object from another library, nothing. It denies now. Return `true`, `false` or `denied(reason, code)` |
1079
+ | 50 | `ctx.peer` | you read the client certificate identity behind a proxy. It is `null` until `configureHttp({ trustClientCertHeader: true })` — set it only where the proxy strips or overwrites `x-forwarded-client-cert` |
1080
+ | 51 | anonymous `POST` / `PUT` / `PATCH` / `DELETE` from a browser | a page on another origin posts to this app without a session — an embedded sign-up form, a marketing site on its own domain. `X_CSRF_BLOCKED` (403). List the origin: `configureHttp({ cors: { origins: ['https://www.example.com'] } })`. Requests with no `Origin` and no `sec-fetch-site` are unaffected |
1081
+ | 52 | repeated 401s | a client, probe or test sends many requests that fail `auth: 'required'` from one address: past `rateLimit.defaultBucket` they are 429 with `Retry-After`. Authenticate the probe, point it at `/healthz`, or raise the bucket |
1082
+ | 53 | `/healthz`, `/readyz` bodies on the web role | a monitor off the box reads `buildId`, the in-flight count or the check names. It gets `{ state, ready, role }`. `configureHttp({ healthDetailPeers: ['loopback', 'private'] })`, or an exact address. Probes that read only the status need nothing. The sync role's routes are unchanged for now |
1083
+ | 54 | `cache-control` set by a handler | it says `max-age=N`, `must-revalidate` or `proxy-revalidate` with no `private`: the response is rewritten as a shared-cache offer would be. For a per-user lifetime write `private, max-age=N` |
1084
+ | 55 | request bodies | a client sends a non-empty body with no `content-type`. `X_BODY_INVALID` (422). Send `content-type: application/json`; a handler that wants raw bytes reads `bodyBytes()` |
1085
+ | 56 | `defineHttpConfig` | an embedder relied on `HOSTNAME` to choose the bind address: pass `hostname`. A config with `buildId: null` and `BUILD_ID` set now runs with skew detection off |
1086
+ | 57 | `E2eBrowser.close()`, `LaunchedBrowser.close()`, `CdpLaunchFailedError` | a test or script calls `close()` without `await`, or reads `closed`: `await browser.close()`. A hand-built `CdpLaunchFailedError` passes `{ executable, attempts }` |
1087
+ | 58 | **operator action** — `x_users.mfa_secret` | any user has MFA enrolled. Do the three steps under the table, in order. Until `x auth seal-mfa` has run, each enrolled user gets `X_MFA_SECRET_UNSEALED` (500) at the second factor. App code that writes the secret calls `saveTotpSecret(auth, userId, secret)` |
1088
+ | 59 | `x_auth_failures`, `x_auth_lockouts` | always, after the rollout completes: `x doctor` reports `X_FRAMEWORK_TABLE_ORPHANED`; run `psql "$DATABASE_URL" -c 'drop table if exists x_auth_failures'`. The new `x_auth_lockouts` columns are added at boot. Failure counts restart; live lockouts carry over |
1089
+ | 60 | a custom `AuthLimiter` | you implement one: replace `assertAllowed` / `recordFailure` with `reserve(key)` and `refund(reservation)` (TS2741). A caller of either removed method calls `reserve` before the check and `refund` on success |
1090
+ | 61 | `verifyApiKey`, `apiKeyActor` | you call either: read `.record` off the result and pass a store with `findUserById`. A key whose owner is disabled or gone stops working. A key issued with a `userId` that is not an `x_users` id: reissue it without `userId` |
1091
+ | 62 | API key scopes | a key holds `*` or `<res>:*`, or more than its owner is granted: the extra scopes are dropped. `issueApiKey` with a wildcard is `X_CONFIG_INVALID` — list the scopes. Where users hold roles, pass `apiKeyResolver(store, { grantsOf })` or their keys resolve with no scopes |
1092
+ | 63 | `X_MFA_REQUIRED` | a client or handler reads `meta.userId` to run the second factor. Read `meta.challenge` and call `completeMfa(auth, challenge, code)` within 5 minutes |
1093
+ | 64 | `redeemRecoveryCode`, `mfaRequired`, `authNotImplemented` | you import any (TS2305). A recovery code goes to `completeMfa`; throw the challenge with `mfaChallengeRequired(auth, userId)` |
1094
+ | 65 | a custom `AuthAdapter` | you ship one: implement the eight members TS2741 names. `BuiltinAdapter` and `MemoryAdapter` are the references |
1095
+ | 66 | `BuiltinAdapter` write errors | you match `X_DB_UNIQUE_VIOLATION` on a user write. Match `X_AUTH_WRITE_FAILED` and read `meta.column` (`email`, `external_id`, `id`) |
1096
+ | 67 | `oauthLogin` | neither `baseUrl` nor `APP_URL` is set. `X_ENV_MISSING` at the start leg: set `APP_URL` to the app's public origin |
1097
+ | 68 | `E2eSession.offline()` | an e2e test toggles offline while a page refuses the script — a page mid-navigation, a crashed tab. `X_CDP_CALL_FAILED` where the call used to resolve and leave that page online. Wait for the page to settle, or close it, before `offline()` |
1098
+ | 69 | `compareValues`, `compareRows`, `matchesFilter`, `isAfterKey` from `@ultimat3/query` | you wrote a custom `SqlSource` or matcher: delete `compareValues` (TS2305) and pass `kindsOf(shape.entity)` as the kinds argument of the other three (TS2554) |
1099
+ | 70 | a read with `.limit()` | a client pages it past the limit with `.page()` / `?_first=` and a cursor. The listing now ends at the limit: the last page inside it answers `hasMore: false` and `nextCursor: null`. Drop the `.limit()` to page to the end. A cursor a client held across the deploy on a limited read is `X_CURSOR_INVALID` once — restart from the first page |
1100
+ | 71 | the MCP tool of a `single: true` read | an agent, prompt or test reads `{ rows }` from it. It answers the row itself, or `X_NOT_FOUND`; its `outputSchema` is the row |
1101
+ | 72 | `adminPermissions`, permissions declared by importing `@ultimat3/admin` | you import `adminPermissions` (TS2305), or your own closed permission set relies on the import having declared `admin:*` before `defineAdmin()` runs: add `...ADMIN_PERMISSIONS` to `definePermissions([...])` |
1102
+ | 73 | `mutator({ … })` | always, for every mutator: add `idempotent: true` (TS2741; `X_MUTATOR_NOT_IDEMPOTENT` from untyped code). `x g mutator` writes it. Running more than one replica: `configureIdempotency({ scope: 'shared' })` |
1103
+ | 74 | `openapi.json` | always, if the file is committed: the `Problem` schema gained `instance`, `requestId`, `issues`, `meta` and lost the `code` pattern. `x manifest`, commit. A generated client that validated `code` against the pattern regenerates |
1104
+ | 75 | `packages/db/schema/` | always, if the dump is committed: `x_idempotency` gains `tx_bound`. `x db gen`, commit. The column itself is added at boot |
1105
+ | 76 | an idempotent action inside `withTransaction` | a test or caller expects the idempotency record `settled` after a rollback: it is `in-flight`, and the key can be retried. A retry that overtakes a slow first attempt makes that attempt fail `X_IDEMPOTENCY_RESERVATION_LOST` (409) |
1106
+ | 77 | `postgresIdempotencyStore` | you build the store yourself: add `origin: () => client` and `reclaimAfterMs: requestDeadlineMs` (TS2741). The framework's boot needs nothing |
1107
+ | 78 | `cache.invalidates`, `bustAfterCommit` | a test reads the cache inside the transaction and expects the bust to have happened, or uses `bustAfterCommit`'s return value unguarded: it is `undefined` when deferred to the commit |
1108
+ | 79 | the `manifest` step, `x manifest --check` | a script reads `X_MANIFEST_DRIFT` for a missing `x.manifest.json` — it is `X_MANIFEST_MISSING`; or relied on `contract-diff` to report a stale `openapi.json` — the `manifest` step does, as `X_MANIFEST_STALE` |
1109
+ | 80 | a job whose worker can die mid-run | it declares `retry.attempts: 1`, or relied on being re-claimed after every lapse. A lease that lapses on the final attempt buries the row `dead` (`failed` with `retry.deadLetter: false`), `lastError` `LEASE_LAPSED_FINAL_ATTEMPT`. Raise `retry.attempts`; requeue a buried row with `x jobs retry <id>` |
1110
+ | 81 | a custom `JobDriver`, `SchedulerState` or `Lease`; a caller of `SQL_CLAIM` / `SQL_SCHEDULER_FIRE` | you ship one: `claim` buries a final-attempt lapse and reports it through `onExhausted`, honouring `dropExhausted`; `fire` lands `watermarkMs`; `Lease` adds `abandon()`. `SQL_CLAIM` binds a fifth parameter and returns buried rows; `SQL_SCHEDULER_FIRE` binds `$4` |
1111
+ | 82 | `cancel` on a finished job | a script or admin action cancels a `dead`, `failed`, `done` or `cancelled` job: `X_JOB_NOT_CANCELLABLE`. To remove a dead letter: `x jobs delete <id>` |
1112
+ | 83 | step writes after a lapsed lease | a job body runs longer than its visibility timeout: its next `step` write is `X_JOB_LEASE_LOST`. Raise the job's visibility timeout or split the step. A custom `StepStore.put` accepts the second argument |
1113
+ | 84 | `enqueue(..., { runId })` | you pass a `runId` that is not a lowercase uuid — an upper-case uuid, a slug, a number as text. `X_ID_INVALID` before anything is staged. Pass `crypto.randomUUID()`, or omit it |
1114
+ | 85 | a custom `EventBus` / `EventLookup` | you ship one: `purgeExpired()` returns `Promise<number>` and rejects on failure; the lookup adds `now(): Promise<number>` (TS2741 / TS2322) |
1115
+ | 86 | list cursors, `backfills.list({ runId })`, `retry.attempts` | a cursor's id is not a uuid (`X_JOB_PAGE_INVALID`), a ledger `runId` is not one (`X_ID_INVALID`), or `retry.attempts` is `Infinity` or a fraction. Take the cursor from the previous page; declare a whole number of attempts |
1116
+ | 87 | a hand-built `RetentionStores`, a reader of `PurgeReport.swept` | you build the stores yourself: add `events` (TS2741). `swept` has a sixth entry, `x_job_events` — read it by name, not by position |
1117
+ | 88 | **operator action** — the sync websocket's origin rule | always, where `APP_URL` is set on the `sync` role or `allowedOrigins` is passed: a declared origin is the whole allow-list, so it must be the origin the pages are served on. Otherwise every socket is `403 X_SOCKET_ORIGIN_REFUSED`; its `fix:` is the `export APP_URL=…` that admits the origin that asked. Compose requires `APP_URL` on `sync`; the Helm chart sets it from `ingress.host`; `x dev` adds its own web origin to the list. Undeclared, the node admits the origin it was reached on. A hand-built `SocketOriginRefusedError` passes `{ reason, asked, admitted }` |
1118
+ | 89 | **operator action** — channels declared with params | the app declares one: `x db gen "replica identity full"`, then `x db migrate`. Until then deletes on those channels are not announced, and the replicator logs `replication.channel_identity_partial` |
1119
+ | 90 | **operator action** — the replication connection | the server asks the replication role for a cleartext or md5 password and the URL's `sslmode` is `prefer` (the default) or `allow`: `X_REPLICATION_FAILED`. Append `?sslmode=verify-full` (or `verify-ca`), move the role to scram-sha-256, or state `?sslmode=disable`. `require` also connects, but verifies no certificate — it protects the password from a passive listener only |
1120
+ | 91 | `parseEnvelope`, `parseChange`, the bus wire | you read change rows off the bus: `timestamp()` columns are `Date`, `bytes()` columns `Uint8Array` (base64 on the wire). A fleet with `replicator` and `sync` on different majors does not interoperate — deploy both together |
1121
+ | 92 | a custom `Transport`, `AdvisoryLock`, `ChangeFeed`, `UpgradeTarget`, `UpgradeDeps` | you ship one: add `Transport.onReconnect(listener)`, `AdvisoryLock.onLost(listener)` and `AdvisoryLock.abandon()`, `ChangeFeed.abandon()`, `requestIP(request)`, `healthDetailPeers` (TS2741). `abandon()` is synchronous and writes no goodbye |
1122
+ | 93 | `/healthz`, `/readyz` bodies on the sync role | a monitor off the box, or any reader whose request carries `Forwarded`, `X-Forwarded-For`, `X-Forwarded-Host`, `X-Forwarded-Proto`, `X-Real-IP` or `Via`, reads more than `{ state, ready, role }`. List the reader in `healthDetailPeers` and reach the node directly. Status codes are unchanged |
1123
+ | 94 | channel guards and subscribes | a subscriber with no actor sat on a channel that declares `row`, or a guard throws a tenancy refusal: the seat is denied (`X_TOPIC_FORBIDDEN`). A guard that throws anything else suspends the seat until a later pass. A channel param value over 128 characters is refused, and a socket holding `maxTopicsPerSocket` latched denials gets no new topic |
1124
+ | 95 | the offline queue | a write is queued, in flight or being flushed while the principal changes — sign-out, switch of org. It rejects with `X_OFFLINE_QUEUE_ABANDONED` and is not sent. Handle the rejection as any outbox refusal; `useMutation` already rolls its optimistic row back. Not covered: the server does not bind a replayed write to its principal — see [Known gaps](Known-Gaps) |
1125
+ | 96 | decoded `records.adopt` / `records.remove` / channel `params` | you call `.hasOwnProperty(…)` or rely on `Object.prototype` members on them: use `Object.hasOwn(value, key)` |
1126
+ | 97 | a catch-all route (`/docs/*path`) | a test or client expects a 404 at the bare prefix (`/docs`, `/docs/`), or the handler assumes a non-empty rest: it is reached with `path: ''`. Handle `path === ''`, or declare a static `/docs`, which still wins the prefix |
1127
+ | 98 | `revalidate: { ttl }` | an `isr` route's only trigger is a zero-length string — `'0s'`, `'0ms'`, `'0m'`. `X_ROUTE_MODE_INVALID` at registration; the page never expired. Write a positive `ttl`, or `tags`. `parseTtlMs('0s')` is `null` |
1128
+ | 99 | `NativeReason` | you `switch` over it exhaustively: add `case 'unparsable':` — an `href` or form action no URL parses, left to the browser |
1129
+ | 100 | CSS modules (`*.module.css`, `*.module.scss`) | always, after a rebuild: every scoped class name changes once — the suffix hashes the compiled CSS. A snapshot holding one updates. An escaped class (`.w-1\.5`) is one key, `styles['w-1.5']`; it was `styles['w-1']`. A word in a declaration or comment (`local(Inter.Regular)`) is no longer a key of `classes` |
1130
+ | 101 | `CompiledPattern.specificity`, `compilePattern` | you store or compare against a number: it is a positional rank, every value changed, higher still wins — compare two patterns' `specificity`. The regex now also matches a catch-all's bare prefix and a percent-encoded literal |
1131
+ | 102 | push notification `url` | a sender links a tap to another host or a non-http scheme: the app root opens instead. Link to a page of the app. Only an app handing `generateServiceWorker` a `vapid` key emits the handler |
1132
+ | 103 | a hand-built `ThemeEnv` | you build one — a test fake, a server env: add `current()` (the document's `data-theme`) and `appDefault()` (its `data-theme-default`), TS2741. A fake that answers `null` for both keeps the OS rule; a wrapper spreads `browserThemeEnv()` |
1133
+ | 104 | `clearTheme()`, `watchOsTheme()`, `resolveTheme()`, `ThemeToggle` | `theme.defaultMode` is `'light'` or `'dark'` and a user or test expected "System" or an OS change to follow the OS: it returns to that mode and ignores the OS. Set `defaultMode: 'system'` for an OS-following app. A `ThemeEnv` that overrode `prefersDark` with the booted theme — the `bootedEnv()` an `x new` app carries in `apps/web/shared/theme-toggle.island.tsx` — must drop it: under `'system'` it freezes the OS answer at mount. Delete it and render `<ThemeToggle mode="toggle" />` with no `env` |
1134
+ | 105 | `THEME_INLINE_SCRIPT`, `themeInlineScriptTag`, `themeInlineScriptHash`, `themeInlineScriptCspSource` | you import any (TS2305). Delete the import, the hand-inlined `<script>` and its `sha256-…` in `script-src`; the boot already inlines `themeScript` and admits its hash |
1135
+ | 106 | `Popover`'s `trigger` | the callback annotates `'aria-controls': string`, or hands it to a prop typed `string` (TS2322). It is `string \| undefined`, absent while closed; spread the control object, or widen the type |
1136
+ | 107 | `Dropzone`, `FileInput` | a server, an e2e test or a handler expects to receive a file the control refused by `accept`, `maxBytes` or `maxFiles`: the input holds only accepted files, and a pick that refused everything empties it — a `required` control then blocks the submit. Read the refusal from the `onSelect` selection's `rejected` |
1137
+ | 108 | a hand-written `RateLimitStore` or `RateLimiter` | you ship one: add `peek` (TS2741). A store answers `peek(key, bucket, nowMs)` from the state it keeps — `rateLimitPeek(bucket, refilledTokens(bucket, stored, lastMs, nowMs))`, or `rateLimitPeek(bucket, bucket.capacity)` for a key it holds nothing for — and writes nothing. A limiter forwards `peek(key, bucketName)` to its store |
1138
+ | 109 | `auth: 'required'` routes behind one address | a probe, a test or a client sends failing credentials from an address others share. Once `rateLimit.defaultBucket` under `unauthenticated\|ip:<address>` is spent, every request from that address to a required route is `429 X_RATE_LIMITED` until it refills — a valid credential included, signed-in users behind the same NAT included. Point the probe at `/healthz`, authenticate it, give it its own address, or raise the bucket |
1139
+ | 110 | MCP error text from `defineAppMcp` (`errorAudience: 'caller'`) | an agent, prompt or test reads the `cause` of a 5xx code core's `hasPublicCause` does not list — `X_DB_STATEMENT_FAILED` above all. It reads a fixed sentence pointing at the logs, and `fix: x errors explain <CODE> --json` unless the error carries a `callerFix`; `resources/read` error data likewise. Read the cause in the log line or the error monitor. `createMcpServer` defaults to `'developer'` and is unchanged |
1140
+ | 111 | `db.query` | an agent's query quotes an identifier as `U&"…"`, or calls `query_to_xml*`, `cursor_to_xml*`, `table_to_xml*`, `schema_to_xml*`, `database_to_xml*`, `ts_stat`, `ts_rewrite` or `pg_import_*`: `X_MCP_QUERY_REJECTED`. Spell the identifier plainly; run the inner statement as the query |
1141
+ | 112 | `surface: 'meta'` and a hand-registered tool | the tool omits `destructive` and writes: it is listed as `query`, was `action`. Declare `destructive: true` |
1142
+ | 113 | `manage_resource` list calls | a call passes an optional input key the query's `listParams` leaves out (`includeDeleted`, `status_in`): an `isError` result carrying `X_INPUT_INVALID`. Add it to `mcp.listParams`, or call the query's own tool. Required input keys are still admitted |
1143
+ | 114 | the MCP endpoint, `mcpHttpRoute`, `x mcp serve --transport http` | a client sends missing or wrong bearer tokens from an address an agent also uses: past 20 failures a minute the address is `429 X_MCP_RATE_LIMITED` before `resolveToken` runs, a right token included. Set `rateLimits: { read, write, unauthenticated: <n> }` on `defineAppMcp` or `mcpHttpRoute`. A host of its own that calls `route.handle(request)` without `{ address }` is not metered |
1144
+ | 115 | `send()` inside `withTransaction` | a handler relied on the mail going out though the transaction rolled back, or a test reads the queue before the commit: the `mail.send` job is staged and published after `COMMIT`, and a rollback sends nothing. A facade installed with `mode: 'required'` refuses a `send()` outside a transaction (`X_OUTBOX_NO_TX`) — wrap the call in a transaction, or pass `{ sync: true }` |
1145
+ | 116 | mail recipients, `unsubscribeUrl`, the `mail.send` job | a `to`, `cc`, `bcc` or `replyTo` holds a control character, `<`/`>` in the mailbox, or a non-ASCII mailbox: `X_MAIL_ADDRESS_INVALID` at `send()` on every driver — Resend and memory delivered it. Write the domain as punycode; a non-ASCII local part needs another mailbox. An unparseable `unsubscribeUrl` is `X_VALIDATION_FAILED`. Always, where `x.manifest.json` is committed and the app sends mail: `contract-diff` reports `jobs.mail.send.input` until `x manifest` |
1146
+ | 117 | `packages/db/schema/` | always, if the dump is committed: the boot creates `x_notify_digests` and its indexes. `x db gen`, commit |
1147
+ | 118 | `x.manifest.json` | always, if the file is committed: every query gains `input`. `x manifest`, commit. The first diff against a file without it reports no change |
1148
+ | 119 | manifest facts | a declaration publishes `NaN`, `Infinity` or `-0` — a budget, a rate limit, a retry count read from an unset variable: `X_MANIFEST_FACT_INVALID` at `x manifest`, naming `meta.path`. Replace `-0` with `0`, and `NaN` or `Infinity` with a finite number |
1149
+ | 120 | an entity column's default | a release drops the default of a NOT NULL column: `contract-diff` reports `…hasDefault` as breaking, so the gate is `X_MANIFEST_BREAKING` without a major bump. Keep the default, or bump the major in `package.json` |
1150
+ | 121 | a hand-written `BudgetStore` | you ship one: add `take(key, tokens, limit)` (TS2741) — add `tokens` only when the total stays at or under `limit`, in one atomic step on the store (a Redis `EVAL`, one conditional `update`), answering `{ taken, spent }` with `spent` read before the take. Never `spent` then `add`: that pair is the race. Another answer is `X_INVARIANT` at the reservation |
1151
+ | 122 | a hand-written or wrapping `Gateway` | you ship one: implement `callLedger(keys: BudgetKeys)` (TS2741), or pass keys at a `callLedger()` call (TS2554). A wrapper forwards `keys` to the wrapped gateway's |
1152
+ | 123 | `createGateway({ budget: { actor, org } })` | the ceiling is declared: every `llm()`, `agent()` and hive member with no `scope()` open counts against `actor:<kind>:<id>` and `org:<orgId>` of its caller, so a caller past it is `X_AI_BUDGET_EXCEEDED` where the call ran. Every anonymous caller is one counter. Raise the ceiling or drop it; key each `gateway.scope()` with `budgetKeysFor(actor)` so a scoped and an unscoped call of one actor share a counter. A hive's members also run under the gateway's `request` ceiling now |
1153
+ | 124 | `agent()` tool failures | the model, a prompt or a test reads the cause of a tool's 5xx code that core's `hasPublicCause` does not list — an app code with no `registerErrorStatus` row counts as 500. It reads `CODE: ` and a fixed sentence, plus a declared `callerFix`. Give a refusal the model should act on a 4xx (`registerErrorStatus({ X_ORDER_LOCKED: 409 })`) or `registerProblemMeta({ X_ORDER_LOCKED: { publicCause: true } })` |
1154
+ | 125 | `fnv1a` | you import it from `@ultimat3/ai` (TS2305). Use core's `fingerprint`; a 32-bit number is `Number.parseInt(fingerprint(text).slice(0, 8), 16)` |
1155
+ | 126 | `HashEmbedder` | a vector store, a seeded database or a fixture holds vectors it wrote, or a test pins a ranking or a vector: every vector changes. Re-run `indexDocument` over each document; update the snapshot |
1156
+ | 127 | an OpenAI-format `finish_reason` | a provider, fake or recorded fixture sends one this build does not know: it reads `max_tokens`, was `end_turn` (or no finish, streamed). An answer that also fails its schema is `X_LLM_TRUNCATED` with no repair turn; `.stream()` throws it. Send `stop` from a fake |
1157
+ | 128 | vector reads in an org's request | a `search`, `searchText` or `hybrid` runs on a store with no tenant bound — no `scope`, or an `allow` list alone — while the ambient actor has an `orgId`: `X_VECTOR_UNSCOPED` (500). `store.scoped({ tenant: ctx.actor.orgId })`; a deliberate cross-tenant read opens the store with `scope: UNSCOPED` or calls `.scoped(UNSCOPED)`. A comparison against `UNSCOPED`'s old `{}` fails — it is `{ crossTenant: true }` |
1158
+ | 129 | a hand-written `VectorStore` | you ship one: add `prune(filter, keep)` (TS2741) — delete every in-scope row `filter` matches except the ids in `keep` |
1159
+ | 130 | `chunk()` metadata | a caller passes its own `metadata.source`: it is overwritten with the document id, the key `indexDocument` prunes by. Store the value under another key |
1160
+ | 131 | `numericTolerance` | the bound is `NaN`, infinite or negative — often read from an unset variable: `X_INVARIANT` where it is called. Pass a finite bound of at least 0; `0` is exact match |
1161
+ | 132 | `describeApp`'s `app()` | a test reads it while the `describe` block registers, before `beforeAll`, and asserts `toBeInstanceOf(ReferenceError)`: it is `X_TEST_APP_NOT_BOOTED`. Assert `toBeUltimateError('X_TEST_APP_NOT_BOOTED')`, or read `app()` inside a `test` body |
1162
+ | 133 | `assertDeterministic` | a test relied on two results differing only in key order failing `X_TEST_NONDETERMINISTIC`: it passes. Results compare by core's `canonicalJson`; a BigInt, Date or Map result compares instead of throwing `TypeError`, a cyclic one by `Bun.deepEquals`. A top-level `undefined`, function or symbol compares by its type: `undefined` then `null` is `X_TEST_NONDETERMINISTIC`. Assert the order itself |
1163
+ | 134 | `frozenClock(now, body)` | a test counts `onClockMoved` announcements: each call adds two, entering and restoring, so frozen-scheduler lease renewals fire inside and after the body. A listener that throws on entry rejects the call, the body does not run, and the earlier instant is restored. Update the count |
1164
+ | 135 | `mountIsland` | a test relied on module state carrying from one mount to the next: each mount loads from its own directory, a fresh module instance. Set the state up in each mount |
1165
+ | 136 | `localBrowser({ options: { args } })` | `args` holds `--proxy-server`, `--proxy-bypass-list`, `--proxy-pac-url`, `--proxy-auto-detect` or `--no-proxy-server` (`-` or `--`, any case), or is not a list of strings: `X_SCRAPE_LAUNCH_ARGS_INVALID` before anything launches, exit configured or not. Set the exit with `localBrowser({ proxy })` or `scrape({ egress })`; the caller's other args are kept and the exit appended after them |
1166
+ | 137 | `CdpBrowserLike.target()` | you ship a launcher or a test double that hands back a browser without it (TS2741): add `target()` answering `{ createCDPSession() }`, whose session has `send` and `on` (`CdpBrowserSessionLike`). Puppeteer has it. A browser that refuses browser-level `Fetch.enable` fails `open()` with `X_SCRAPE_BROWSER_UNREACHABLE` |
1167
+ | 138 | a CDP browser with no `cookies()` or `setCookie()` | `session()` on it, or `restore()` of a session holding cookies: `X_NOT_IMPLEMENTED`, was an empty jar or dropped cookies. Use a puppeteer-core that exposes both, or set `auth: { reuse: false }` on the `scrape()` definition |
1168
+ | 139 | `burnSession(plan, seen)` | you call it with one argument (TS2554): pass `recordVersion(restored)`, the version of the record the run used. A record another run saved since is kept. A hand-written `ScrapeSessionStore` keeps the new optional `SessionState.version` it is handed. A refused login now also tombstones over a record the run found but did not reuse (`reuse: false`, past `maxAge`) |
1169
+ | 140 | `expect.maxDrop` | the value is `1` or above (`50` for 50%) or negative: `X_INVARIANT` at `scrape()`. Write a fraction in `[0, 1)` — `0.5` |
1170
+ | 141 | `urlSecretValues`, `redactSecrets` | a log, artifact or snapshot expected every query value and the whole path concealed: only credential-shaped ones are — a query key whose words (split on `_`, `-`, `.`, camelCase) include a credential word (`apiKey` does, `keyboard` does not), a 16+ character value mixing letters and digits, or the id after `/devtools/<browser\|page>/`. `redactSecrets` also redacts each secret's percent-encoded (hex in either case), form-encoded and HTML-escaped spellings. Update the snapshot |
1171
+ | 142 | headers on the HTTP leg | a declared header shares a name with the session's in another case and a request relied on the two being joined (`BrowserUA, Mine`): the declared one replaces it. Declare the whole value |
1172
+ | 143 | `allowHosts` wildcards on the HTTP leg and the robots read | a host admitted only by a wildcard resolves to an address that is not public: `X_SCRAPE_HOST_BLOCKED`, and the connection is pinned to the approved address. Add an exact rule for the internal host. Exact rules and proxied runs are unchanged; the browser leg is not pinned |
1173
+ | 144 | the robots read, `createRobotsGate`, `robotsFetcher` | a site's `robots.txt` redirects more than 5 times, or to a host off `allowHosts` — every hop screened, the first included — or, on a gate built by hand with no `allowHosts`, to any hostname other than the starting one (a subdomain included; a scheme upgrade, its port change and a moved path are followed): the hop is never requested and the read is "no robots", so no rule applies. List the host it redirects to; pass `allowHosts` to a gate built by hand. A `scrape()` run always passes its own |
1174
+ | 145 | `X_SCRAPE_FIXTURE_STALE` | a recorded fixture's `recordedAt` is not a date and `maxAgeMs` is set: the recording is stale, was fresh. Re-record it |
1175
+ | 146 | `KeysetBound.value`, `AdminCursor.value` | you ship an `AdminRepo` whose `list` reads `keyset.value` as a string (TS2345 / TS18047): it is `string \| null`, `null` for a NULL sort value, was `''`. NULL sorts as the largest value — last ascending, first descending |
1176
+ | 147 | the admin edit form, `_version` | a script, a test or a hand-built page posts an edit without the `_version` the form rendered, or after the row changed: `409`, nothing written, a `failed` entry with `admin.error.row-changed`. Post `rowVersion(row)` of the row the edit was made against. `adminUpdate` with no `version` — MCP, a script — is unchanged |
1177
+ | 148 | `AdminFormProps.version` | you render `AdminForm` yourself (TS2741): pass `rowVersion(row)` for an edit, `null` for a create |
1178
+ | 149 | `CrudResult` | a `switch` over `kind` is exhaustive: handle `'stale'` — `row` as it is now, `version`, `audit` — which `adminUpdate(…, { version })` answers when the version no longer matches |
1179
+ | 150 | create and update policies | a rule reads the subject's `input`: an admin create or update is decided again after validation with the written values as `input` (an update beside `row`), so the rule now fires there. A write it refuses is denied and audited as one |
1180
+ | 151 | `/admin/audit`, a row's history card | an actor with an `orgId` — a test, an operator — expected another org's entries: only its own org's are listed. An actor with no `orgId` still reads every tenant's |
1181
+ | 152 | an admin action, a set-based `matching` call, a queued batch | a test expects the work to stay written when its audit entry cannot be: on `postgresAuditLog` the work rolls back with the entry, and the log holds one `failed` entry. A queued batch's id is derived from its content, so a retry dedupes onto chunks still live |
1182
+ | 153 | `?scope=*`, `listHref({ scope: null })` | a list scope is declared as `*`: `X_ADMIN_FILTER_INVALID`, rename it. `listHref({ scope: null })` writes `?scope=*` and opens every row; it opened the default scope |
1183
+ | 154 | admin list filters | a link or bookmark sends a number filter as a blank, hex (`0x10`), an exponent (`1e3`) or `Infinity`, or a date the calendar lacks (`2026-02-30`): `X_ADMIN_FILTER_INVALID`. Send a decimal or a real day |
1184
+ | 155 | split sitemap parts | a CDN, an object store, a search console or a link holds `/sitemap-<n>.xml`: the parts are `/sitemaps/<n>.xml` (`SITEMAP_PARTS_DIR`), served by the web role, the index unchanged at `/sitemap.xml`. Rebuild, upload `sitemaps/`, delete or redirect the old files, re-submit the index |
1185
+ | 156 | `restorePermissions(names, declaredAt)` | you call it with one argument (TS2554): capture `permissionDeclarationSites()` with `knownPermissions()` and pass both back. The one-argument call restored names with no declaration site, which the `policy` step never judges — `X_PERMISSION_BORROWED` was silently off for them. Pass `{}` only when you mean no provenance |
1186
+ | 157 | `x build --target static --out <dir>` | the directory exists, is not empty and holds no `.x-export`: `X_BUILD_OUT_UNSAFE`, was deleted. An `--out` that is the app root or holds it is `X_BUILD_OUT_UNSAFE`, was `X_CLI_BAD_FLAG`. Empty it once or build to `.x/static` (exempt); every build writes the marker |
1187
+ | 158 | a static build | a module of the app fails to import: `X_BUILD_FAILED`, the last export left as it was; the build succeeded without that page. `x verify --only manifest --json` lists each module |
1188
+ | 159 | `x build` flags | `--tag` off the docker target, `--out` on it, or any of `--tag`, `--out`, `--no-preflight` on prebuilt: `X_CLI_BAD_FLAG`, was ignored. Drop the flag |
1189
+ | 160 | `x g --dry-run` | a script reads `ok` or `data.files`: `ok: false` with the real run's conflicts, and `data.files` lists only what would be written |
1190
+ | 161 | `x g` and `apps/web/api/index.ts` | the primitive's camelCased file name is already bound in the index — another feature's module, `api`, `defineApi`, `health`: `X_GENERATE_CONFLICT` before anything is written. Run the `fix:` — the same command under a feature-prefixed name |
1191
+ | 162 | `x g` names | a plural `resource` or `entity` name, a non-ASCII letter, or a name whose type the generated code uses (`promise`, `omit`, `partial`, `row`): `X_CLI_BAD_FLAG`. Run the `fix:` — `x g resource post`, `x g entity uber`, `x g resource promise-resource` |
1192
+ | 163 | `x affected`, `x test --affected` | a CI job relies on a change under `scripts/`, `guards/`, `bin/` or to `tsconfig.base.json` selecting nothing: any non-doc path no workspace owns is root-wide — every workspace, every test file |
1193
+ | 164 | test discovery | an app has a directory named `build`, `examples` or `dummy` below its root: its tests run under `x test` and on the gate's steps, `live` and `e2e` included, and may fail. Fix them; only the root's are skipped |
1194
+ | 165 | `filesize`, `errors`, `i18n` | a `guards/*.ts`, `apps/*/server.ts` or `apps/*/prerender.ts` is over 500 lines or has a `fix:` that is not one runnable command: the gate fails. Split it or make the fix a command. The `i18n` step no longer reads `guards/` |
1195
+ | 166 | `/robots.txt`, `/sitemap.xml`, `/sitemaps/<n>.xml` on a running web role | a test or an operator expects a change to the app's routes or SEO settings on the next request: each origin's answer is kept an hour, 8 origins at most. Restart the process |
1196
+ | 167 | `x new`'s `solid-js` | a fresh scaffold pins `1.9.15`, was `1.9.14`. An existing app moves its pin by hand |
1197
+ | 168 | `x new`'s app `typecheck` scripts | a fresh scaffold's `apps/web` and `apps/admin` run `tsc --noEmit -p ../../tsconfig.json`, the root program: slower, and it reports a type error another workspace's declaration causes. An existing app keeps its scripts until it changes them |
1198
+ | 169 | the `policy` step | an app action, query or route guard requires a permission only a package declared — `defineAdmin()`'s `<entity>:read\|write\|delete`: `X_PERMISSION_BORROWED`. Add the name to the app's own `definePermissions([...])`; the finding names the file |
1199
+ | 170 | the `drift` step | a params channel's `records` table or a live query's `subscribes:` table needs `REPLICA IDENTITY FULL` and no migration records it: `X_DB_SCHEMA_UNMIGRATED`. `x db gen "record replica identity full"`, then `x db migrate`. A `subscribes:` name no entity declares is `X_QUERY_SUBSCRIBES_UNKNOWN` |
1200
+ | 171 | `ProcessRegistrySnapshot` | you build one by hand (TS2741): add `permissionSites` — `permissionDeclarationSites()`, or `{}` |
1201
+ | 172 | a handler's own `content-security-policy` | a handler sets one — to loosen the app's, or a test reads the header: it is kept and the app's is added beside it, both enforced. Loosen through `security.csp.extend`; read both policies in the test. The report-only header follows the same rule |
1202
+ | 173 | the `fix:` of `X_ROUTE_NOT_FOUND`, the duplicate-route and mount-conflict errors | a monitor, script or test matches `x routes list --json`: it reads `x routes --json`. Codes unchanged |
1203
+ | 174 | a serving role's boot, `ROLE=migrate` | a deploy starts `web`, `sync`, `worker`, `scheduler` or `replicator` on an external `DATABASE_URL` before `ROLE=migrate` ran this build, or never runs it: `X_FRAMEWORK_SCHEMA_UNAPPLIED`, the pod refuses to boot. Run `ROLE=migrate` (`x db migrate`) first — the shipped chart's hook and `docker-compose.prod.yml` already do. An embedded database applies its own |
1204
+ | 175 | `TRUSTED_PROXY_HOPS` | a malformed value: `X_TRUSTED_PROXY_HOPS_INVALID`, was `X_PORT_INVALID`. Write decimal digits, 1 to 64; repoint an alert on the old code |
1205
+ | 176 | `PORT`, `METRICS_PORT`, `ROLE` | a port written `0x1F90`, `8e3` or `+80`: `X_PORT_INVALID`, was read as a number. Write decimal digits. A blank `ROLE` now boots `web`, where it was refused |
1206
+ | 177 | `/_storage`, `/media` | a link opens an uploaded file that is not a raster image, audio or video — PDF, SVG, HTML, XML — in a tab: it downloads, with `content-security-policy: sandbox`. Embed images, audio and video as before; link to a viewer page for the rest |
1207
+ | 178 | `worker`, `scheduler`, `replicator` on SIGTERM | a process relied on `drain.readinessGraceMs` before it stopped claiming: it stops at once. `web` and `sync` keep the grace |
1208
+ | 179 | the replicator's readiness, the chart | an alert reads a not-ready replicator as an outage, or a chart was written by `x new`: the replicator is ready only while its stream runs (`/readyz?deep=1` on the metrics port), and its metrics Services publish not-ready addresses. Copy the two edits from `docker/helm/templates/_helpers.tpl` and `service.yaml` into a scaffolded chart |
1209
+ | 180 | `x help <name>` | a script asks for a name no command has: `X_CLI_UNKNOWN_COMMAND`, exit 1 — was the catalogue and 0. Use the name the fix names |
1210
+ | 181 | `x i18n add`, `x i18n sync`, `x db seed`, `x db backfill` | a run reports a finding — an app module that will not import, included: exit 1, was 0. Fix the module the finding names |
1211
+ | 182 | `x doctor`, `DoctorProbe` | `PORT` is set and `--port` is not: the probe reads `PORT`, was always 3000. A loopback `APP_URL` on another port is `X_APP_URL_PORT_MISMATCH`, a malformed one `X_CONFIG_INVALID`. A hand-built `DoctorProbe` adds `appUrl: string \| undefined` (TS2741) |
1212
+ | 183 | the `/_x` dev dashboard | a script runs `GET /_x/db?sql=`, posts from another origin, or reaches `x dev` under a Host that is not loopback: `405 X_METHOD_NOT_ALLOWED`, `X_CSRF_BLOCKED`, `421 X_DEV_HOST_REFUSED`. `curl -sS -H 'accept: application/json' --data-urlencode 'sql=select 1' http://localhost:3000/_x/db` |
1213
+ | 184 | `x jobs show\|retry\|cancel\|rm\|promote <id>` | the id is not a uuid: `X_JOB_UNKNOWN`, was `X_DB_STATEMENT_FAILED` on Postgres. Pass the job's id |
1214
+ | 185 | `x shot --out <relative path>` | a script ran it outside the app root: the file lands relative to the cwd, was the app root. Pass the path from where you run it |
1215
+ | 186 | `x dev`'s `X_PORT_IN_USE` | a script parses the fix: it suggests `--port N+2`, was `N+1` |
1216
+ | 187 | `x verify merge` | a part's step has no numeric `durationMs` or malformed findings: `X_VERIFY_MERGE_INPUT`. Sharded `files` that repeat a file or do not hash to `corpusHash`: red. Shard every job by one rule — all with `--timings`, or none |
1217
+ | 188 | MCP `tests.run` | an agent or test read `failed: 0` from a run with an error outside any test, or a non-zero exit: each counts as a failure |
1218
+ | 189 | `x mcp serve --transport http` | the port is taken: `X_PORT_IN_USE`, with a neighbouring port as the fix, was `X_CLI_UNEXPECTED` |
1219
+ | 190 | `x routes` | a script passes a word — `x routes list --json`: `X_CLI_UNKNOWN_COMMAND`, exit 1. `x routes --json`, `--surface <s>` to filter |
1220
+ | 191 | `checkErrorCodesThrown` (`@ultimat3/cli`) | a repository of your own calls it and relied on a reference row saying "not thrown" or "thrown by nothing" to waive a code: the code is `X_ERROR_CODE_UNTHROWN`. Pass the retired codes as the third argument — `checkErrorCodesThrown(root, page, new Set(['X_MY_RETIRED']))`; it defaults to this repository's `UNTHROWN_CODES`. A listed code that is constructed again is `X_ERROR_CODE_UNTHROWN_STALE` |
1221
+ | 192 | the chart's NetworkPolicy | the cluster's CNI enforces policies and something outside the cluster scrapes `/metrics`, or reaches a port other than `http`: refused. List the scraper in `networkPolicy.metricsFrom`, narrow `networkPolicy.httpFrom` to the ingress controller if you want, or set `networkPolicy.enabled: false` |
1222
+ | 193 | `tmp.sizeLimit` | a role writes more than 512Mi to `/tmp`: the pod is evicted. A values file that drops `tmp.sizeLimit` does not render. Raise it per your measurement |
1223
+ | 194 | `existingSecret`, `roles.<role>.existingSecret` | the release-wide `existingSecret` is empty and a role names none of its own: `helm template` fails naming the role. Set one or the other for every role; the migrate Job reads `migrate.existingSecret` |
1224
+ | 195 | the sync listener at port 0 | a test or tool runs `x dev --port 0`, `startRoles({ port: 0 })` or a scratch server and dials the web port + 1: the sync node is on its own kernel-chosen port. Read `syncUrl` from the boot's answer; pages dial `/_x/sync` on their own origin and are unaffected |
1225
+ | 196 | `budget.lcp` on `defineRoute` | a route declares `lcp`: a type error, and `X_ROUTE_MODE_INVALID` at `registerRoute`. Delete it from every `budget: { … }`. A reader of `lcp` in `x routes --json`, of `budget.lcp` in `x.manifest.json` or of `lcpMs` in `.x/build-stats.json` drops it |
1226
+ | 197 | `x test --worker I` | a script passes it: `X_CLI_BAD_FLAG`, exit 1. `x verify --only unit --shard i/n` |
1227
+ | 198 | `toWireSchema`, `toOutputSchema` (`@ultimat3/mcp`), `mcpSchemaOf` (`@ultimat3/action`), `toMcpInputSchema` (`@ultimat3/schema`) | you import one: not exported. `import { toWireSchema, toWireOutputSchema } from '@ultimat3/schema'`; for a full draft-07 document `toJsonSchema(s, { dialect: 'draft-07', includeDialect: false })` |
1228
+ | 199 | a tool schema: `.tool().inputSchema`, `@ultimat3/ai`'s `JsonSchema` | you read `format` off it, or build a `ProjectableAction` with `additionalProperties: <schema>`: no `format` is published, and `additionalProperties` is a boolean — write `true`. The action's parse still enforces the format |
1229
+ | 200 | `defineConfig`'s `name` | the config is JavaScript or untyped and has no string `name`: `X_CONFIG_INVALID` at load. `defineConfig({ name: 'my-app', … })` |
1230
+ | 201 | `storageNotImplemented`, `dbNotImplemented`, `scrapeNotImplemented`, `JobsNotImplementedError`, `CliNotImplementedError`, `NotImplementedError` (`@ultimat3/pwa`, `@ultimat3/realtime`) | you import one, or catch `X_NOT_IMPLEMENTED` as `instanceof StorageError` or another package base: not exported, and no longer an instance. `import { NotImplementedError } from '@ultimat3/core'`, or match `err.code === 'X_NOT_IMPLEMENTED'` |
1231
+ | 202 | `readCookie` (`@ultimat3/auth`), `escapeAttribute` (`@ultimat3/seo`) | you import one: not exported. `import { escapeHtml, readCookie } from '@ultimat3/core'` |
1232
+
1233
+ Entry 58, **the one step an operator must not skip.** A deployment with MFA-enrolled users:
1234
+
1235
+ | Order | Do | Until it is done |
1236
+ |---|---|---|
1237
+ | 1 | make the master key exist: `x secrets init`, or set `ULTIMATE_SECRETS_KEY` in the deploy | `X_SEAL_KEY_MISSING` at `login()` for an enrolled user |
1238
+ | 2 | deploy this release | — |
1239
+ | 3 | `x auth seal-mfa --json`, once | `X_MFA_SECRET_UNSEALED` (500) at the second factor for every enrolled user; `x doctor` reports the count |
1240
+
1241
+ Step 3 answers `{ sealed, alreadySealed, skipped }`. It is idempotent, and `skipped` counts rows
1242
+ that changed while it ran — run it again and they are `alreadySealed`. A custom adapter calls
1243
+ `sealMfaSecrets({ adapter })`. A deployment with no MFA users needs none of this.
1244
+
1245
+ Entry 39, the orphaned variants. These commands were **not run against a real disk or bucket** for
1246
+ this page — list first, read the list, then delete. The pattern matches a variant of either shape,
1247
+ so new-shape variants already written are deleted too and regenerate on the next request. A key
1248
+ of your own that ends in such a suffix (`photos/team@w640.png`) matches as well.
1249
+
1250
+ ```sh
1251
+ # local disk — list, then repeat with -delete in place of -print
1252
+ find <root> -type f -regextype posix-extended -regex '.*@(full|((w|h|q)[0-9]+|cover|contain)(-((w|h|q)[0-9]+|cover|contain))*)\.(avif|webp|jpg|png)' -print
1253
+ # their sidecars live under <root>/.meta/ as <key>.json — the same pattern with \.json appended
1254
+ find <root>/.meta -type f -regextype posix-extended -regex '.*@(full|((w|h|q)[0-9]+|cover|contain)(-((w|h|q)[0-9]+|cover|contain))*)\.(avif|webp|jpg|png)\.json' -print
1255
+ # s3 — list, then `aws s3 rm s3://<bucket>/<key>` for each key
1256
+ aws s3 ls s3://<bucket> --recursive | grep -E '@(full|[whq][0-9]+|cover|contain)[^/]*\.(avif|webp|jpg|png)$'
1257
+ ```
1258
+
1259
+ ### Not breaking, but you will see it
1260
+
1261
+ | Surface | What changed |
1262
+ |---|---|
1263
+ | a union in HTTP coercion | every member is tried: `t.union(t.literal('auto'), t.number)` reads `'12'` as `12`. A string a member accepts as a string is never converted |
1264
+ | `SchemaError#toJSON()` | carries `retry: 'terminal'`; a `bigint` or a cycle in `meta` no longer throws |
1265
+ | image errors | a header declaring a zero, negative, fractional or `NaN` size is `X_IMAGE_DECODE_FAILED`, was `X_IMAGE_TOO_LARGE`. A HEIC (`mif1` only) is no longer sniffed as AVIF |
1266
+ | OTLP export | an endpoint with a query string keeps it after the signal path; a `NaN` or infinite attribute is dropped |
1267
+ | `X_VERIFY_STEP_TIMEOUT` | names the test file still running and its `fix:` runs it; `--json` findings gain `meta` |
1268
+ | `fix:` lines | `X_REGISTRAR_MISSING` / `X_REGISTRAR_CONFLICT` name the owning package; `X_SECRETS_KEY_INVALID` names the key file when the file is what is wrong |
1269
+ | `x db gen` and a changed `primaryKey` | the migration is written — drop `<table>_pkey`, add the new key, `drop not null` for a declared-nullable column leaving it. `X_MIGRATION_IRREVERSIBLE` for a key another table's foreign key references, and for a new key over a column the same migration adds with no default, or a `null` one: add and backfill the column in one migration, change the key in the next |
1270
+ | `x tasks` | `next`, `last` and `upcoming` are unchanged in form; they come from `isoInZone` in `@ultimat3/time` |
1271
+ | db error codes | a syscall error (`EPIPE`, `E2BIG`) is `X_DB_UNAVAILABLE`, was `X_DB_STATEMENT_FAILED`; a ragged array or Invalid Date parameter is `X_INVARIANT` on both drivers, was `X_DB_UNAVAILABLE` |
1272
+ | `addBusinessDays`, `businessDaysBetween` | the wall time survives a DST day; a date the zone skipped is not counted |
1273
+ | `Accept-Language` | a `q` that is not a plain decimal ranks 0, not 1 |
1274
+ | `formatMoney({ trimZeroFraction: true })` | 1250 USD is `$12.50`, was `$12.5`; a whole amount still drops `.00` |
1275
+ | `robots.txt` | `seo.robots.disallow` is in every group, and a `User-agent: *` group is emitted when none is declared |
1276
+ | ISR documents | carry `Surrogate-Key` and `Cache-Tag` from `revalidate.tags`; a response rewritten to `private` or `no-store` drops both |
1277
+ | feeds | Atom gains the channel `<author>`, `<rights>`, `<icon>`; RSS an item author and `<media:content>` for an item `image` |
1278
+ | storage error codes | a refused write is `X_STORAGE_PUT_FAILED`, a refused read `X_STORAGE_READ_FAILED`, a local-disk key under another key `X_STORAGE_KEY_CONFLICT` — each was a bare error. An empty org is `X_STORAGE_ORG_MISMATCH` (404), was `X_STORAGE_PATH_UNSAFE` (400) |
1279
+ | `GET /_storage/:disk/*key` | no `Last-Modified` when the disk reports no date |
1280
+ | the Redis cache tier | one extra round trip before each `load()` and one after the `SET`; a fill whose load outlives 60 s is not written to Redis |
1281
+ | `transition(column, id, move)` | an `undefined` or `null` id is `X_NOT_FOUND` and moves no row; on Postgres it moved every row in the `from` state |
1282
+ | entity tests on `memoryDriver()` | a unique declared as `invariant(name, c.unique([...]))` is enforced (`X_DB_UNIQUE_VIOLATION`); `bigint()` refuses a value outside int8; `trimmed()` strips spaces only; `url()` refuses what the column's CHECK refused — each as Postgres already did |
1283
+ | `update` / `updateWhere` under an app-only invariant | on Postgres a refused write is rolled back; it used to stay written |
1284
+ | seed `dryRun` | runs every verb in a transaction and rolls back, so its metrics are a real run's and a seed that would fail fails |
1285
+ | drift findings | each `fix:` is one runnable command, mostly `psql "$DATABASE_URL" -c '…'` then `x db migrate`; for a table outside `public` it sets `search_path` first, and unexpected tables and objects are schema-qualified |
1286
+ | `X_CSRF_BLOCKED` | title is "an unsafe request that did not prove same-origin"; code and status unchanged |
1287
+ | 5xx problem documents | a withheld cause carries `callerFix` or `x errors explain <CODE> --json`, never the developer `fix:` |
1288
+ | `requestTimeoutMs`, `x-request-timeout-ms` | above 2,147,483,647 the config is `X_CONFIG_INVALID` and the header is ignored; both used to time every request out at once |
1289
+ | redirects | a non-http(s) target is never handed to the client router; the locale-prefix redirect stays on this origin |
1290
+ | `Set-Cookie` | two set in one request both reach the wire |
1291
+ | `X_CDP_LAUNCH_FAILED` | Chrome gets a 60 s launch deadline and one retry; the error lists each attempt in `cause` and `meta.attempts` |
1292
+ | `disableUser` | also revokes the user's live API keys; the result gains `apiKeysRevoked` |
1293
+ | `updatePrivileges` with a `passwordHash` | ends the user's other sessions; the result gains `sessionsRevoked` |
1294
+ | OAuth route failures | the body carries one fixed `cause` and `x errors explain <CODE> --json`; the detail is the `auth.oauth.refused` log line |
1295
+ | OAuth discovery and JWKS | a document answering for another issuer is refused; a key set with no importable key keeps the cached keys |
1296
+ | `x doctor` | two auth probes: unsealed second-factor secrets, and an orphaned framework table |
1297
+ | `X_CDP_TIMEOUT` | `meta.reading` says `target-gone`, `lost-in-transport` or `no-answer`, with the frames seen since the call |
1298
+ | `search()` refusals | `X_INPUT_INVALID` (400), were `X_INVARIANT` (500) |
1299
+ | entity tests on `memoryDriver()`, again | decimal kinds compare by value: `'2.5'` matches a stored `'2.50'`, `10` matches a `bigint()` column — as Postgres does |
1300
+ | the typed read client | a `Date` input goes as its ISO instant; an omitted required array reads `[]` |
1301
+ | `transition()` | declared idempotent; its `id` input follows the entity's key instead of always `t.uuid`, so the published schema of a transition on a non-uuid key changes |
1302
+ | `.contract()` | checks the registry-wide OpenAPI document; an unregistered `.named()` twin of a registered route is `X_CONTRACT_DRIFT` |
1303
+ | `ERROR_STATUS` | unchanged for importers; the table now lives in per-tier files |
1304
+ | `requeue` of an unknown id | `X_JOB_NOT_FOUND` (404) on both drivers; was `X_INVARIANT` on memory, `X_DRIVER_UNAVAILABLE` on Postgres |
1305
+ | `BulkResult.remaining`, `stats()` | `remaining` counts only what a second call would move; queues are ordered by code unit on both drivers |
1306
+ | worker logs | new lines `jobs.claim.exhausted`, `jobs.worker.slot-renewal-failed`, `jobs.step.failure-unrecorded`; the claim loop no longer spins during a database outage |
1307
+ | `x_job_events` | swept by the hourly `x.purge`; no DDL changed in this slice |
1308
+ | the replicator | a stream that ends is restarted on `retryDelayMs`; a web+replicator process answers `/readyz` 503 while its stream is down or a change is still being refused by the bus; `ReplicatorStats` gains `restarts` and `failure` |
1309
+ | the page socket | beats every 10 s by default, was 15 s; the node names the real beat in `hello.heartbeatMs` |
1310
+ | realtime `fix:` lines | nine Postgres-client fixes are `x doctor --json`; a bad `NATS_URL` is named without echoing it |
1311
+ | records channels | a bus reconnect or a sequence gap sends `replay-gap` on every open records channel, not only to live queries |
1312
+ | the outbox | a store that cannot be opened or wiped warns `X_LOCAL_STORE_UNAVAILABLE` and queues in memory instead of rejecting for the rest of the tab |
1313
+ | a zone that is not a string | `X_TIMEZONE_INVALID` from every zoned function in `@ultimat3/time`, was a bare `TypeError` |
1314
+ | a static route outside ASCII | matches its percent-encoded request (`/precios-espa%C3%B1a`), was a 404; a server declaring one starts, where `Bun.serve` threw |
1315
+ | ISR regeneration | a render that never settles frees its path after `regenerateDeadlineMs` (default 30,000); a render slower than that overlaps the next one, and its late page is dropped when a newer one started |
1316
+ | ISR route lookup | a stored path is filed under the most specific matching route, as the request router picks it, not the first in table order |
1317
+ | the client router | a reload or full-load Back lands at the saved offset; the page can enter the back/forward cache; `<a href="#">` is the browser's; form line breaks go as CRLF; an aborted navigation's stylesheets are retired. Router script +774 B raw |
1318
+ | render refusals | a non-text `prerender()` item or param is `X_PRERENDER_FAILED`, `asset()` of a non-string `X_ASSET_MISSING`, a non-string island resolver answer `X_ISLAND_INVALID` — each was a bare `TypeError` |
1319
+ | `display_override` | follows `display`: `fullscreen` → `fullscreen, standalone, minimal-ui`; `minimal-ui` → `minimal-ui`; `browser` → none; `standalone` unchanged |
1320
+ | the service worker | a non-JSON push body is the notification text; the install fill skips another build's stamped answer; route rules match encoded literals and a catch-all's bare prefix, in the request router's order — `sw.js` changes once |
1321
+ | `personalPages: 'last-member'` | a personal page declaring `offline: 'precache'` is kept in the member's partition; it was never kept |
1322
+ | the boot's theme script | stamps `data-theme-default` too: 52–54 B longer, a new `script-src` hash the boot admits itself. `BUILD_STATS_RULES` is 4, so an old `.x/build-stats.json` is stale until `x build --target static` |
1323
+ | `<QrCode>` | every code changes its modules — it now decodes; each was unreadable. A snapshot of its markup updates |
1324
+ | `DataTable` | leaves its error or empty state when the query recovers; those states now render inside the table's wrapper, which carries `class` |
1325
+ | `defineTheme()` | a role overridden in light only is answered with the shipped dark channels in the dark media block; that brand's CSS and `brandStyleCspSource()` hash change |
1326
+ | `createFormBinding` | a local `validate` that throws or rejects is a failed submit, not a `submit()` that rejects and stays `submitting` |
1327
+ | `CommandPalette`, `Tooltip`, `Popover` | centred under `dir="rtl"`; `Popover`'s inline placements sit beside the anchor, and `align` applies to `block-*` placements only; the vertical `Tabs` indicator mirrors |
1328
+ | `useId` in an island | `<prefix>-<scope>-<n>` with a per-bundle random scope; server ids unchanged |
1329
+ | `Textarea` in an island | sets `.value`; the leading newline is written on the server only |
1330
+ | `RelativeTime`, `BarChart`, `linkTarget`, `Meter` | "1 hour ago", was "60 minutes ago"; bars still drawn from 201 points; `//host`, `HTTPS://…` and a leading space are external; `aria-valuenow` clamped to the drawn bar |
1331
+ | the icon generator | a non-JSON body or a malformed node is `X_UI_INVALID_VALUE`; `bun run --filter @ultimat3/ui icons --bump` moves the pin and regenerates |
1332
+ | a `.pattern()` with flags | its JSON Schema carries `x-ultimate-pattern-flags` beside `pattern`, in OpenAPI and MCP `inputSchema`; the input's fact in `x.manifest.json` moves with it, so step 45 covers it. MCP argument validation applies the flags — `/^[a-z]+$/i` accepts `ABC` |
1333
+ | `db.query` | a column named like a keyword plus digits (`set2`) is read, was refused as `SET` |
1334
+ | outgoing mail headers | `Reply-To` encodes a non-ASCII display name; an ASCII one holding a special is quoted (`"Doe, Jane" <jane@x.test>`); `List-Unsubscribe` is punycode and percent-encoded. `X_MAIL_HEADER_INVALID` gains `meta.reason` |
1335
+ | `SMTP_URL` | a `%` in the user or password that starts no escape is `X_CONFIG_INVALID`, was a bare `URIError` |
1336
+ | `registeredMails()` | ordered by code unit, as `registeredMailIds()` is |
1337
+ | notify fan-out | a recipient id named twice is delivered once; it failed `X_STEP_DUPLICATE` part-way through the audience |
1338
+ | `x.purge` | sweeps `x_notify_digests` windows closed longer ago than `PgDigestStore.retentionMs` (7 days); `PurgeReport.swept` has seven entries — read them by name |
1339
+ | `BudgetStore` traffic | an `actor` or `org` counter is written only where that ceiling is declared — an app with no `budget` writes nothing. Under a declared ceiling the default `MemoryBudgetStore` holds one counter per caller who spent, per process (no window; a counter that returns to zero is deleted; `size()` reports the count). A daily or monthly ceiling needs a shared store whose keys expire |
1340
+ | `llm()` / `agent()` with a non-object `output` | `respond`'s schema is `{ value: <output> }`; the answer comes back unwrapped, and a prose answer that is not JSON is taken as the text |
1341
+ | the gateway response cache | every key is a 16-hex `fingerprint` over the whole request, tools included, so every entry misses once; a failed `cache.set` is the log line `ai.cache.write_failed`, the answer still returned |
1342
+ | the semantic cache | an entry is written under `<prompt hash>:<fingerprint>`; lookup is by embedding, so stored entries still answer — unless `HashEmbedder` wrote them (entry 126) |
1343
+ | `estimateTokens`, `reserve` | the completion is capped at the model's `maxOutput`, so a `maxTokens` above it reserves less |
1344
+ | provider failure details | read to 4 KiB, 300 characters shown, on every transport; `RemoteEmbedder` scrubs its key from them |
1345
+ | eval regressions, `numericTolerance(0)` | both sides and the drop rounded to 3 decimals, so a drop of exactly `tolerance` passes; an exact match at `tolerance: 0` scores 1, was 0 |
1346
+ | `chunk()` | no chunk exceeds `size`: the carried overlap yields to a unit it does not fit beside, so a re-index may cut differently |
1347
+ | the install | `@ultimat3/ai` depends on `@ultimat3/http` |
1348
+ | island mount cleanup | the `disposeLiveIslands` file-boundary hook ships with the island fixture, not `@ultimat3/testing/preload`: any preload that installs the leak guard runs it. Each mount's scratch directory goes on dispose, on a throw, or at the next file boundary — none left in the temp root |
1349
+ | island template parsing | `<!>` and `<!-- … -->` are one empty node; `&amp;`, `&lt;`, `&gt;`, `&quot;`, `&apos;` and numeric references are decoded once in text and attributes — a reference to 0, a surrogate or past U+10FFFF is U+FFFD |
1350
+ | a failed scrape session save | after a successful login it is the log line `scrape.session.write_failed` (`step: 'session.save'`) and the attempt goes on; it failed the run |
1351
+ | admin list paging | rows sharing a sort value are neither skipped nor repeated at a page boundary, and a backward page answers rows; relation labels resolve every id on the page |
1352
+ | an admin datetime edit | a field left untouched is left out of the patch, so its stored seconds and sub-seconds survive |
1353
+ | admin search | a repo that throws lists its resource under `skipped` (`admin.search.skipped.failed`), not a failed search |
1354
+ | the timeline and cache dev panels | only `DevSourceUnavailableError` is drawn as "unavailable"; any other error from a source is thrown |
1355
+ | `BATCH_QUEUED_REASON` | declared in `batch-queue.ts`, still exported from `@ultimat3/admin` |
1356
+
1357
+ ### Where the sites are
1358
+
1359
+ ```sh
1360
+ grep -rnE "\.default\(" apps packages --include=*.ts --include=*.tsx
1361
+ grep -rnE "retry\(|retryDecision\(|createFlightGate\(|withChildContext\(|allowHosts" apps packages --include=*.ts --include=*.tsx
1362
+ grep -rnE "LOG_LEVEL|ULTIMATE_CURSOR_SECRET|OTEL_(EXPORTER_OTLP_(TRACES|METRICS)_HEADERS|TRACES_SAMPLER)" docker .github apps packages
1363
+ grep -rnE "(ssl|enabled|expose): *process\.env" apps packages --include=*.ts
1364
+ grep -rnE "withTransaction\(" -A12 apps packages --include=*.ts | grep -E "catch|Promise\.all|isolation|readOnly|deferrable|client:"
1365
+ grep -rnE "formatRelative\(|addDaysInZone\(|formatDuration(Iso)?\(|cron: |expiresAt|reportEveryMs|introspect\(|\.generated\b|DriftKind" apps packages --include=*.ts --include=*.tsx
1366
+ grep -rnE "promoteAttachment\(|\.lastModified|implements StorageDriver|: StorageDriver = |canonicalRequest\(|signConstraints\(|variantKey\(|fitDimensions\(|\.get\(|allowedContentTypes|responsiveImage\(|usableWidths\(|declareTags\(|revalidate:" apps packages --include=*.ts --include=*.tsx
1367
+ grep -rnE "assertAllowed\(|X_FORBIDDEN|definePolicy\(|status: 403|\.preload\(|dbDrift|ENTITY_ERROR_CODES|: Driver = |implements Driver" apps packages --include=*.ts --include=*.tsx
1368
+ grep -rnE "ctx\.peer|trustProxy|cache-control|defineHttpConfig\(|healthz|readyz|\.close\(\)|\.closed\b|CdpLaunchFailedError" apps packages docker .github --include=*.ts --include=*.tsx --include=*.yml --include=*.yaml
1369
+ grep -rnE "mfaSecret|redeemRecoveryCode|mfaRequired|authNotImplemented|verifyApiKey\(|apiKeyActor\(|actorFromApiKey\(|issueApiKey\(|apiKeyResolver\(|assertAllowed\(|recordFailure\(|implements AuthAdapter|: AuthAdapter = |: AuthLimiter = |X_DB_UNIQUE_VIOLATION|oauthLogin\(|meta\.userId" apps packages --include=*.ts --include=*.tsx
1370
+ grep -rnE "compareValues|compareRows\(|matchesFilter\(|isAfterKey\(|adminPermissions|single: true|\.limit\(|_first=" apps packages --include=*.ts --include=*.tsx
1371
+ grep -rnE "mutator\(|postgresIdempotencyStore\(|configureIdempotency\(|bustAfterCommit\(|invalidates:|X_MANIFEST_DRIFT" apps packages scripts .github --include=*.ts --include=*.tsx --include=*.yml
1372
+ grep -rnE "attempts: 1\b|cancelJob\(|\.cancel\(|runId:|purgeExpired|EventLookup|SQL_CLAIM|SQL_SCHEDULER_FIRE|RetentionStores|\.swept|implements JobDriver|: JobDriver = " apps packages scripts --include=*.ts --include=*.tsx
1373
+ grep -rnE "APP_URL|SYNC_URL|sslmode|allowedOrigins" docker .env.production .github 2>/dev/null
1374
+ grep -rnE "NativeReason|\.specificity|compilePattern\(|ttl: *'0|/\*[A-Za-z]+|notificationclick|showNotification" apps packages --include=*.ts --include=*.tsx
1375
+ find apps -type d -name '[[]...*[]]'
1376
+ grep -rnF '\.' apps --include='*.module.css' --include='*.module.scss'
1377
+ grep -rnE "channel\(|parseEnvelope\(|parseChange\(|implements Transport|: Transport = |AdvisoryLock|UpgradeTarget|hasOwnProperty\(|OfflineQueue" apps packages --include=*.ts --include=*.tsx
1378
+ grep -rnE "ThemeEnv|prefersDark|clearTheme\(|watchOsTheme\(|THEME_INLINE_SCRIPT|themeInlineScript|aria-controls|<Dropzone|<FileInput|defaultMode" apps packages --include=*.ts --include=*.tsx
1379
+ grep -rnE "RateLimitStore|RateLimiter|defaultBucket|rateLimits|errorAudience|mcpHttpRoute\(|\.handle\(|destructive|listParams|createJobsFacade\(" apps packages --include=*.ts --include=*.tsx
1380
+ grep -rnE "send\(|replyTo|cc:|bcc:|unsubscribeUrl|SMTP_URL" apps packages docker --include=*.ts --include=*.tsx --include=*.yml --include=*.env*
1381
+ grep -rnE "BudgetStore|callLedger|createGateway\(|\.scope\(|fnv1a|HashEmbedder|finish_reason|VectorStore|\.scoped\(|UNSCOPED|chunk\(|indexDocument\(|numericTolerance\(|agent\(" apps packages --include=*.ts --include=*.tsx
1382
+ grep -rnE "describeApp\(|ReferenceError|assertDeterministic\(|frozenClock\(|onClockMoved|mountIsland\(" apps packages --include=*.ts --include=*.tsx
1383
+ grep -rnE "localBrowser\(|args:|CdpBrowserLike|burnSession\(|maxDrop|urlSecretValues|redactSecrets|allowHosts|createRobotsGate\(|robotsFetcher\(|recordedAt" apps packages --include=*.ts --include=*.tsx
1384
+ grep -rnE "existingSecret|sizeLimit|networkPolicy|metricsFrom|port: 0|--port 0|syncUrl" docker deploy helm charts apps packages --include=*.yaml --include=*.yml --include=*.ts --include=*.tsx
1385
+ grep -rnE "ROLE=|migrate|TRUSTED_PROXY_HOPS|METRICS_PORT|PORT=|readinessGraceMs|readinessProbe|content-security-policy|/_storage|/media|/_x/db|x routes list|x help |DoctorProbe|tests\.run|x verify merge|x shot" docker .github apps packages scripts --include=*.ts --include=*.tsx --include=*.yml --include=*.yaml --include=*.tpl --include=*.sh
1386
+ grep -rnE "sitemap-[0-9]|restorePermissions\(|ProcessRegistrySnapshot|--out|--tag|--no-preflight|--dry-run|--affected|solid-js|\"typecheck\"" apps packages scripts .github --include=*.ts --include=*.tsx --include=*.json --include=*.yml
1387
+ grep -rnE "keyset\.value|AdminCursor|<AdminForm|CrudResult|adminUpdate\(|_version|listHref\(|scope=|scopes:|definePolicy\(" apps packages --include=*.ts --include=*.tsx
1388
+ ```
1389
+
1390
+ Tier 0: the `typecheck` step finds none of 1–16 — every entry is a value, not a type. A typed
1391
+ `app.config.ts` already refused most of entry 6 at compile time; the ones it did not are
1392
+ `'/'`-less paths, empty lists and a locale spelled twice. Entries 4 and 6 throw at the first import;
1393
+ 7 and 16 at boot; 8 and 9 where the call is made. Entries 1–3, 5, 10 and 11 need the unit,
1394
+ contract and e2e suites; 12–15 need a read of the deploy environment and the monitor.
1395
+
1396
+ Tier 1: the `typecheck` step finds 20, and 18, 26 and 28 where a literal, a `switch` or a string
1397
+ read exists. Entries 19 and 30 throw at the first import of the declaring file; 24 and 31 at the
1398
+ call. It finds none of 17, 21–23, 25, 27 or 29 — run the unit, contract, job and live suites, and
1399
+ read every `catch` and every `Promise.all` inside a `withTransaction` body.
1400
+
1401
+ Slice 03: the `typecheck` step finds 35, 36, 37 and the changed signatures of 41. Entry 43 throws
1402
+ at registration and 33 where the response is built. It finds none of 32, 34, 38, 39, 40 or 42 —
1403
+ run the unit, contract and e2e suites, and do step 13 by hand after the deploy.
1404
+
1405
+ Slice 04, entity and policy: the `typecheck` step finds 44, 45 and a `403`-typed status in 48. It
1406
+ finds none of 46, 47 or 49, nor a test asserting on 48's status or title — run the unit, contract
1407
+ and policy suites.
1408
+
1409
+ Slice 04, http and testing: the `typecheck` step finds a read of `closed` and a
1410
+ `CdpLaunchFailedError` built with `detail` (57). It finds none of 50–56 and no unawaited
1411
+ `close()` — run the unit, contract and e2e suites, and read the deploy's proxy, probe and monitor
1412
+ configuration for 50, 52 and 53.
1413
+
1414
+ Slice 05, auth: the `typecheck` step finds 60, 64, 65 and the changed shapes of 61. `x doctor`
1415
+ finds 58 (the count of unsealed rows) and 59. Nothing finds 62, 63, 66 or a missing `APP_URL` (67)
1416
+ before a request does — run the unit, contract and e2e suites and sign in once with an MFA user
1417
+ after step 20. The e2e suite finds 68.
1418
+
1419
+ Slice 06, query, mcp and admin: the `typecheck` step finds 69 and the removed `adminPermissions`
1420
+ of 72. It finds neither 70, 71 nor a permission set that leaned on the import — run the unit,
1421
+ contract and e2e suites.
1422
+
1423
+ Slice 06, action and cli: the `typecheck` step finds 73 and 77. The gate finds 74 and 79 (the
1424
+ `manifest` step) and 75 (the `drift` step). It finds neither 76 nor 78 — run the unit, contract
1425
+ and job suites.
1426
+
1427
+ Slice 07, jobs: the `typecheck` step finds the interface members of 81, 85 and 87. It finds none
1428
+ of 80, 82, 83, 84 or 86, nor an unbound statement parameter in 81 — run the unit and job suites,
1429
+ and do step 30 by reading.
1430
+
1431
+ Slice 08, realtime: the `typecheck` step finds 92. Nothing finds 88–90 before the deploy does —
1432
+ they are steps 32–34, done by reading the deploy's environment. Entries 91 and 93–96 need the unit,
1433
+ live and e2e suites; 91 also needs both roles deployed together.
1434
+
1435
+ Slice 09, http, render and pwa: the `typecheck` step finds 99. Entry 98 throws at registration. It
1436
+ finds none of 97, 100, 101 or 102 — run the unit, contract and e2e suites after a fresh `x build`,
1437
+ and read each push sender's `url` for 102.
1438
+
1439
+ Slice 09, ui: the `typecheck` step finds 103, 105 and an annotated or `string`-typed trigger in 106.
1440
+ It finds neither 104 nor 107 — run the unit and e2e suites after `x build --target static`, and
1441
+ read `theme.defaultMode` in `app.config.ts` for 104.
1442
+
1443
+ Slice 10, first half — http, mcp, mail, notify and manifest: the `typecheck` step finds 108. The
1444
+ gate finds 116's job schema and 118 (the `manifest` and `contract-diff` steps), 117 (the `drift`
1445
+ step), 119 at the first manifest build and 120 at `contract-diff` the day a default is dropped.
1446
+ It finds none of 109–115 or 116's recipient rule — run the unit, contract, job and e2e suites,
1447
+ and read the deploy's probes and agents for 109 and 114.
1448
+
1449
+ Slice 10, ai: the `typecheck` step finds 121, 122, 125 and 129. Entry 131 throws where
1450
+ `numericTolerance` is called — an eval file's import — and 128 at the first unscoped read in an
1451
+ org's request. It finds none of 123, 124, 126, 127 or 130 — run the unit, eval and e2e suites, read
1452
+ the gateway's `budget` before the deploy for 123 (step 49), each agent tool's thrown codes for
1453
+ 124, and re-index what `HashEmbedder` wrote for 126.
1454
+
1455
+ Slice 11, testing and scraping: the `typecheck` step finds 137 and a one-argument `burnSession`
1456
+ (139). Entry 140 throws at `scrape()` — the declaring file's import — and 136 when the browser
1457
+ launches. It finds none of 132–135, 138 or 141–145 — run the unit, job and e2e suites, and read
1458
+ each `allowHosts` wildcard and each site's `robots.txt` redirect for 143 and 144.
1459
+
1460
+ Slice 11, admin: the `typecheck` step finds 146, 148 and 149. Entry 153's `*` scope throws where
1461
+ the resource is declared. It finds none of 147 or 150–152, nor 154's links — run the unit, contract
1462
+ and e2e suites, read each policy rule over `input` for 150, and each script that posts an edit form
1463
+ for 147.
1464
+
1465
+ Slice 12a, seo, policy, cli and testing: the `typecheck` step finds 156 and 171. The gate finds
1466
+ 169 (the `policy` step), 170 (the `drift` step) and 165 (`filesize`, `errors`); 164 is a test that
1467
+ now runs. `x build` finds 157–159 and `x g` 161–162 at the call. Nothing finds 155, 160, 163 or
1468
+ 166 — read the sitemap's host, each script that reads `x g --dry-run` or `--affected`, and each
1469
+ check that expects a fresh SEO answer. 167 and 168 change only a fresh scaffold.
1470
+
1471
+ Slice 12b, http and cli: the `typecheck` step finds 182's `DoctorProbe`. Entries 174–176 stop the
1472
+ boot — 174 on every serving pod until `ROLE=migrate` ran, which is step 65, done before the deploy.
1473
+ Each command in 180, 181, 183–187, 189 and 190 refuses at the call. Nothing finds 172, 173, 177,
1474
+ 178, 179 or 188 — run the unit, contract and e2e suites, and read each handler's CSP, each link to
1475
+ an upload, the chart's replicator and each monitor that matches a fix string.
1476
+
1477
+ Slice 13a, cli: nothing finds 191 but the call — a repository of your own that runs
1478
+ `checkErrorCodesThrown` reads its findings once after the upgrade.
1479
+
1480
+ Slice 13b, the chart: `helm template` finds 193's missing `tmp.sizeLimit` and 194's role with no
1481
+ Secret — before the deploy, if it runs in CI. Nothing finds 192 but a scraper that stops reading,
1482
+ nor 195 but a dial that is refused — read each values file and each port-0 test.
1483
+
1484
+ ## 22.x → 23.0.0, entry by entry
1485
+
1486
+ **Sixty-six entries**, the `23.0.0` section of `CHANGELOG.md`, in its order — which is the order
1487
+ an existing app meets them.
1488
+
1489
+ ### What changed for an agent
1490
+
1491
+ Six things an agent-written app does differently from 23.0.0 on. Each is the idiom the
1492
+ generators now emit, so copying generated code copies it.
1493
+
1494
+ | Do | Instead of | Read |
1495
+ |---|---|---|
1496
+ | read and write through the typed handle: `db.<table>.where({ id }).one()`, `repo.list(limit)` | `sql` template literals and an `orgId` argument in `repo.ts` — the handle scopes every read to the actor's org | [Entities and migrations → The repo](Entities-And-Migrations#the-repo) |
1497
+ | call the server from an island through `browserClient` / `browserQueries` (`apps/web/shared/browser-client.ts`) | `fetch(` — the gate refuses it | [Client data](Client-Data) |
1498
+ | store a credential in a `text().sealed()` column | a plaintext column, or a vault of your own | [Entities and migrations](Entities-And-Migrations) |
1499
+ | record a run's ending in `onSettled` | a status column written from the body, or a second job that polls | [Jobs and workflows](Jobs-And-Workflows) |
1500
+ | keep the tests `x g` emits, and raise the floor in `x.verify.json` when coverage rises | deleting an emitted test, or a floor nobody states | [Testing → Coverage](Testing#coverage) |
1501
+ | operate the queue at `/admin/jobs`, and give an operator a screen by declaring `resources:` / `actions:` (`when`, `batch`) on `defineAdmin()` | a hand-written jobs page, or an admin page per operation | [Admin dashboard](Admin-Dashboard) |
1502
+
1503
+ ### The upgrade, top to bottom
1504
+
1505
+ Run each step; the entries it closes are in the last column.
1506
+
1507
+ | # | Do | What you see until you do | Entries |
1508
+ |---|---|---|---|
1509
+ | 1 | pin every `@ultimat3/*` to the one new version, `bun install` | nothing yet — a mixed install is untested | — |
1510
+ | 2 | add `RUN bun node_modules/@ultimat3/cli/src/bin.ts build --target prebuilt` to `docker/Dockerfile`, after the runtime stage's `COPY . .`, above `ENV NODE_ENV=production` | every web pod logs `X_IMAGE_NOT_PREBUILT` at boot and compiles every island and stylesheet | 1, 2 |
1511
+ | 3 | `x db gen`, then commit `packages/db/schema/` and `packages/db/.gitattributes` | the `drift` step is `X_SCHEMA_DUMP_DRIFT` | 3–6 |
1512
+ | 4 | `x verify --only unit --json`, then paste the `coverage` line its finding carries into `x.verify.json` | the `unit` step is `X_COVERAGE_FLOOR_UNSTATED` | 7–10 |
1513
+ | 5 | rewrite each raw request the `boundaries` step names (table under entry 11) | `X_BROWSER_TRANSPORT_BYPASS` / `X_BROWSER_SERVER_BARREL`, one finding per site | 11, 12 |
1514
+ | 6 | `git mv` a `defineAdmin()` under `apps/*/src/` to `apps/admin/app/admin/admin.ts`; then `x manifest` and commit `x.manifest.json` | the `manifest` step is `X_ADMIN_UNSCANNED`, then `X_MANIFEST_DRIFT` | 13, 14 |
1515
+ | 7 | `x doctor --json`, then `x g guard <name>` for each name in `data.guards.missing` you adopt, and fix what `x verify --only boundaries` reports | nothing — upgrading installs no guard | — |
1516
+ | 8 | drop `--feature <other>` from `x g resource` and `--live` from every `x g` but `query` in your scripts; read `x tasks --json` rows by key; `git mv apps/admin/src/pages apps/admin/app/admin/pages` | `X_CLI_BAD_FLAG` | 17–22 |
1517
+ | 9 | `bun run typecheck` and fix each job, driver, export, scraping, admin and type-union site it names | TS2741 / TS2345 / TS2339 / TS2353 at each hand-built literal, driver, store or option | 23–43, 55–57, 63, 64 |
1518
+ | 10 | delete the admin host: every `page.tsx` under an admin URL — `/admin/jobs` included — the `AdminRepo` adapter, the admin action routes, a hand-written jobs nav item; declare `defineAdmin({ entities, db })`. A `pages:` page that keeps your own shell or an island keeps its file: on 27.1.0 or later, [claim it](#mounted-admin-pages-that-keep-your-shell-271) instead | `X_ADMIN_REPO_UNBOUND` at load, `X_ROUTE_DUPLICATE` for each page file, `X_ADMIN_PAGE_PATH_INVALID` for a `pages:` entry under `/jobs` | 49–55 |
1519
+ | 11 | `x verify --only policy,i18n`, then grant every permission it names in the role map and add every admin key it names to each non-`en` catalog | `X_PERMISSION_UNGRANTED`, one per permission a mounted admin route asks for; `X_CATALOG_MISSING_KEYS` per locale | 15, 16 |
1520
+ | 12 | `x verify --only unit,job` and fix the tests it fails | a test that leaned on `runJobs` calling the body directly or on a lease that never renewed, an event published in `beforeAll`, a row action's old redirect, an admin write or action the row scope now refuses, a social tag on a `noindex` page | 44–46, 48, 58–61 |
1521
+ | 13 | `x secrets init` where no master key exists; correct any credential a site had refused before the first scrape | `X_SEAL_KEY_MISSING` before the browser opens; every stored session is logged in again | 62, 65, 66 |
1522
+ | 14 | `x verify` | green, or a finding whose `fix:` is the edit — `X_PACKAGE_SHAPE` for a published package's fixture | 47 |
1523
+
1524
+ ### Entry by entry
1525
+
1526
+ | # | Surface | Costs you an edit if |
1527
+ |---|---|---|
1528
+ | 1 | the app image | your `docker/Dockerfile` predates 23.0.0. `x build --target docker` no longer writes `.x/islands/`; the store is built inside the image. Add the step-2 line. `app.config.ts` must import with no deployment environment while `NODE_ENV` is unset — the line runs above `ENV NODE_ENV=production` |
1529
+ | 2 | `ROLE=worker`, `ROLE=scheduler` | a job reaches a `defineService`, `defineStorage` or `defineCatalogs` only through a module that also imports a component. Import that module from `apps/web/api/index.ts`. The image needs `x.manifest.json` and a stamped `BUILD_ID` for the smaller load; without either the role imports everything and logs the fix. A gap is `X_ROLE_LOAD_INCOMPLETE`, at boot and in the `manifest` step |
1530
+ | 3 | `packages/db/schema/` | the app has a migration. Step 3. A release that changes a framework table changes `framework/`, and this one does: `x_jobs`, `x_outbox`, `x_scheduler_state`, and four new tables (`x_job_pauses`, `x_job_workers`, `x_job_counters`, `x_admin_audit`). `x db gen` needs `@electric-sql/pglite`: `bun add -d @electric-sql/pglite # then: x db gen` |
1531
+ | 4 | an extension the embedded database does not ship (`vector`, `postgis`) | a migration creates one. Set `TEST_DATABASE_URL` (or `DATABASE_URL`) to a server that has it, with a role that may create a database, in CI and wherever `x db gen` runs. Contrib extensions (`citext`, `pgcrypto`, …) need nothing |
1532
+ | 5 | `x db gen`'s exit code | a script treats exit 1 as "nothing written". It now exits 1 after writing the migration when the dump fails; the cause names the migration — keep it |
1533
+ | 6 | `x db migrate`, `DriftKind` | your dev database holds a trigger, function, view, type or sequence no migration creates (`unexpected-object`, `X_DB_DRIFT`) — add the migration or drop the object. An exhaustive `switch` over `DriftKind` adds `case 'unexpected-object':` |
1534
+ | 7 | `x.verify.json` `coverage` | always. Step 4. Under 95 the line needs a `"why"`; an `exclude` entry is `{ "glob": "…", "why": "…" }`. Keep the floor at the measured number — it is `X_COVERAGE_FLOOR_STALE` once the tree passes it by 1.5 points, and `X_COVERAGE_BELOW_FLOOR` names the ten worst files when it drops |
1535
+ | 8 | a slow gate step | a step runs past 8 minutes (`unit`, `contract`, `live`, `job`, `e2e`, `eval`) or 5 (every other): `X_VERIFY_STEP_TIMEOUT`. Raise one with `"stepTimeoutMs": { "unit": 900000 }` in `x.verify.json` |
1536
+ | 9 | `x verify --json` in a pipeline | you parse `2>&1` as JSON. Read stdout only; stderr carries one `{"step","ok","ms"}` line per finished step, and `still running:` heartbeat lines — keep the lines that start with `{` |
1537
+ | 10 | test-order dependence | a unit test relied on which files shared a `--parallel` worker. The step runs slices of at most 16 files, one process each |
1538
+ | 11 | `fetch(`, `new WebSocket(`, `new XMLHttpRequest(`, `new EventSource(` in browser code | any `*.island.tsx`, anything that calls `clientTransport`/`pageClient`, or anything they import holds one. The finding names the line; rewrite with the table below |
1539
+ | 12 | a value import of `@ultimat3/entity` / `@ultimat3/query` in browser code | import `@ultimat3/entity/record` or `@ultimat3/query/client`; `import type` is unaffected |
1540
+ | 13 | `x.manifest.json` | always: step 6. It gains an `admin` section (each resource's filters, sorts, scopes, row scope, `sections`, `formGroups`, `related`, `actions`, and the audit store's `kind`), a `concurrency` member on a job that declares one, `onSettled: true` on one that declares that |
1541
+ | 14 | `defineAdmin()` under `apps/*/src/` | your admin is declared there — the reference app's was. No boot imports that directory, so `/admin` was never mounted; the `manifest` step now says so: `X_ADMIN_UNSCANNED`. `git mv apps/admin/src/index.ts apps/admin/app/admin/admin.ts`, repoint its relative imports, `x verify --only manifest` |
1542
+ | 15 | the `policy` step and a mounted admin | a role should open the admin but the role map does not grant every permission its routes ask for. Each one is `X_PERMISSION_UNGRANTED`, one finding per permission per mount: grant `admin:read` and `<table>:read\|write\|delete` — and `job:read` / `job:manage` for the jobs dashboard, `audit:read` for the audit screen — in the role map |
1543
+ | 16 | the `i18n` step and a mounted admin | a locale lacks a key a mounted admin renders — resource titles, field labels, sections, scopes, columns, the nav, action labels (`admin.action.<name>` with no `labelKey`) and their input labels, the branding key: `AdminApp.catalogKeys()`. The step was green while the page drew `⟦admin.<table>.title⟧`; it is now `X_CATALOG_MISSING_KEYS` naming each. `en` is answered by the framework's catalog; a non-`en` app adds the framework admin keys to its own |
1544
+ | 17 | `x g entity`, `x g resource` | you generate after upgrading. New repos read through `db.<table>`, export `list(limit)` (was `listByOrg(orgId, limit)`), and refuse a read under an actor with no org (`X_TENANCY_ACTOR_ORG_REQUIRED`). Existing repos keep working. The first `x g entity` in an older app writes `packages/db/src/client.ts` and exits with `X_DB_HANDLE_UNREGISTERED` naming the line for `packages/db/src/index.ts` |
1545
+ | 18 | `x g resource <name> --feature <other>` | a script passes it. Drop the flag (`X_CLI_BAD_FLAG`) |
1546
+ | 19 | `x g <kind> --live` for any kind but `query` | a script passes it. It was ignored on a resource (whose list query is already live); it is `X_CLI_BAD_FLAG`, fix `x g query <name>-feed --feature <name> --live` |
1547
+ | 20 | a query `x g query` / `x g resource` generates | you generate after upgrading. The read takes `{ limit }` — no `orgId` input, no `.where({ orgId })`: the org is the actor's, scoped by the typed handle. The generated `can<X>Read` checks only that the actor has an org. Already-generated slices keep working; a caller of a newly generated one passes `{ limit }` |
1548
+ | 21 | `x g admin:page` | a script or doc expects `apps/admin/src/pages/`, which the app scan never imported. It writes `apps/admin/app/admin/pages/` by default: `git mv apps/admin/src/pages apps/admin/app/admin/pages`, then update the import in `apps/admin/app/admin/admin.ts` |
1549
+ | 22 | `x tasks` | a script runs it with no queue reachable, or reads `--json` rows by position. It opens the queue when a task is declared; rows gain `lastMs`, `last`, `lastFiredAtMs` |
1550
+ | 23 | `<job>.run({ … })` by hand | a test calls it. Add `finalAttempt: isFinalAttempt(<job>.retry, attempt)` and `progress: () => undefined` |
1551
+ | 24 | a hand-built job literal or exhaustive `switch` | over `JobOutcome` (`'refused'`, `'dropped'`), `QueueStats` (`failed`), `WorkerStats` (`refused`, `dropped`, `pollDelayMs`), `JobHandle`, `JobDescriptor`, `JobRecord`, `JobTrace`. Add the member the compiler names |
1552
+ | 25 | a hand-written `JobDriver` | you ship one. `ack`/`nack` take `{ workerId, claim }` and answer `Promise<boolean>`: `driver.ack(id, claimOf(claimed))`. Increment a claim ordinal in `claim()`, fence both settles on it, file a row `failed` when `nackState(options)` says so, and answer an existing `EnqueueRequest.id` with that job and `deduped: true` |
1553
+ | 26 | a hand-written `JobIntrospection`, or a caller of `list()` | you implement one: add every member TS2741 names — `promoteMany` among them, which the admin's jobs dashboard calls. A caller that asked `list()` for more than 200 rows walks pages with `list({ after: jobCursor(lastRow) })`; over 200 or a foreign cursor is `X_JOB_PAGE_INVALID` |
1554
+ | 27 | a hand-written `LeaseStore`, `SchedulerState`, `LeaderElection`, or `createFleetSlots` caller | add `holders(key)`; `fire(driver, { task, occurrenceMs, jobs })`; `renewEveryMs`; read `acquire`'s `SlotGrant` |
1555
+ | 28 | a custom `EventBus`, `createPgEventBus({ clock })` | add `stored` and `now()`; delete the `clock` option. `eventPrompt()` on a bus with `stored: false` is `X_DRIVER_UNAVAILABLE` outside development and test |
1556
+ | 29 | a staged `enqueue()` | you read its `id` or `runId`: they are now the job's own. A custom `OutboxStore` persists `runId` |
1557
+ | 30 | `retry: { deadLetter: false }` | a job declares it. An exhausted run is now `failed` (outcome `dropped`) instead of re-run forever — check what relied on the loop |
1558
+ | 31 | `concurrency: 0`, a negative, a fraction | caught as `X_INVARIANT`. It is `X_JOB_DECLARATION_INVALID` |
1559
+ | 32 | `lastError` | you compare it to a rendered message. It ends with ` — fix: …` now; compare a prefix |
1560
+ | 33 | the memory job driver | a test relied on a `Date`, an `undefined` member or a non-enumerable property surviving the queue. The payload is its JSON form, as on Postgres |
1561
+ | 34 | `exportRows({ sink })` | always: `sink` is a thunk, `sink: () => disk('exports')`, resolved per write. A value is a type error — at runtime `definition.sink is not a function` on the first part — and evaluated `disk()` at module load, before boot |
1562
+ | 35 | `rpc({ pathStyle })` in island code, a match on `X_ROUTE_NOT_FOUND` | delete the `pathStyle` — the document carries it. A wrong style is `X_CONTRACT_DRIFT` (still 404) |
1563
+ | 36 | a live query's source | it names an org by hand. Drop the argument: the source is read as the subscriber's tenant. `LiveQueryDefinition.snapshot` receives `{ input, tenant }` |
1564
+ | 37 | `EntityCore<Row>['$schema']`, a hand-built `RecordProjection` | read the type off the `entity()` result (`typeof posts.$schema`); add `sealed: []` to the projection |
1565
+ | 38 | `x secrets rotate` | something asserts on `secrets.enc.json`'s exact contents. It now keeps `ULTIMATE_SECRETS_RETIRED_KEYS`; drop a key with `x secrets rotate --drop <keyId>` once nothing is sealed under it |
1566
+ | 39 | `interface X extends LinkProps` / `PaginationProps` / `DataTableProps<Row>` | extend one member: `TextLinkProps`/`ButtonLinkProps`, `PaginationCallbackProps`/`PaginationLinkProps`, `DataTableCallbackProps<Row>`/`DataTableLinkProps<Row>` |
1567
+ | 40 | `guard.check(root)` in a test | pass `guardSources(root)` from `@ultimat3/cli` as the second argument |
1568
+ | 41 | `checkAppBoundaries`, a `switch` over `BuildTarget` | delete the import; add `case 'prebuilt':` |
1569
+ | 42 | a hand-written e2e driver | read the third argument, `{ timeoutMs }` |
1570
+ | 43 | a hand-written `E2eApp` double | you build one. `E2eApp` has a required `log(): string` — the spawned app's bounded output, which a failed e2e test now prints (its last 40 lines). Add `log: () => ''` |
1571
+ | 44 | `runJobs` in a test | a test relied on it calling the body directly. Each pass is a real worker's `tick()`: keyed `concurrency` waits, `whenBusy: 'fail'` refuses with `X_JOB_KEY_BUSY`, and runs claimed in one pass run concurrently. A test that expected two runs of one key to both complete sees a refusal or a wait |
1572
+ | 45 | `clock.advance()` past the visibility timeout in a `runJobs` test | a run is in flight while the test advances. `runJobs` renews its lease on the test clock as a real worker does, so the lapse is `X_JOB_LEASE_LOST`, as it would be in production. A test that cancels a running job then awaits `runJobs.drain()` adds `clock.advance(1)` after the cancel |
1573
+ | 46 | the jobs event bus in a test | a test published an event in `beforeAll` and read it in a test. The app's test preload resets the bus before every test: publish inside the test |
1574
+ | 47 | a non-private workspace package with a `src/**/*-fixture.ts` | you publish one. Its `files` must carry `!src/**/*-fixture.ts`, and a fixture reachable from an entry point is `X_PACKAGE_SHAPE` naming the file to rename. Private packages — every generated app's — are exempt |
1575
+ | 48 | `og:*` / `article:*` / `twitter:*` on a `robots: { index: false }` page | a test or a crawler expected them. A document that may not be indexed carries none |
1576
+ | 49 | `defineAdmin` | always: `defineAdmin({ entities, db })` (`adminEntitiesOf(db)` lists every entity on the handle). Delete each `page.tsx` under an admin URL, the `AdminRepo` adapter and the admin action routes. Without `db`: `X_ADMIN_REPO_UNBOUND`; a leftover page: `X_ROUTE_DUPLICATE` |
1577
+ | 50 | `guardedPage`, `AdminRouteConfig.component`, `RegisteredRepo`, `adminRouteConfig(route)` | `guardedScreen(app, route, body)` or `route.respond(…)`; `adminRouteConfig(app, route)`. The admin view components take no handlers and no `loading` |
1578
+ | 51 | `AdminList`, `pageRequestOf`, `adminList` | you render the list yourself: `hrefFor={(location) => listHref(basePath, resource, location)}` plus `request`, `scope`, `counts`. An unknown URL parameter is `X_ADMIN_FILTER_INVALID`, not ignored |
1579
+ | 52 | the tenant column in the admin | a form posted it. It is stamped from the actor; the posted value is ignored |
1580
+ | 53 | `describeRoutes()`, `Stylesheet` | you list routes or build a `Stylesheet` by hand: mounted routes carry `mount`, a stylesheet carries `claimed` |
1581
+ | 54 | `/admin/jobs/*`, a hand-written jobs page or nav item | always: every `defineAdmin()` now serves the jobs dashboard — resources `x_jobs`, `x_job_queues`, `x_job_tasks`, `x_job_workers` and the overview at `/admin/jobs`. Delete the page and the nav item; a `pages:` entry at those paths is `X_ADMIN_PAGE_PATH_INVALID`. A test that renders `/admin/jobs` installs a queue first: `setJobDriver(createMemoryDriver())` |
1582
+ | 55 | `defineAdmin({ jobs })`, `AdminApp.jobs` | delete both: the dashboard reads the queue itself. TS2353 names the site |
1583
+ | 56 | a hand-written `AuditLog`, or a caller of `entries()` | `entries(query)` is async and takes `AuditQuery` (`entity`, `entityId`, `actorId`, `orgId`, `limit`, `before`, `changes`): write `await log.entries(…)`. A custom log adds `atomic(run)` and `kind` |
1584
+ | 57 | `InvokeResult` | you read `decision` off a failure. Failures carry `kind: 'denied' \| 'not-applicable' \| 'invalid'`; narrow on `kind === 'denied'` first |
1585
+ | 58 | a posted row action's redirect | a test asserts its `Location`. It is the row (303), not the list |
1586
+ | 59 | a row-scoped resource's create and update | a write put a row outside the actor's `rows(actor)`. It is refused before the repo is called and audited as `admin.error.row-out-of-scope`. A text `gt`/`lt` row scope cannot be decided here and refuses every write |
1587
+ | 60 | an action on a row the actor cannot see | it ran. A row that is gone or outside `rows` is refused, `when` or none: `X_ADMIN_ACTION_NOT_APPLICABLE`, 409, the same over MCP |
1588
+ | 61 | `permissionsForOperation('admin', op)` | you read a second element. It answers one permission (`['admin:read']`), not the same one twice |
1589
+ | 62 | stored scraping sessions | always. Step 13. **Sessions and refusal markers stored before 23.0.0 are deleted on first load**, so every run logs in again, and a credential the site had already refused is presented once more. Correct it first on a site that locks an account after repeated failures |
1590
+ | 63 | `storageSessionStore(disk)`, `artifacts: { storage }` | pass a thunk: `storageSessionStore(() => disk('sessions'))` |
1591
+ | 64 | a hand-built `PromptRequest`, `AuthContext`, `ScrapeSecrets`, `ScrapeReport`, `SessionInit`, a `createPrompt` call | add the members TS2741 names; `createPrompt({ scrape, handler, page, runId, clock, … })` |
1592
+ | 65 | `meta.cdpUrl` of `X_SCRAPE_CDP_ATTACH_FAILED` | you read the full URL from it. It is `wss://host:port` |
1593
+ | 66 | `localBrowser({ proxy })` with credentials | your launcher's page has no `authenticate()` (`X_SCRAPE_EGRESS_UNSUPPORTED`). Upgrade the launcher or drop the credentials from the URL |
1594
+
1595
+ Entry 11, the rewrite for each raw request:
1596
+
1597
+ | It was | Write |
1598
+ |---|---|
1599
+ | `fetch('/api/…', { method: 'POST' })` — an action | `await browserClient.<action>(input)` |
1600
+ | `fetch('/_x/query/…')` — a read | `useQuery(<QUERY_REF>, input)`; outside a component, `await browserQueries.<query>(input)` |
1601
+ | `new WebSocket(…)` / `new EventSource(…)` | `useQuery(<QUERY_REF>, input)` for rows, `useChannel(<CHANNEL_REF>, params, { onEvent })` for events |
1602
+ | an upload, `fetch` or XHR to a signed URL | `await uploadFile({ file, grant, onProgress })` from `@ultimat3/storage` |
1603
+ | anything else | `await clientTransport({ method: 'GET', url })` from `@ultimat3/core/page` |
1604
+
1605
+ An app scaffolded before 23.0.0 has no `apps/web/shared/browser-client.ts`; write it as
1606
+ [Client data](Client-Data) shows, and add `export type Api = typeof api` to `apps/web/api/index.ts`.
1607
+
1608
+ ### Where the sites are
1609
+
1610
+ ```sh
1611
+ grep -rnE "fetch\(|new (WebSocket|XMLHttpRequest|EventSource)\(" apps packages --include=*.tsx --include=*.ts | grep -v -E "route\.ts|\.test\.ts"
1612
+ grep -rnE "\.run\(\{|ack\(|nack\(|E2eApp|createPgEventBus|storageSessionStore\(|createPrompt\(|guardedPage|adminRouteConfig\(|checkAppBoundaries|pathStyle|exportRows\(|\.entries\(|InvokeResult|permissionsForOperation\('admin'" apps packages --include=*.ts --include=*.tsx
1613
+ grep -rlE "page\.tsx" apps/admin
1614
+ grep -rlE "defineAdmin\(" apps/*/src
1615
+ ls apps/admin/src/pages 2>/dev/null
1616
+ grep -rnE "x g [a-z:]+ .*--(live|feature)" scripts package.json .github 2>/dev/null
1617
+ ```
1618
+
1619
+ The `typecheck` step finds entries 23–29, 34, 37, 39–43, 50, 51, 55–57, 63 and 64. The gate finds
1620
+ 3, 4, 7, 11–16, 47 and 54; `x db migrate` finds 6; `x g` finds 18 and 19. It does not find entry 2
1621
+ (a worker that misses a side-effect registration logs it at boot), 20, 21, 32, 33, 36, 44–46, 48,
1622
+ 58–61 or 62 — run the unit, job, live and scraping suites, and read the first boot's log.
1623
+
1624
+ ### Mounted admin pages that keep your shell (27.1+)
1625
+
1626
+ A 22.x console that mounted each `pages:` entry in its own `<path>/page.tsx` — the app's frame
1627
+ around `adminRouteFor(…).component`, islands declared in the mount — does not have to fold into the
1628
+ admin's layout. Upgrade to 27.1.0 or later (an earlier 23–27 has no seam: the island is
1629
+ `X_ISLAND_NOT_HYDRATED`, the file `X_ROUTE_DUPLICATE`), keep every file, and make one edit per
1630
+ mount plus one in the shared helper:
1631
+
1632
+ | In | 22.x | 27.1+ |
1633
+ |---|---|---|
1634
+ | each `<path>/page.tsx` | `const mounted = adminRouteFor(admin, path)` (or your `mountedPage(page)`) FIRST, the `island()` calls after it | the `island()` calls first, then `export const config = claimAdminRoute(admin, path, { hydrate, budget, navigation, load })` LAST — its `defineRoute` drains them |
1635
+ | each `<path>/page.tsx` | `defineRoute({ render, offline, policy: mounted.policy, meta, cache, load: () => …mounted.component({ ctx, params, url })… })` | nothing: `render`, `offline`, `policy`, `meta`, private `no-store` and the guarded body are the claim's. Drop `cache: 'no-store'` (the default for a gated page) and `meta` |
1636
+ | the page component | `props.data` = your `load`'s object, `body` from `route.component` | `props.data: AdminClaimData<YourShellData>` — `body`, `status`, `denied` (the body is the refusal, with its own `<h1>`), `titleKey`, `nav` (`admin.navFor(ctx)`), and `app` = what your `load` returned |
1637
+ | the shared mount helper | a `load` that resolved the actor, called the guarded component, computed `denied` with `decideAll`, read `navFor`, and `withStatus(403 \| 404, …)` | only the shell's own data: `load: ({ ctx, url, denied }) => ({ staffRole, mfaEnrolled, locale, path })`. Return `withStatus(404, data)` when the body asked for a 404; the 403 is the admin's |
1638
+ | `/admin/page.tsx` (your own home) | a route at the base path | `claimAdminRoute(admin, admin.basePath, { load: () => loadHome() })` — the dashboard is claimable; `data.app` is your home's data |
1639
+
1640
+ `pages:` stays the source of the path, title, nav item and permission pair; only a `pages:` entry
1641
+ or the dashboard can be claimed, and only by the config `claimAdminRoute` returned for that exact
1642
+ path ([Admin dashboard → An admin page in your own shell](Admin-Dashboard#an-admin-page-in-your-own-shell)).
1643
+
1644
+ ## 21.x → 22.0.0, entry by entry
1645
+
1646
+ **Twenty-three entries**, the `22.0.0` section of `CHANGELOG.md`. Entries 4, 7, 9, 10, 11, 12, 13, 14, 18, 20 and 21 are compile
1647
+ errors, and so are parts of 3 (`planDeploy`'s fourth argument), 6 (a declaration with no `policy`)
1648
+ and 19 (`selectTransport`'s second argument, `'redis'`). Entry 15 throws when a `sync` pod boots,
1649
+ entry 19 when any realtime role boots with a transport and `NATS_URL` that disagree, and entry 6
1650
+ when a channel module loads. Entries 1, 2 and 5 compile unchanged and refuse a string they used to
1651
+ accept; entry 17 changes a `meta.code`, and entry 23 throws where a query silently answered `[]`.
1652
+ Entry 3 also installs a **new** helm release beside the old one unless you name it. Entry 16 is a
1653
+ dependency you can drop, entry 22 is one `x db gen` if `drift` asks for it, and entry 8 needs no
1654
+ edit. 22.0.0 is plan 101's deep sweep: every framework package, both tracked apps and the deploy
1655
+ path.
1656
+
1657
+ | # | Surface | Costs you an edit if |
1658
+ |---|---|---|
1659
+ | 1 | `t.date` | you send a date that is not ISO-8601 in shape (`'March 14, 2026'`, `'3/14/2026'`, `'12'`). It is refused at validation now (`coerceQuery` no longer rewrites it). `new Date` read it at the host's local midnight, so the stored day depended on `TZ`. Send `'2026-03-14'`. Epoch-ms numbers and `Date` objects are unchanged |
1660
+ | 2 | `timestamp()` columns | you write a string with no `Z` and no offset (`'2026-03-14T09:00'`) or a non-ISO one. Refused now; it used to be parsed in the host's zone. Write `'2026-03-14T09:00:00Z'` or a `Date` |
1661
+ | 3 | `x deploy --method helm`, `planDeploy` | you deployed with helm before. The release is now named after `app.config.ts`'s `name`, not the literal `app`, so the next deploy installs a **second** release beside the old one. Keep the old one with `x deploy --method helm --release app`, or `helm uninstall app` after the new one is up. The deploy now waits for the rollout (default `--timeout 15m`) and reports `data.rollout`. `planDeploy(image, 'helm', root)` needs a fourth argument, `{ release, namespace, timeout }`. `--release`, `--namespace` and `--timeout` are refused on `--method compose` |
1662
+ | 4 | `invokeAdminAction({ … expectedConfirmation })` | you pass `expectedConfirmation`. Delete it; the gate derives the token from the action's entity and `subject.id`, and the browser still echoes `confirmationToken(entity, id)` in `confirmation`. With both fields omitted a destructive action used to run unconfirmed; it is refused now |
1663
+ | 5 | `@ultimat3/ui` `DateTime`, `toDate` | you pass a non-ISO string (`'August 14, 2026 09:00'`, `'8/14/2026'`). Pass an ISO date, a date-time with `Z` or an offset, or a `Date` |
1664
+ | 6 | `channel(name, { … })` with no `policy` | a declaration omits it. It is a type error and throws `X_CHANNEL_DECLARATION_INVALID` at load. A public channel says so: `policy: allow('public')`. `ChannelDescription.policy` is `string`, no longer `string \| null` — delete a `null` branch |
1665
+ | 7 | a custom `QueueStore` or `LocalStore` | you implement one. `QueueStore.save(state)` → `write(change: QueueChange)`, and `LocalStore.saveQueue(scope, state)` → `writeQueue(scope, change)`: one change per mutation, by key. Whole-queue records a 21.x page wrote are converted on first read, so queued writes survive the upgrade. The shipped stores need nothing |
1666
+ | 8 | the page's SharedWorker | nothing to edit. The worker is named per build now (`workerName(scope, buildId)`, internal), so tabs of two deploys never share a socket host |
1667
+ | 9 | `ChangeOp` | you `switch` over it exhaustively. Add `case 'truncate':` — a rowless change: a `TRUNCATE` empties the affected live windows and starts a new epoch on every open channel topic |
1668
+ | 10 | `Result`, `Ok`, `Err`, `ok`, `err`, `map`, `mapErr`, `isOk`, `isErr`, `tryCatch`, `unwrap`, `unwrapOr` from `@ultimat3/core` | you import any of them. Gone: `throw` an `UltimateError` and `try`/`catch` it |
1669
+ | 11 | `X_USERS_TABLE`, `X_SESSIONS_TABLE`, `X_ACCOUNTS_TABLE`, `X_VERIFICATIONS_TABLE`, `X_API_KEYS_TABLE`, `X_USERS_MIGRATION_1_3` from `@ultimat3/auth` | you import them, or pasted them into a migration. Delete both: every boot applies `AUTH_TABLES` (the 1.3 upgrade included, as `add column if not exists`) |
1670
+ | 12 | internals removed from package barrels | you import a runtime value that no other package used — `CHANGELOG.md`'s `22.0.0` section lists them per package (auth, ui, core, http, entity, query, mcp, ai, mail, notify, pwa, render, scraping, manifest). None is an error class, a code table or a documented API. Copy the constant into your app, or use the documented API it served |
1671
+ | 13 | the e2e driver: `installE2eDriver`, `e2eFixtures`, `startE2eApp`, `e2eApp`, `e2eBaseUrl`, `e2eBrowser`, `openE2eBrowser`, `cdpConnect`, `cdpE2eTab`, `findChrome`, `launchChrome`, the `Cdp*Error` / `E2e*Error` classes and their types, from `@ultimat3/cli` | you import any of them. Import them from `@ultimat3/testing`; the test preload is `@ultimat3/testing/e2e-preload`. `FRAMEWORK_SCRIPTS` and `FRAMEWORK_INLINE_SCRIPTS` stay on `@ultimat3/cli`. The `X_E2E_*` / `X_CDP_*` codes are unchanged |
1672
+ | 14 | `entityRow`, `camel` from `@ultimat3/realtime/server` | you decode WAL tuples yourself. `entityRow(physical)` → `entityRow(relation, physical, 'before' \| 'after')`; it decodes through the registered entity (`decodeRow`, `.column()` renames and money included) and refuses a table with no registered entity (`X_REPLICATION_PROTOCOL`). `camel` is gone |
1673
+ | 15 | a `sync` role on a real database | it has no reachable change feed. It refuses to boot with `X_REALTIME_TOPOLOGY`; it used to come up healthy and deliver nothing. Set `NATS_URL` on `web`, `sync` and one `replicator`, or leave `sync` off. (`x dev --role sync,replicator` runs both in one process, for development.) The scaffolded chart (`roles.sync.enabled: false`) and Compose file (`replicas: 0`) ship `sync` off, with the enable recipe beside the switch; a chart or compose file you copied earlier keeps whatever it had |
1674
+ | 16 | `x shot`, `x shot --island`, the `ui.*` MCP tools | you installed `puppeteer-core` for them. They drive Chrome over raw CDP on the e2e step's launcher now: `bun remove puppeteer-core` if nothing else imports it. `X_SHOT_BROWSER_MISSING` now means "no Chrome to launch" — `export CHROME_PATH=<binary>` where Chrome is not at `/usr/bin/google-chrome` or the other probed paths, or pass `--cdp-url`. A request to a host off the allow list is refused inside the browser and recorded as `refused: "host"` in the verdict |
1675
+ | 17 | `ui.interact` step failures | you match `meta.code` against `X_SCRAPE_*`. A failed step carries `X_SHOT_ELEMENT_MISSING`, `X_SHOT_ELEMENT_UNREADY` or `X_SHOT_KEY_INVALID` now |
1676
+ | 18 | `backoffDelay` from `@ultimat3/realtime` | you import it. Import core's: `import { backoffDelay } from '@ultimat3/core'`, and pass `attempt: n + 1` — realtime's copy counted from 0, core's counts from 1. A failed channel catch-up now retries after the base wait, not twice it |
1677
+ | 19 | `realtime.transport`, `NATS_URL`, `selectTransport` | your deploy relied on `NATS_URL` alone to pick the bus. `realtime.transport` decides now: `'nats'` dials the variable `realtime.urlEnv` names and refuses the boot (`X_CONFIG_INVALID`) when it is unset — it used to fall back to in-process in silence — and `'memory'` with `NATS_URL` set refuses too. Set `transport: 'nats'` where the fleet shares a bus, or unset `NATS_URL`. `realtime.enabled: false` now starts no `sync` node and no replicator. `transport: 'redis'` (never built) no longer typechecks. `selectTransport(env)` → `selectTransport(env, { transport, urlEnv })` |
1678
+ | 20 | `startLiveReplicator`, `LiveReplicator`, `LiveReplicatorOptions` from `@ultimat3/testing` | you import them — a live-query test or a hand-rolled dev boot. Import them from `@ultimat3/realtime/server`; `@ultimat3/testing`'s `subscribe` fixture needs nothing |
1679
+ | 21 | `@ultimat3/cli`'s barrel | you import anything from it but `newCommand`, `dbCommand`, `verifyCommand` and the documented API — 236 internals are gone (command objects, scan internals, report helpers, option types). `maskLiterals` / `stripComments` → `import { maskLiterals, stripComments } from '@ultimat3/core'`. The CLI no longer depends on `@ultimat3/scraping`, so a workspace that reached scraping through it declares it itself |
1680
+ | 22 | `x db gen`'s schema hash | `x verify --only drift` reports drift right after the upgrade with no entity edited: the hash now uses core's `canonicalJson`. Run `x db gen` once. Neither tracked app needed it |
1681
+ | 23 | a query's `sql: () => from(…)` over rows its loader `select`ed | the query filters (`where`) or sorts (`orderBy`) on a column the loader's `select({ … })` leaves out. That read used to answer `[]` in silence; it throws `X_QUERY_COLUMN_UNSELECTED` (`QueryColumnUnselectedError`) now. Add the column to the loader's `select`, or drop the filter if the loader already applies it. The reference app's `publicPostSlugs` was one — the blog prerendered no article until `publishedSlugs` selected `status` and `publishedAt` |
1682
+
1683
+ ### Where the sites are
1684
+
1685
+ ```sh
1686
+ grep -rnE "from<|from '@ultimat3/cli'|LiveReplicator|puppeteer|X_SCRAPE_|backoffDelay|selectTransport|transport: 'redis'|expectedConfirmation|planDeploy\(|\bResult<|\b(tryCatch|unwrapOr|mapErr)\(|X_(USERS|SESSIONS|ACCOUNTS|VERIFICATIONS|API_KEYS)_TABLE|X_USERS_MIGRATION_1_3|saveQueue|entityRow\(|\bcamel\b|case 'delete':" apps packages --include=*.ts --include=*.tsx
1687
+ grep -rnE "from '@ultimat3/cli'" apps packages scripts --include=*.ts | grep -E "e2e|cdp|Chrome|E2e|Cdp"
1688
+ grep -rnE "channel\(" apps packages --include=*.ts | grep -v policy
1689
+ ```
1690
+
1691
+ The `typecheck` step finds every removed name, `planDeploy`'s arity and a missing `policy`. It does
1692
+ not find a non-ISO date string in a fixture, a seed or a client — those fail at validation, so run
1693
+ your contract and e2e suites — nor a helm release still named `app`.
1694
+
1695
+ ## 20.x → 21.0.0, entry by entry
1696
+
1697
+ **Twenty-seven entries.** Entries 1–3, 5, 8, 9, 11–17, 20–23 and 27 are compile errors. Entry 24
1698
+ is a stale file one rebuild replaces, entry 25 is a `budgets` finding, and entry 26 is capacity
1699
+ planning, not code. Entry 10
1700
+ throws when the module loads, and entry 18 when `docker compose up` starts. Entry 19 is a
1701
+ number that means something else. Entries 4, 6 and 7 compile
1702
+ unchanged and answer differently: a wider `isSuperseded`, a coded error where a bare `TypeError`
1703
+ or `SyntaxError` used to arrive, and a new response body for non-framework HTTP clients. Entry 2
1704
+ also changes what `'last-write-wins'` keeps, and entry 8 leaves two error codes that nothing
1705
+ throws. 21.0.0 is the client data layer: one record store per tab, one HTTP seam and one socket
1706
+ per origin. The entries are `CHANGELOG.md`'s `21.0.0` section.
1707
+
1708
+ | # | Surface | Costs you an edit if |
1709
+ |---|---|---|
1710
+ | 1 | `AsyncState` | you import it from `@ultimat3/ui`. It moved to `@ultimat3/core` unchanged, and ui does not re-export it. `import type { AsyncState } from '@ultimat3/ui'` → `import type { AsyncState } from '@ultimat3/core'`, plus `@ultimat3/core` in that workspace's `dependencies` if it is missing. `AsyncRegion`, `asyncBranch`, `AsyncBranch` and `AsyncFlags` stay in ui |
1711
+ | 2 | a mutator's `conflict: custom(merge)` | you wrote a `merge`. It now receives the local and server **rows** of the record, not the mutator's parsed outputs: `custom<PostRow>((local, server) => ({ ...server, title: local.title }))`. The output-shaped merge never ran. Realtime's rebase holds rows, so it dropped the policy without a word and used its default. Also: `resolveConflict` moved to `@ultimat3/core` (its answer is `Row`, so narrow it); `Conflict`, `CustomConflict` and `strategyOf` are gone from `@ultimat3/action`, and `strategyOf(c)` is `typeof c === 'string' ? c : c.kind`. **No compile error warns you** that `'last-write-wins'` now keeps the local row only when its `updatedAt` is newer than the server's. A row with no numeric clock resolves to the server's |
1712
+ | 3 | `ConflictLike`, `custom`, `CustomMerge`, `MergeArgs` or `ConflictStrategy` from `@ultimat3/realtime` | you import any of them. Types: `import type { ConflictPolicy } from '@ultimat3/core'`. Realtime's own `custom(({ local, base, server }) => …)` becomes action's `custom((local, server) => …)`: no `base`, and the merge must return a row with a string `id`, or the rebase throws `X_REBASE_CONFLICT`. **Delete any branch that returned `null` to accept a delete.** The merge is no longer called when the server deleted the row or the client never held one; the server's answer lands as it is |
1713
+ | 4 | `isSuperseded(error)` | you took `true` to mean `X_SUPERSEDED`. It is also `true` for `X_CLIENT_SCOPE_CHANGED`, a read in flight when the page changed principal. Code that branches on `isSuperseded` needs nothing; code that needs the specific code reads `error.code` |
1714
+ | 5 | `QueryRequestFailedError`, `QueryProblem` from `@ultimat3/query` | you import either. A query's failure is a plain `UltimateError` now: `if (e instanceof QueryRequestFailedError)` → `if (isUltimateError(e))`, then switch on `e.code`. A query's non-2xx with no framework code is `X_CLIENT_TRANSPORT_FAILED`, not `X_RPC_FAILED`, so update a match on the old code |
1715
+ | 6 | a typed client's network fault, non-JSON 2xx, or non-2xx with no framework code | you catch `TypeError`, `SyntaxError` or `RpcFailedError`, or match `'X_RPC_FAILED'`, around `rpc()`, `.client()`, `queryClient()` or `@ultimat3/storage`'s `uploadFile()` on its no-XHR fallback (`fetchSignedPut`). All of them are `X_CLIENT_TRANSPORT_FAILED` now: `if (isUltimateError(e) && e.code === 'X_CLIENT_TRANSPORT_FAILED')`. `RpcFailedError` still exports and nothing throws it, so an `instanceof` check against it compiles and never matches. `RemoteActionError` (the server's own code) and `X_CONTRACT_DRIFT` are unchanged |
1716
+ | 7 | the body and OpenAPI `200` of an action whose output references an entity row | something other than an `@ultimat3/*` client reads it: a generated SDK, `curl`, a test posting with `fetch`. Under `x-ultimate-records: 1` the output is `body.data`, beside `records`. Regenerate an SDK from the new `openapi.json`. `rpc()` and `.client()` strip the envelope, so typed callers need nothing |
1717
+ | 8 | `@ultimat3/pwa`'s background sync | you pass `backgroundSync: { flushEndpoint }` to `generateServiceWorker`, import `DEFAULT_FLUSH_ENDPOINT` or `BackgroundSyncOptions`, call `backgroundSyncSource(opts)`, or mounted `/_x/outbox/flush` yourself. Delete the option and the imports, call `backgroundSyncSource()` with no arguments, and delete the route. The worker now posts `OUTBOX_DRAIN_MESSAGE` to open tabs, and the page's outbox replays. `pwa.backgroundSync: true` in `app.config.ts` is unchanged. **Also delete any branch matching `X_PWA_SYNC_FLUSH_FAILED` or `X_PWA_SYNC_INCOMPLETE`:** both stay registered, nothing throws them, and such a branch compiles and never runs |
1718
+ | 9 | `ClientScope.principal` | you narrow it with `!== null` and use it as a `string`. It is `string \| null \| undefined` now. `undefined` is an unscoped page, rendered for nobody, for which nothing is persisted. Test `typeof principal === 'string'`, and give a `switch` an `undefined` arm |
1719
+ | 10 | a mutator declaring `conflict: 'last-write-wins'` | its entity has no **number** `updatedAt` the server writes (a `timestamp()` string does not count), or its output carries no entity row. It now throws `X_MUTATOR_CLOCK_MISSING` when the module loads. Add `updatedAt` as a number column (epoch ms) that the server writes on every update, or declare `conflict: 'server-wins'`. Without a clock the server row already won every time, so `'server-wins'` changes no behaviour |
1720
+ | 11 | `setLiveClient`, `clearLiveClient`, `hasLiveClient`, `LiveClient`, `ClientSocket` | your island builds a `LiveClient`. Delete the socket adapter, the sync-URL module, `new LiveClient(…)`, `client.connect()` and `setLiveClient(client)`. Add `installRealtime({ signal: createSignal })` in `mount` before the first render. `hasLiveClient()` → `hasPageSocket()`. `client.subscribe(topic, fn)` → `useChannel(decl, params, { onEvent, onPresence })` or `usePresence(decl, params)` |
1721
+ | 12 | `useLive`, `LiveRows`, `LiveInput`, `liveHookFor` | you read a live query. `useLive<Row>({ name }, input)` → `useQuery<Row>({ name, live: true }, input)`. `feed.state() === 'live'` → `feed().status === 'ready'`, the rows are `feed().data`, and `unsubscribe()` → `release()`. Delete `liveHookFor` bindings |
1722
+ | 13 | `IdentityMap`, `privateScope`, `rowKey`, `RowScope`, `RowKey` | you import them. `RecordStore`, `recordKey(type, key)`, `RecordKey`; read one record with `useRecord(type, key)` |
1723
+ | 14 | `createOpfsLocalStore`, `RebaseLog`, `reconcile`, `rebaseFrame`, `strategyName`, `serverRenderLiveClient`; the journalling `MemoryLocalStore` / `LocalStore` | you pass a store, queue or log to the client, or use them in a test. Delete them: the optimistic apply needs nothing now. `MemoryLocalStore` and `LocalStore` still export, but name the **persistence** store now (`rows`, `write`, `queue`, `wipe`), not 20.x's journal (`apply`, `rollback`, `commit`), so code written against the old shape stops compiling. Durable offline writes ship: an entity declared `persist: true` is restored from IndexedDB by the page boot (`openLocalStore`, `pageLocalStore`), and its queued mutations replay through `pageOutbox()` |
1724
+ | 15 | `useMutation`, `useMutationQueue`, `MutatorLike` | you call `drain()`, set `entity:` on a `MutatorLike`, or re-query after a write. Delete `drain()` and `entity:`. The call resolves with the action's output, so read it directly. Writes go over HTTP, so a mutation now persists where it used to answer `X_NOT_IMPLEMENTED`. With no response at all (`meta.failure: 'network'`) the call resolves `undefined` and the write is queued; code that needs the output checks for it. A `'status'` or `'body'` failure still rejects |
1725
+ | 16 | sync protocol 3; `createSyncNode({ onMutate })`, `MutationHandler` | you run a `sync` node. Delete `onMutate`, and redeploy clients and nodes together: a v2 client and a v3 node refuse each other with `X_PROTOCOL_VERSION` |
1726
+ | 17 | `topic` / `Topic` from `@ultimat3/realtime/server` | you import it there. Import it from `@ultimat3/realtime` |
1727
+ | 18 | Compose deploys: `docker-compose.prod.yml`, the scaffold, both tracked apps | you deploy with Compose. `web` now refuses to start without `SYNC_URL`. Set `SYNC_URL=ws://<host>:3001/_x/sync` in `.env.production` and run Compose with `--env-file .env.production` (`x deploy` does), or proxy `/_x/sync` to `sync` and set `SYNC_URL=wss://<host>/_x/sync`. A copied compose file adds the same line to `web` |
1728
+ | 19 | `x verify --json` → `data.durationMs` | you sum step times or read the total as a sum. It is wall time now; the static steps overlap the serial suites |
1729
+ | 20 | the `presence` frame, `PresenceFrame` | you read rosters. Declare the room `channel(name, { …, events: true })` and read `readPresence(frame.event)` in its events handler; a roster is an `events` frame `{ presence: op, members, total? }` now |
1730
+ | 21 | `ChannelHub#guard`, `subscribe(socket, topic)`, `publish(topic)`, `publishFrame`, `channelFrame`, `TopicGuard*`, `nodeId`, `{ kind: 'topic' }` | you run a hub. Declare each channel with `channel(name, { params, policy, row?, catchUp, records?, events? })`. Build the hub as `new ChannelHub({ transport, sockets })`. `guard` → the declaration's `policy`; `publish(topic, event)` → `hub.publishEvent(decl, params, event)`. Delete `nodeId`. An undeclared name is `X_TOPIC_FORBIDDEN` |
1731
+ | 22 | `LocalTable`, `LocalRow`, `tx.<type>.insert(row)` / `upsert(row)` / `update(id, …)` | you write rows in a `local` twin, or type one. Tables are addressed by record key: `tx.posts.insert(post.id, post)`, `upsert(key, row)`, `update(key, patch)`, `delete(key)`, and `get(key)` / `all()` to read. `LocalRow` is gone: `LocalTable<Post>` |
1732
+ | 23 | `cdpE2ePage`, `CdpE2ePageOptions` from `@ultimat3/cli` | your e2e suite opens a page itself. Use `openE2eBrowser()` and its page tab, `session.newTab()` for a second tab, or `cdpE2eTab({ … })` on an existing connection |
1733
+ | 24 | `.x/build-stats.json` | you ran `x build` before upgrading. Every budgeted route reads `X_BUDGET_UNMEASURED` until `x build --target static` runs again, because the stats file carries the measurement rules that wrote it and v1 files are not read |
1734
+ | 25 | `hasPageSocket()` as a guard in page code | a module no island imports calls it (an offline banner, an update prompt in a layout). The `budgets` step now reports it as `X_LIVE_ROUTE_NO_ISLAND`. Move the module into an island: `x g island <route-dir> --at <route-dir>`, import it from the island's `mount()`, declare `island({ src })` |
1735
+ | 26 | a browser's reconnect backoff | you sized a sync node on the 30 s spread of reconnects. Browsers now redial within 4 s (`equal` jitter from 500 ms). Check the node's `AcceptBudget` sheds a SIGKILL herd arriving in 2–4 s. Nothing to change in app code |
1736
+ | 27 | `LaunchedBrowser.endpoint` from `@ultimat3/cli` | you launch Chrome yourself for tests. `cdpConnect(browser.endpoint)` → `browser.connection`, which is already answering over the debugging pipe. `cdpConnect(url)` remains for a remote browser |
1737
+
1738
+ ### Where the sites are
1739
+
1740
+ ```sh
1741
+ grep -rnE "import type \{[^}]*AsyncState[^}]*\} from '@ultimat3/ui'" apps packages --include=*.ts --include=*.tsx
1742
+ grep -rnE "ConflictLike|ConflictStrategy|CustomMerge|MergeArgs|CustomConflict|strategyOf|resolveConflict|custom\(|QueryRequestFailedError|QueryProblem|X_RPC_FAILED|instanceof (TypeError|SyntaxError|RpcFailedError)|isSuperseded|flushEndpoint|DEFAULT_FLUSH_ENDPOINT|BackgroundSyncOptions|outbox/flush|X_PWA_SYNC_|\.principal|last-write-wins|LiveClient|useLive|liveHookFor|IdentityMap|rowKey|privateScope|LocalStore|RebaseLog|reconcile|serverRenderLiveClient|drain\(|onMutate|realtime/server|PresenceFrame|\.guard\(|publishFrame|channelFrame|TopicGuard|nodeId" apps packages --include=*.ts --include=*.tsx
1743
+ ```
1744
+
1745
+ The `typecheck` step finds every removed name for you. It does not find a `custom(` merge whose
1746
+ type argument is still the output type: `custom<TRow>` takes any object type, so that merge
1747
+ compiles and reads `undefined` for every output field that is not a column of the row. The second
1748
+ grep lists every `custom(`. Read each one.
1749
+
1750
+ ## 19.x → 20.0.0, entry by entry
1751
+
1752
+ **Both entries are `@ultimat3/ui` component behaviour, and neither changes a type** — so nothing
1753
+ fails to compile and nothing throws. They change what a screen DOES, which is why they are a major:
1754
+ a silent rendering change is worse than a build error, and you would have found these by looking.
1755
+
1756
+ | # | Surface | Costs you an edit if |
1757
+ |---|---|---|
1758
+ | 1 | `DataTable` while reloading | you relied on skeletons appearing on every load. A FIRST load (`rows: []`) still renders skeletons; a RELOAD now keeps the existing rows, dimmed, under `aria-busy`. Replacing rendered content with placeholders on every refresh is a second layout change for no news, and it is what makes a fast list feel slow — the reader has already read those rows, and taking them away to say "loading" tells them nothing they can act on. No edit if you pass fresh `rows` on each load, which is the ordinary case. If you genuinely want the placeholder back on a refresh, pass `rows: []` while the fetch is in flight |
1759
+ | 2 | `Button` with `loading` | you style `button[disabled]`, or you assert on the `disabled` attribute in a test. `loading` no longer sets the native attribute — it sets `aria-disabled` + `aria-busy` and refuses the click in `onClick` with `preventDefault()`. The click is still refused; the mechanism moved. A `disabled` control loses focus the moment it becomes disabled — mid-flow, with no announcement — is exempt from the WCAG contrast minimum precisely because nobody is meant to read it, which is wrong for a control you are asking someone to WAIT on, and it does not actually prevent a double submit, because that race is server-side and always was. The edit is one selector: style `button[aria-disabled='true']` beside `button[disabled]`, and assert on `aria-disabled` |
1760
+
1761
+ **`defineTheme()` can now refuse your brand** — `X_UI_CONTRAST_INSUFFICIENT`. Listed here rather
1762
+ than as a third entry because it throws at declaration with the measured ratio, the required one and
1763
+ the role to move, so it cannot ship silently and needs no search. An override whose RESOLVED
1764
+ channels put a pairing below WCAG 2.2 AA (4.5:1 text, 3:1 focus ring, 1.4:1 border) fails at boot.
1765
+ Only pairings your brand can have CHANGED are measured — blaming an app for the framework's own
1766
+ colours is how a rule gets switched off. The usual defect is half a pairing: a new `accent` against
1767
+ the shipped white `accent-fg`. `@ultimat3/ui` exports `contrastRatio` and `roleContrast` so you can
1768
+ measure a candidate before shipping it. AA and never APCA: APCA is not a standard, and AA is the
1769
+ operative legal benchmark.
1770
+
1771
+ **Nothing else in this release costs an edit.** The five new app guards ship in `x new` only; an
1772
+ existing app gains one with `x g guard <name>` (since 23.0.0; `x doctor --json` lists the shipped
1773
+ ones it lacks in `data.guards.missing`), one file per rule, and deleting one drops that rule. `x shot --all-islands` is additive, and
1774
+ `x g island` / `x g resource` now write a `.island.states.ts` beside what they generate.
1775
+
1776
+ ## 18.x → 19.0.0, entry by entry
1777
+
1778
+ **Both entries cost an edit only if you had `pwa.enabled: true`** — the same block 18.0.0 grew, for
1779
+ the same reason one release later: 18.0.0 wired the web manifest and left the service worker with
1780
+ zero callers, so `pwa.offline` steered nothing and `/offline` was a path no build precached.
1781
+ `x dev`, the container and `x build --target static` all emit `sw.js` and `x-sw-register.js` now,
1782
+ and the config had to be able to say what they emit.
1783
+
1784
+ | # | Surface | Costs you an edit if |
1785
+ |---|---|---|
1786
+ | 1 | `AppConfig.pwa.offline` | you set `pwa.enabled: true`. It was `'precache' \| 'runtime' \| 'network-only'` — an app-wide **default** for a field `defineRoute` makes **required** on every route, so it defaulted nothing and was read by nobody. It is now `{ fallback, image, font, neverCache }`, and **`fallback` is required** once `enabled` is true: an absolute route path, screened at `defineConfig` rather than at `x build`. The edit is one line — `offline: 'runtime'` → `offline: { fallback: '/offline' }` — plus a route at that path. A relative value is refused because it resolves against whatever document registered the worker, so `offline` served under `/posts/1` is `/posts/offline`: a 404 cached as the answer to every offline navigation. **Or set `pwa.enabled: false`.** Per-route `offline:` is unchanged, and is still where the strategy is declared |
1787
+ | 2 | `x new`'s scaffold | you regenerate an app, or you copied the old scaffold. The offline fallback is `apps/web/site/offline/page.tsx`, not `apps/web/app/offline.tsx`: the directory is the URL and `<name>.tsx` is not a route file, so the old path shipped a component nothing imported and left `/offline` a URL that did not exist. `site/` and `render: 'static'` deliberately — the document that answers a lost network must render with no network, no session and no database, which `app/` (`ssr \| stream`) cannot promise. An existing app moves the file and adds `offline: 'precache'`, `hydrate: 'never'` and `robots: { index: false }` to its `defineRoute` |
1788
+
1789
+ **`x build --json` renames one field**: `precacheWarnings` → `serviceWorkerWarnings`, and the
1790
+ terminal row is labelled `service-worker`. It is listed here rather than as a third entry because
1791
+ no released build ever wrote the old key — `PrecacheManifest.warnings` had no reader at all until
1792
+ this release. The list carries the precache byte ceiling **and** `pwa.push: true` with no VAPID key
1793
+ to sign a subscription with, which is the one PWA capability still unwired.
1794
+
1795
+ ## 17.x → 18.0.0, entry by entry
1796
+
1797
+ **Two of the five cost an edit only if you had `pwa.enabled: true`**, which until this release did
1798
+ nothing at all — no Ultimate app had ever served a web manifest, so the block was a switch with no
1799
+ reader. **One is `bun upgrade`.** The other two are types.
1800
+
1801
+ | # | Surface | Costs you an edit if |
1802
+ |---|---|---|
1803
+ | 1 | the Bun floor, `>=1.4.0` in `engines.bun` and in `x`'s own check | you run Bun below 1.4.0. **The edit is `bun upgrade`.** The floor said `>=1.3.0` while `x test` emitted `bun test --isolate`, a flag Bun introduced in **1.3.13** — so on 1.3.0..1.3.12 the gate's dominant step died on an unknown flag and `x doctor` called the runtime fine. `1.4.0` rather than `1.3.13` because a floor is a claim about a runtime somebody tested: CI pins `1.4.x` and both images build on `oven/bun:1.4-*` |
1804
+ | 2 | `AppConfig.pwa` | you set `pwa.enabled: true`. It now also requires `pwa.name` and `pwa.colors.light` / `pwa.colors.dark`, each with `themeColor` and `backgroundColor`. `defineConfig` refuses an incomplete block **at boot**, not at `x build`, and the `fix:` carries the whole block. There is nothing to derive them from: `app.name` is a slug, so an install prompt offering `ledger-demo` is wrong rather than rough, and a browser paints the install splash before a stylesheet loads. Raw hex is legal here — one of two places in an app it is, beside `theme.tokens` (itself removed in 25.0.0). **Or set `pwa.enabled: false`**, which is what it effectively was |
1805
+ | 3 | `@ultimat3/pwa`'s exports | you import `PwaConfig`, `ThemeTokens` or `SchemeColors` from it. `PwaConfig` is now `WebManifestInput` — two exported types of one name with no map between them is axiom 1, and this was the one that lied: its doc said "the `pwa` block of `app.config.ts`" and it was the generator's input. `ThemeTokens`/`SchemeColors` are `PwaColors`/`PwaSchemeColors` from `@ultimat3/core`, which is where the config lives |
1806
+ | 4 | `x test --json`, and four `@ultimat3/cli` exports | you read `data.shards[]` or `data.failed`, or import `planShards`, `shardArgs`, `SHARD_COMMAND_PREFIX` or `Shard`. `x test` runs one `bun test --parallel=N` now, so there are no shards to report: read `data.ok` instead of scanning for a failure and `data.reproduce` instead of rebuilding the rerun. `testArgs(…)` builds the argv, `filesIn(command)` reads the file list back out. `X_TEST_SHARD_FAILED` still exists and is `x test --worker I`'s alone |
1807
+ | 5 | `ScrapeTarget` / `ScrapePage` | you implement either interface yourself. `ScrapeTarget` gains `setColorScheme` and `ScrapePage` gains `colorScheme`; a driver of your own stops compiling until it has both. Calling `@ultimat3/scraping` rather than implementing it costs nothing |
1808
+
1809
+ **`x test live --workers 8` now runs one worker, and that is a fix rather than a regression.** The
1810
+ gate always ran `live` and `e2e` serially and the command did not, so the same files ran eight
1811
+ processes under one entry point and one under the other. A logical replication slot is named at the
1812
+ Postgres **cluster** level, so a per-worker database never isolated it. `--workers` is still
1813
+ accepted and clamps to 1.
1814
+
1815
+ ## 16.x → 17.0.0, entry by entry
1816
+
1817
+ **Three breaking entries, and all three are the same sweep**: a numeric option that used to accept
1818
+ `NaN`, `±Infinity`, a fraction or a negative now refuses it. The refusal is at **boot or at the call
1819
+ boundary**, never mid-request, so one `bun test` or one `x verify` surfaces every one of them at
1820
+ once and each `fix:` line carries the edit.
1821
+
1822
+ **An app passing real numbers is unaffected.** An app that was passing `NaN` was not working: the
1823
+ bound it declared was not being enforced, and nothing said so. `??` guards *nullish*, and `NaN` is
1824
+ not nullish — so `Number(process.env.X)` on an unset variable walked past the default and landed on
1825
+ the bound intact. `Math.max`, `Math.min` and `Math.floor` all propagate `NaN`, and this repo was
1826
+ relying on all three as guards.
1827
+
1828
+ | # | Surface | Costs you an edit if |
1829
+ |---|---|---|
1830
+ | 1 | Every numeric option in the table under 17.0.0's **Changed** heading — across `http`, `query`, `jobs`, `realtime`, `auth`, `ai`, `render`, `pwa`, `mail`, `manifest`, `mcp`, `notify`, `ui`, `scraping`, `testing`, `cli`, `admin`, `core`, `time` | you pass one a value that is not finite, or is fractional or negative where the option counts things. Read the row for the floor: **`0` stays legal wherever it means something** — `port: 0` asks the OS for a free port, `timeout: 0` is one look, `seed: 0` is a seed, `maxAgeSeconds: 0` is "revalidate every time", `concurrency: 0` is one worker |
1831
+ | 2 | `http.trustedProxyHops` | you declare `0`. That was the **failure state, not a setting**: `forwarded.ts` returns `undefined` for `hops < 1`, so a declared `0` silently trusted nothing while reading as configured. The domain is `1…64`. It is a boot-owned key, so no app can write it — the blast radius is embedders calling `defineHttpConfig` directly |
1832
+ | 3 | `search().page(input, { first })` | your page would **cut rows**. A window narrower than the read is now served when nothing is dropped, and refused — naming both edits — when rows would be lost. This is a *widening* against 16.x for the common case: `limit` defaults to 20 and `first` has no default, so `search({…}) + .page(input, { first: 10 })` used to mint a cursor that page two then threw on |
1833
+
1834
+ **`TRUSTED_PROXY_HOPS` widened from 1–16 to 1–64** and is listed here only so the change is not a
1835
+ surprise: it accepts strictly more than 16.x did, so no configuration that worked stops working.
1836
+
1837
+ ## 15.x → 16.0.0, entry by entry
1838
+
1839
+ **One breaking entry**, and it fires at `entity()` — before a migration exists, before anything
1840
+ reaches a database. Nothing migrates, and an app whose patterns are already portable emits exactly
1841
+ what it emitted at 15.x.
1842
+
1843
+ | # | Surface | Costs you an edit if |
1844
+ |---|---|---|
1845
+ | 1 | `c.<col>.matches(/re/)` refuses an unportable construct | your pattern uses `\b`, `.`, `\w`, `\s`, a backreference, a named group, an inline flag, a POSIX class, `\A`/`\Z`, `\x`, a leading `]` in a class, or a non-ASCII range endpoint |
1846
+
1847
+ ### 1. `matches(/re/)` refuses what the two engines read differently
1848
+
1849
+ At 15.x this accepted **any** `RegExp` and emitted a maybe-equivalent POSIX pattern. That is the
1850
+ defect, not the refusal: `matches(/\bfoo/)` shipped a CHECK that compiled cleanly, errored nowhere,
1851
+ and enforced a **BACKSPACE** — measured on 18.4, `'foo' ~ '\bfoo'` is FALSE while `/\bfoo/.test('foo')`
1852
+ is true. Two rules under one name, with nothing anywhere to report it.
1853
+
1854
+ Nothing is translated now either, and that is the mechanism: `pattern.source` is the string
1855
+ `.test()` runs **and** the string spliced into the constraint. That is only safe because every
1856
+ construct where the engines disagree is refused at declaration.
1857
+
1858
+ **How to find them:** run your entities — `bun test`, or `x db gen`. The refusal is at `entity()`
1859
+ time, so one run surfaces every one, and each names the construct, its index, both readings, and
1860
+ the repair.
1861
+
1862
+ | Construct | Measured on 18.4 | Write instead |
1863
+ |---|---|---|
1864
+ | `\b` | `'foo' ~ '\bfoo'` **false** — ARE reads BACKSPACE | a predicate: `matches((v) => /\bfoo/.test(v))`, app-only, `sql: null` |
1865
+ | `.` | `'a\nb' ~ 'a.b'` **true**, JS false | `[^\n\r]` |
1866
+ | `\w` `\W` | `'é' ~ '^\w$'` **true** — locale alnum class | `[A-Za-z0-9_]` |
1867
+ | `\s` `\S` | `'\u00a0' ~ '^\s$'` **false**, JS true | `[ \t\n\r\f\v]` |
1868
+ | `\A` `\Z` | anchor in ARE, a letter in JS | `^` / `$` |
1869
+ | `\x` | `'Д' ~ '^\x414$'` **true** — ARE takes 3 hex, JS 2 | a predicate |
1870
+ | leading `]` in a class | `[]a]` differs | `\]` |
1871
+ | `\1`–`\9`, `(?<name>…)`, `(?i)`, `[[:alpha:]]`, non-ASCII range endpoint | backreference, named group, inline flag, POSIX class, collation-ordered range | a predicate |
1872
+
1873
+ Kept, and each measured to AGREE: literals, `^ $ | ( ) * + ?`, lazy quantifiers, `{n}`/`{n,}`/`{n,m}`,
1874
+ `(?:` `(?=` `(?!` `(?<=` `(?<!`, `\d \D \n \r \t \f \v`, punctuation escapes, bracket expressions
1875
+ with ASCII ranges, non-ASCII *members*, and `\uwxyz` at exactly four hex digits.
1876
+
1877
+ Two of those earn a note. **`\d` is in by measurement, not by reading** — POSIX pins `[[:digit:]]`
1878
+ at the ten ASCII digits, and `'٣'`/`'5'` are false on both sides. **`\uwxyz` is in because Bun
1879
+ escapes a regex literal's non-ASCII characters** (`/^é$/.source` is `^\u00E9$`), so refusing it
1880
+ would have refused every i18n pattern written the ordinary way.
1881
+
1882
+ **The subset is falsifiable, not asserted.** One live test runs every refused construct against a
1883
+ real server and asserts the two engines still disagree; another runs the kept subset and asserts
1884
+ they agree. A future Postgres that grows JavaScript's `\b` turns those red rather than leaving a
1885
+ stale exclusion in place.
1886
+
1887
+ ### Not breaking, but you will see it
1888
+
1889
+ `x db gen` now emits `drop index` / `drop constraint` for a recorded index no entity declares.
1890
+ At 15.x it emitted nothing, so those objects stayed on the database while the next sidecar stopped
1891
+ recording them — and `drift` was green over it. Your first generation after upgrading may carry
1892
+ drops you did not expect; read them, they are objects your entities genuinely no longer declare.
1893
+ A recorded unique CONSTRAINT gets a `drop constraint … if exists` **before** the `drop index`,
1894
+ because `drop index` on a constraint-backed index is `2BP01` and `if exists` does not suppress it.
1895
+
1896
+ ## 14.x → 15.0.0, entry by entry
1897
+
1898
+ **One breaking entry**, and it is a compile error only for code that exhaustively switches over a
1899
+ type it does not own. Nothing changes at runtime and no data migrates.
1900
+
1901
+ | # | Surface | Costs you an edit if |
1902
+ |---|---|---|
1903
+ | 1 | `DriftKind` gains `missing-check` | you `switch` over `DriftKind` with **no `default`** |
1904
+
1905
+ ### 1. `DriftKind` gains `missing-check`
1906
+
1907
+ The same shape 4.0.0 recorded when it gained `changed-foreign-key`. Add a `default`, or handle the
1908
+ new member:
1909
+
1910
+ ```ts
1911
+ case 'missing-check':
1912
+ // the snapshot names a CHECK constraint the catalog does not hold
1913
+ break;
1914
+ ```
1915
+
1916
+ **It compares `conname` and never the definition**, and that is not an optimisation — it is the
1917
+ only comparison that can work. `pg_get_constraintdef` answers Postgres' *own rewriting*:
1918
+ `status in ('draft','published')` comes back as
1919
+ `CHECK ((status = ANY (ARRAY['draft'::text, 'published'::text])))`. Comparing that text against a
1920
+ generated predicate would report a correct database as drifted, forever.
1921
+
1922
+ Only the **declared** side is judged, so a `NOT NULL` (`contype='n'` on PG17+), an `enumerated()`
1923
+ column's old anonymous constraint and an extension's own constraints are all silent.
1924
+
1925
+ ## 13.x → 14.0.0, entry by entry
1926
+
1927
+ **Eight breaking entries.** **Four** are compile errors the moment you upgrade (3, 4, 5, 8). **Two**
1928
+ are security fixes that change behaviour with nothing failing to compile (1, 2). **One** changes the
1929
+ text of a validation issue path (6), and **one** is a driver interface (7).
1930
+
1931
+ **No `app.config.ts` key moves.** No codemod: each entry names its own manual edit.
1932
+
1933
+ | # | Surface | Costs you an edit if |
1934
+ |---|---|---|
1935
+ | 1 | a frame verb acts on the frame, not the parent document | never — a **fix**. `frame(…).fill()` used to clear the parent's same-id field and append to the frame's |
1936
+ | 2 | a session key hashes each segment | never an edit — but **every stored session logs in once more**, see below |
1937
+ | 3 | `$migration()`, `toSql`, `invariantsToSql`, `constraintName` removed | you called one — nothing in this repo did, and `$migration()` rendered `ALTER TABLE "<entity name>"`, a relation that does not exist |
1938
+ | 4 | `t.number.int()` demands a **safe** integer | you relied on `2^53` passing a boundary that the row write then refused |
1939
+ | 5 | `and()` / `or()` refuse an empty clause list | you build a policy from a list that can filter to empty — `and()` used to answer **allowed** |
1940
+ | 6 | a `t.record` issue path names the entry by **position** | you parse issue paths and expect the caller's key. The key *is* caller data and reached the log line |
1941
+ | 7 | `ScrapeTarget.setOfflineMode` required; `CdpTargetInit.ringCapacity` deleted | you implement `ScrapeTarget` yourself |
1942
+ | 8 | `Repo.insert`/`insertAll`/`upsertAll` take `RowWrite<T>` | you implement `Repo` — otherwise this **accepts more** than before |
1943
+
1944
+ ### 2. Every stored session key changes spelling
1945
+
1946
+ Segments used to be sanitised by collapsing every run of non-`[a-zA-Z0-9._-]` to a single `-`, so
1947
+ `alice@corp.com` and `alice-corp.com` produced **one key**. The browser then loaded account A's
1948
+ cookies, `auth.validate()` answered `true` — the session *is* valid, for the wrong account — and
1949
+ A's rows were stored under B's tenant.
1950
+
1951
+ **Nothing to run.** A stored record is not found under the new spelling, a miss reads as "no
1952
+ session", so the run logs in again and writes the new key: one extra login per stored session, no
1953
+ error, and the old objects are orphaned until your bucket's lifecycle rule collects them.
1954
+
1955
+ ### 5. `and()` with no clauses used to admit anonymous callers
1956
+
1957
+ ```ts
1958
+ // before — answered ALLOWED, and admitsAnonymous() agreed, so http did not 401 first
1959
+ policy: and(...requiredCaps.map(can)) // requiredCaps filtered to empty
1960
+
1961
+ // after — refused where it is written
1962
+ policy: requiredCaps.length === 0 ? allow('public') : and(...requiredCaps.map(can))
1963
+ ```
1964
+
1965
+ `allow('public')` is the explicit spelling for "no clauses required"; `deny('<reason>')` is the one
1966
+ that carries a reason. Both are refused because a combinator with no clauses states nothing, and
1967
+ `and()` stated the opposite of what its author meant.
1968
+
1969
+ ### 8. `Repo`'s whole-row writes take `RowWrite<T>`
1970
+
1971
+ They took the **row** type where money's **write** type belongs, so `Repo.insert` demanded a
1972
+ `MoneyValue` while `narrowMoney` exists precisely to narrow a `bigint` handed to a driver — a
1973
+ compile error on public API for a value the framework documents, implements and stores correctly.
1974
+ `RowWrite<T>` accepts both spellings. If you implement `Repo`, widen those three signatures; if you
1975
+ only call them, this accepts strictly more than before and costs you nothing.
1976
+
1977
+ ## 12.x → 13.0.0, entry by entry
1978
+
1979
+ **Two breaking entries**, and both are compile errors the moment you upgrade. Nothing changes at
1980
+ runtime, no data migrates, no cursor or protocol moves. This major is wide in what it ADDS —
1981
+ notifications, full-text search, webhooks, exports, state machines, form binding — and narrow in
1982
+ what it breaks.
1983
+
1984
+ **No `app.config.ts` key moves.** No codemod: each entry names its own manual edit.
1985
+
1986
+ | # | Surface | Costs you an edit if |
1987
+ |---|---|---|
1988
+ | 1 | `ServiceFactory` receives `CtxFacts`, not `Ctx` | your `defineService` factory annotates its parameter `(ctx: Ctx)`, or reads a **sibling service** off it |
1989
+ | 2 | `PageLike.content()` is deleted | you called it in an e2e test, or implement `PageLike` yourself |
1990
+
1991
+ ### 1. `ServiceFactory` receives `CtxFacts`
1992
+
1993
+ `defineService`'s factory is handed the framework's own facts — `actor`, `now()`, `clock`, `tz`,
1994
+ `locale`, `requestId` — and **not** the app's augmented services. It always worked this way; the
1995
+ type said otherwise.
1996
+
1997
+ ```ts
1998
+ // before — compiles, and is circular: the factory that BUILDS ctx.posts
1999
+ // declares that ctx.posts must already exist
2000
+ export const posts = defineService('posts', (ctx: Ctx) => ({ … }));
2001
+
2002
+ // after — the documented form, which cannot drift from the signature
2003
+ export const posts = defineService('posts', (ctx) => ({ … }));
2004
+ ```
2005
+
2006
+ Drop the annotation. If you genuinely need to name the type, `CtxFacts` is now exported from
2007
+ `@ultimat3/core` — it was the parameter type all along and the barrel never re-exported it, so no
2008
+ app could name what its own factory was handed.
2009
+
2010
+ **Reading a sibling service inside a factory no longer typechecks.** That was already documented as
2011
+ unsupported — factories run in registration order and a sibling may not exist yet — but the type
2012
+ permitted it. Move the read into the method that needs it, where `useService()` resolves at call
2013
+ time.
2014
+
2015
+ ### 2. `PageLike.content()` is deleted
2016
+
2017
+ `PageLike`'s comment claimed *"every member is one the reference app's e2e suite already calls"*.
2018
+ An audit found that false for three of eleven: `content()` had **zero call sites anywhere in the
2019
+ repository**, and `title()` / `reload()` are named only by `x g route`'s generated template, which
2020
+ nothing executes.
2021
+
2022
+ `content()` is gone. `title()` and `reload()` stay, with the caveat recorded on each. If you drive
2023
+ a browser yourself, delete `content` from your `PageLike` implementation; if you called it, read the
2024
+ DOM through `evaluate()` instead.
2025
+
2026
+ ## 11.x → 12.0.0, entry by entry
2027
+
2028
+ **Sixteen breaking entries**, in three groups. **Eight** are compile errors the moment you upgrade
2029
+ (4, 5, 6, 8, 9, 13, 14, 16). **Four** need an action before or at the deploy and nothing fails to
2030
+ compile (1, 2, 3, 11) — every persisted pagination cursor stops working, rows sharing a sort value
2031
+ change order, one index migration, and **`sync` nodes and browser clients must ship together**. The
2032
+ last **four** are visible only to a caller at runtime (7, 10, 12, 15).
2033
+
2034
+ **No `app.config.ts` key moves**, because the surface this major opens never had one: `AppConfig` has
2035
+ never carried an `http` member, so `configureHttp()` is an addition and not a migration — see the
2036
+ last table. No codemod: every entry names its own manual edit.
2037
+
2038
+ | # | Surface | Costs you an edit if |
2039
+ |---|---|---|
2040
+ | 1 | every cursor minted before 12.0.0 is `X_CURSOR_INVALID` | you persisted a cursor — in a URL, a job payload, a client store. Nothing fails to compile |
2041
+ | 2 | the primary-key tiebreak takes the **last declared** key's direction | you depended on the order of rows sharing a sort value. Nothing fails to compile |
2042
+ | 3 | an index declaring `where` or `order` is renamed `<table>_<cols>_<hash8>_idx` | you declared one — **a migration, before the deploy** |
2043
+ | 4 | a physical column or table name must be `[a-z_][a-z0-9_$]*`, at most 63 bytes | you wrote `.column('createdAt')` or an `entity(name)` / `table` that is not lower-snake |
2044
+ | 5 | `Repo` gains `aggregate` and `approximateCount`; `ReadBuilder` gains five terminals | you implement `Repo` or `Driver` yourself |
2045
+ | 6 | `Operator` gains `contains`, `contained-by`, `overlaps`, `has-key` | you `switch` exhaustively over `Operator` |
2046
+ | 7 | `introspect()` returns app tables only | you read its output, or assert how many catalog queries it issues |
2047
+ | 8 | `rateLimitKey` is deleted; `RateLimitConfig.tenantBucket` is required | you called `rateLimitKey`, or built a full `RateLimitConfig` |
2048
+ | 9 | `Ctx` gains a required `deadlineAt` | you hand-build a `Ctx` — a test fixture, a custom host |
2049
+ | 10 | `traceHeaders()` sends the remaining request budget | never for the caller; a downstream service now receives `x-request-timeout-ms` |
2050
+ | 11 | `PROTOCOL_VERSION` 1 → 2, and five realtime exports are deleted | you imported one — and **every** deployment redeploys clients and `sync` nodes together |
2051
+ | 12 | MCP rate limits are enforced, 120 read / 20 write per minute per actor | an agent exceeded them; it was previously unmetered |
2052
+ | 13 | `memoryAuditSink()` is bounded at 1,000 and discards oldest-first | you used it as a system of record, or implement `MemoryAuditSink` |
2053
+ | 14 | `RouteBudget.css`, `.cls` and `.tbt` are deleted | you declared one — it was ignored, and now it does not compile |
2054
+ | 15 | `claim({ queues: [] })` is refused; the memory driver's `claim` is `async` | you call a `JobDriver` directly |
2055
+ | 16 | `DoctorProbe` gains a required `database()` | you implement `DoctorProbe` |
2056
+
2057
+ ### Entries 4, 5, 6, 8, 9, 13, 14 and 16 — a compile error the moment you upgrade
2058
+
2059
+ **4. Spell every physical name lower-snake.** `X_INVARIANT_VIOLATED` at `entity()`, before any
2060
+ statement runs.
2061
+
2062
+ ```diff
2063
+ - createdAt: timestamp().column('createdAt'),
2064
+ + createdAt: timestamp().column('created_at'),
2065
+ ```
2066
+
2067
+ A **derived** name is unaffected: `columnName` is `meta.name ?? snake(property)` and `snake()`
2068
+ lower-cases, so `createdAt: timestamp()` still writes `created_at`. What changed is that the derived
2069
+ branch is now checked too — for three majors only `meta.name` was, so a property named
2070
+ `n" , "x" text); drop table t; --` put a real `drop table` inside a generated `create table`.
2071
+ Quoting is not a defence against a value that can close the quote. `entity(name)` and `table` go
2072
+ through the same assertion.
2073
+
2074
+ **5. Implement the two new `Repo` members, or stop hand-rolling one.** `TS2739`.
2075
+
2076
+ ```diff
2077
+ const repo: Repo<Post> = {
2078
+ findById, findMany, insert, update, delete: remove, count, countBy,
2079
+ + aggregate: (fn, column, args) => driverAggregate(fn, column, args),
2080
+ + approximateCount: async () => null, // `null` is "never analysed", and always legal
2081
+ };
2082
+ ```
2083
+
2084
+ `approximateCount` may answer `null` unconditionally — that is the documented value for a table
2085
+ nobody has `ANALYZE`d, and every caller already handles it. `aggregate` cannot be stubbed the same
2086
+ way: a wrong number is worse than no number, so raise `X_AGGREGATE_UNSUPPORTED` if you will not
2087
+ implement it.
2088
+
2089
+ **6. Widen the `switch`.** `TS2366`, or a silent fallthrough if it had a `default`.
2090
+
2091
+ ```diff
2092
+ case 'is-not-null': return sql`${col} is not null`;
2093
+ + case 'contains': return sql`${col} @> ${bind}`;
2094
+ + case 'contained-by': return sql`${col} <@ ${bind}`;
2095
+ + case 'overlaps': return sql`${col} && ${bind}`;
2096
+ + case 'has-key': return sql`${col} ? ${bind}`;
2097
+ ```
2098
+
2099
+ The four exist so a declared `json()` or `arrayOf()` column is no longer write-only from the query
2100
+ language.
2101
+
2102
+ **8. `rateLimitSpends` answers a LIST, not a key.** `TS2305`.
2103
+
2104
+ ```diff
2105
+ - const key = rateLimitKey(route, ctx);
2106
+ - await limiter.assert(key, bucket);
2107
+ + for (const spend of rateLimitSpends(route, ctx, config)) {
2108
+ + await limiter.assert(spend.key, spend.bucket);
2109
+ + }
2110
+ ```
2111
+
2112
+ One request spends the caller's bucket and then the tenant's, stopping at the first refusal. The old
2113
+ builder answered `actor` **else** `org` **else** `ip`, exclusively — and the anonymous actor answers
2114
+ `null` for both of the first two — so no HTTP request ever spent an org bucket. `RateLimitConfig`
2115
+ gains a required `tenantBucket: string | null`; `null` is "this app has no per-tenant allowance",
2116
+ which is the default and the previous behaviour.
2117
+
2118
+ **9. Add `deadlineAt` to a hand-built `Ctx`.** `TS2741`.
2119
+
2120
+ ```diff
2121
+ const ctx: Ctx = {
2122
+ requestId, traceId, locale, tz, buildId, role, actor, now,
2123
+ + deadlineAt: null, // null is "no deadline", which is what a job or a test has
2124
+ };
2125
+ ```
2126
+
2127
+ `createContext({ … })` already defaults it, so only a literal pays. It is what
2128
+ `remainingBudgetMs(ctx)` reads and therefore what entry 10 propagates.
2129
+
2130
+ **13. `memoryAuditSink()` is not a system of record.** The interface break is `TS2739` on
2131
+ `size`/`dropped`; the behaviour break is silent.
2132
+
2133
+ ```diff
2134
+ - setAuditSink(memoryAuditSink());
2135
+ + setAuditSink(postgresAuditSink({
2136
+ + executor: { query: (text, values) => db().query({ text, values }) },
2137
+ + }));
2138
+ ```
2139
+
2140
+ Past 1,000 records the memory sink drops the **oldest** on every write, so an audited action can run,
2141
+ succeed, be recorded and leave nothing behind. `{ maxRecords }` raises the bound and does not remove
2142
+ it. `dropped` is non-zero exactly when the sink is telling you it is the wrong one. `x_audit` is
2143
+ applied at boot beside the jobs, idempotency and rate-limit tables, so there is no migration to write.
2144
+
2145
+ **14. Delete the budget key.** `TS2353`.
2146
+
2147
+ ```diff
2148
+ budget: {
2149
+ js: '40kb',
2150
+ - css: '12kb',
2151
+ - cls: 0.1,
2152
+ },
2153
+ ```
2154
+
2155
+ There is nothing to replace them with. All three were declared on the route contract, flattened away
2156
+ by `registerRoute` — which projects a budget to `budgetJs` + `budgetLcp` and nothing else — and read
2157
+ by no consumer anywhere, so a declared CSS budget was ignored while the `budgets` step reported
2158
+ green. A new budget key is now a build error until the descriptor projects it
2159
+ (`_EveryBudgetKeyIsProjected`, `packages/render/src/type-pins.tsx`). `budget.lcp` survives and is
2160
+ **published, not enforced**: nothing in the build observes a paint.
2161
+
2162
+ **16. Implement `DoctorProbe.database()`.** `TS2741`. Only a hand-written probe pays — `x doctor`'s
2163
+ own is unchanged.
2164
+
2165
+ ```diff
2166
+ const probe: DoctorProbe = {
2167
+ bunVersion, root, port, production, devCursorSecret, devStorageSecret,
2168
+ + database: () => probeDatabase(process.env['DATABASE_URL']),
2169
+ };
2170
+ ```
2171
+
2172
+ `x doctor` answered "shippable" while probing the web port alone: a wrong password or a database that
2173
+ does not exist accepts the socket and refuses the session, which a port check cannot see. It now
2174
+ probes both ports and the database.
2175
+
2176
+ ### Entries 1, 2, 3 and 11 — do this before the deploy
2177
+
2178
+ **1. Drop every persisted cursor.** Nothing fails to compile; a stored cursor is refused at decode
2179
+ with `X_CURSOR_INVALID`.
2180
+
2181
+ | Where a cursor lives | Do this |
2182
+ |---|---|
2183
+ | a URL a client holds | nothing — request the first page (`after: null`) and re-mint |
2184
+ | a job payload, a resumable `inBatches()` position | re-enqueue from the start, or from a business key of your own |
2185
+ | a client store, a saved view | clear it on the version bump |
2186
+
2187
+ Two changes make an old cursor unreadable, and both were forced by one defect. A `timestamp()` sort
2188
+ key is now carried as a **microsecond epoch** rather than an ISO string, because `ORDER BY` evaluates
2189
+ `timestamptz` at microsecond precision while the seek treated a whole millisecond as one equality
2190
+ class — two different equality classes over one page boundary. Reproduced against Postgres 16: three
2191
+ rows inside one millisecond, uuid-v7 ids, `orderBy('createdAt','desc').limit(1)` returned **1 of 3
2192
+ rows and stopped**, every time. And every entry is **tagged** — `~` for an absent value, `!` before a
2193
+ present one — so an absence can be told from the text that spells it, which is what nullable sort
2194
+ keys need.
2195
+
2196
+ The seek is now a plain `<` / `>` / `=` against `$n::timestamptz`; `nextMillisecond` and the
2197
+ `>= v and < v + 1ms` window are gone. A read ordered by a `timestamp()` column carries one extra
2198
+ output column on the wire, `"<col>$US"` — a name no entity can declare, stripped by `decodeRow`, so
2199
+ it reaches no caller's row. **Only a test that asserts SQL text sees it**, which is exactly the suite
2200
+ that hid this defect for three majors.
2201
+
2202
+ **2. The default total order is uniform-direction.**
2203
+
2204
+ ```diff
2205
+ - ORDER BY created_at DESC, id ASC
2206
+ + ORDER BY created_at DESC, id DESC
2207
+ ```
2208
+
2209
+ `totalOrder` appends the primary key in the **last declared** key's direction rather than always
2210
+ ascending, so `orderBy('createdAt','desc')` runs `created_at desc, id desc`. **Rows sharing a sort
2211
+ value come back in the opposite order to before**, and no page is lost either way — the seek matches
2212
+ the order it is built from.
2213
+
2214
+ Two things follow. A mixed-direction order was un-indexable by this framework's own index DSL, so the
2215
+ default one could never be served by a declared index. And a uniform order is now emitted as a row
2216
+ comparison `(a, b) < ($1, $2)`, measured on PG16 as an `Index Only Scan` against `BitmapOr` + `Sort`
2217
+ for the or-chain. The or-chain remains for an order you wrote as mixed yourself, and for one whose
2218
+ keys are not all `NOT NULL`: a row comparison has no null ordering, so a NULL on either side makes
2219
+ the whole comparison unknown.
2220
+
2221
+ **3. Rename the indexes that declare `where` or `order`.** A declared index is matched by name, so
2222
+ the old one is not dropped and the new one is not created until you say so.
2223
+
2224
+ ```sh
2225
+ x db gen "rename partial and ordered indexes" # then read the emitted up/down before applying
2226
+ ```
2227
+
2228
+ ```sql
2229
+ -- what the generated migration looks like, one pair per affected index
2230
+ alter index posts_author_id_idx rename to posts_author_id_9f2c1ab4_idx;
2231
+ ```
2232
+
2233
+ The discriminator is `sha256("<order>|<where>")`, first 8 hex. **Plain and `unique()` names are
2234
+ unchanged**, deliberately: Postgres names a column-level `unique()` index `<table>_<column>_key`
2235
+ itself, so a discriminator there would make the generator emit a second `create unique index` for an
2236
+ index that already exists (`42P07`), and a foreign key's own index is deduped against a hand-declared
2237
+ one by the plain name.
2238
+
2239
+ Without it, two **different** partial indexes on one column were one name — `posts_author_id_idx` for
2240
+ both `where status = 'published'` and `where status = 'draft'` — and the second was dropped with no
2241
+ error, no warning and no drift finding. So this migration may create an index you declared years ago
2242
+ and never had. A name over 63 bytes is now refused at declaration rather than truncated by the server
2243
+ in silence.
2244
+
2245
+ **11. Redeploy clients and `sync` nodes together.** `PROTOCOL_VERSION` moves 1 → 2 and a skewed peer
2246
+ is refused with `X_PROTOCOL_VERSION`, in **both** directions — a cursor rides the client's `subscribe`
2247
+ and the node's `snapshot`, and the deleted fields were decoded through `str()` / `num()`, which throw
2248
+ on absence. There is no rolling window in which the two versions interoperate.
2249
+
2250
+ ```diff
2251
+ - import { digestOf, DIGEST_UNVERIFIED, fnv1a } from '@ultimat3/realtime';
2252
+ + // nothing replaces them
2253
+ ```
2254
+
2255
+ `LiveCursor.digest` and `LiveCursor.count` are gone with them. Every snapshot ran `canonicalJson`
2256
+ over every row and hashed it for a value no code path read — a full serialize-and-hash of every
2257
+ result set, per live query, per reconnecting socket, in the restart storm this package is benchmarked
2258
+ on. `count` would have been wrong had it ever gained a reader: `advance` seeds its set from the
2259
+ already-truncated `ids`, so a delete past `CURSOR_ID_LIMIT` never decremented it. `@ultimat3/flags`
2260
+ and `@ultimat3/ai` keep their own `fnv1a` and are untouched.
2261
+
2262
+ ### Entries 7, 10, 12 and 15 — a caller can see the difference
2263
+
2264
+ **7. `introspect()` returns app tables only.**
2265
+
2266
+ | Relation | Before | Now |
2267
+ |---|---|---|
2268
+ | an ordinary or partitioned table | returned | returned |
2269
+ | a view, a materialised view, a foreign table | returned | **excluded** |
2270
+ | anything Postgres records as extension-owned (`pg_depend`, `deptype = 'e'`) | returned | **excluded** |
2271
+ | a table someone created by hand | returned | returned, and still `unexpected-table` |
2272
+
2273
+ `IntrospectOptions.exclude` no longer decides the set on its own — it narrows what survives the rule
2274
+ above, and cannot bring an excluded relation back. **This is what makes a stock managed Postgres
2275
+ deployable**: `create extension pg_stat_statements` in `public` is the CNPG, RDS, Supabase and Neon
2276
+ default, and its view read as `unexpected-table` with `x db gen "add pg_stat_statements"` as the
2277
+ printed fix — so every deploy failed terminally and following the fix would have written an
2278
+ extension's internal view into the app's migration set. Ownership rather than a name prefix, because
2279
+ that rule covers the view and misses PostGIS's `spatial_ref_sys`.
2280
+
2281
+ **Edit only if a test asserts the statement count**: it issues four catalog queries where it issued
2282
+ three.
2283
+
2284
+ **10. A downstream service now receives `x-request-timeout-ms`.** No edit on the calling side —
2285
+ `traceHeaders()` is spread by both typed clients before your own headers, so an explicit value still
2286
+ wins.
2287
+
2288
+ | Situation | Header sent |
2289
+ |---|---|
2290
+ | in a request with 12s left of its budget | `x-request-timeout-ms: 12000` |
2291
+ | in a request whose budget is spent | none — **never `0`**, which the far side reads as "the caller asked for nothing" |
2292
+ | in a job, a test, a browser | none; there is no ambient deadline |
2293
+
2294
+ The receiving end may only be **shortened** by it: `resolveTimeoutMs` takes the minimum of its own
2295
+ configured budget and the header. Before this, a 30s gateway budget already spent to t=29 handed the
2296
+ next service a fresh 30s, so work ran for another half minute holding a pool slot and a vendor
2297
+ connection after the caller's socket had already been answered `X_TIMEOUT`.
2298
+
2299
+ **12. MCP callers are metered.** 120 read and 20 write per minute, per actor, per class —
2300
+ `X_MCP_RATE_LIMITED`, 429, with `Retry-After`.
2301
+
2302
+ ```ts
2303
+ mcpHttpRoute({ server, resolveToken, rateLimits: { read: 600, write: 60 } });
2304
+ // or defineAppMcp({ …, rateLimits: { read: 600, write: 60 } })
2305
+ ```
2306
+
2307
+ A `tools/call` naming a `destructive: true` tool spends `write`; **so does any call this server
2308
+ cannot resolve**, fail-closed, so a probing client never gets the cheap bucket. Everything else,
2309
+ `initialize` included, spends `read` — a coarse per-route rule would have thrown an agent off on its
2310
+ handshake. The key names the actor and never reaches the caller.
2311
+
2312
+ **Behind more than one replica, pass the store too** — the default counts per process, which is
2313
+ honest for `x mcp serve` and a lie for N replicas behind one URL, each enforcing the full allowance
2314
+ on its own:
2315
+
2316
+ ```ts
2317
+ mcpHttpRoute({ server, resolveToken, rateLimitStore: postgresRateLimitStore({ executor }) });
2318
+ ```
2319
+
2320
+ `X_MCP_RATE_LIMITED` is its own code and not `X_RATE_LIMITED` because the knob differs: that one's
2321
+ `fix:` names the HTTP pipeline's buckets, which do not govern this route, so raising them would run
2322
+ and change nothing.
2323
+
2324
+ **15. Name the queue.** `X_JOB_CLAIM_QUEUES_EMPTY` from both drivers.
2325
+
2326
+ ```diff
2327
+ - await driver.claim({ queues: [], limit, visibilityTimeoutMs, workerId });
2328
+ + await driver.claim({ queues: ['default'], limit, visibilityTimeoutMs, workerId });
2329
+ ```
2330
+
2331
+ An empty list named no queue and meant two different things: **every** queue on the memory driver,
2332
+ **the `default` queue** on Postgres, with `ClaimOptions.queues` documenting neither. Each meaning is
2333
+ silently wrong in the other's deployment — one takes work this worker was never configured for, the
2334
+ other drains nothing and reads as an idle queue. There is no third meaning to pick.
2335
+
2336
+ `createWorker` passes exactly one queue per pass, so only an embedder calling a driver directly is
2337
+ affected. The memory driver's `claim` is now `async` to raise the refusal, so a caller that read its
2338
+ return synchronously gets a `Promise`.
2339
+
2340
+ ### Added and fixed in the same release, and none of it costs an edit
2341
+
2342
+ Read these if you built a workaround for one.
2343
+
2344
+ | Change | What it means |
2345
+ |---|---|
2346
+ | **read replicas** | `DATABASE_REPLICA_URL` plus a `withReplicaReads(fn)` scope. Opt in **twice** and byte-identical when unconfigured. Read-your-writes is the rule, not an option: one write at any depth pins the rest of the scope to the primary, and a transaction is always the primary's. Three consecutive replica failures park it for ten seconds. **The URL must name a read-only standby** |
2347
+ | **`configureHttp()`** | the entire HTTP tuning surface — CORS origins, body limit, request timeout, max in-flight, rate-limit buckets — was reachable from **no app config key that existed**. `AppConfig` has never had an `http` member, so every `fix:` line naming `http.<key>` in `app.config.ts` resolved against nothing. Call it at module scope in a file under `apps/*/`. `rateLimit.scope` stays boot-owned |
2348
+ | **a durable audit sink** | `postgresAuditSink({ executor })`, append-only, no purge — retention is a legal question with a different answer per app. The row is a fixed allow-list and never a walk of the `Ctx`, which on an HTTP surface carries the caller's `Authorization` and `Cookie` |
2349
+ | **aggregates and containment** | `sum` / `avg` / `min` / `max` / `approximateCount`, and four containment operators. `min`/`max` on text is refused (Postgres orders by collation, JS by code unit); `avg` over money is refused, naming `sum()` + `count()` |
2350
+ | **nullable sort keys order** | `asc nulls last` / `desc nulls first`, with the null position carried in the cursor. Only a nullable **primary-key** column is still refused — `null = null` is unknown, so the tiebreak cannot break a tie. The refusal also moved to plan time: it used to fire only when a next page existed, so it was green on 15 seeded rows and `X_INVARIANT_VIOLATED` on the first real read |
2351
+ | **a `policy` gate step**, twentieth | every permission an app grants or requires must be one it declares. the scaffold shipped an app that answered `X_PERMISSION_UNKNOWN` on two of its three routes — status 500 — under a green gate. It skips in the framework monorepo, which declares no roles |
2352
+ | **`X_MANIFEST_MISSING`** | an app root with no `x.manifest.json` fails the `manifest` step. Nothing ever ran `x manifest`, so the file did not exist in any app `x new` produced while the step reported green. Run `x manifest` once and commit it |
2353
+ | **`scripts/declaration-readers.ts`** | every leaf key of every primitive declaration needs a reader in shipped source. 173 leaves across 18 roots, ratchet at zero |
2354
+ | MCP `minLength` / `maxLength` count code points | the validator counted UTF-16 code units while the schema that publishes those numbers counts code points, so an astral-character argument was passed and then refused by the action's own parse, or refused outright on a bound the agent had obeyed |
2355
+ | `x i18n add <locale>`, `x dev --port N` | a locale file that turned the gate red printing a fix that repaired nothing; and a dev server dying on port N+1 with a caught `Error` rendered into the cause and `X_CLI_UNEXPECTED` rather than a stable code |
2356
+
2357
+ ## 10.x → 11.0.0, entry by entry
2358
+
2359
+ **Seven breaking entries, from a shutdown, cache and disclosure sweep.** Four are compile errors the
2360
+ moment you upgrade. Three are not — and one of those changes what a CDN is allowed to store for a
2361
+ signed-in visitor, so read entries 3, 4 and 5 even if nothing here fails to compile. **No
2362
+ `app.config.ts` key moves in this major**, so there is no config edit to start with. No codemod.
2363
+
2364
+ | # | Surface | Costs you an edit if |
2365
+ |---|---|---|
2366
+ | 1 | `isrKey(url, locale)` takes the negotiated locale, and it is part of the store key | you call `isrKey` — the document a multi-locale `isr` route serves changes either way |
2367
+ | 2 | `IsrStore` gains a required `markStale(path)` | you implement `IsrStore` yourself |
2368
+ | 3 | `HttpConfig.drainTimeoutMs` is `number \| null`, default `null` | you read the resolved field — and the drain budget moves 15s → 25s for an app that declared neither |
2369
+ | 4 | an unclassified 5xx problem document carries no exception text | a client reads `title` / `detail` / `cause` off a 500 |
2370
+ | 5 | a request carrying an identity gets `private, max-age=0`; every shared response varies on `cookie` and `x-timezone` | never — a CDN leak, closed. Personalised pages stop being shared-cacheable, which is the fix |
2371
+ | 6 | `initialsOf(name, locale)` takes a required locale | you call `initialsOf` directly; `<Avatar>` is unchanged |
2372
+ | 7 | `@ultimat3/pwa` deletes `RetryPolicy`, `DEFAULT_RETRY`, `retryDelayMs`, `shouldRetry` and `BackgroundSyncOptions.retry` | you imported one **from `@ultimat3/pwa`**. `@ultimat3/jobs` exports two of those names and is untouched |
2373
+
2374
+ ### Entries 1, 2, 6 and 7 — a compile error the moment you upgrade
2375
+
2376
+ **1. Pass the negotiated locale to `isrKey`.** `TS2554`, one argument short.
2377
+
2378
+ ```diff
2379
+ - const served = await isr.serve(isrKey(url), () => renderPage(url));
2380
+ + const served = await isr.serve(isrKey(url, ctx.locale), () => renderPage(url));
2381
+ ```
2382
+
2383
+ `isrKey` is on `@ultimat3/render/server`, where 9.0.0 put it. The locale rides in a reserved query
2384
+ parameter — `__x_locale`, exported as `ISR_LOCALE_PARAM` — and not as a prefix, because `routePathOf`
2385
+ splits a key at its `?`: an `es:/blog` key matches no route, so `descriptorFor` answers `undefined`
2386
+ and a declared `revalidate: { ttl }` silently becomes tag-only.
2387
+
2388
+ One entry per path served visitor 2 the document negotiated for visitor 1 — `<html lang>`, every
2389
+ `t()` — for the whole TTL, and `s-maxage` told the CDN to do the same. `toResult` emits
2390
+ `vary: accept-language` now for the CDN half; the rest of the shared key comes from `@ultimat3/http`'s
2391
+ `cache-headers` stage, which sees the actor this function cannot.
2392
+
2393
+ The **time zone is deliberately not a dimension**: a locale set is declared and bounded, a zone list
2394
+ is not. A date on an `isr` page belongs in a zone the page itself names, or the page belongs in `ssr`.
2395
+
2396
+ **2. Implement `markStale` in place.** Only a custom `IsrStore` pays this — `memoryIsrStore()` has it.
2397
+
2398
+ ```diff
2399
+ const store: IsrStore = {
2400
+ get: (path) => map.get(path),
2401
+ set: (entry) => { map.delete(entry.path); map.set(entry.path, entry); },
2402
+ + markStale: (path) => {
2403
+ + const entry = map.get(path);
2404
+ + if (entry === undefined) return false;
2405
+ + map.set(path, { ...entry, stale: true }); // in place: the position IS the eviction order
2406
+ + return true;
2407
+ + },
2408
+ delete: (path) => { map.delete(path); },
2409
+ paths: () => [...map.keys()].sort(),
2410
+ };
2411
+ ```
2412
+
2413
+ **Never `set({ ...entry, stale: true })`** — that read-modify-write is the defect the member exists to
2414
+ end. `set` means "this page was just generated" and a store is entitled to order eviction by exactly
2415
+ that, so marking through it made the **stalest** page the newest: a tag bust protected the pages that
2416
+ most needed regenerating.
2417
+
2418
+ Second half, and it costs nothing: `regenerate` samples a cache fence **before** the render and does
2419
+ not store an entry the fence invalidated. A bust landing mid-render was previously erased by
2420
+ `store.set({ stale: false })`, and for a tag-only route `isFresh` is true forever — so the process
2421
+ served pre-write HTML for the rest of its life.
2422
+
2423
+ **6. Pass the locale to `initialsOf`.** `TS2554`.
2424
+
2425
+ ```diff
2426
+ - initialsOf(member.displayName)
2427
+ + initialsOf(member.displayName, useUi().locale)
2428
+ ```
2429
+
2430
+ `<Avatar>` reads `useUi().locale` itself, so a component tree pays nothing. A bare
2431
+ `toLocaleUpperCase()` reads the **runtime's** default locale — a server's `LANG`, a browser's UI
2432
+ language, never the request's — so one Turkish name uppercased to `İ` on the server and `I` in the
2433
+ browser, out of identical props. `@ultimat3/ui` has no ambient locale to fall back on, by rule.
2434
+
2435
+ **7. Delete the import; there is nothing to replace it with.** `TS2305`.
2436
+
2437
+ ```diff
2438
+ - import { backgroundSyncSource, DEFAULT_RETRY, type RetryPolicy } from '@ultimat3/pwa';
2439
+ + import { backgroundSyncSource } from '@ultimat3/pwa';
2440
+
2441
+ - backgroundSyncSource({ flushEndpoint, retry: { ...DEFAULT_RETRY, maxAttempts: 5 } });
2442
+ + backgroundSyncSource({ flushEndpoint });
2443
+ ```
2444
+
2445
+ `BackgroundSyncOptions` is `{ flushEndpoint?: string }` and nothing else — in this release. 21.0.0
2446
+ removes it as well, together with the flush route; see entry 8 of `20.x → 21.0.0`. This package schedules no
2447
+ retry and never did: the one-shot `sync` handler rejects and the **platform** decides when to wake it
2448
+ again. Of the policy only `maxAttempts` reached the emitted worker, as a `SYNC_MAX_ATTEMPTS` constant
2449
+ nothing read, and `X_PWA_SYNC_INCOMPLETE`'s `fix:` told the reader to raise
2450
+ `pwa.backgroundSync.retry.maxAttempts` — a key `PwaConfig` has never carried, because
2451
+ `backgroundSync` is a boolean. [Error codes](Error-Codes) already says so.
2452
+
2453
+ **`@ultimat3/jobs` is a different package carrying two of those names.** `RetryPolicy` and
2454
+ `DEFAULT_RETRY` are still exported from it, still read by the worker, unchanged. Only pwa's copies are
2455
+ gone, and a `RetryPolicy` on a `job()` is not one of them.
2456
+
2457
+ ### Entries 3, 4 and 5 — nothing fails to compile, and a caller can see the difference
2458
+
2459
+ **3. `drainTimeoutMs` is `number | null`, and `null` means "this app did not say".**
2460
+
2461
+ | What the app declared | Drain deadline before | Now |
2462
+ |---|---|---|
2463
+ | nothing | 15,000ms | **25,000ms** — core's own `DEFAULT_DEADLINE_MS` |
2464
+ | `configureLifecycle({ deadlineMs: 600_000 })` | **15,000ms** — reverted by the next line of boot | 600,000ms |
2465
+ | `defineHttpConfig({ drainTimeoutMs: 5_000 })` | 5,000ms | 5,000ms |
2466
+
2467
+ `createServer` calls `configureLifecycle({ deadlineMs })` only when the app declared one
2468
+ ([`packages/http/src/server.ts:101`](https://github.com/developerz-ai/ultimate/blob/main/packages/http/src/server.ts#L101)).
2469
+ Unconditional, with `defineHttpConfig` defaulting the number, that line reverted the exact edit
2470
+ `X_SHUTDOWN_TIMEOUT`'s own `fix:` prints — silently, in every process that serves web.
2471
+
2472
+ **Edit only if you relied on the 15s default** — write it down:
2473
+
2474
+ ```diff
2475
+ - defineHttpConfig({ rateLimit: { scope: 'process' } })
2476
+ + defineHttpConfig({ rateLimit: { scope: 'process' }, drainTimeoutMs: 15_000 })
2477
+ ```
2478
+
2479
+ The INPUT field is still `number | undefined`, so a declaration compiles unchanged. The RESOLVED
2480
+ field is `number | null`, so `const ms: number = config.drainTimeoutMs` is `TS2322` — that is the
2481
+ compile half, and it reaches only a caller that reads the merged config back.
2482
+
2483
+ **4. An unclassified 5xx says nothing about the exception that caused it.**
2484
+
2485
+ | Member | On a 5xx nobody classified | On a coded refusal |
2486
+ |---|---|---|
2487
+ | `type`, `status`, `code`, `fix`, `docs`, `requestId` | unchanged | unchanged |
2488
+ | `title` | `unhandled server error` | the code's own title |
2489
+ | `detail`, `cause` | one fixed sentence pointing at this process's logs, under the request id | the authored cause |
2490
+
2491
+ "Unclassified" is `X_INTERNAL`, or a code with no row in `@ultimat3/http`'s table **and** no
2492
+ `registerErrorStatus` row — deliberately not `status >= 500`, which would have blanked `X_DRAINING`'s
2493
+ one instruction. `dev: true` is unchanged, and the text is not lost: the `error-map` stage logs it as
2494
+ a redactable field and reports every 5xx to the error monitor, both keyed by `requestId`.
2495
+
2496
+ A `pg` message quoting the rejected row, a driver message quoting the DSN, went to any non-HTML client
2497
+ in production. `error-page.ts` had locked the browser out of exactly this, so the two audiences
2498
+ disagreed about one condition.
2499
+
2500
+ **Edit only if a client parsed those members.** Match on `code`, correlate on `requestId`. A 5xx of
2501
+ your own that should keep its authored cause needs a status of its own — that is what makes it
2502
+ classified:
2503
+
2504
+ ```ts
2505
+ registerErrorStatus({ X_PAYMENTS_UNREACHABLE: 502 }); // from @ultimat3/http, once at boot
2506
+ ```
2507
+
2508
+ **5. A request carrying an identity is `private, max-age=0`, whatever the handler declared.**
2509
+
2510
+ ```diff
2511
+ - cache-control: public, max-age=0, s-maxage=30, stale-while-revalidate=300
2512
+ + cache-control: private, max-age=0
2513
+ ```
2514
+
2515
+ The `cache-headers` stage **reviews** a `cache-control` the handler wrote instead of standing down: a
2516
+ declaration offering the response to a shared cache (`public`, or an `s-maxage`) plus a non-anonymous
2517
+ actor is replaced. `immutable` is the one exception — it asserts the body is a function of the URL
2518
+ alone, which a content-addressed island chunk or image is, and demoting those re-downloads every
2519
+ chunk on every navigation for every signed-in user.
2520
+
2521
+ An **anonymous** shared response is unchanged except that it now carries
2522
+ `vary: accept-language, cookie, x-timezone`. Both halves are needed: `private` for the identified
2523
+ request, `vary: cookie` for the shared one.
2524
+
2525
+ What was happening: `ssrHeaders` offers any route without a `policy` to a CDN for 30 seconds, and
2526
+ `meta.auth` is `'public' | 'required'` — so the commonest page in any app, public but greeting you by
2527
+ name when you are signed in, is a `'public'` route whose own header said `s-maxage`. That is the shape
2528
+ `x g route --surface app` scaffolds.
2529
+
2530
+ **No edit, and expect the shared-cache hit rate on personalised pages to go to zero** — that is the
2531
+ fix, not a regression. A route that really is a function of the URL alone says so:
2532
+ `cache-control: public, max-age=31536000, immutable`.
2533
+
2534
+ ### Fixed in the same release, and none of it costs an edit
2535
+
2536
+ Read these if you built a workaround for one.
2537
+
2538
+ | Fix | What stops happening |
2539
+ |---|---|
2540
+ | the framework's CSP admits its own hydration runtime in production | **no island booted after deploy, anywhere the policy is enforced.** `script-src` was `'self' 'wasm-unsafe-eval'` with no hash while the runtime is an inline module — report-only under `x dev`, enforced in a container. `startWeb` now hashes the seven `HYDRATE_RUNTIME_BODIES` into `script-src`, as it already did for styles |
2541
+ | a browser gets an error page, not `problem+json` | a 404 or a 500 rendering the internal `cause` and the author-facing `fix:` into a visitor's window. Copy is the catalog's `errors.*` keys; override per status with `apps/web/site/errors/<status>.html`, and `x dev` keeps the overlay |
2542
+ | `worker`, `scheduler` and `sync` drain in two phases, and `holdUntilShutdown` reaches the exit | one `accept` hook spending the whole budget before "stop listening" and "stop upgrading" had been invoked at all — 4 hooks started, none finished — and an overrun wedging the process until the kubelet's SIGKILL, where the job lease lapsed and another worker re-ran it. **Behaviour change**: a job outrunning `configureLifecycle({ deadlineMs })` is abandoned (`jobs.worker.drain-abandoned`) and the queue redelivers it, where the teardown used to hang forever with the driver open. Raise the budget past your slowest job |
2543
+ | a worker's fleet slot is released before the driver closes, and a renewal interval is `unref`ed | a `concurrency: 1` job unclaimable by the replacement pod for a full visibility window after every deploy, and a refed interval holding a drained process open until SIGKILL |
2544
+ | the scheduler re-asserts leadership before **every** task, not once per round | the tail of a round dispatching under a lease another node already took. The occurrence key does not absorb it: `SQL_ENQUEUE`'s conflict target is partial over the live states, so a duplicate landing after that job finished inserts a new row and the handler runs twice |
2545
+ | a replayed backfill batch writes no ledger row | 4,800 `x_backfills` UPDATEs before a resumed 5M-row sweep read a single new row, on every attempt, inside the visibility lease |
2546
+ | a server render gets a live client instead of a 500 | a page whose body reads a live query failing on the server; it renders its loading branch and the browser takes over on hydrate. `mutate()` / `drain()` there are `X_LIVE_SERVER_RENDER` |
2547
+ | `createLogger({ level: 'verbose' })` is refused at construction | an unknown level failing **open** — every level emitted |
2548
+ | one documented first run, and it is `bin/setup` | `cd myapp && x dev` failing on `X_BUILD_FAILED` because `x new` installs nothing — which eight doc pages, and `x new`'s own closing line, told the reader to do. `wiki/Installation.md` also listed six `x new` flags that do not exist |
2549
+
2550
+ ## 9.x → 10.0.0, entry by entry
2551
+
2552
+ **Nineteen breaking entries, from a twelve-audit correctness sweep.** Every one deletes or corrects
2553
+ a declaration that promised something the code did not do. **One `app.config.ts` edit.** Six compile
2554
+ errors. Five that no compiler will find — read those even if nothing else here applies. Four refuse
2555
+ a declaration that was *already* broken, and three are corrected underneath you at no cost. No
2556
+ codemod.
2557
+
2558
+ | # | Surface | Costs you an edit if |
2559
+ |---|---|---|
2560
+ | 1 | `ERROR_DOCS_BASE` and `errorDocsUrl(code)` are deleted | you construct an `UltimateError` with an explicit `docs:` |
2561
+ | 2 | a problem document's `type` is `urn:ultimate:error:<CODE>` | a client matches on that string |
2562
+ | 3 | `@ultimat3/time` refuses a malformed locale with `X_LOCALE_INVALID` | you catch `RangeError` around a formatter |
2563
+ | 4 | `realtime.tier` and `RealtimeTier` are deleted | your `app.config.ts` sets `realtime.tier` — **the one config edit in this major** |
2564
+ | 5 | the WAL decoder returns parsed values, not Postgres' own text | you name `PgOutputMessage`, `entityRow` or `PhysicalRow` |
2565
+ | 6 | a delta resume no longer seats a pre-policy cursor | never — a cross-tenant leak, closed |
2566
+ | 7 | `verifyDigest()` is deleted from `@ultimat3/realtime` | you called it, which nothing could have |
2567
+ | 8 | `defineAuth({ providers })` defaults to `[]` | you serve an OAuth route and name no provider |
2568
+ | 9 | `@ultimat3/auth` writes `x_accounts.access_token` / `refresh_token` as `null` | your own SQL reads either column |
2569
+ | 10 | a multi-audience id token needs a matching `azp`; a future `nbf` is refused | your OAuth provider issues multi-audience id tokens, or a host clock is ahead |
2570
+ | 11 | `MemoryAdapter.createUser` enforces `x_users`' two UNIQUE constraints | a test registers one address twice |
2571
+ | 12 | two admin resources may not claim one `path:` | your `defineAdmin` already had four screens unreachable |
2572
+ | 13 | `registerLayout(name, layout)` refuses a name already taken | two modules register one layout name |
2573
+ | 14 | `assertReadOnly` returns a `ReadOnlyVerdict` | you call it from `@ultimat3/admin/dev` |
2574
+ | 15 | `generate()` no longer collects a LOCAL refusal | you catch `X_AI_PROVIDER_UNAVAILABLE` to mean "the model call failed" |
2575
+ | 16 | `@ultimat3/render`'s graph-based island budget API is removed | you imported `routeJsBytes`, `graphFor`, `checkBudget`, `checkBudgets` or `assertBudget` |
2576
+ | 17 | `@ultimat3/pwa`'s `routeRules` orders by specificity, wildcards last | you ship a generated `sw.js` |
2577
+ | 18 | `subscriptionState` takes a `Clock` | you passed epoch milliseconds |
2578
+ | 19 | `StaticReport` gained a required `unmeasured` | you construct one by hand |
2579
+
2580
+ ### Start here — the one config edit
2581
+
2582
+ ```diff
2583
+ realtime: {
2584
+ enabled: true,
2585
+ - tier: 'live-queries',
2586
+ transport: 'nats',
2587
+ urlEnv: 'NATS_URL',
2588
+ },
2589
+ ```
2590
+
2591
+ `TS2353`, and nothing else. `RealtimeConfig` is `{ enabled, transport, urlEnv }` —
2592
+ [`packages/core/src/config.ts:132`](https://github.com/developerz-ai/ultimate/blob/main/packages/core/src/config.ts#L132).
2593
+
2594
+ `tier` accepted `'channels' | 'live-queries' | 'local-first'`, defaulted, was documented with
2595
+ per-value semantics, and was set by both tracked apps and every scaffolded app — and **nothing read
2596
+ it**. No comparison, no branch, no dereference. `tier: 'local-first'` bought exactly what
2597
+ `'channels'` bought, and the durable local store it advertised does not exist. Which tier an app is
2598
+ on is decided by what it **declares**: a `channel()` topic, a `live: true` query, a local store.
2599
+
2600
+ **Leaving the line in also works, and that is the hazard.** `section()` copies an unknown key
2601
+ through, so a stale `tier:` still boots and still does nothing; an app that builds its config into a
2602
+ variable before passing it to `defineConfig` loses excess-property checking and sees no error at
2603
+ all. Same shape as `jobs.driver` in 5.0.0 and `realtime.heartbeatMs` in 4.0.0 — the thirteenth
2604
+ instance of that class. `bun run scripts/config-readers.ts` is what keeps the fourteenth out.
2605
+
2606
+ ### Entries 1, 7, 14, 16, 18 and 19 — a compile error the moment you upgrade
2607
+
2608
+ **1. Omit `docs:`; do not substitute the new constant.**
2609
+
2610
+ ```diff
2611
+ - import { errorDocsUrl, UltimateError } from '@ultimat3/core';
2612
+ + import { UltimateError } from '@ultimat3/core';
2613
+
2614
+ export class BillingDeclinedError extends UltimateError {
2615
+ constructor(cause: string) {
2616
+ - super({ code: 'X_BILLING_DECLINED', cause, fix: 'retry with another card', docs: errorDocsUrl('X_BILLING_DECLINED') });
2617
+ + super({ code: 'X_BILLING_DECLINED', cause, fix: 'retry with another card' });
2618
+ }
2619
+ }
2620
+ ```
2621
+
2622
+ The constructor already resolves `docs` from the registry, so passing it by hand is a second
2623
+ declaration of one fact. `ERROR_DOCS_URL` is exported from `@ultimat3/core` for a caller rendering
2624
+ the link *outside* an error — not as a drop-in for the deleted function.
2625
+
2626
+ Why: `https://ultimate.dev/errors/<code>` answered **HTTP 404**, host included, on every error this
2627
+ framework has ever thrown, including the first line a new agent reads. One URL rather than one per
2628
+ code, because codes live on [Error codes](Error-Codes) in **table rows** and a row has no anchor —
2629
+ a `#X_DB_DRIFT` fragment would be a second dead declaration, not a fix for the first.
2630
+
2631
+ **7. Delete the `verifyDigest()` call.** That is the whole migration, and nobody had one to delete:
2632
+ a delta-resumed cursor carries `DIGEST_UNVERIFIED`, so the check answered `false` for every cursor
2633
+ drift can occur in, and `identity-map.ts` merges columns across queries by design — any app with two
2634
+ reads over one entity would have reported permanent drift. Drift is the server's `desynced` mark.
2635
+
2636
+ **14. `assertReadOnly` returns a verdict, and the verdict carries the string to run.**
2637
+
2638
+ ```diff
2639
+ - const refusal = assertReadOnly(sql);
2640
+ - if (refusal !== null) return { refused: refusal };
2641
+ - const rows = await client.query(sql);
2642
+ + const verdict = assertReadOnly(sql);
2643
+ + if (verdict.kind === 'refused') return { refused: verdict.refused };
2644
+ + const rows = await client.query(verdict.sql);
2645
+ ```
2646
+
2647
+ `ReadOnlyVerdict` is `{ kind: 'runnable'; sql } | { kind: 'refused'; refused }`. **Execute
2648
+ `verdict.sql`, never the string you passed in**: every check ran on a stripped form and the verdict
2649
+ is the reconciled one. The `/_x` panel discarded it and ran the textarea's own bytes, so two callers
2650
+ of one guard disagreed about which string runs.
2651
+
2652
+ **16. Delete the import.** Removed from `@ultimat3/render`: `routeJsBytes`, `graphFor`,
2653
+ `checkBudget`, `checkBudgets`, `assertBudget` and their types. Every one was exported from the
2654
+ barrel and called by nothing; the budget gate that actually runs is `@ultimat3/cli`'s and it
2655
+ measures the emitted document. `parseByteBudget`, `defaultIslandBudget` and `islandModuleIds` are
2656
+ unchanged.
2657
+
2658
+ **18. `subscriptionState` takes a `Clock` where it took epoch milliseconds** — `TS2345` on a
2659
+ `number`. The parameter is optional and defaults to `systemClock`, so most callers delete an
2660
+ argument:
2661
+
2662
+ ```diff
2663
+ - subscriptionState(record, lastStatus, Date.now())
2664
+ + subscriptionState(record, lastStatus)
2665
+ + subscriptionState(record, lastStatus, frozenClock(NOW)) // a test, from @ultimat3/core
2666
+ ```
2667
+
2668
+ **19. `StaticReport` gained a required `unmeasured`** — every budgeted route a build could not
2669
+ weigh, with the reason, which is the list `X_BUDGET_UNMEASURED`'s `fix:` cites by name and which
2670
+ until now reached no `x` command's output at all.
2671
+
2672
+ ```diff
2673
+ const report: StaticReport = {
2674
+ target: 'static', out, buildId, emitted, skipped,
2675
+ + unmeasured: [],
2676
+ };
2677
+ ```
2678
+
2679
+ **Reading one costs nothing**: `parseStaticReport` takes the field as optional and answers `[]` when
2680
+ it is absent, so a `.x/static-report.json` written by an older build still parses. Only
2681
+ hand-construction moves.
2682
+
2683
+ ### Entries 2, 3, 8, 15 and 17 — nothing fails to compile, and a caller can see the difference
2684
+
2685
+ **2. A problem document's `type` is a URN, per code.**
2686
+
2687
+ ```diff
2688
+ - if (problem.type === 'https://ultimate.dev/errors/X_RATE_LIMITED') …
2689
+ + if (problem.type === problemTypeFor('X_RATE_LIMITED')) …
2690
+ ```
2691
+
2692
+ `problemTypeFor(code)` is `urn:ultimate:error:${code}`, exported from `@ultimat3/http`. **`code` is
2693
+ unchanged and is the simpler match** — `problem.code === 'X_RATE_LIMITED'` needs no import. `type`
2694
+ and `docs` used to carry the same dead link on every 4xx and 5xx; they are two values now because
2695
+ they answer two questions — `type` is RFC 9457's identifier for the problem *kind*, a URN so it has
2696
+ no host left to rot, and `docs` is the one wiki page.
2697
+
2698
+ **3. A malformed locale is refused with a code instead of dying as a bare `RangeError`.**
2699
+
2700
+ | Tag | Before | Now |
2701
+ |---|---|---|
2702
+ | `en`, `en-GB`, `de-DE` | formats | formats |
2703
+ | `zz` — well-formed, unknown | `Intl` falls back | `Intl` falls back, **still not refused** |
2704
+ | `en_US`, `''`, a raw `Accept-Language` value | bare `RangeError` out of `Intl`, several frames from the header it came from | `X_LOCALE_INVALID`, with a runnable `fix:` |
2705
+
2706
+ Every `@ultimat3/time` entry point taking a `locale` passed the caller's raw tag to an `Intl`
2707
+ constructor. `assertLocale` is the single gate now, and the list is one command:
2708
+
2709
+ ```sh
2710
+ grep -rn 'assertLocale(' packages/time/src
2711
+ ```
2712
+
2713
+ **Edit only if you catch `RangeError`** around a formatter; screen header input with
2714
+ `Intl.DateTimeFormat.supportedLocalesOf([tag])`. The code's row is on [Error codes](Error-Codes).
2715
+
2716
+ **8. `defineAuth({ providers })` defaults to `[]`, not the live OAuth registry.**
2717
+
2718
+ ```diff
2719
+ export const auth = defineAuth({
2720
+ adapter,
2721
+ + providers: ['github', 'google'],
2722
+ });
2723
+ ```
2724
+
2725
+ An app already passing `providers:` needs nothing. An app that passed none now serves **no**
2726
+ `/auth/oauth/<id>` route — name the ones you mean. The default was every provider any dependency had
2727
+ registered, so the uniform 404 the option exists for could never fire, and an import decided the
2728
+ app's login surface. With the credentials fix in the same release, that closed an enumeration
2729
+ oracle: 500 meant registered, 404 meant not, and the 500 published the app's own `*_CLIENT_ID` and
2730
+ `*_CLIENT_SECRET` names.
2731
+
2732
+ **15. `X_AI_PROVIDER_UNAVAILABLE` now means one thing: the transport failed, on every provider
2733
+ tried.** A *local* refusal reaches the caller with its own code and its own runnable `fix:`.
2734
+
2735
+ | Code | Raised when | Its `fix:` |
2736
+ |---|---|---|
2737
+ | `X_AI_KEY_MISSING` | no key configured and none passed | `export ANTHROPIC_API_KEY=<key>`, or pass `{ apiKey }` to the provider |
2738
+ | `X_AI_REQUEST_INVALID` | a reasoning control the chosen model does not have | set `model:` on the `llm()` request |
2739
+ | `X_AI_PROVIDER_UNAVAILABLE` | a non-2xx, an in-band `error` event, or a stream cut before `message_stop` | retry, or configure a second provider |
2740
+
2741
+ A `catch` treating `X_AI_PROVIDER_UNAVAILABLE` as "the model call failed" stops seeing the two
2742
+ misconfigurations, which is the point: collecting one discarded its instruction and made
2743
+ `generate()` and `stream()` answer one misconfiguration two ways.
2744
+
2745
+ **17. Regenerate `sw.js` — with the call, because no command writes it.** `x build` emits no service
2746
+ worker and nothing in the framework calls `generateServiceWorker`; the generated file's own
2747
+ `regenerate:` header names the call for that reason.
2748
+
2749
+ ```ts
2750
+ generateServiceWorker(routes, config, buildId); // from @ultimat3/pwa
2751
+ ```
2752
+
2753
+ The emitted file changes for any app with a dynamic route above a static sibling. `ruleFor` returns
2754
+ the **first** pattern that matches and the order was alphabetical: `:` (0x3A) and `*` (0x2A) sort
2755
+ before every letter, so `/posts/:id` shadowed `/posts/new`, and a single `/*` shadowed the whole
2756
+ table — every `PRECACHE_MANIFEST` entry downloaded at install and then never looked up. Path is
2757
+ still the tie-break, so identical input still emits an identical file.
2758
+
2759
+ ### Entries 10, 11, 12 and 13 — a refusal of something that was already broken
2760
+
2761
+ **If one of these fires, your app was half-broken before the upgrade** — each produced a declaration
2762
+ that was silently unreachable, not a rule the framework tightened for its own sake. The refusal
2763
+ names the finding.
2764
+
2765
+ | # | Refuses | Code | What had been happening |
2766
+ |---|---|---|---|
2767
+ | 10 | an id token naming several audiences whose `azp` is not this client, and an `nbf` in the future | `X_OAUTH_TOKEN_INVALID` | both only narrow, on the `ID_TOKEN_CLOCK_SKEW_MS` `id-token.ts` already exported to `workload.ts` and did not itself enforce — an `nbf` ten years out verified, and a token an OP minted for another client that also lists yours verified with it (OIDC Core 3.1.3.7) |
2768
+ | 11 | a second `x_users` row with one `email`, or with one `external_id` | `X_AUTH_WRITE_FAILED` | `MemoryAdapter` is what `x new` scaffolds and what every test runs against, so the duplicate path was exercised only against the permissive half of the seam: two `register()` calls at one address made two rows, and the second was unreachable forever |
2769
+ | 12 | two `defineAdmin` resources claiming one `path:` | `X_ADMIN_PAGE_PATH_INVALID` | eight routes over four paths, with the second resource's four screens silently unreachable. The `fix:` hands you a `path:` for one of them |
2770
+ | 13 | `registerLayout(name, …)` on a name already registered | `X_MAIL_DUPLICATE` | `layouts.set` answered whichever module ran last, `base` included, so a dependency could re-shell every framework mail in silence |
2771
+
2772
+ ### Entries 5, 6 and 9 — corrected underneath you
2773
+
2774
+ | # | What changed | What you do |
2775
+ |---|---|---|
2776
+ | 5 | the WAL decoder returns the values a repository row holds. `PgOutputDecoder` decoded a `timestamptz` as `'2026-08-09 12:00:00+00'`, a `text[]` as `'{a,b}'` and a `bytea` as `'\x0102'`, while the shared live window holds rows `@ultimat3/entity` parsed — and `compareValues` normalises a `Date` to its epoch, so **an edit to any column of any row jumped that row to the top of every `orderBy('createdAt', 'desc')` feed for every subscriber**, carrying the raw string into the window. `post.tags.map(…)` threw on the first patch | nothing, unless you name `PgOutputMessage`, `entityRow` or `PhysicalRow` from `@ultimat3/realtime/server` — `after`/`before` and `entityRow`'s return widen to `PhysicalRow`. It is a wire **convergence**: only a real walsender diverged, because `setRowObserver` emits already-parsed rows and the parity test handed the same object to both sides |
2777
+ | 6 | a delta resume no longer seats a pre-policy cursor. `resumeFrom` advanced across the retained patch list, which is pre-policy by design, so a subscriber reconnecting inside the retain window gained the id of every row inserted for every **other** actor while it was away — and then received a `delete` frame carrying another tenant's row id | nothing. The leak `subscriber-gate` exists to close, re-opened one layer up and closed again |
2778
+ | 9 | `@ultimat3/auth` no longer persists provider access or refresh tokens. `x_accounts.access_token` and `refresh_token` held live third-party credentials in the clear under a `tables.ts` header promising "no column holds a plaintext secret", and nothing ever read either one back | nothing, unless **your own** SQL selected either column — both are written `null` now. The type and the DDL are unchanged; keep a token you actually call out with in your own table, encrypted |
2779
+
2780
+ ### Fixed in the same release, and none of it costs an edit
2781
+
2782
+ Read these if you built a workaround for one.
2783
+
2784
+ | Fix | What stops happening |
2785
+ |---|---|
2786
+ | `Gateway.stream()` resolved the provider between `reserve()` and the `try/finally` that releases it | a registered model no configured provider serves debited the estimate and never credited it back — on `MemoryBudgetStore`, which is per process and never expires, **five refused streams spent an org's whole ceiling with nothing ever sent**, and every later call was `X_AI_BUDGET_EXCEEDED` for the life of the process ([#319](https://github.com/developerz-ai/ultimate/issues/319)) |
2787
+ | a successful login cleared the per-IP failure bucket | one credential the attacker owns bought unlimited stuffing — 4 guesses, 1 login, repeat: 160 guesses from one address against a 5-attempt limit, never locked ([#317](https://github.com/developerz-ai/ultimate/issues/317)) |
2788
+ | the OAuth callback published uncoded internal exception text to an unauthenticated caller | connection strings and bind passwords reaching the browser, on two independent paths. The token-endpoint body is no longer reflected either — that request carries `client_secret` ([#318](https://github.com/developerz-ai/ultimate/issues/318)) |
2789
+ | `readonly-sql` ended a `--` comment at `\n` only | a CR terminated the comment for Postgres and not for the scanner, hiding the payload from all four layer-3 checks — including `select pg_advisory_lock(42)`, whose **session** lock survives `ROLLBACK` and outlives the read on a pooled connection ([#316](https://github.com/developerz-ai/ultimate/issues/316)) |
2790
+ | `diffRows` threw on a `money()` column | every `adminUpdate` on a money-bearing entity failed with an uncoded `TypeError` **after** `repo.update()` committed, landing zero audit entries ([#321](https://github.com/developerz-ai/ultimate/issues/321)) |
2791
+ | `x db gen --allow-destructive` emitted a migration Postgres refuses | `drop table` with no preceding FK drop, tables ordered alphabetically rather than by dependency — `SQLSTATE 2BP01` during `ROLE=migrate`, with a `down` that cannot restore |
2792
+
2793
+ ## 8.x → 9.0.0, entry by entry
2794
+
2795
+ **Five breaking entries, from closing the ten findings the 8.0.0 sweep filed rather than absorbed.**
2796
+ Four are compile errors the moment you upgrade. The fifth changes what your cache ladder *is*, at
2797
+ runtime, and it is the one to read even if nothing else here applies. No codemod.
2798
+
2799
+ | # | Surface | Costs you an edit if |
2800
+ |---|---|---|
2801
+ | 1 | `@ultimat3/render` splits into `.` and `./server` | you import a build-time name, or relied on importing the barrel to install the `.tsx` loader |
2802
+ | 2 | `cache.tiers` names the ladder's own rungs, and is now read | your `app.config.ts` sets `cache.tiers` — **runtime behaviour changes even if it compiles** |
2803
+ | 3 | `@ultimat3/storage` renames `IMAGE_FORMATS` / `ImageFormat` | you imported either |
2804
+ | 4 | `@ultimat3/pwa` drops the forced-reload half of `version-skew` | you called `updateSignal` or `updatePolicy` |
2805
+ | 5 | `@ultimat3/core` replaces `CacheTier` with `CacheTierName` | you named the type |
2806
+
2807
+ ### 1. `@ultimat3/render` splits into `.` and `./server`
2808
+
2809
+ ```diff
2810
+ - import { defineRoute, renderToHtml } from '@ultimat3/render';
2811
+ + import { defineRoute } from '@ultimat3/render';
2812
+ + import { renderToHtml } from '@ultimat3/render/server';
2813
+ ```
2814
+
2815
+ 55 names moved: the render pipeline (`renderToHtml`, `renderSsr`, `renderStatic`, `renderStreamHtml`,
2816
+ the ISR controller) and the loaders (`installRenderLoader`, `compileStylesheet`, `stylesFor`,
2817
+ `transformTsx`). `.` keeps the authoring vocabulary — `defineRoute`, `h`, `Fragment`, `island`,
2818
+ `hydrate*`, the registry, the mode tables.
2819
+
2820
+ **Second, easily-missed half: importing `@ultimat3/render` no longer installs the `.tsx`/`.scss`
2821
+ loader.** `@ultimat3/render/server` does. A test that did `await import('@ultimat3/render')` before
2822
+ loading a page module must now import `/server`.
2823
+
2824
+ Why: `bun build --target=browser` on the barrel failed outright — *"Browser polyfill for module
2825
+ `node:url` doesn't have a matching export named `fileURLToPath`"*, out of `css-modules.ts`. The
2826
+ island this framework tells you to write could not be bundled.
2827
+
2828
+ ### 2. `cache.tiers` names the ladder's rungs, and the ladder is now that declaration
2829
+
2830
+ ```diff
2831
+ - cache: { tiers: ['memo', 'lru', 'shared', 'isr'] }
2832
+ + cache: { tiers: ['request-memo', 'lru', 'redis'] }
2833
+ ```
2834
+
2835
+ `memo` → `request-memo`, `shared` → `redis`, and **delete `isr`** — it is a `RenderMode`, and the
2836
+ routes that want it declare `render: 'isr'`. It named a cache rung that never existed.
2837
+
2838
+ **Read this even if your config already compiles.** The key was previously read by *nothing*:
2839
+ `startCacheTiers` registered memo + lru unconditionally, redis on `REDIS_URL`, cdn on a purge
2840
+ credential. An app declaring `tiers: ['request-memo']` measurably got
2841
+ `['request-memo', 'lru', 'redis', 'cdn']`. Now the ladder is the declaration, which means:
2842
+
2843
+ - naming a rung the environment cannot supply **refuses the boot** — `redis` without `REDIS_URL`,
2844
+ `cdn` without a purge credential — rather than quietly building a shorter ladder
2845
+ - an environment offering a rung the config does not name logs `cache.tier.unnamed` and builds nothing
2846
+
2847
+ If you relied on the old always-on `lru`, or on `REDIS_URL` adding a tier your config never
2848
+ mentioned, **name it**.
2849
+
2850
+ ### 3. `@ultimat3/storage` renames its image vocabulary
2851
+
2852
+ ```diff
2853
+ - import { IMAGE_FORMATS, type ImageFormat } from '@ultimat3/storage';
2854
+ + import { VARIANT_FORMATS, type VariantFormat } from '@ultimat3/storage';
2855
+ ```
2856
+
2857
+ Both packages exported those two names over **different sets**, so a storage caller narrowing on
2858
+ storage's type had a type saying `gif` cannot occur and a value from core's probe that was one. If
2859
+ you were probing rather than minting variants, the six-format set is `IMAGE_FORMATS` from
2860
+ `@ultimat3/core` — which is what you actually had.
2861
+
2862
+ `variantKey()` also now refuses a format outside `VARIANT_FORMATS` instead of returning a key ending
2863
+ `.undefined`.
2864
+
2865
+ ### 4. `@ultimat3/pwa` drops the forced-reload half of `version-skew`
2866
+
2867
+ Removed: `updateSignal`, `updatePolicy`, `DEFAULT_GRACE_MS`, and the types `ForceReason`,
2868
+ `UpdatePolicy`, `UpdatePolicyInput`, `UpdateSignalInput`. `AppUpdateAvailable` narrows to
2869
+ `{ type, to }`, losing `from`, `forced` and `deadlineAt`.
2870
+
2871
+ Nothing performed the reload they described, and no runtime could have called them: `@ultimat3/http`
2872
+ (tier 2) and `@ultimat3/realtime` (tier 3) both sit *below* `pwa` (tier 4). **Forcing a reload is not
2873
+ a capability this framework has.** Notification is, and is complete — read
2874
+ `useConnection().updateAvailable`, or compare the worker's posted `to` with `detectSkew`, and render
2875
+ your own affordance.
2876
+
2877
+ ### 5. `CacheTier` → `CacheTierName` in `@ultimat3/core`
2878
+
2879
+ The type behind entry 2. `@ultimat3/cache` still exports a `CacheTier` — it is the tier *interface*,
2880
+ a different thing, and it is unchanged. The two sharing one name is what made a type error about
2881
+ `CacheTier` unreadable.
2882
+
2883
+ ## 7.x → 8.0.0, entry by entry
2884
+
2885
+ **Six breaking entries, from one whole-repo bug sweep.** Five are compile errors the moment you
2886
+ upgrade. The sixth is a **silent** behaviour change, and it is the one to read even if nothing else
2887
+ here applies to you. No codemod.
2888
+
2889
+ | # | Surface | Costs you an edit if |
2890
+ |---|---|---|
2891
+ | 1 | `@ultimat3/realtime` has two entries | you import a **server** name — NATS, pg replication, the sync node, the channel hub |
2892
+ | 2 | `IdempotencyStore.settle` / `fail` take a reservation id | you call either, **or implement the interface** |
2893
+ | 3 | `pwa.installPrompt`, `auth.afterSignInPath`, `ai.modelEnv` deleted | your `app.config.ts` sets one |
2894
+ | 4 | `@ultimat3/manifest` drops `canonical` | you imported it |
2895
+ | 5 | `@ultimat3/render` drops `matchRoute` / `RouteMatch` | you imported either |
2896
+ | 6 | `SQL_CANCEL` projects its columns | you asserted on that constant's text |
2897
+
2898
+ ### 1. `@ultimat3/realtime` splits into `.` and `./server`
2899
+
2900
+ ```diff
2901
+ - import { ChannelHub, createSyncNode, LiveQueryRegistry } from '@ultimat3/realtime';
2902
+ + import { ChannelHub, createSyncNode, LiveQueryRegistry } from '@ultimat3/realtime/server';
2903
+ ```
2904
+
2905
+ Client names — `useLive`, `liveHookFor`, `LiveClient`, the offline queue, rebase, the wire protocol,
2906
+ cursors — are **unchanged on `.`**. A file importing both halves now writes both imports.
2907
+
2908
+ Why: the single barrel carried `useLive` beside `openNatsClient`, so `bun build --target=browser` on
2909
+ an entry importing *only* the hook failed with *"Browser build cannot require() Node.js builtin:
2910
+ `stream/web`"*, out of `nats`. **The island this framework tells you to write could not be bundled.**
2911
+
2912
+ The two barrels are **disjoint** — `./server` re-exports no client name — so which half a symbol
2913
+ lives in is checkable rather than conventional. If an import stops resolving, the name moved to
2914
+ `./server`; nothing was deleted.
2915
+
2916
+ ### 2. `IdempotencyStore.settle` and `fail` take the reservation id
2917
+
2918
+ ```diff
2919
+ - await store.settle(key, value);
2920
+ + await store.settle(key, value, reservation.record.id);
2921
+ ```
2922
+
2923
+ `reservation` is what `store.reserve(key, hash)` answered. Same shape for `fail`.
2924
+
2925
+ **Read this if you implement the interface — it is the one silent entry in this major.** A store with
2926
+ the old two-parameter method **still compiles**, because a shorter function is assignable to a longer
2927
+ signature, and it **silently loses the fence**. Both statements now match on **id and state**, so a
2928
+ straggler from a slow first attempt can no longer overwrite a replacement reservation still in
2929
+ flight. The `fail` half was the worse one: a straggler's failure marked a *live* replacement
2930
+ `failed`, and the replacement's own settle was then fenced out.
2931
+
2932
+ ### 3. Three config fields are deleted
2933
+
2934
+ ```diff
2935
+ - pwa: { enabled: true, offline: 'runtime', installPrompt: true },
2936
+ + pwa: { enabled: true, offline: 'runtime' },
2937
+ - auth: { signInPath: '/signin', afterSignInPath: '/dashboard' },
2938
+ + auth: { signInPath: '/signin' },
2939
+ - ai: { mcp: { expose: true, path: '/mcp' }, modelEnv: 'ANTHROPIC_MODEL' },
2940
+ + ai: { mcp: { expose: true, path: '/mcp' } },
2941
+ ```
2942
+
2943
+ **There is no replacement key, because there was never a behaviour.** Each was declared, defaulted,
2944
+ merged, and read by nothing. Use `createInstallController` from `@ultimat3/pwa`, send the visitor
2945
+ from your own sign-in route, and pass `model` on the `llm()` request.
2946
+
2947
+ `ai.modelEnv`'s own doc comment argued for its deletion: *"an intention, not a behaviour… nothing
2948
+ consumes the merged value… So the exact thing this key exists to prevent — a model string baked into
2949
+ the image — is what actually happens."*
2950
+
2951
+ Same precedent as `JobsConfig.driver` in 5.0.0 and `realtime.heartbeatMs` in 4.0.0. All three fail at
2952
+ **typecheck only** — and an app that builds its config into a variable before passing it loses
2953
+ excess-property checking and sees no error at all. `scripts/config-readers.ts` now keeps the class out.
2954
+
2955
+ ### 4. `@ultimat3/manifest` no longer exports `canonical`
2956
+
2957
+ Use `canonicalJson` from `@ultimat3/core`. It was the third of five copies of one serialiser;
2958
+ `manifest`'s fed `buildId` **and the contract-diff equality**, so a `-0`/`NaN`/`Date` fold could make
2959
+ a breaking API change diff as *"no change"* and ship silently.
2960
+
2961
+ ### 5. `@ultimat3/render` no longer exports `matchRoute` or `RouteMatch`
2962
+
2963
+ Two exported route matchers existed with different precedence. `@ultimat3/http`'s trie is the live
2964
+ one; render's had zero consumers repo-wide.
2965
+
2966
+ ### 6. `SQL_CANCEL` projects its columns instead of `returning *`
2967
+
2968
+ It fed `toJobRecord`, which does `Number(row.run_at)` — so against a text-decoding `PgExecutor` every
2969
+ timestamp came back `NaN`. Only an edit if you asserted on the constant's SQL text.
2970
+
2971
+ ## 6.x → 7.0.0, entry by entry
2972
+
2973
+ **Four breaking entries, and only one of them can reach you at runtime.** Three are compile errors
2974
+ the moment you upgrade; the fourth is a type you may never have named. None ships a codemod.
2975
+
2976
+ | # | Surface | Costs you an edit if |
2977
+ |---|---|---|
2978
+ | 1 | `ScrapeTarget.pageErrors` | you implement `ScrapeDriver`/`ScrapeTarget` yourself |
2979
+ | 2 | `PwaRenderMode` | you import that type name |
2980
+ | 3 | `PwaOfflineStrategy` | you import that type name |
2981
+ | 4 | `PrerenderReport.skipped` | you read `x build --target static --json`, or the report in code |
2982
+
2983
+ ### 1. `ScrapeTarget` gains a required `pageErrors: PageErrorRing`
2984
+
2985
+ **Only a third-party driver author pays this**, and nothing in an ordinary app implements
2986
+ `ScrapeTarget`. If you build one — the shape `packages/scraping/README.md`'s driver-author example
2987
+ builds — construct the ring and, if your transport can observe uncaught page exceptions, push to it.
2988
+
2989
+ ```diff
2990
+ + import { createRing, type PageErrorRing } from '@ultimat3/scraping';
2991
+
2992
+ const target: ScrapeTarget = {
2993
+ // …
2994
+ + pageErrors: createRing(200),
2995
+ };
2996
+ ```
2997
+
2998
+ A driver that **cannot** observe them builds the ring and never pushes — which is exactly what the
2999
+ offline targets do. That is the whole migration.
3000
+
3001
+ **Required rather than optional, deliberately.** An optional ring lets a driver stay *silent* about
3002
+ errors it can see, which is the gap this closed: nothing in `@ultimat3/scraping` subscribed to
3003
+ `pageerror` at all, so an island that **threw** was invisible. A throw calls no console method, so
3004
+ `console()` answered `[]` and a page whose script had died read as clean.
3005
+
3006
+ New on `ScrapePage`, and additive — no edit needed to consume them: `pageErrors()` and
3007
+ `pageErrorsDropped()`. The dropped count makes the list a **floor**, not a total.
3008
+
3009
+ ### 2 and 3. `PwaRenderMode` and `PwaOfflineStrategy` are deleted from `@ultimat3/pwa`
3010
+
3011
+ Two type-only renames. No member changed — only the name the type is declared under.
3012
+
3013
+ | Was | Is | Members, unchanged |
3014
+ |---|---|---|
3015
+ | `PwaRenderMode` | `RenderMode` | `'static' \| 'isr' \| 'ssr' \| 'stream'` |
3016
+ | `PwaOfflineStrategy` | `OfflineStrategy` | `'precache' \| 'runtime' \| 'network-only'` |
3017
+
3018
+ ```diff
3019
+ - import type { PwaRenderMode, PwaOfflineStrategy } from '@ultimat3/pwa';
3020
+ + import type { RenderMode, OfflineStrategy } from '@ultimat3/pwa';
3021
+ ```
3022
+
3023
+ `@ultimat3/pwa` re-exports both under the canonical name, so the import path does not have to move —
3024
+ `@ultimat3/core` is where they are declared and is equally correct.
3025
+
3026
+ **Why the alias existed and why it could not stay.** Tier 4 may not import tier 4, so `@ultimat3/pwa`
3027
+ wrote its own copy of a set `@ultimat3/render` already had. That copy is what kept `spa` mapped to
3028
+ `cache-first` after `spa` was deleted in 6.0.0 — the one strategy that gives an `app/` route a
3029
+ **shared** cache entry, i.e. one signed-in member's HTML served to the next. The vocabulary is now
3030
+ declared once at tier 0, and `bun run scripts/render-modes.ts --json` refuses a second declaration
3031
+ anywhere in `packages/*/src`.
3032
+
3033
+ ### 4. `PrerenderReport.skipped` carries the reason, not just the path
3034
+
3035
+ `readonly string[]` → `readonly SkippedRoute[]`, where a `SkippedRoute` is
3036
+ `{ route, surface, render, reason, why }`. `PrerenderedPage` also gains `route`, the declared path a
3037
+ concrete URL came from.
3038
+
3039
+ ```diff
3040
+ - for (const path of report.skipped) console.log(`skipped ${path}`);
3041
+ + for (const skipped of report.skipped) console.log(`skipped ${skipped.route}: ${skipped.why}`);
3042
+ ```
3043
+
3044
+ `x build --target static --json` now returns `emitted` and `skipped`, and the human path prints the
3045
+ same rows.
3046
+
3047
+ **Why it changed.** `.x/static/` held a partial site and said nothing about the difference: `app/`
3048
+ routes exist only through the server, so a tool pointed at the directory filed *"the island did not
3049
+ mount"* against a route that was never emitted. A list of paths cannot distinguish "not emitted
3050
+ because it needs a server" from "not emitted because it is broken", and those are opposite facts.
3051
+
3052
+ ## 5.x → 6.0.0, entry by entry
3053
+
3054
+ **Installable `As of 2026-08-21`** — `npm view @ultimat3/core version` answers `7.0.0`, so 6.0.0 is behind `latest` and every entry below is a step you take on the way to it. Run that command anyway rather than trusting this line; a version written into a page goes stale on the next tag.
3055
+
3056
+ Seven breaking entries, and the first is a **runtime** refusal with no compile error in front of it.
3057
+
3058
+ ### Start here — the one edit
3059
+
3060
+ Every single-label timezone name except `UTC` is refused. `isValidTimeZone` answers `false`, `canonicalTimeZone` answers `undefined`, `assertTimeZone` throws `X_TIMEZONE_INVALID` — and every `@ultimat3/time` formatter is downstream of that one call. **43 names change answer**, tabulated once under [the `6.0.0` section of `CHANGELOG.md`](https://github.com/developerz-ai/ultimate/blob/main/CHANGELOG.md#600); that table is the source and is deliberately not copied here.
3061
+
3062
+ ```diff
3063
+ - formatDate(at, { locale, zone: 'CET' })
3064
+ + formatDate(at, { locale, zone: 'Europe/Paris' })
3065
+ ```
3066
+
3067
+ ### Which class the name is in decides whether the swap is mechanical
3068
+
3069
+ | Class | Names | Replacement |
3070
+ |---|---|---|
3071
+ | geographic link — 24 of the 43 | `Japan`, `GB`, `Hongkong`, `NZ`, … | the `Area/Location` spelling: `Asia/Tokyo`, `Europe/London`, `Asia/Hong_Kong`, `Pacific/Auckland`. Textual — identical wall clock, identical offset |
3072
+ | UTC alias | `UCT`, `Universal`, `Zulu` | `UTC` |
3073
+ | the `GMT` family | `GMT`, `GMT0`, `GMT+0`, `GMT-0`, `Greenwich` | `Etc/GMT`, which still renders the label `GMT`. **Not `UTC`**, which renders `UTC` — same instant, different text on any surface that prints the zone name |
3074
+ | abbreviation | `CET`, `EET`, `MET`, `WET`, `EST`, `MST`, `HST`, `EST5EDT`, `CST6CDT`, `MST7MDT`, `PST8PDT` | **none, and that is the defect.** An abbreviation names no jurisdiction and carries no DST rule, so only the author knows which city's clock was meant: `Europe/Paris` for `CET`, `America/New_York` for `EST5EDT`, `America/Phoenix` for `MST` |
3075
+
3076
+ `Etc/GMT+2` is unaffected — only a **leading** sign is a bare offset, and that `+` sits inside a real zone name. `US/Eastern` and `Asia/Calcutta` are unaffected too: a deprecated two-label alias is still `Area/Location`.
3077
+
3078
+ ### Where the names hide, and why no build error finds them
3079
+
3080
+ `TimeZone` is `string` in `@ultimat3/time`, so `'CET'` compiles. Nothing fails until the call runs.
3081
+
3082
+ | Site | Spelling | At 6.0.0 |
3083
+ |---|---|---|
3084
+ | a formatter, or zone arithmetic | `zone:` on `formatDate`, `formatDateTime`, `formatRange`, `zonePartsAt`, … | throws `X_TIMEZONE_INVALID` on the first call |
3085
+ | a scheduled task | `tz:` on `task()` | refused where the task is declared — `task()` validates through `isValidTimeZone`, so this one is caught at boot |
3086
+ | `app.config.ts` | `defaultTimeZone` | refused at boot — `defineConfig` validates through core's own statement of the structural rule, so a stale key is `X_CONFIG_INVALID` naming the field, with the swap in its `fix:` |
3087
+ | a client's `x-timezone` header | any of the 43 | no error — `resolveTimeZone` falls through to the configured default, so a hand-written client sending `CET` silently renders in your default zone. Browsers are unaffected: `Intl.DateTimeFormat().resolvedOptions().timeZone` is always `Area/Location` |
3088
+
3089
+ Find every candidate:
3090
+
3091
+ ```sh
3092
+ grep -rnE "(zone|tz|defaultTimeZone): *'[^/']+'" --include='*.ts' --include='*.tsx' .
3093
+ ```
3094
+
3095
+ Run it from the app root. Every hit is a single-label zone; `'UTC'` is the only one already correct.
3096
+
3097
+ ### Why it changed
3098
+
3099
+ `Intl` answers "can I format this", never "is this an IANA zone", and at ICU 78 the two stopped agreeing: Bun 1.4 resolves `CET`, `EST`, `GMT` and `MST` where ICU 75 threw. A **runtime upgrade alone** therefore reopened the "no date without an explicit IANA zone" rule — silently, and in the direction that fails dangerous, because an abbreviation carries no DST rule. The judgement is now structural instead of delegated: an identifier is `Area/Location`, and `UTC` is the one legal exception. That refuses the single-label `backward` links along with the abbreviations, and is meant to — no structural rule keeps `CET` out while letting `Japan` in, both being one label, and the alternative is a denylist that grows with every tzdata release. [#251](https://github.com/developerz-ai/ultimate/issues/251), and [Timezones and dates](Timezones-And-Dates) for the rule it restores.
3100
+
3101
+ ### Fixed, and neither costs an edit
3102
+
3103
+ | Fix | What changes for you |
3104
+ |---|---|
3105
+ | island JSX compiles through `babel-preset-solid` | client-side Solid reactivity inside an island works at all. An island containing JSX compiled to `React.createElement` and threw `ReferenceError: React is not defined` on first interaction, with the gate green. Two build-time dependencies join `@ultimat3/cli`; zero bytes reach your client bundle ([#243](https://github.com/developerz-ai/ultimate/issues/243)) |
3106
+ | `@ultimat3/core` loads in a browser bundle | **core's** three module-scope `AsyncLocalStorage` constructions — the request context, the active span, the impersonation reason — moved onto one lazy seam, so `@ultimat3/ui` no longer throws `TypeError: undefined is not a constructor` at module evaluation ([#244](https://github.com/developerz-ai/ultimate/issues/244)). Six more constructions **outside** core were untouched at 6.0.0 and carry the same defect — `@ultimat3/db`, `@ultimat3/entity`, `@ultimat3/ai`; they are `[Unreleased]`, along with the guard that makes the rule a build error ([#255](https://github.com/developerz-ai/ultimate/issues/255)) |
3107
+
3108
+ Rebuild to pick either up.
3109
+
3110
+ ## 4.1.0 → 5.0.0, entry by entry
3111
+
3112
+ Two breaking entries over six surfaces, one of which needs an edit. There is no codemod, and there
3113
+ does not need to be: **the whole migration is deleting one line, and only if you wrote it.**
3114
+
3115
+ ### Start here — the one edit
3116
+
3117
+ ```diff
3118
+ jobs: {
3119
+ - driver: 'postgres',
3120
+ queues: ['app-default'],
3121
+ concurrency: 8,
3122
+ },
3123
+ ```
3124
+
3125
+ `jobs.driver` accepted `'postgres' | 'redis' | 'nats'` and had **no reader anywhere**. Boot always
3126
+ built `createPgDriver`, so setting it to `redis` did not throw, did not warn and did not boot Redis
3127
+ — it changed nothing and you silently got Postgres. If you were relying on it doing something, it
3128
+ was not: you were on Postgres the whole time.
3129
+
3130
+ Which driver runs is `setJobDriver(driver)`, and only that:
3131
+
3132
+ ```ts
3133
+ setJobDriver(createPgDriver({ executor })) // production
3134
+ setJobDriver(createMemoryDriver()) // a test
3135
+ ```
3136
+
3137
+ `JobsDriver` (the type) goes with it. `JobsConfig.driver` was its only use.
3138
+
3139
+ **Leaving the line in also works.** A spread carries a key no type names, so a stale
3140
+ `app.config.ts` still boots and the field still does nothing — `packages/core/src/config.test.ts`
3141
+ pins exactly that. TypeScript will flag it; the runtime will not.
3142
+
3143
+ ### The other four need no edit unless you wrote a test driver
3144
+
3145
+ They are `@ultimat3/testing`'s `subscribe` fixture, which was **declared and had no driver** — so
3146
+ nothing could have been implementing these types. They changed because they described an API that
3147
+ could not work: `LiveTarget` was `{ name, queryHash }`, and a node keys a subscription by
3148
+ `(name, input)`; a hash is the input already thrown away.
3149
+
3150
+ | Was | Is |
3151
+ |---|---|
3152
+ | `Subscribe = (target) => Promise<LiveFeed>` | `(target, input, actor?) => Promise<LiveFeed>` |
3153
+ | `LiveTarget = { name, queryHash }` | `{ name }` — the query itself |
3154
+ | `LiveFeed` had no `reconnect()` | it has one |
3155
+ | `DRIVER_FIXTURE_NAMES` held `subscribe` | `FRAMEWORK_FIXTURE_NAMES` does; the framework builds it |
3156
+
3157
+ A test that destructured `subscribe` and called it now reads:
3158
+
3159
+ ```diff
3160
+ -const feed = await subscribe(liveFeed.as(actorFor(ada), { orgId: acme.id }));
3161
+ +const feed = await subscribe(liveFeed, { orgId: acme.id }, actorFor(ada));
3162
+ ```
3163
+
3164
+ The actor is the third argument rather than baked into the target because that is where the
3165
+ framework puts it: the shared window is built with **no subject**, and every decision about an
3166
+ actor is per subscriber.
3167
+
3168
+ ### Behaviour that changed without breaking a type
3169
+
3170
+ **Error fields are escaped where they are built.** `UltimateError` and `SchemaError` run
3171
+ `singleLine` over `code`, `title`, `cause`, `fix` and `docs` in their constructors, so `.message`,
3172
+ `.cause`, `format()`, `toJSON()` and any renderer you write are one line by construction. Measured
3173
+ over every shipped `cause:`/`fix:` literal: none contains a newline, so no framework message
3174
+ changed. If you build error text from a value a CALLER controls, you no longer have to remember —
3175
+ and if you were already escaping, `singleLine` is idempotent, so nothing doubles.
3176
+
3177
+ **One `fix:` line changed text.** `X_REPLICATION_FAILED` on SQLSTATE `42704` said
3178
+ `x db replication init`, which is not a command — `x db` takes `gen`, `migrate`, `reset`, `seed`,
3179
+ `studio`, `branch`, `backfill`. It now names the `CREATE PUBLICATION` an operator can paste.
3180
+
3181
+ ### One thing to know before you subscribe to a projected live query
3182
+
3183
+ Not a change in this release — a defect it made visible. If a `query({ live: true })` declares an
3184
+ `orderBy` on a column its rows do **not** carry (a projection that omits it), every change to a row
3185
+ reads as a move, and the re-delivered row is the raw entity row rather than the projection. Columns
3186
+ you left out of the projection reach the subscriber. [#230](https://github.com/developerz-ai/ultimate/issues/230),
3187
+ and [Known gaps](Known-Gaps) carries it. Until it is fixed, order a live query by a column its rows
3188
+ carry.
3189
+
3190
+ ## 3.0.0 → 4.0.0, entry by entry
3191
+
3192
+ Twenty-five `BREAKING —` entries. Most are one of two shapes: a **declaration nothing read**, deleted rather than implemented, and a **surface that answered the wrong thing**, corrected. Full rationale per row in [`CHANGELOG.md`](https://github.com/developerz-ai/ultimate/blob/main/CHANGELOG.md)'s `4.0.0` section.
3193
+
3194
+ **Start here — these three change behaviour whether or not you edit anything:**
3195
+
3196
+ | Surface | The edit |
3197
+ |---|---|
3198
+ | `on delete` now reaches the generated SQL. Any app that ever declared `references(…, { onDelete })` generates **different DDL** | run `x db gen` and read the diff before migrating. Every `add constraint` this framework had ever emitted dropped the rule, so the database has been refusing deletes under a declared `cascade`. Drift also gains `changed-foreign-key`, whose `fix:` hands over a `drop constraint` / `add constraint` pair — `add constraint` alone is `42710` on a name already taken |
3199
+ | `llm()`'s `cache.semantic.scope` receives `{ input, ctx }` and **defaults to the calling actor**, not `'global'` | `scope: (input) => input.orgId` → `scope: ({ ctx }) => ctx.actor.orgId ?? 'none'`, or delete `scope` and take the default. A semantic lookup is a cosine nearest-neighbour with no tenant predicate, so the old shared store answered one tenant with another tenant's completion — reproduced at similarity 1.0. A deliberately shared cache must now say so |
3200
+ | `reapBranches()` skips branches whose base is not `current_database()` | none, and re-read it if you run two Ultimate apps on one Postgres: `listBranches()` walks `pg_database` for the whole server, so one nightly sweep was dropping the *other* app's branches. A pre-4.0 marker records no base and is now skipped rather than dropped; the next `createBranch` writes it down, so it self-heals with no migration |
3201
+
3202
+ **Deleted because nothing read them** — in every case the edit is "delete the option":
3203
+
3204
+ | Surface | The edit |
3205
+ |---|---|
3206
+ | `CaptureOptions.timeoutMs` and `CaptureRequest.timeout` (`@ultimat3/scraping`) | delete them. The port required a timeout, `page-over-target.ts` threaded it, and **no driver honoured it** |
3207
+ | `ScrapeTarget.click`'s `index` parameter | delete it. It was unreachable from the public vocabulary — `ScrapeFrame.click` takes `(selector, options?)` and has no index — and the two drivers disagreed on it |
3208
+ | `PrecacheAsset.critical` (`@ultimat3/pwa`) | delete it. `buildPrecacheManifest` never copied it, and the documented promise ("critical assets are precached even if large") was vacuous — there is no size filter at all |
3209
+ | `PERIODIC_SYNC_TAG`, `BackgroundSyncOptions.periodicMinIntervalMs` (`@ultimat3/pwa`) | delete them. Periodic Background Sync was never implemented in any sense: no listener, no registration, no capability flag |
3210
+ | `realtime.heartbeatMs` (`RealtimeConfig`) | delete the key — `RealtimeConfig` is now `{ enabled, tier, transport, urlEnv }`. The socket beat is `new LiveClient({ heartbeatMs })` (browser code, which cannot read server config) and the presence beat is derived. **There is no runtime refusal**: `section()` copies unknown keys through, so a stale key is silently inert (25.0.0 refuses it at boot) |
3211
+ | `@ultimat3/seo` no longer exports `extensionOf` | delete the import; `parseImageQuery` reads the format off the query |
3212
+ | `@ultimat3/realtime` no longer exports `qidOf` or `canonicalJson` | change the import: `queryHash` from `@ultimat3/query`, `canonicalJson`/`fingerprint` from `@ultimat3/core`. **No live subscription re-keys** — the two spellings differed only on values JSON cannot carry |
3213
+
3214
+ **Corrected, because they answered the wrong thing:**
3215
+
3216
+ | Surface | The edit |
3217
+ |---|---|
3218
+ | `adminResource` no longer pluralises an entity name | set `path:` explicitly if you relied on the doubled URL. Every entity in both tracked apps is already named plural, so `entity('orgs')` was served at `/admin/orgses`. Which plural a name takes is an app's convention, not a mechanism the framework can own (axiom 8) |
3219
+ | A local disk's signed URLs carry the **registered disk name**, not the driver kind | none, if you use `defineStorage` — it calls `registerAs(diskName)` at boot. A disk registered as `uploads` used to 404 every signature it had just written |
3220
+ | `ordinal(value)` takes no locale | delete the second argument. It picked the plural category with your locale and appended the **English** suffix regardless, so `ordinal(1, 'de')` was `'1th'` |
3221
+ | `registerFrameworkCatalog()` and `registerMailCatalog()` take no `locale` | delete the argument. `defineCatalogs` called them once per locale, seating the English-only catalog under **every** locale an app declared — an app shipping only `es` served English chrome with `isMiss` reading `false`, which is a fallback locale chain the i18n package forbids by name |
3222
+ | `t.date` refuses a date-time with no offset and no `Z` | send `2026-08-19T10:00:00Z`. `2026-08-19T10:00` resolved against the **host process's** zone, so one wire value meant a different instant on each pod — reachable from a request through `coerceQuery`, and published as `format: 'date-time'`, which RFC 3339 requires an offset for |
3223
+ | `in` with a non-array operand matches **no** rows on both drivers | pass an array. It matched one row in Postgres (the scalar was wrapped) and none in memory; `in` with a NULL in the list disagreed in the other direction, and the SQL now emits `(col in (…) or col is null)` |
3224
+ | `isValidCron` / `parseCron` refuse an unsatisfiable day/month pair (`'0 0 30 2 *'`) | fix the expression; the refusal names the pair. It used to parse clean and then burn ~184ms of blocking CPU per tick in the scheduler's leader loop before throwing |
3225
+ | `createRateLimiter({ now })` → `createRateLimiter({ clock })` | `{ config, now: () => t }` → `{ config, clock: { now: () => new Date(t) } }`. Callers that passed neither are unaffected |
3226
+ | `requiresApp` is enforced by the dispatcher | none, unless a script matched on the old message. Outside an app, `x secrets set` and its siblings now answer `X_NOT_IN_APP` |
3227
+ | `NackOptions.countsAsAttempt: false` no longer files a job `suspended` | none. "Do not burn an attempt" and "this is a `step.sleep` suspension" were one flag, so the worker's limiter and `job.concurrency` sheds pushed rows out of `ready` — and `queue_depth` / `queue_oldest_ready_seconds` under-reported because of it |
3228
+ | A read whose input carries a `Date`, `Map` or `Set` gets a new cache key and cursor scope, **once** | none. `Object.keys(date)` is `[]`, so every date rendered `{}` and one key answered for every date window a read ever served. Affected cursors answer `X_CURSOR_INVALID` once with "request the first page again"; ordinary inputs are byte-identical |
3229
+
3230
+ **Type-level, for hand-built literals and exhaustive switches:**
3231
+
3232
+ | Surface | The edit |
3233
+ |---|---|
3234
+ | `ColumnDescription` / `ReferenceDescription` gain `onDelete: OnDelete \| null` | add the field to hand-built description literals (a test fixture, a custom generator). `null` is Postgres' `no action` and is the old behaviour |
3235
+ | `DriftKind` gains `changed-foreign-key` | a `switch` over `DriftKind` with no `default` no longer compiles |
3236
+ | `BranchInfo` gains `base: string \| null` | re-type if you built the shape by hand |
3237
+ | Five generators write **typed** test filenames | re-run the generator, or rename by hand. `x verify` selects a suite by filename, so a generated `contractTest(…)` inside a plain `*.test.ts` ran under `unit` while `x test contract` answered `X_TEST_NO_FILES` — a step that passed by having nothing to run. `x g action`/`x g mutator` now also write `<name>.contract.test.ts`, `x g query --live` writes `<name>.live.test.ts`, and `x g job`/`x g task`/`x g backfill` write `<name>.job.test.ts` |
3238
+
3239
+ **One migration to run:** the `x_jobs` idempotency index gains the tenant. It was `(name, idempotency_key)` while the row already carried `tenant_id`. `x db migrate` applies it.
3240
+
3241
+ ## 2.0.0 → 3.0.0, entry by entry
3242
+
3243
+ Ten `BREAKING —` entries, all from one bug sweep. Each was a documented surface that did nothing, or did the wrong thing; the fix is the edit named beside it. Full rationale per row in [`CHANGELOG.md`](https://github.com/developerz-ai/ultimate/blob/main/CHANGELOG.md)'s `3.0.0` section.
3244
+
3245
+ | Surface | The edit |
3246
+ |---|---|
3247
+ | `defineAuth({ mfa: { required: true } })` — refused at boot (`X_CONFIG_INVALID`), and `AuthMfaPolicy.required` narrowed to the literal `false` | delete `mfa.required`; enforce the requirement in your own enrolment flow. Nothing ever read the flag, so a user who never enrolled got a fully-privileged session under it |
3248
+ | `enrolTotp(input)` → `enrolTotp(auth, input)`; `input.issuer` is now optional | pass the `auth` you built with `defineAuth`. The configured issuer never reached the `otpauth://` URI before |
3249
+ | `@ultimat3/http` no longer exports `appErrorStatus()` | read your own registration module. `registerErrorStatus()` and `statusFor()` are unchanged |
3250
+ | `SyncSocket.lastSeenAt` → `lastSeenMonotonicMs`, on `Clock.monotonic()` | rename the read. If you were formatting it as a date you were already wrong — the rename makes `new Date(...)` a compile error |
3251
+ | `SQL_OUTBOX_RELEASE` and `SQL_OUTBOX_MARK_PUBLISHED` take one more parameter each (1 → 2, 2 → 3): the claimant | pass the claimant. `OutboxStore.release`/`markPublished` take it as an optional trailing argument, so an unfenced store still compiles |
3252
+ | `SocketRegistry.sweepIdle()` → `idle()`, which returns the over-budget sockets and removes nothing | call `idle()` and evict through the node, or set the budget with `createSyncNode({ idleTimeoutMs })` |
3253
+ | `DESCRIPTION_MIN_LENGTH` deleted from `@ultimat3/seo` | delete the import. There is no replacement and no minimum description length is checked — the constant was documented as enforced and was read by no validator |
3254
+ | A metric redeclared with different `bounds` or a different `observe` is refused (`X_METRIC_NAME_INVALID`) | make the second declaration state the same `bounds`/`observe`, or fetch the handle without options — `gauge(name)` is unchanged |
3255
+ | `Seed.run()` resolves with `SeedRun` instead of `void` | re-type the result if you typed it `void`. Awaiting it for the side effect alone is unaffected |
3256
+ | `SeedContext.insert` skips a stored row instead of overwriting it | expect `skipped`, not an overwrite. `upsert` is the verb for a row the table keys |
3257
+
3258
+ `cachedFormatter` and `canonicalLocale` moved from `@ultimat3/time` to `@ultimat3/core` and are re-exported from `time`, so **no import breaks** — it is listed here because the move is real, not because it costs an edit.
3259
+
3260
+ ## 1.x → 2.0.0, entry by entry
3261
+
3262
+ **Thirty-three `BREAKING —` entries — the largest major this project has shipped, and the first one semver covered.** Written up here `As of 2026-08`; the page carried a row pointing at this section for six releases and never carried the section. Full rationale per entry in [`CHANGELOG.md`](https://github.com/developerz-ai/ultimate/blob/main/CHANGELOG.md)'s `2.0.0` section — the numbers below are that section's own order. No codemod.
3263
+
3264
+ Two things are not compile errors and are the ones to read first: the **seven behaviour changes** under *Start here*, and the **one migration** every app with a `money()` column owes.
3265
+
3266
+ | # | Surface | Costs you an edit if |
3267
+ |---|---|---|
3268
+ | 1 | `x db branch` takes a verb | you ever ran the bare-name form, which created a database |
3269
+ | 2 | `x new` writes no migration | you scaffold a new app, or your app carries a hand-written `0000_initial.sql` |
3270
+ | 3 | an MCP tool is named by its export name, verbatim | you read a tool name off `openapi.json`, `describe().mcp.tool` or `.tool().name` |
3271
+ | 4 | `selectMailDriver` refuses with no mail credential | you send mail from `staging` or `production` |
3272
+ | 5 | a lapsed fleet slot cannot be renewed by its holder | a job run outlives its slot lease |
3273
+ | 6 | `@ultimat3/query` ships no read-cache seam of its own | you called `setReadCache`, `invalidateQueryTags`, or imported `ReadCache` |
3274
+ | 7 | `@ultimat3/auth` drops `requireRole` / `requireScope` | you gate a route with either |
3275
+ | 8 | `@ultimat3/db` drops `readOnly()` and its four companions | you imported any of them |
3276
+ | 9 | `@ultimat3/seo` drops the performance-budget surface | you imported `checkBudgets`, `parseBytes`, a `Budget*` type, or set `RouteRecord.budget` |
3277
+ | 10 | `@ultimat3/seo` drops `renderLd` | you called it |
3278
+ | 11 | seven zone and locale helpers are gone | you imported `attachTimeZone`, `timeZoneOf`, `attachLocale`, `localeOf`, `negotiateLocale`, `isValidTimeZone` or `resolveTimeZone` |
3279
+ | 12 | `@ultimat3/seo` drops `renderHeadTags` | you called it |
3280
+ | 13 | a derived `BudgetLedger` bills its parent | you already sit at an `llm()` budget ceiling — no signature changed |
3281
+ | 14 | `idempotencyKeyFor` takes the actor, required and third | you call it, **or** you hold idempotency records written before the deploy |
3282
+ | 15 | `Idempotency-Key` is enforced at 255 characters | a client sends a longer key |
3283
+ | 16 | `@ultimat3/action`'s `fingerprint` is SHA-256/16 | you enqueue an action job across the deploy boundary |
3284
+ | 17 | `markReady()` throws `X_LIFECYCLE_DRAINED` after a drain | a test or a process drains and then starts a role |
3285
+ | 18 | a drain is bounded at 25s, and a hook that outruns it is abandoned | your drain legitimately takes longer |
3286
+ | 19 | `cacheKeyFor` takes a fourth, required `authority` | you call it directly |
3287
+ | 20 | the query fingerprint is SHA-256/16 hex | you hold cursors minted before the deploy |
3288
+ | 21 | `semantic.remember` refuses a TTL the tiers refuse | you passed a non-finite or negative lease |
3289
+ | 22 | `OutboxRelay.stop()` returns `Promise<void>` | you await teardown, or implement the interface |
3290
+ | 23 | `TierFailure.tier` is `TierLabel` | you `switch` over it with no `default` |
3291
+ | 24 | `hello` carries no cursors | you build a `hello` frame by hand, or read `FRAME_LIMITS.resume` |
3292
+ | 25 | three more `@ultimat3/realtime` surfaces move | you call `qidOf`, read a mutation's `status` after a drain, or implement `SyncNode` |
3293
+ | 26 | four projection changes — what a value becomes when it leaves the process | you have a `money()` column (**a migration**), read `schema.nullable`, pass an unclonable `.default`, or take a nested object as `query({ input })` |
3294
+ | 27 | `EPOCH` is gone; call `epoch()` | you imported it, or declare a 6-field cron |
3295
+ | 28 | `job()` and `backfill()` require a `tenant` | you declare any job or backfill |
3296
+ | 29 | one `resolveEnvironment`, and it is `@ultimat3/core`'s | you imported seo's, or wrote `'preview'` |
3297
+ | 30 | the NATS wire client is `nats@2.29.3`, behind the same transport seam | you imported a hand-rolled NATS name, or faked a byte stream in a test |
3298
+ | 31 | `@ultimat3/cli` exports `checkSourceDrift`, not `checkDrift` | you imported the CLI's |
3299
+ | 32 | `invariants` is a function, and `invariant()` takes a built expression | you declare an entity with invariants |
3300
+ | 33 | the framework's version is a call, not a constant | you imported `FRAMEWORK_VERSION`, `DEFAULT_SERVER_INFO` or `CLI_VERSION` |
3301
+
3302
+ ### Start here — entries 4, 5, 13, 15, 17, 18 and 21 change behaviour with nothing failing to compile
3303
+
3304
+ | # | What changes | What you do |
3305
+ |---|---|---|
3306
+ | 4 | with neither `SMTP_URL` nor `RESEND_API_KEY`, `staging` and `production` install a driver that rejects every send with `X_MAIL_CREDENTIAL_MISSING`. `development` and `test` are unchanged, and an app that sends no mail still boots — the refusal is on the send, not at boot | set one of the two env keys in every environment that sends. The SMTP `Message-ID`, and so `SendResult.id`, is now content-derived and stable across attempts of one send |
3307
+ | 5 | `SQL_LEASE_RENEW` fences on `expires_at > now()` as well as `holder`, matching the memory store. A run whose slot lapsed is cancelled with `X_JOB_SLOT_LOST` instead of running on uncapped past `job.concurrency` | nothing, unless a handler holds a slot longer than its lease — raise the lease, or shorten the run. This is what the documented contract already said and what `x dev` already did |
3308
+ | 13 | a derived ledger bills its parent, so a call that used to slip past a `request` ceiling can throw `X_AI_BUDGET_EXCEEDED`, and `gateway.spent()` returns a larger — correct — number | raise the ceiling, or accept the refusal. Listed as breaking because it is observable to an app already at its limit, even though it makes *"derive can only tighten"* true for the first time |
3309
+ | 15 | `Idempotency-Key` is enforced at 255 characters. The OpenAPI operation published `maxLength: 255` all along and nothing checked it | shorten the key. A client sending longer keys worked by accident and now gets a 400 |
3310
+ | 17 | `markReady()` throws `X_LIFECYCLE_DRAINED` on a drained lifecycle instead of declining in silence | call `resetLifecycle()` between a drain and the next start — which is what three test files were already doing by hand. A process that drains and then starts a role now fails at the mistake rather than binding a socket that answers 503 forever |
3311
+ | 18 | a drain is bounded at **25s** by default and a hook that outruns it is **abandoned, not stopped** — it is still running when the process exits. `drainDeadlineMs()` returns a `number` always, and `remainingBudget()` is a `number` rather than `number \| undefined` | if your drain legitimately takes longer, say so — and move the pair together, or you have only relocated the kill |
3312
+ | 21 | `semantic.remember` puts its TTL through `assertTtl` like every other write, with `jitterFraction: 0` | pass a finite, non-negative lease. It used to compute `ttlMs` itself and hand a tier a value no other write path can produce |
3313
+
3314
+ Entry 18's pair, both sides or neither:
3315
+
3316
+ ```ts
3317
+ configureLifecycle({ deadlineMs: 600_000 }); // and terminationGracePeriodSeconds >= 600
3318
+ ```
3319
+
3320
+ `jobs` and `realtime` are the two roles that most need a bound and declared none, so before 2.0.0 they drained unbounded — a worker pod holding a long job past `terminationGracePeriodSeconds` is `SIGKILL`ed by the kubelet mid-statement, which is the failure the deadline exists to prevent.
3321
+
3322
+ ### 26. What a value becomes when it leaves the process — and the one migration
3323
+
3324
+ A `money()` property is **three** physical columns, not two: `<p>_minor`, `<p>_currency` and the new `<p>_scale`. **Every existing app needs a migration** — without the column, every read of that table names a column it does not have.
3325
+
3326
+ ```sql
3327
+ alter table "<t>" add column "<p>_scale" integer check (<p>_scale is null or (<p>_scale >= 0 and <p>_scale <= 15));
3328
+ ```
3329
+
3330
+ Byte-for-byte what `generateMigration`'s `columnClause` emits. `NULL` is the right value for every existing row: it means *the currency's own minor unit*, which is what those rows always meant, where `0` would mean whole units. `examples/dummy/packages/db/migrations/0002_money_scale.sql` is the worked example, hand-written because `x db gen` answers `X_MIGRATION_SNAPSHOT_MISSING` in an app whose `0001` records no snapshot.
3331
+
3332
+ The other three projections in the same entry:
3333
+
3334
+ | Was | Now |
3335
+ |---|---|
3336
+ | `t.nullable(x)` emitted `{ …converted, nullable: true }` | `{ anyOf: [<converted>, { type: 'null' }], …annotations }`. `nullable` is an OpenAPI 3.0 keyword no later draft defines, so every validating consumer rejected `null`. A hand-written consumer reading `schema.nullable` reads `schema.anyOf` instead |
3337
+ | `.default(value)` accepted any value | a default `structuredClone` refuses — a function, a class instance, a `Proxy` — throws `X_SCHEMA_DEFAULT_UNSHAREABLE` at the **first import of the file that declares it**. Pass a plain value, or a factory the handler calls |
3338
+ | `query({ input })` accepted any schema | an input that cannot survive a query string is refused at `query()` with `X_QUERY_INPUT_UNENCODABLE`, in the declaring file. A read is `GET /_x/query/<name>`, so its input is characters: flatten the nested object, or make it an `action` |
3339
+
3340
+ ### Entries 6, 14, 16 and 20 — state that does not survive the deploy boundary
3341
+
3342
+ No edit for most apps, and each is a one-time cost worth knowing before it is a support ticket.
3343
+
3344
+ | # | What goes cold, or re-runs | Why, and what to do |
3345
+ |---|---|---|
3346
+ | 6 | a cached query is cold once | `@ultimat3/query` no longer ships its own read-cache seam. Removed: `setReadCache`, `getReadCache`, `invalidateQueryTags`, `MemoryReadCache`, `DEFAULT_READ_CACHE_MAX_BYTES`, and the types `ReadCache` and `ReadCacheEntry`; `DEFAULT_READ_CACHE_TTL_MS` stays. A Redis deployment's read path changes in **both** directions — it was the Redis tier alone, so every cached read was a network round trip; it is now read-down/promote-up across `request-memo → lru → redis`, and concurrent misses of one key share a single load |
3347
+ | 14 | an in-flight idempotency record is unreachable | the stored key's shape changed with the signature, so on the shared Postgres store a retry crossing the deploy boundary finds no record and **re-runs the handler**, inside the 24h window. `truncate x_idempotency` after deploying makes that state honest rather than half-reachable. The memory store dies with its process and is unaffected |
3348
+ | 16 | an action job does not dedupe against its pre-deploy row | `@ultimat3/action`'s `fingerprint` is SHA-256/16, so `job-handle.ts`'s dedupe key `action:<name>:<fingerprint>` changed. Action idempotency itself is unaffected in practice, because the key changed too |
3349
+ | 20 | a cursor minted before the deploy is rejected once | the query fingerprint is SHA-256/16 hex where it was FNV-1a/32 — 4×10⁹ values, brute-forceable offline in seconds, and a fingerprint here is a **sharing key over client-chosen input**. The canonical form is unchanged, so only the hash moved; `X_CURSOR_INVALID`'s `fix:` is already *request the first page again* |
3350
+
3351
+ An app that installed its own read cache registers it where every other cached surface already took one:
3352
+
3353
+ ```diff
3354
+ - setReadCache(myCache);
3355
+ - invalidateQueryTags(tags);
3356
+ + registerTier(myTier); // from @ultimat3/cache
3357
+ + invalidateTags(tags); // literally the same call
3358
+ ```
3359
+
3360
+ A process that registers no tier reads **uncached** rather than filling a store no fan-out can see.
3361
+
3362
+ ### Entries 1 and 2 — the CLI
3363
+
3364
+ `x db branch` takes a verb. The argument *was* the branch name and the dispatcher fell through to it, so `x db branch` with the argument `ls` (the verb is `list` since 26.0.0) — the `fix:` line the planned `x branch` command hands out — cloned the database into one called `ls`. A stray database is not a typo an agent can see: it is a copy of production-shaped data with a name nobody will recognise a week later.
3365
+
3366
+ ```diff
3367
+ - x db branch feat-new-billing
3368
+ + x db branch create feat-new-billing
3369
+ ```
3370
+
3371
+ | Verb | What it does |
3372
+ |---|---|
3373
+ | `x db branch create <name>` | the old bare-name form, said out loud |
3374
+ | `x db branch list` | name, location, created-at, size |
3375
+ | `x db branch delete <name>` | what only `dropBranch('<name>', { force: true })` could do before |
3376
+
3377
+ Every verb is itself a legal branch name, so verb-first is the only shape where a name cannot be read as a subcommand. A word outside that set is `X_CLI_UNKNOWN_COMMAND`, and its `fix:` hands your own word back inside the command that still creates it. `delete` (`drop` until 26.0.0) takes no confirmation flag deliberately — it may only remove what `list` shows. `branchSql` is removed with the `psql` shell-out it was the text for; an external clone now runs through `@ultimat3/db`'s `createBranch()`, which is what makes `list` work at all — the old path wrote the database and no marker comment, so every branch the CLI made was invisible to the only lister the framework has. Branches created by the old path carry no marker and are listed and dropped by neither.
3378
+
3379
+ `x new` writes no migration: the `0000_initial.sql` it wrote under `packages/db/migrations/`, and its `.hash`, are gone from the scaffold, and `x db gen` is that directory's single writer (axiom 1). A hand-written first migration could not carry the `.snapshot.json` only the generator produces. **A scaffold that declares an entity is therefore red on `x verify`'s `drift` step until the first generate runs, and that is correct behaviour:**
3380
+
3381
+ ```sh
3382
+ x db gen "initial"
3383
+ x db migrate
3384
+ ```
3385
+
3386
+ `bin/setup` runs both for you, generating only when the directory holds no `.sql`.
3387
+
3388
+ ### 3. An MCP tool is named by its export name, verbatim, on every surface
3389
+
3390
+ `snake_case` tool names are gone, and so is `toToolName`. One primitive was reachable under one name and published under another — the **served** name has only ever been the export name, while three *publishers* spelled the same tool `publish_post`. So an agent handed `openapi.json` called `tools/call { name: "publish_post" }` and got ToolNotFound: the catalog it was given was the wrong one.
3391
+
3392
+ | Was | Now |
3393
+ |---|---|
3394
+ | `publishPost.tool().name` → `'publish_post'` | `'publishPost'` |
3395
+ | `openapi.json` → `"x-ultimate": { "mcpTool": "publish_post" }` | `"mcpTool": "publishPost"` |
3396
+ | `publishPost.describe().mcp.tool` → `'publish_post'` | `'publishPost'` |
3397
+ | `import { toToolName } from '@ultimat3/action'` / `'@ultimat3/query'` | removed from both — there is no derivation left to call |
3398
+
3399
+ Nothing that *worked* moves: a `tools/call`, a `scopes:` entry and a `visibleTo` list were already spelled verbatim, and a snake_case `scopes:` entry was already `X_MCP_SCOPE_UNKNOWN` at boot. What moves is everything read off the published contract — run `x manifest` to regenerate `openapi.json`, then re-point any agent prompt, saved tool allowlist, generated client or test that took its tool name from `x-ultimate.mcpTool`, `describe().mcp.tool` or `.tool().name`. `x.manifest.json` is unaffected: its `mcp` fact never carried a tool name.
3400
+
3401
+ ### Entries 27, 29, 31 and 33 — renamed, one import each
3402
+
3403
+ ```diff
3404
+ - import { EPOCH } from '@ultimat3/time';
3405
+ + import { epoch } from '@ultimat3/time'; // 27 — call it: epoch()
3406
+
3407
+ - import { resolveEnvironment } from '@ultimat3/seo';
3408
+ + import { resolveEnvironment } from '@ultimat3/core'; // 29
3409
+
3410
+ - import { checkDrift } from '@ultimat3/cli';
3411
+ + import { checkSourceDrift } from '@ultimat3/cli'; // 31 — same signature, same findings
3412
+
3413
+ - import { FRAMEWORK_VERSION } from '@ultimat3/core';
3414
+ + import { frameworkVersion } from '@ultimat3/core'; // 33 — call it: frameworkVersion()
3415
+ ```
3416
+
3417
+ | # | Why the spelling had to move |
3418
+ |---|---|
3419
+ | 27 | `EPOCH` was one shared mutable `Date` exported from a tier-1 package, so any consumer calling `EPOCH.setUTCFullYear(...)` corrupted it for every other consumer in the process, permanently and silently. A `Date` cannot be frozen — `Object.freeze` does not close `setTime` — so it could not be fixed in place. `instant()` also returned the caller's own object and now does not, and `describeCron` **refuses** a 6-field expression with `X_CRON_NOT_DESCRIBABLE` where it used to return a wrong sentence |
3420
+ | 29 | the name existed in `@ultimat3/core` and `@ultimat3/seo` with different parameters and different return unions — the axiom-1 violation the 1.1.0 notes named and deferred. Core's takes an options object, `resolveEnvironment({ env })`, and **throws** `X_ENVIRONMENT_INVALID` on a typo'd `ULTIMATE_ENV`; `tryResolveEnvironment()` is the caller that must answer rather than fail |
3421
+ | 31 | two functions named `checkDrift` answered two different questions. `@ultimat3/db`'s keeps its name and its meaning — the live database against the ledger. The CLI's is the entity source hashed against what `x db gen` recorded, no database. Nothing an app writes calls either |
3422
+ | 33 | read at module scope, the version resolved before `main` in every process that imported core, so `x build --target binary` produced an executable that threw at import. `@ultimat3/mcp`'s `DEFAULT_SERVER_INFO` becomes `defaultServerInfo()` and `@ultimat3/cli`'s `CLI_VERSION` becomes `cliVersion()` for the same reason — a constant holding the result is the module-scope read again, one import away |
3423
+
3424
+ Entry 29 also renames one environment across seo's surface. `isIndexable()` and `RobotsConfig.environment` take core's `Environment`, so `'staging'` is accepted and `'preview'` is a compile error; **no `robots.txt` body changes**, because neither spelling was ever indexable and only the `# environment:` comment line moves.
3425
+
3426
+ ```diff
3427
+ - buildRobots({ environment: 'preview' })
3428
+ + buildRobots({ environment: 'staging' })
3429
+ - import type { SeoEnvironment } from '@ultimat3/seo';
3430
+ + import type { Environment } from '@ultimat3/core';
3431
+ ```
3432
+
3433
+ ### 30. The NATS wire client is `nats@2.29.3`, and the transport seam did not move
3434
+
3435
+ `@ultimat3/realtime` hand-rolled the protocol — framing, parser, PING/PONG, TLS upgrade, inbox muxing and reconnect, 1,019 LOC plus a 431-line fake nats-server to test it. All of it is deleted, on [`docs/idea/18-build-vs-wrap.md`](https://github.com/developerz-ai/ultimate/blob/main/docs/idea/18-build-vs-wrap.md)'s criterion: own what must join the transaction, context and error machinery; wrap a wire protocol with a dominant maintained client, because an agent knows that client's semantics from training and can never know a reimplementation. `nats` is the first external runtime dependency any `@ultimat3/*` package has taken, pinned exact, importable from exactly one file.
3436
+
3437
+ `Transport`, `NatsTransport`, `NatsTransportOptions` and `selectTransport` are the same seam and cost no edit. The test seam moved up one level, from an injected byte stream to an injected client:
3438
+
3439
+ ```diff
3440
+ - new NatsTransport({ url, bucket, open: (target) => Promise.resolve(stream) });
3441
+ + new NatsTransport({ url, bucket, connect: fakeNatsConnect(broker) });
3442
+ ```
3443
+
3444
+ | Direction | Names |
3445
+ |---|---|
3446
+ | removed | `NatsConnection`, `NatsConnectionOptions`, `NatsConnectOptions`, `NatsProtocolParser`, `NatsOperation`, `NatsServerInfo`, `NatsStream`, `natsStreamOver`, `bunNatsStream`, `FakeNatsServer`, `fakeNatsStream` |
3447
+ | added | `NatsClient`, `NatsConnect`, `NatsClientOptions`, `NatsRequestOptions`, `NatsRequestManyOptions`, `openNatsClient`, `FakeNatsBroker`, `fakeNatsConnect` |
3448
+ | unchanged, moved to `nats-client.ts` | `NatsHeaders`, `NatsMessage`, `NatsMessageHandler`, `NatsSubscription`, `NatsTarget`, `parseNatsUrl` |
3449
+
3450
+ The JetStream KV layer stays ours: this client's KV abstraction expresses neither per-message TTL nor a batch `multi_last` direct get.
3451
+
3452
+ ### Entries 7–12 and 24 — deleted because nothing read them
3453
+
3454
+ Every one had zero callers in the framework and in both tracked apps. In each case the edit is *delete the import*, and the replacement — where there is one — is named beside it.
3455
+
3456
+ | # | Gone | Instead |
3457
+ |---|---|---|
3458
+ | 7 | `requireRole` / `requireScope` (`@ultimat3/auth`) | declare the rule as a `Policy` — `can('admin:access')`. They decided a 403 outside `@ultimat3/policy`, so a route gated that way reported `policy: null` in `x routes`, in `framework.manifest.json` and in `openapi.json`, and `x policy list` reported its permission unenforced. `requireActor` / `currentActor` stay — those assert *authentication* |
3459
+ | 8 | `readOnly()`, `assertReadOnly()`, `inspectStatement()`, `MutationVerdict`, `ReadOnlyOptions`, `readonlyViolation()` (`@ultimat3/db`); `X_READONLY_VIOLATION` is retired | `readOnly(db()).query(f)` → `readOnlyQuery(text, { role: await ensureReadOnlyRole() })`, which reports which defences engaged. The deleted lexer judged statement keywords and nothing else, so `select pg_sleep(60)` and `select pg_read_file('/etc/passwd')` both read as reads |
3460
+ | 9 | `checkBudgets`, `assertBudgets`, `parseBytes`, `DEFAULT_BUDGET`, `BUDGET_UNITS`, the four `Budget*` types, `budgetExceeded()`, `RouteBudget`, `RouteRecord.budget` (`@ultimat3/seo`); `X_SEO_BUDGET_EXCEEDED` is retired | nothing to call — the gate that runs is `@ultimat3/cli`'s, raising `@ultimat3/render`'s `X_BUDGET_EXCEEDED`. seo is tier 1 and cannot see a build's bytes, so it was never the package that could answer. The retired code's row moves under *Reserved codes* so an old log line still resolves |
3461
+ | 10 | `renderLd` (`@ultimat3/seo`) | `ld.*` and `meta.ld` — `renderMeta` already emits one `<script type="application/ld+json">` per node, and an app calling both emitted its graph twice |
3462
+ | 11 | `attachTimeZone`, `timeZoneOf` (`@ultimat3/time`), `attachLocale`, `localeOf` (`@ultimat3/i18n`), `negotiateLocale`, `isValidTimeZone`, `resolveTimeZone` (`@ultimat3/http`) | write the zone with `createContext({ tz })` or `withChildContext({ tz })`, read it with `currentTimeZone()`, and take the other three from the packages that own them. `HttpConfig.locale` and `HttpConfig.tz` hold header and cookie **names** only |
3463
+ | 12 | `renderHeadTags` (`@ultimat3/seo`) | `renderHead(headFromMeta(meta, seoRenderers()))`. It escaped `</` and nothing else and had no caller, while `renderHead` — the path every `x dev` and every build takes — escaped nothing at all: two serializers, the unused one weaker and the used one vulnerable. It could not borrow render's escapers, because `xml.ts` escapes **into** entities, which is right for XML and exactly wrong inside a raw-text element |
3464
+ | 24 | `HelloFrame.resume` and `FRAME_LIMITS.resume` | drop the key. A cursor rides its own `subscribe` frame, which is where resume was always decided — the node replied `resume: []` and read the field from nobody, so every reconnect shipped each cursor twice, up to 512 ids per subscription, during the exact restart storm the herd bound exists to flatten |
3465
+
3466
+ `PROTOCOL_VERSION` was deliberately **not** bumped for entry 24: `decode` builds a whitelist, so a new node drops an old client's `resume` and an old node reads a new client's omission as the empty list it always received. Both skews are readable, and bumping would refuse every in-flight client on a rolling deploy to buy nothing — the version guards incompatibility, not novelty.
3467
+
3468
+ Entry 11 also brings a stricter zone rule with it: `CET`, `EST5EDT`, `+01:00` and `''` are refused, and a resolved zone comes back canonically spelled, so one zone is one formatter-cache key. The supported locale set and fallback are `defineCatalogs({ locales, default })`; the fallback zone is `configureTime({ defaultZone })`. `TimeZoneSources` gains `cookie`, and the default order is `user, cookie, query, header` — explicit before inferred.
3469
+
3470
+ ### Entries 19 and 28 — a required argument, because an optional one is one a call site can forget
3471
+
3472
+ ```diff
3473
+ - cacheKeyFor(name, input, tags)
3474
+ + cacheKeyFor(name, input, tags, readAuthority(ctx.actor, 'actor'))
3475
+ ```
3476
+
3477
+ `readAuthority(actor, scope)` is the only thing that produces the value, and `'actor'` keeps 1.2.0 behaviour for a per-caller read. The forgotten authority is a cross-tenant read, which is why it is positional and required rather than an option with a default. Entry **14** is the same argument on `idempotencyKeyFor(name, input, actor)`, where the forgotten one is a cross-actor replay.
3478
+
3479
+ Entry 28 puts one new line on every `job()` and every `backfill()`:
3480
+
3481
+ ```diff
3482
+ export const notifySubscribers = job({
3483
+ input: t.object({ postId: t.uuid, orgId: t.uuid }),
3484
+ idempotencyKey: ({ postId }) => `notify:${postId}`,
3485
+ + tenant: ({ orgId }) => orgId,
3486
+ retry: { attempts: 5, backoff: 'exponential' },
3487
+ async run({ input, ctx }) { /* … */ },
3488
+ });
3489
+ ```
3490
+
3491
+ A definition with no `tenant` is `X_JOB_TENANT_REQUIRED` at declaration. `tenant: 'none'` is the other legal answer and means the **opposite thing on each side of the factory**: on a `job()` it declares the body touches no tenant-scoped table, because every scoped read then fails closed with `X_TENANCY_ACTOR_ORG_REQUIRED`; on a `backfill()` — which forwards `tenant` verbatim — it is how a sweep declares it spans every tenant, and `backfillPass` opens the bounded `crossTenant` scope for it, never the author.
3492
+
3493
+ In the same slice, on the read primitive, a bare boolean policy bypass gains a reason:
3494
+
3495
+ ```diff
3496
+ - sourceFor(target, input, { ctx, enforce: false })
3497
+ + sourceFor(target, input, { ctx, unenforced: 'explain returns no rows' })
3498
+ ```
3499
+
3500
+ The reason is required, a blank one is refused before the source is built, and one `query.policy.unenforced` audit line is written at `debug`.
3501
+
3502
+ ### Entries 22, 23, 25 and 32 — types, and anything implementing an interface structurally
3503
+
3504
+ | # | Was | Now |
3505
+ |---|---|---|
3506
+ | 22 | `OutboxRelay.stop()` returned `void` | `Promise<void>`. It cleared the timer and returned *underneath* the pass in flight, so a role shutdown that awaited it resumed while a publish and its `markPublished` were still running — a torn write against a closing pool. Callers ignoring the return value keep compiling and keep the old race |
3507
+ | 23 | `TierFailure.tier` was `TierName` | `TierLabel = TierName \| 'query-read'`, because `@ultimat3/query`'s read tier degrades through the same `bestEffort` wrapper and had nowhere to report as. A `switch` over it needs a `'query-read'` arm |
3508
+ | 25 | `qidOf(name, input)` was `<name>:<fnv1a 32-bit>` | `<name>:<first 16 hex of SHA-256>`. A `qid` is a **sharing** key — a hit hands back the seated window, carrying the first subscriber's input and rows — and input is client-chosen, so 32 bits is a collision found offline in seconds and one client served out of another's window. A rolling deploy costs one bounded snapshot per subscription |
3509
+ | 25 | `queue.drain(send)` marked each mutation `acked` when `send` resolved | a drained mutation stays `inflight` until the server settles it with `ack`/`fail` or `requeueInflight()` returns it. `DrainReport.remaining` is now what is still **sendable**; a UI rendering *unsynced* should read `pending()`, which is unchanged and still counts both |
3510
+ | 25 | `SyncNode` had one teardown, `stop()` | it also declares `stopAccepting()`, called by the SIGTERM `accept` phase — additive for a `createSyncNode` caller, **breaking** for anything implementing the interface structurally. `SyncNode.websocket` no longer carries `publishToSelf` |
3511
+ | 32 | `invariants: [ invariant(name, (c) => …) ]` | `invariants: (c) => [ invariant(name, …) ]` — see the diff below |
3512
+
3513
+ `SyncSocket.subscribeTopic` / `unsubscribeTopic` no longer call Bun's `ws.subscribe` / `ws.unsubscribe` either: every channel message is one filtered `send` per socket through `SocketRegistry.deliver`, because a native publish cannot be refused per socket, cannot report the frame it dropped and cannot mark a subscriber desynced.
3514
+
3515
+ Entry 32 is mechanical — move the `[` to after `(c) => `, drop each `(c) =>` inside `invariant()`, drop every `!`:
3516
+
3517
+ ```diff
3518
+ - invariants: [
3519
+ - invariant('post_title_not_blank', (c) => c.title!.trimmed().minLength(1)),
3520
+ - invariant('post_price_non_negative', (c) => c.price!.minor.atLeast(0)),
3521
+ - ],
3522
+ + invariants: (c) => [
3523
+ + invariant('post_title_not_blank', c.title.trimmed().minLength(1)),
3524
+ + invariant('post_price_non_negative', c.price.minor.atLeast(0)),
3525
+ + ],
3526
+ ```
3527
+
3528
+ The defect it fixes is why every generated entity needed a `!`: `InvariantColumns` was an index-signature type, so under `noUncheckedIndexedAccess` every `c.title` was `ColumnExpr | undefined`. It is now a mapped type over the declared columns, so `c.title` is a `ColumnExpr` and `c.titel` is `TS2551: Property 'titel' does not exist … Did you mean 'title'?`. `unique()` and `satisfies()` take `keyof C & string`, so a typo in a column *list* is caught too. `indexes[].where` is unchanged — it was already a callback, and its `c` is now typed too.
3529
+
3530
+ ## What semver covers
3531
+
3532
+ The covered and uncovered surface, and what each bump means, are on [Stability](Stability).
3533
+
3534
+ ## Release policy
3535
+
3536
+ | Rule | Detail |
3537
+ |---|---|
3538
+ | Pins | between `@ultimat3/*` packages, exact (`"@ultimat3/core": "<version>"` in each `package.json`). An app declares `^<version>`, as `x new` writes (`packages/cli/src/templates/scaffold-repo.ts`), and `bun.lock` pins the exact build: `bun install --frozen-lockfile` never upgrades silently |
3539
+ | Lockstep releases | one release bumps all 30 packages — 29 `@ultimat3/*` plus the unscoped `create-ultimate` — to the same version. One version, one commit, one tag. A mixed set is unsupported |
3540
+ | Published with provenance | npm via OIDC trusted publishing. Every tarball from 3.0.0 onward carries an attestation — verified through 5.0.1; **2.0.0's do not**, that release went out by hand. Per version: `npm view @ultimat3/core@<version> dist.attestations` |
3541
+ | Breaking changes land with the edit named | **no release has shipped a codemod**, so every `BREAKING —` entry names the manual edit itself; `x upgrade` walks the pins, the Bun floor and the gate, and points at the section |
3542
+ | Dependency upgrades are framework work | Solid is pinned to **`1.9.14`, the stable line** — Solid 2 is still prerelease (`2.0.0-beta.N`, DOM renderer split into `@solidjs/web`) and every app inherits whatever core this repo pins. Bumping it is a framework release, never an app-level `bun update`. There is no ArkType or Drizzle pin to carry: `@ultimat3/schema` ships dependency-free builtin validators (ArkType is an optional provider you adapt yourself) and `@ultimat3/entity` ships its own `postgresDriver()` |
3543
+ | Bun floor | `>=1.4.4` (`REQUIRED_BUN`, `As of 2026-11-02`), target 2.0. Below the floor → `X_BUN_VERSION`. It was `>=1.3.0` until 2026-08-27, while `x test` emitted `bun test --isolate` — a flag Bun added in 1.3.13 — so the declared floor named runtimes the CLI could not run on |
3544
+ | Not shipped `As of 2026-08`, behind the interfaces that ship today | realtime tier 3 (`persist: true`, local-first), the plugin API and multi-region replication. The Redis **job** driver shipped (`@ultimat3/jobs/redis`) and the NATS stub is deleted, `As of 2026-11-02` |
3545
+
3546
+ Do not upgrade a transitive dependency of a `@ultimat3/*` package by hand. Open an issue instead — the pin is deliberate.
3547
+
3548
+ ## `x upgrade`, one major per run
3549
+
3550
+ `As of 30.0.0`. Every hop gets its own gate, so a red one is that hop's. No codemod, no `openapi.json` step of its own (`x manifest` writes it): `x verify`'s findings carry what a section's rows do not.
3551
+
3552
+ ```bash
3553
+ x upgrade --dry-run --json # what this hop would do; writes nothing
3554
+ x upgrade --json # one hop: pins, Bun floor, bun install, the gate
3555
+ bunx @ultimat3/cli@latest upgrade --json # an app below 30.0.0, whose own CLI has no such command
3556
+ ```
3557
+
3558
+ | # | Do | Why |
3559
+ |---|---|---|
3560
+ | 1 | commit or stash first | a dirty tree refuses (`X_UPGRADE_DIRTY_TREE`): git is the only state between runs |
3561
+ | 2 | run it | exact pins in every workspace, `overrides` included, the Bun floor raised, `bun install`, then `x manifest`, `x doctor`, `x verify` from the CLI just installed |
3562
+ | 3 | open `data.section.url` | that major's entries, in full. Only what the gate cannot see needs it |
3563
+ | 4 | close each `data.verify.findings[]` by its `fix`, then `x verify --json` | `data.done` is verify's verdict, and the exit code follows it |
3564
+ | 5 | review `git diff`, commit | it never commits |
3565
+ | 6 | re-run until `data.next` is `null` | the pins say where the walk stands |
3566
+
3567
+ A repository with pins and no `app.config.ts` hops too: no `x verify` can run, so it runs the `typecheck` and `test` scripts it declares, and `data.proof` (`verify`, `scripts`, `none`) says what `done` rests on.
3568
+
3569
+ Refusals name their own fix: `X_UPGRADE_MIXED_PINS` (`x upgrade --to <the higher version> --json`), `X_UPGRADE_HOP_TOO_WIDE`, `X_UPGRADE_PIN_UNREADABLE`, `X_UPGRADE_REGISTRY_UNREACHABLE`, `X_UPGRADE_VERSION_UNPUBLISHED`, `X_UPGRADE_INSTALL_FAILED`. `data.bun.manual[]` lists what only you can move: a digest-pinned image, an expression. `data.guards.differs[]` offers `x g guard <name> --force`, and your guard is never touched. A hop below 30.0.0 reports `X_BUDGET_UNMEASURED` until `x build --target static --json` has run. Field by field: [CLI reference](CLI-Reference#x-upgrade).
3570
+
3571
+ ## Breaking-change classes and how each is detected
3572
+
3573
+ Nothing here relies on you reading a changelog carefully. Each class is a build error.
3574
+
3575
+ | Class | Detected by | Code | Fix |
3576
+ |---|---|---|---|
3577
+ | Action/query contract change | `x verify`'s `contract-diff` step, against the committed `x.manifest.json` | `X_MANIFEST_BREAKING` | `x verify --json` to read the finding, then bump the major version or restore the input/output shape |
3578
+ | Breaking published surface | manifest contract diff, breaking subset | `X_MANIFEST_BREAKING` | bump the app's major version; old clients keep the old shape |
3579
+ | Schema vs migrations | schema introspection vs migration history | `X_DB_DRIFT` | `x db gen "<message>"` then `x db migrate` |
3580
+ | Stale generated facts | manifest freshness check | `X_MANIFEST_STALE` | `x manifest` |
3581
+ | Import-tier change | `scripts/boundaries.ts` re-run over the new tier table | `X_BOUNDARY_VIOLATION` | move the import down a tier or invert the dependency |
3582
+ | Budget ratchet | a release lowering a default budget | `X_BUDGET_EXCEEDED` | fix the regression, or set an explicit `budget` on the route |
3583
+ | Config field rename/removal | config schema parse, and the compiler before it — an unknown key is an excess property on `Input<AppConfig>`, so it fails `typecheck` rather than reaching a runtime parse | `X_CONFIG_INVALID` | the cause names the field. No codemod has shipped yet, so this is a manual edit |
3584
+ | Env schema change | typed env parse at boot | `X_ENV_MISSING` | add the key; fails in ~40ms, not as a later 500 |
3585
+ | Renamed job step | duplicate/unknown step names in one `run` | `X_STEP_DUPLICATE` | renaming a step invalidates its stored result — treat as a new step |
3586
+
3587
+ Budgets ratchet **down** across releases. That is intentional: a framework release that makes bundles smaller should not leave your app's slack unclaimed.
3588
+
3589
+ ## Version skew during a deploy
3590
+
3591
+ A client running build `A` requesting an asset from build `B` is the failure mode that actually breaks PWAs — not caching strategy.
3592
+
3593
+ | Mechanism | Behavior |
3594
+ |---|---|
3595
+ | Immutable build ID | content hash of the build, stamped into the HTML, every asset path, `x.manifest.json` and `sw.js`'s cache names — the worker half was emitted by no build until [#390](https://github.com/developerz-ai/ultimate/issues/390), `As of 2026-08`. Never a timestamp, never `latest` |
3596
+ | Client sends its build ID | `X-Ultimate-Build` on RPC, query, and WS handshake — so the server answers "you are stale" instead of guessing |
3597
+ | N-deploy asset retention | the last **3** builds' assets stay served — `retentionPlan(deploys, keep = 3)` in [`packages/pwa/src/version-skew.ts`](https://github.com/developerz-ai/ultimate/blob/main/packages/pwa/src/version-skew.ts). A count of deploys, with **no time component**: there is no 7-day half, and **no `pwa.retention` field** — `PwaConfig` was `{ enabled, offline, installPrompt, backgroundSync, push }` at 7.0.0 (`installPrompt` is deleted in 8.0.0). Pass `keep` at the call site to hold more |
3598
+ | `AppUpdateAvailable` signal | a Solid signal flips when the server reports a newer build. Your app renders its own "Update available — reload". No forced navigation, no lost form state |
3599
+ | Forced reload | **not a capability this framework has, `As of 2026-08`.** 9.0.0 deleted `updateSignal`, `updatePolicy`, `DEFAULT_GRACE_MS` and their types — they computed a grace, a `forced:` flag and a `deadlineAt`, and nothing performed the reload, nor could: `@ultimat3/http` (tier 2) and `@ultimat3/realtime` (tier 3) both sit below `pwa` (tier 4). Notification is complete — read `useConnection().updateAvailable`, or compare the worker's posted `to` with `detectSkew`, and render your own affordance. `x deploy --critical` was **removed in 4.0.0** for the same reason: echoed into the deploy plan, read by nothing |
3600
+ | Skew is observable | the `/_x` live panel reports the build-ID distribution of connected clients. `x status --json` is **planned**, not shipped |
3601
+ | The realtime **wire** is versioned separately | `PROTOCOL_VERSION` is a small integer in `@ultimat3/realtime`, `2` `As of 2026-08-24`. It is not the build id and it does not move per release: it moves only when a frame one side writes is a frame the other cannot read. A mismatch is `X_PROTOCOL_VERSION` on the frame — **clients and `sync` nodes are redeployed together across a bump**, because a cursor rides both the client's `subscribe` and the node's `snapshot`, so the skew breaks resume from either end ([Realtime](Realtime#wire-protocol-version)) |
3602
+
3603
+ Server behavior on a stale build ID:
3604
+
3605
+ | Request | Response |
3606
+ |---|---|
3607
+ | Asset within retention | serve it |
3608
+ | Asset outside retention | `410 Gone` + `X-Ultimate-Build-Current`; the SW serves the fallback and flips `AppUpdateAvailable` |
3609
+ | Action / query | executed if the contract is compatible; otherwise `X_BUILD_SKEW` with a `fix:` line |
3610
+ | WS handshake | accepted, then an `update-available` frame carrying the server's `buildId` → signal flips. The socket is **not** killed |
3611
+
3612
+ Full detail: [PWA and offline](PWA-And-Offline).
3613
+
3614
+ ## Migrating jobs between drivers — **still nowhere to migrate to**
3615
+
3616
+ **There is no `jobs.driver` field.** 5.0.0 deleted it, because it selected nothing: boot always built the Postgres driver, so `jobs: { driver: 'redis' }` gave you Postgres in silence; since 25.0.0 the key is refused by name (`X_CONFIG_INVALID`). Which driver runs is `setJobDriver(driver)` at boot, and only that.
3617
+
3618
+ **There is no driver migration to perform**, `As of 2026-10`. Postgres is the only durable driver — 25.0.0 deleted the NATS and Redis stubs, whose every method threw `X_NOT_IMPLEMENTED` — and `x jobs drain` is planned: it exits `X_NOT_IMPLEMENTED` before the queue boots, pointing at `x jobs list --json`. Its `--to` and `--dry-run` flags went with the drain body in 25.0.0 and are now `X_CLI_BAD_FLAG`. Before that a drain onto either stub leased the pending batch for five minutes, failed every enqueue and nacked it back; and the `memory` target, refused by name since 2026-09, acked every durable row off the source and lost the copy when the command exited — a target that dies with the command is not a migration.
3619
+
3620
+ The procedure for the day a second durable driver ships, with the drain itself **planned** — no invocation of it ships, so none is written here:
3621
+
3622
+ | Order | Step |
3623
+ |---|---|
3624
+ | 1 | deploy with the old driver still installed |
3625
+ | 2 | drain the old queue onto the new driver — **planned** (`X_NOT_IMPLEMENTED` today) |
3626
+ | 3 | change the `setJobDriver(…)` call at boot, `x verify`, deploy |
3627
+ | 4 | confirm with `x jobs list --json` that the old queue is empty before removing its infra |
3628
+
3629
+ Job code never changes across a driver: `steps` is a driver member, so step persistence is identical on all of them. The outbox table stays the transactional record. At-least-once delivery is preserved; atomicity is not negotiable ([Jobs and workflows](Jobs-And-Workflows)).
3630
+
3631
+ ## Migrating realtime tiers
3632
+
3633
+ | From → to | Change | Notes |
3634
+ |---|---|---|
3635
+ | tier 1 → tier 2 | `live: true` on the query | needs a `replicator` role and `orderBy` + `limit` on the `sql` |
3636
+ | tier 2 → tier 3 | `persist: true` on the **entity** | shipped in 21.0.0, opt-in per entity. No new mutators, no new authz, no new server code ([Realtime](Realtime#tier-3-shipped-in-2100)) |
3637
+ | `memory` → `nats` transport | `realtime.transport: 'nats'`, and **`realtime.urlEnv`** — the env *key name*, not a URL (default `NATS_URL`). There is no `realtime.url` field | roll `sync` and `replicator`; clients reconnect with server-directed backoff. **`realtime.transport` decides the bus** (`selectTransport(env, { transport, urlEnv })`), `As of 2026-09-23` (in the tree, not yet tagged): `'nats'` with its variable unset refuses the boot, and so does `'memory'` with `NATS_URL` set (`X_CONFIG_INVALID`). On 21.x the presence of `NATS_URL` decided it and the config field was never read ([Configuration](Configuration)) |
3638
+
3639
+ ## Where the facts live
3640
+
3641
+ | Source | Contents |
3642
+ |---|---|
3643
+ | [`CHANGELOG.md`](https://github.com/developerz-ai/ultimate/blob/main/CHANGELOG.md) | Keep a Changelog format, `Added` / `Changed` / `Removed`. A `BREAKING —` entry names its manual edit **inline**; there is no per-entry `Migration` block convention and never a codemod name — `grep -c '\*\*Migration' CHANGELOG.md` answers `9` `As of 2026-08-23`, against 111 breaking entries |
3644
+ | [`docs/idea/14-roadmap.md`](https://github.com/developerz-ai/ultimate/blob/main/docs/idea/14-roadmap.md) | the twelve milestones, 0–10 shipped. Milestone 11's two-platform deploy proof is the one item still open |
3645
+ | [`docs/idea/15-risks.md`](https://github.com/developerz-ai/ultimate/blob/main/docs/idea/15-risks.md) | what could still change shape — the sync engine is roughly 70% of total effort |
3646
+ | [`docs/architecture/19-cutting-a-major.md`](https://github.com/developerz-ai/ultimate/blob/main/docs/architecture/19-cutting-a-major.md) | how this page is maintained: one section per major, written when the first breaking change lands. Maintainer-facing — read it if you are opening a PR against the framework, not if you are upgrading an app |
3647
+ | `x.manifest.json` | generated, per build. Diff two manifests to see exactly what a release changed in your app |
3648
+
3649
+ Read the changelog **backwards from your current pin to the target**, and read the `BREAKING —` entries only — the rest is regenerated for you.
3650
+
3651
+ ## When an upgrade fails
3652
+
3653
+ ```
3654
+ git revert <the upgrade commit> # or redeploy the previous image tag
3655
+ x verify --json > verify.json
3656
+ ```
3657
+
3658
+ | Situation | Do |
3659
+ |---|---|
3660
+ | Prod is already rolling | redeploy the previous image tag. Assets from the previous build are inside the retention window, so sessions survive |
3661
+ | An entry's named edit did not compile | keep the diff. It is the most useful part of the bug report, and it is the entry that is wrong |
3662
+ | `x verify` fails on one check | read that step's findings from `x verify --json`, then reproduce it with the command its `fix` names |
3663
+ | Cause is unclear | `x errors explain <CODE> --json` |
3664
+
3665
+ File an issue with `verify.json` attached, your previous and target versions, and the entry you were following. The JSON is the report — do not paraphrase the terminal.
3666
+
3667
+ Symptom-first fixes: [Troubleshooting](Troubleshooting). Code index: [Error codes](Error-Codes).