@ultimat3/cli 29.4.0 → 30.1.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 +1314 -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,868 @@
1
+ # Configuration
2
+
3
+ One file: `app.config.ts` at the repo root. There is no per-environment config directory, no `config/production.ts`, no `.env.local` cascade. Environment differences are **env vars**, validated once at boot.
4
+
5
+ `As of 2026-08-22`. Stable API — semver from here ([Upgrading](Upgrading)). Field names are covered by semver: renaming or removing one needs a major, and the entry in [Upgrading](Upgrading) names the edit. **There is no codemod** — `x upgrade` is a `PLANNED_COMMANDS` entry ([`packages/cli/src/cmd-planned.ts`](https://github.com/developerz-ai/ultimate/blob/main/packages/cli/src/cmd-planned.ts)), so every migration below is a hand edit.
6
+
7
+ ```ts
8
+ import { defineConfig, defineEnv } from '@ultimat3/core';
9
+
10
+ // Module scope, in `app.config.ts` itself: the file is imported at boot, so the env gate runs
11
+ // before any listener binds. There is no separate `env.ts` — one config file means one.
12
+ export const env = defineEnv({
13
+ APP_URL: { type: 'url' },
14
+ DATABASE_URL: { type: 'url' },
15
+ SESSION_SECRET: { type: 'string', secret: true },
16
+ });
17
+
18
+ export const config = defineConfig({
19
+ name: 'postly',
20
+ // No connection string and no pool size: both are env (`DATABASE_URL`, `DATABASE_POOL_MAX`),
21
+ // read where the client is built, so the same image deploys to every environment.
22
+ database: { ssl: true },
23
+ // The tiers ARE the selection: naming `redis` is what builds the shared rung, and it reads
24
+ // `REDIS_URL` — there is no second `driver` field to agree with, and no env key to name.
25
+ cache: { tiers: ['request-memo', 'lru', 'redis'] },
26
+ jobs: { queues: ['postly-default'], concurrency: 8 },
27
+ pwa: { enabled: true, offline: { fallback: '/offline' } },
28
+ });
29
+ ```
30
+
31
+ **This example compiles.** It did not until 2026-08-22 — it passed `database: { urlEnv, poolSize }`, two keys deleted from `DatabaseConfig`, so the first thing an agent copied off this page was `TS2353`. Every table below is now derived from the interfaces in [`packages/core/src/config.ts`](https://github.com/developerz-ai/ultimate/blob/main/packages/core/src/config.ts) rather than curated beside them.
32
+
33
+ Everything derivable from code is **not** in this file — routes, actions, policies, jobs, tags all live in the generated `x.manifest.json`. Inspect the resolved config with `x config show --json`.
34
+
35
+ `AppConfigInput` ([`packages/core/src/config.ts`](https://github.com/developerz-ai/ultimate/blob/main/packages/core/src/config.ts)) carries exactly these keys `As of 2026-10-06`: `name`, `theme`, `auth`, `pwa`, `roles`, `database`, `cache`, `jobs`, `realtime`, `notify`, `ai`, `drain`, `health`, `site`, `seo`, `navigation`, `islands`, `mail`, `clientErrors`, `assets`, `failureCapture`, `time`. That type is the contract, and **every table below names only its members** — a block with no key here (`http`, `budgets`, `storage`, `otel`) is not an `app.config.ts` field and its section says where the real knob is instead.
36
+
37
+ **The shape is closed**, `As of 25.0.0`: a key `defineConfig` does not declare — at any depth, in `app.config.ts` or any `config/*.ts` overlay — is refused at boot with `X_CONFIG_INVALID`, naming the full path and the nearest real key (`drian is not an app.config.ts key — did you mean drain?`, fix `rename drian to drain in app.config.ts`). Before 25.0.0 `section()` copied an unknown key through and nothing read it, so a typo or a JS config that skipped the compiler was a switch with no wire. A key an earlier major **deleted** gets its own refusal first, naming the major and the replacement (`REMOVED_CONFIG_KEYS` in [`packages/core/src/config-removed.ts`](https://github.com/developerz-ai/ultimate/blob/main/packages/core/src/config-removed.ts)). The known keys are the defaults' own plus `SECTION_KEYS` in [`packages/core/src/config-keys.ts`](https://github.com/developerz-ai/ultimate/blob/main/packages/core/src/config-keys.ts) — the members with no default (`pwa.id`, `pwa.shortcuts[]`, `pwa.colors`, `mail.retainMime`, `navigation`), each row checked against its interface by `tsc`. The only positions whose keys an app chooses are `OPEN_CONFIG_PATHS`: the locale-keyed manifest texts (`pwa.description`, a shortcut's `name`/`shortName`/`description`, a screenshot's `src`/`label`). A key written as `undefined` is a layer not saying, and is never refused.
38
+
39
+ ## Top level
40
+
41
+ | field | type | default | notes |
42
+ |---|---|---|---|
43
+ | `name` | `string` | required | `^[a-z][a-z0-9-]{1,63}$`. Names the dev DB, the image, the queue prefix |
44
+ | ~~`locales`~~ · ~~`defaultLocale`~~ | — | — | **Deleted in 25.0.0** — a second declaration of the app's locales. The one source is `defineCatalogs({ default: 'en', locales: { en, es } })` from `@ultimat3/i18n`: the keys are the locales, `default` the fallback. A config still writing either is refused at boot, `X_CONFIG_INVALID` naming the key and the replacement |
45
+ | ~~`defaultTimeZone`~~ | — | — | **Deleted in 25.0.0** — read by nothing but its own validator, and an ambient zone is what the framework forbids. Pass the zone at the call: `formatDate(at, { locale, zone })`, `task({ tz })`, the actor's `tz` (`userActor({ id, locale, tz })`). Refused at boot like the two above |
46
+ | ~~`defaultCurrency`~~ | — | — | **Deleted in 25.0.0** — read by nothing; every `Money` carries its own `currency`. An app that wants a default declares its own constant. Refused at boot like the three above |
47
+ | `theme.defaultMode` | `'light' \| 'dark' \| 'system'` | `'system'` | what a visitor with no stored choice gets. The boot inlines the no-flash theme script into every document with this as its fallback and admits it to the CSP — `As of 20.2.0`; before that the key was read by nothing. ~~`theme.tokens`~~ was **deleted in 25.0.0** (read by nothing; the theme is `export const brand = defineTheme(…)` from `@ultimat3/ui` in `apps/web/shared/theme.ts`) and is refused at boot |
48
+ | `roles` | `Role[]` | every `ROLE` | which runtime roles this app runs. Empty is `X_CONFIG_INVALID` |
49
+
50
+ There is no `url` field. The canonical origin is an env key the app reads at its point of use (`APP_URL`), so the same image deploys to every environment.
51
+
52
+ ## `database`
53
+
54
+ `DatabaseConfig` is **two fields**, deliberately — the connection itself is env, not config.
55
+
56
+ | field | type | default | notes |
57
+ |---|---|---|---|
58
+ | `database.driver` | `'postgres'` | `'postgres'` | one driver; `postgresDriver()` from `@ultimat3/entity` is its only implementation |
59
+ | `database.ssl` | `boolean` | `false` | `true` on managed Postgres |
60
+ | ~~`database.urlEnv`~~ | — | — | **Deleted 2026-08.** Refused at boot since 25.0.0 (`X_CONFIG_INVALID`, naming the replacement). `@ultimat3/db`'s `client.ts` reads `process.env['DATABASE_URL']` as a hardcoded literal, so a different key here could never be honoured. Migration: delete the key; the connection string is `DATABASE_URL` |
61
+ | ~~`database.poolSize`~~ | — | — | **Deleted 2026-08.** Refused at boot since 25.0.0 (`X_CONFIG_INVALID`, naming the replacement). A second, non-functioning spelling of `DATABASE_POOL_MAX`, which `baseClient()` layers over the role profile and which works. Migration: delete the key; set `DATABASE_POOL_MAX`. The sizing rule is unchanged — the pool is per PROCESS, so keep `replicas × poolMax` under Postgres `max_connections` |
62
+ | ~~`database.schema`~~ | — | — | **Deleted 2026-08.** Refused at boot since 25.0.0 (`X_CONFIG_INVALID`, naming the replacement). Nothing emits `SET search_path`. Migration: delete the key; there is no replacement, and `entity()` tables live in `public` |
63
+
64
+ Wiring the three instead would have needed a tier-0 → tier-1 read the tier table forbids. Deleting is axiom 3 applied to configuration: a value that produces neither a build error nor a runtime effect is worse than no field, because an SRE sets `poolSize: 3`, redeploys, and nothing changes.
65
+
66
+ ### Read replicas
67
+
68
+ `As of 2026-08-24`. **Opt in twice**, and the second opt-in is the safety argument rather than an ergonomic one.
69
+
70
+ | Step | What it is | Without it |
71
+ |---|---|---|
72
+ | 1. the pool | `DATABASE_REPLICA_URL` names a read-only standby. `defaultClient()` reads it once and builds a primary + replica client; the replica inherits the role's pool profile and `DATABASE_POOL_MAX`, so a fleet sized against `max_connections` sizes against **two** servers | one pool, statement for statement what it always was |
73
+ | 2. the scope | `withReplicaReads(fn)` from `@ultimat3/db` — inside it, a statement that is provably a plain read may be answered by the standby | nothing routes, whatever is configured |
74
+
75
+ ```ts
76
+ import { db, sql, withReplicaReads } from '@ultimat3/db';
77
+
78
+ declare const id: string;
79
+
80
+ await withReplicaReads(async () => {
81
+ await db().query(sql`select id from posts limit 20`); // -> replica
82
+ await db().execute(sql`insert into posts (id) values (${id})`);
83
+ await db().query(sql`select id from posts limit 20`); // -> primary, for the rest of the scope
84
+ });
85
+ ```
86
+
87
+ | Rule | Detail |
88
+ |---|---|
89
+ | read-your-writes | one write anywhere in the scope, at any depth, across any `await`, and every later read in it is the **primary's** for the rest of the scope. The flag is a mutable value on the async context, not a per-statement computation, which is what lets a write ten frames down be seen by the read after it |
90
+ | a transaction | always the primary. `reserve()` is delegated there, so `BEGIN`, the body and `COMMIT` are one connection on one server — a `BEGIN` that landed on a standby is not a transaction, it is `25006` on the first write inside it. A `readOnly: true` transaction leaves the scope unmarked |
91
+ | what is eligible | `select` / `table` / `values` / a read-only `with`, minus locking reads, `select … into` and the functions a standby answers instead of refusing. **Everything the classifier cannot vouch for is the primary's**, including `begin` and `set` |
92
+ | a replica that fails | the statement is re-run on the primary — exactly-once, because only plain reads are sent there and a `25006` refusal never executed. **Three consecutive failures park it for 10 seconds**, so an outage costs 3 doubled reads rather than every read |
93
+ | observability | `client.stats` (`replica`, `primary`, `fallbacks`, `parked`) and a `db.replica_fallback` warning per fallback |
94
+
95
+ **The URL must name a read-only standby.** The server's own `25006` is the safety net under a classifier that cannot be complete; pointed at a writable node, a misroute becomes a write on the wrong server, silently.
96
+
97
+ **Nothing opens the scope for you yet.** `withReplicaReads` ships first and wrapping a request in it is the app's call, so until that lands no production traffic is routed.
98
+
99
+
100
+ ## `auth`
101
+
102
+ `AuthConfig` on `app.config.ts` is **one field**. Authorization is **not** here — it is [Policies and authz](Policies-And-Authz), and the authentication policy is `defineAuth()`, below.
103
+
104
+ | field | type | default | notes |
105
+ |---|---|---|---|
106
+ | `auth.signInPath` | `string \| null` | `null` | where a browser that failed `auth: 'required'` is sent. **`null` keeps the redirect off**, and that is the default on purpose: the framework may not invent one of its app's routes, and guessing `/login` would send every unauthenticated visitor to a 404. Null means the visitor gets the problem document — right for an agent, and what a browser got in production until this existed |
107
+ | ~~`auth.afterSignInPath`~~ | — | — | **Deleted in 8.0.0.** Refused at boot since 25.0.0 (`X_CONFIG_INVALID`, naming the replacement). Declared, defaulted and merged, and read by no file — an app that set `/dashboard` got whatever its own sign-in route already did. Migration: delete the key, and send the visitor where you mean from the sign-in route itself, which is the only code that can honour it |
108
+
109
+ **The rest of authentication is `defineAuth()`, not `app.config.ts`** ([`packages/auth/src/auth.ts`](https://github.com/developerz-ai/ultimate/blob/main/packages/auth/src/auth.ts)). It takes an adapter and the policies, because each of them needs a value — a session store, a limiter, a clock — that a serialisable config field cannot carry:
110
+
111
+ | `defineAuth` key | shape | notes |
112
+ |---|---|---|
113
+ | `adapter` | `AuthAdapter` | required; where users and sessions are read and written |
114
+ | `session` | `Partial<SessionPolicy>` | `absoluteTtlMs` 30d, `idleTtlMs` 7d, `cookieName` **`'__Host-x_session'`**, `rotateOnPrivilegeChange` true. The `__Host-` prefix is a browser-enforced contract — Secure, `Path=/`, no `Domain` — so a subdomain (or an XSS on one) cannot overwrite it |
115
+ | `password` | `Partial<PasswordPolicy>` | |
116
+ | `rateLimit` | `Partial<AuthRateLimitPolicy>` | `scope: 'shared'` must be matched by a `limiter` that says the same, or `defineAuth` refuses at boot rather than at 3am on the first spray |
117
+ | `limiter` / `orgLimiter` | `AuthLimiter` | omitted means one process' worth of state, i.e. `maxAttempts × N` for N replicas. An attempt is reserved before the KDF (`reserve` / `refund`), never checked and recorded after it |
118
+ | `mfa` | `Partial<AuthMfaPolicy>` | `required` is typed `false` and cannot be set true: both credential paths branch on `user.mfaSecret`, so a user who never enrolled would be locked out for good |
119
+ | `totpReplay` | `TotpReplayGuard` | which TOTP steps are spent, one atomic `claim(subject, step, at)`, called by `completeMfa`. Omitted means the guard the host installed: a framework boot's is `postgresTotpReplayGuard` (`x_totp_steps`), so a code is single-use across the fleet. With no host, `memoryTotpReplayGuard()`, single-use per replica. A guard with no `claim` is `X_CONFIG_INVALID` |
120
+ | `providers` | `OAuthProviderId[]` | **defaults to `[]`**, `As of 2026-08-23` — an empty list is "no OAuth", and every `/auth/oauth/<id>` answers `X_OAUTH_PROVIDER_UNKNOWN`. Never the live registry: that would let any dependency that calls `registerOAuthProvider` turn on a login route this app never enabled |
121
+ | `link` | `OAuthLinkPolicy` | defaults to `'verified-email'` |
122
+ | `passkeys` | `PasskeyPolicyInput` | `{ rpId, origins, rpName?, challengeTtlMs? }` (`As of 2026-11`): the relying party every WebAuthn ceremony verifies against. `rpId` a bare lowercase host, never a URL or an IP; each origin exact (`URL.origin` round-trips it), https or `http://localhost`, on `rpId` or a subdomain; `challengeTtlMs` 5 min. Anything else is `X_CONFIG_INVALID` at `defineAuth`. Absent means no passkeys: `passkeyLogin(auth)` / `passkeyStepUp(auth)` refuse at definition ([Auth](Auth#passkeys)) |
123
+
124
+ There is no `auth.trustedOrigins` in either place, and passkeys are `defineAuth`'s, never `app.config.ts`'s, `As of 2026-11`.
125
+
126
+ ## `jobs`
127
+
128
+ | field | type | default | notes |
129
+ |---|---|---|---|
130
+ | ~~`jobs.driver`~~ | — | — | **Deleted in 5.0.0.** It accepted `'postgres' \| 'redis' \| 'nats'` and was read by nothing: boot always built the Postgres driver, so `jobs: { driver: 'redis' }` did not throw, did not warn, and silently gave you Postgres. Which driver runs is `setJobDriver(driver)` and only that — `setJobDriver(postgresJobDriver({ executor }))`, or `setJobDriver(memoryJobDriver())` in a test ([Jobs and workflows](Jobs-And-Workflows)). Since 25.0.0 a config still writing it is refused at boot, `X_CONFIG_INVALID` naming that call |
131
+ | `jobs.queues` | `string[]` | `['<name>-default']` | derived from `name`, not the literal `['default']`. A `worker` serves these **and** every queue a registered `job()` is enqueued on (the union, so a job naming no queue is never stranded on `default`). A Deployment that sets [`WORKER_QUEUES`](#env-vars) serves exactly that list instead. Empty is `X_CONFIG_INVALID` |
132
+ | `jobs.concurrency` | `number \| Record<queue, number>` | `8` (`JOBS_CONCURRENCY_DEFAULT`) | slots per queue, per worker process. A number applies to every queue served; a table gives each its own (`{ banks: 4, 'banks-long': 2 }`), and a served queue the table does not name runs at `8`. `As of 25.0.0`. A count below 1 or not whole, an empty table or a blank queue name is `X_CONFIG_INVALID` naming the entry (`jobs.concurrency.banks`). An overlay's table replaces the base's whole |
133
+ | `jobs.maxInFlight` | `number` | unset (no process cap) | runs one worker **process** holds at once across every queue it serves — Sidekiq's `concurrency`; `jobs.concurrency` still bounds each queue under it. At the cap the worker claims nothing; each run that ends frees one claim, given to the next queue in turn that has work, so no queue starves. `jobWorker`'s `maxInFlight`. `As of 2026-10-28`. Not a whole number of at least 1: `X_CONFIG_INVALID` |
134
+ | `jobs.maxAttempts` | `number` | `5` | per-job `retry` overrides it |
135
+ | `jobs.backoff` | `'exponential' \| 'fixed'` | `'exponential'` | **two values, not three** — there is no `'linear'` |
136
+ | `jobs.visibilityTimeoutMs` | `number` | `30000` | milliseconds, not a duration string. Lease length; expiry is how a killed worker's job resumes |
137
+ | `jobs.doneRetentionMs` | `number` | `1209600000` (14 days, `JOBS_DONE_RETENTION_MS_DEFAULT`) | age (from the row's last settle, `updated_at`) past which the hourly `x.purge` deletes a `done` job and its `x_job_steps`. Batched: at most 50 batches of 1,000 rows per pass, each batch one statement plus a follow-up when two sweeps split a run, and one statement per pass for step records whose run has no row left. A job input can hold personal data (`mail.send`: the address and the body), so this has a default, unlike the inbox windows. `As of 2026-10-17` |
138
+ | `jobs.deadRetentionMs` | `number` | `7776000000` (90 days, `JOBS_DEAD_RETENTION_MS_DEFAULT`) | the same for `dead`, `failed` and `cancelled` rows, which staff read to explain a failure. A live row (`ready`, `delayed`, `running`, `suspended`) is never deleted, and neither is one an unpublished `x_outbox` row or a `running` `x_backfills` row still names. Not a whole number of at least 1: `X_CONFIG_INVALID` |
139
+ | `jobs.outboxRetentionMs` | `number` | `604800000` (7 days, `JOBS_OUTBOX_RETENTION_MS_DEFAULT`) | age of `published_at` past which the hourly `x.purge` deletes a **published** `x_outbox` row (its job is already in `x_jobs`). An unpublished row is never deleted. Same batching as the job rows. `As of 2026-10-17` |
140
+
141
+ There is no `jobs.retry` object, no `jobs.visibilityTimeout` and no `jobs.retention` block — `JobsConfig` is `{ queues, concurrency, maxInFlight, maxAttempts, backoff, visibilityTimeoutMs, doneRetentionMs, deadRetentionMs, outboxRetentionMs }` and the flat spellings above are the whole surface.
142
+
143
+ **Splitting queues across Deployments** (`As of 25.0.0`). Every Deployment runs the same image and the same `app.config.ts`, so which queues a Deployment claims is env, never config:
144
+
145
+ ```yaml
146
+ # worker-short: 15 min grace # worker-long: 2 h grace
147
+ ROLE: worker ROLE: worker
148
+ WORKER_QUEUES: banks WORKER_QUEUES: banks-long
149
+ ```
150
+
151
+ Each worker then claims its list and nothing else. Every entry must be a queue the app knows — in `jobs.queues` or a registered job's — or the boot refuses with `X_WORKER_QUEUE_UNKNOWN` (`As of 29.0.0`). At boot it logs `jobs.worker.queue-unserved` (a warning, not a refusal) naming every queue a registered job is enqueued on that it will not claim, so a queue no Deployment serves is visible in the first log line rather than as a backlog. `x jobs list --json` shows each queue's workers.
152
+
153
+ ## `realtime`
154
+
155
+ `RealtimeConfig` is five fields and no more `As of 2026-10-05` ([`packages/core/src/config.ts`](https://github.com/developerz-ai/ultimate/blob/main/packages/core/src/config.ts)):
156
+
157
+ | field | type | default | notes |
158
+ |---|---|---|---|
159
+ | `realtime.enabled` | `boolean` | `true` | **on by default since 22.0.0**, when the boot began obeying the key: an app with no `realtime` section keeps the `sync` node it always got. `enabled: false` is the opt-out — for an app that declares no `channel()` topic and no `live: true` query |
160
+ | `realtime.transport` | `'memory' \| 'nats' \| 'redis'` | `'memory'` | `memory` = in-process, single node, dev and small deploys. `redis` type-checks and is never built — `selectTransport` resolves in-process or NATS only |
161
+ | `realtime.urlEnv` | `string` | — | the **env key name**, never a URL. Required unless `memory`; missing → `X_CONFIG_INVALID` |
162
+ | `realtime.maxSubscriptionsPerActor` | `number` | unset = `1000` | live-query subscriptions one actor may hold on one `sync` node, **across all its sockets**. An anonymous socket counts against the client NETWORK resolved at the upgrade — an IPv4 address exactly, an IPv6 address by its /64, an IPv4-mapped IPv6 as its IPv4 (`TRUSTED_PROXY_HOPS` honoured, as `ctx.ip` is). Exceeded → `X_SUBSCRIPTION_LIMIT`, scope `actor`. A whole number ≥ 1, else `X_CONFIG_INVALID` |
163
+ | `realtime.maxSocketsPerActor` | `number` | unset = `16` | sockets one signed-in actor may hold on one `sync` node. An anonymous NETWORK (keyed exactly as above) gets **8 ×** this — 128 at the default — because it is often many people: one office behind a corporate NAT viewing a public live page. The next upgrade is refused before a socket exists: `429 X_SOCKET_LIMIT`. Closing a socket frees its slot. 16 × the per-socket 128 subscriptions passes the 1,000 above, so normal tabs hit neither. A whole number ≥ 1, else `X_CONFIG_INVALID` |
164
+
165
+ **Behind a proxy, set `TRUSTED_PROXY_HOPS`.** An anonymous socket is keyed by the client network the upgrade resolved (IPv6 grouped by /64, so one host cannot rotate through its block for fresh budgets). With nothing trusted, that address is the proxy's own, so every anonymous visitor shares ONE 1,000-subscription and 128-socket budget — the same failure as the anonymous rate-limit bucket. A socket that names no actor and no address at all shares one `address:unknown` budget. That fails closed: those sockets are still bounded, just together.
166
+
167
+ **Why 1,000.** A node holds at most 10,000 live windows (`maxEntries`) and a socket 128 subscriptions, so before this cap one actor with 79 sockets filled the node and every other user's next subscribe was refused. A tenth of the node keeps any one principal out of reach of the node cap, and still fits eight tabs at the per-socket 128. Raise it for an app whose single users legitimately watch more; lower it for a public, anonymous surface:
168
+
169
+ ```ts
170
+ // app.config.ts
171
+ export const config = defineConfig({ name: 'shop', realtime: { maxSubscriptionsPerActor: 200 } });
172
+ ```
173
+
174
+ Since 22.0.0 the transport is chosen by `realtime.transport`, never by `NATS_URL` alone — the config decides, the env supplies the URL `realtime.urlEnv` names. `'memory'` with `NATS_URL` set, and `'nats'` with the named variable unset, are both `X_CONFIG_INVALID` at boot ([`17-scale-ladder.md`](https://github.com/developerz-ai/ultimate/blob/main/docs/idea/17-scale-ladder.md)).
175
+
176
+ **`realtime.tier` is gone**, `As of 2026-08-23` — the same shape as `jobs.driver` and
177
+ `realtime.heartbeatMs` before it. It accepted
178
+ `'channels' | 'live-queries' | 'local-first'`, defaulted to `'channels'` — and **nothing
179
+ read it**: no comparison, no branch, no dereference anywhere in `packages/*/src`. The only code
180
+ that touches `config.realtime` reads `.transport` and `.urlEnv`. So an app declaring
181
+ `tier: 'local-first'` got exactly what an app declaring `tier: 'channels'` got, and this page
182
+ documented per-value semantics the framework never had. Which realtime tier you are on is decided
183
+ by what you **declare** — a `channel()` topic, a `live: true` query, a local store — never by a
184
+ config key → [Realtime](Realtime). **Delete `tier:` from the `realtime` block in `app.config.ts`**;
185
+ that is the whole migration. Since 25.0.0 a stale `tier:` is also refused at boot
186
+ (`realtime.tier was removed in 10.0.0`), where before it was a typecheck error only. `bun run scripts/config-readers.ts` is the ratchet that
187
+ now refuses the next one.
188
+
189
+ **`realtime.heartbeatMs` is gone**, `As of 2026-08-19`. It was declared here with a default of
190
+ 15 000 and read by nothing; the socket beat is the page socket's own 10 s,
191
+ fixed in browser code, which cannot read server config, and the presence beat is derived
192
+ (`PresenceRegistry.heartbeatMs` is `max(1000, floor(ttlMs / 3))`). **A config still writing the key
193
+ is refused at boot since 25.0.0** (`realtime.heartbeatMs was removed in 4.0.0`,
194
+ `X_CONFIG_INVALID`). Before 25.0.0 `section()` copied every own key of the patch, so a leftover
195
+ `heartbeatMs` was silently kept at runtime and only `TS2353` caught it — never for an app that built
196
+ its config into a variable first.
197
+
198
+ **`realtime.limits.*`, `realtime.changeBuffer.*` and `realtime.drain.*` are not `app.config.ts` fields** `As of 2026-08-19`, and never were — `RealtimeConfig` is `{ enabled, transport, urlEnv, maxSubscriptionsPerActor, maxSocketsPerActor }` ([`packages/core/src/config.ts`](https://github.com/developerz-ai/ultimate/blob/main/packages/core/src/config.ts)). Writing one is a typecheck failure (`TS2353`, excess property on `Input<RealtimeConfig>`) and, since 25.0.0, a boot refusal (`X_CONFIG_INVALID`, the closed shape above) for a config the compiler never saw. The caps and the ring are **constructor options**, passed where the node is built:
199
+
200
+ | Option | Where | Default | Effect |
201
+ |---|---|---|---|
202
+ | `maxPerSocket` | `new LiveQueryRegistry({ … })` | `128` | subscriptions per socket. Exceeded → `X_SUBSCRIPTION_LIMIT`, scope `socket`. **Always applies** |
203
+ | `maxPerTenant` | same | none | subscriptions per tenant. Exceeded → `X_SUBSCRIPTION_LIMIT`, scope `tenant` |
204
+ | `tenantOf` | same | none | `(actor) => tenantId \| null`. **Required for `maxPerTenant` to do anything** — `assertCapacity` returns early when either is absent |
205
+ | `maxPerActor` | same | `1000` | subscriptions per actor (anonymous: per resolved client address) across its sockets. Exceeded → `X_SUBSCRIPTION_LIMIT`, scope `actor`. **Always applies**; the boot passes `realtime.maxSubscriptionsPerActor` |
206
+ | `maxEntries` | same | `10000` | distinct `(query, input)` pairs this node will hold — a `qid` derives from client-chosen input, so each one is a matcher and a row window. Exceeded → `X_SUBSCRIPTION_LIMIT`, scope `node` |
207
+ | `maxTopicsPerSocket` | `new ChannelHub({ … })` | `64` | channel topics one socket may join. Exceeded → `X_SUBSCRIPTION_LIMIT`, scope `socket` |
208
+ | `maxTopicsPerNode` | same | `10000` | distinct topics this node bridges, one transport subscription each. Exceeded → `X_SUBSCRIPTION_LIMIT`, scope `node` |
209
+ | `maxSocketsPerActor` | `syncNode({ … })` | `16` | sockets per actor at the upgrade; an anonymous network gets 8 × it (`ANONYMOUS_SOCKET_MULTIPLIER`). Exceeded → `429 X_SOCKET_LIMIT`. **Always applies**; the boot passes `realtime.maxSocketsPerActor` |
210
+ | `maxBufferedBytes` | `syncNode({ … })` | `1 MiB`, exported as `DEFAULT_MAX_BUFFERED_BYTES` | outbound bytes queued on one socket before this node starts **dropping** its frames. A live-query patch is re-snapshotted; a channel frame is lost → [Realtime](Realtime) |
211
+ | `maxDroppedFrames` | same | `32` | drops one socket may take before it is closed with `1013` (`overloaded`), reason `backpressure` |
212
+ | `capacity` | `new RingChangeBuffer({ … })` | `1024` | retained patches per query hash; a reconnect inside the window is a delta, not a snapshot |
213
+ | `maxQueries` | same | `4096` | retained query hashes, least-recently-written dropped first |
214
+
215
+ **The per-socket and per-actor caps are enforced by every boot; the per-tenant cap is opt-in** `As of 2026-10-04`. The `sync` role ([`role-sync.ts`](https://github.com/developerz-ai/ultimate/blob/main/packages/cli/src/role-sync.ts)) passes `realtime.maxSubscriptionsPerActor` into the registry, or leaves the registry's 1,000. It sets **no tenant cap**: one tenant is one person and the next is five thousand seats, so no default is defensible — set `maxPerTenant` **and** `tenantOf` on the registry yourself if you need one (either alone arms nothing).
216
+
217
+ ## `cache`
218
+
219
+ `CacheConfig` is **two flat fields**. See [Caching and invalidation](Caching-And-Invalidation).
220
+
221
+ | field | type | default | notes |
222
+ |---|---|---|---|
223
+ | `cache.defaultTtlMs` | `number` | `60000` | milliseconds, not a duration string |
224
+ | `cache.tiers` | `CacheTierName[]` | `['request-memo', 'lru']` | `'request-memo' \| 'lru' \| 'redis' \| 'cdn'`; order is fixed regardless of listing order, an EMPTY list is refused (`X_CONFIG_INVALID`), and a rung the environment cannot supply refuses the boot |
225
+ | ~~`cache.driver`~~ | — | — | **Deleted in 9.0.0.** Refused at boot since 25.0.0 (`X_CONFIG_INVALID`, naming the replacement). It was the second way to ask for Redis and the losing one: the ladder is built from `cache.tiers`, so `driver: 'redis'` beside `tiers: ['request-memo', 'lru']` asked for a rung nothing built. Name `redis` in `tiers` |
226
+ | ~~`cache.urlEnv`~~ | — | — | **Deleted in 9.0.0** and refused at boot since 25.0.0, `database.urlEnv`'s defect verbatim: the Redis tier reads the literal `REDIS_URL`, so `urlEnv: 'MY_REDIS'` made nothing read `MY_REDIS` |
227
+
228
+ **The per-tier byte caps and TTLs are constructor options, not config** — the same shape as the realtime caps above. `cache.memo.maxBytes`, `cache.lru.maxBytes`, `cache.redis.*` and `cache.ttl.*` are not fields and never were; writing one is `TS2353`, excess property on `Input<CacheConfig>`, and `X_CONFIG_INVALID` at boot.
229
+
230
+ | Option | Where | Default | Effect |
231
+ |---|---|---|---|
232
+ | `maxBytes` | `lruTier({ … })` | 64 MiB | byte budget for the whole tier; a single value over it is `X_CACHE_TOO_LARGE` rather than a silent drop |
233
+ | `defaultTtlMs` | same | `60_000` | applied when a `set` omits `ttlMs` |
234
+ | `jitterFraction` | same | `DEFAULT_TTL_JITTER_FRACTION` | TTL spread in `[0, 1)`; `0` disables it, which is how a stampede is reproduced in a test |
235
+ | `clock` / `rng` | same | system | injected so a jittered expiry is deterministic |
236
+ | `loadDeadlineMs` | `cacheStack(tiers, { … })` | `30_000` | how long one `load()` may hold its key before a later reader starts its own. Frees the KEY, never the load — the wedged call runs on. Anchored to `http`'s own request timeout: a load still running at 30 s has no reader left to serve |
237
+ | `schedule` | same | `setTimeout` | injected so the deadline above is provable without a test waiting one out |
238
+
239
+ **One vocabulary names the tiers, `As of 2026-08-23`.** `CACHE_TIERS` in [`packages/core/src/cache-vocabulary.ts`](https://github.com/developerz-ai/ultimate/blob/main/packages/core/src/cache-vocabulary.ts) is `request-memo | lru | redis | cdn`, in ladder order — it is what `@ultimat3/cache`'s `sortTiers` orders a stack by, imported, not a second list that agrees with it (the `TIER_ORDER` alias was deleted in 25.0.0). Until 9.0.0 there were two: the config accepted `memo | lru | shared | isr | cdn` while the ladder built `request-memo | lru | redis | cdn`, so `memo`/`request-memo` and `shared`/`redis` were one rung spelled twice and **`isr` named a rung that did not exist** — it is a `RenderMode`, not a cache tier. `bun run render-modes` refuses a second declaration on the member set, so the two cannot re-diverge.
240
+
241
+ ### CDN purge
242
+
243
+ The purge driver is selected from the **environment**, not from a config field — the same law the
244
+ mail transports follow, and for the same reason: nothing loads `app.config.ts`'s contents at
245
+ runtime, so one image deploys to every environment.
246
+
247
+ | Key | Selects | Notes |
248
+ |---|---|---|
249
+ | `FASTLY_API_TOKEN` + `FASTLY_SERVICE_ID` | Fastly | batch surrogate-key purge, 256 keys per call |
250
+ | `CLOUDFLARE_API_TOKEN` + `CLOUDFLARE_ZONE_ID` | Cloudflare | cache-tag purge, 30 tags per call, Enterprise zones |
251
+
252
+ The surrogate keys are built from the tags by `@ultimat3/cache`'s `surrogateKeys()` — `post`, `post:1`, plus one `e:<entity>` index key per entity ([Caching](Caching-And-Invalidation#the-cdn-leg)). They are sent on an `isr` document whose route declares `revalidate.tags` **while the response is shared-cacheable** — the same document answered to a signed-in visitor is rewritten to `private` and carries neither header — as `Surrogate-Key` (space-separated, what Fastly reads) and `Cache-Tag` (comma-separated, what Cloudflare reads), so the edge purges what `invalidates: [tag.post]` busts. `As of 2026-10-02` — before it no shipped response carried either header; `x-cache-tags`, which neither edge reads, is gone.
253
+
254
+ | Failure | Code | Raised by | Lands |
255
+ |---|---|---|---|
256
+ | both pairs set — two CDNs claim one purge | `X_CONFIG_INVALID` | `selectPurgeDriver` | boot |
257
+ | half a pair — a token with no id, or an id with no token | `X_CONFIG_INVALID` | `selectPurgeDriver` | boot |
258
+ | the provider refused — 401, 429, `success: false` | `X_CACHE_PURGE_FAILED` | the purge driver | `report.errors`, never the write |
259
+
260
+ Half a pair is refused because "no CDN" is the one wrong reading — a deployment then ships
261
+ believing it purges. Both refusals name the keys that are actually set, never their values.
262
+
263
+ `X_CONFIG_INVALID` covers a configuration that cannot boot, env **or** `app.config.ts`
264
+ ([Env vars](#env-vars)). `X_CACHE_PURGE_FAILED` is provider refusal only — never a configuration
265
+ problem, and never fatal to the write that triggered the bust.
266
+
267
+ `x dev` prints which one it installed — `cdn=none`, or `cdn=external(fastly via
268
+ FASTLY_API_TOKEN)`. The env **key** is reported, never its value.
269
+
270
+ ## `site` and `seo`
271
+
272
+ `As of 2026-10`. Supported `app.config.ts` blocks: the public origin every absolute URL is built against, the app's own favicon, production crawler directives and sitemap generation. The types and defaults are `SiteSectionsInput`, `SeoConfigInput` and `mergeSite` in [`packages/core/src/config-site.ts`](https://github.com/developerz-ai/ultimate/blob/main/packages/core/src/config-site.ts); `AppConfigInput` extends `SiteSectionsInput`.
273
+
274
+ ```ts
275
+ import { defineConfig } from '@ultimat3/core';
276
+
277
+ export const config = defineConfig({
278
+ name: 'postly',
279
+ site: {
280
+ origin: 'https://www.example.com',
281
+ // The app's SVG favicon, under the app's site assets. Every document carries it.
282
+ favicon: 'assets/brand/icon.svg',
283
+ },
284
+ seo: {
285
+ robots: {
286
+ disallow: ['/panel', '/api'],
287
+ // Everyone may fetch /s/ (link previews unfurl, Google reads its noindex); AI crawlers may not.
288
+ groups: [
289
+ { userAgent: '*', allow: ['/'] },
290
+ { userAgent: ['GPTBot', 'CCBot', 'ClaudeBot', 'archive.org_bot'], disallow: ['/s/'] },
291
+ ],
292
+ },
293
+ sitemap: {
294
+ extra: ['/verificar', '/estado'],
295
+ lastmod: 'git',
296
+ sections: [{ name: 'blog', match: '/blog/**' }],
297
+ pageSize: 50000,
298
+ },
299
+ },
300
+ });
301
+ ```
302
+
303
+ The example assumes `/verificar` and `/estado` already match registered `app/` routes declaring **no policy**. `extra` lists existing public pages; it does not create routes or bypass authorization. Unknown paths, `site/` pages (already listed) and policy-gated routes are refused with `X_SITEMAP_EXTRA_INVALID` when the sitemap is built.
304
+
305
+ Only the exact environment `production` emits the configured robots groups and a sitemap directive. Every other environment emits `User-agent: *` and `Disallow: /`, with no sitemap advertised. Global `seo.robots.disallow` rules also filter sitemap URLs; group-only `allow`/`disallow` rules affect `robots.txt` only, not sitemap membership.
306
+
307
+ | field | type | default | notes |
308
+ |---|---|---|---|
309
+ | `site.origin` | `string \| null` | `null` | scheme + host (+ port) only; a path, query or fragment is `X_CONFIG_INVALID`. Canonical, `og:url`, hreflang and the sitemap are absolute against the first of `APP_URL`, `SITE_ORIGIN`, `site.origin`; with none, the request's own origin (served) or `https://localhost` (static build, which warns on stderr when `ULTIMATE_ENV=production`) |
310
+ | `site.favicon` | `string \| undefined` | `undefined` | the app's own SVG favicon, named as a site asset path (`assets/brand/icon.svg`) — the file lives under `apps/web/site/assets/`. Every rendered document carries `<link rel="icon" type="image/svg+xml" href="…">` at the content-hashed URL `/assets/*` serves, never the raw path; the URL is hashed per render, so an edited icon moves the href. A path that is not an `assets/…` SVG, or one with an empty, `.` or `..` segment, is `X_CONFIG_INVALID`; a declared file that is not on disk is `X_ASSET_MISSING`, raised while the page renders. Undeclared: no link, and the browser's own `/favicon.ico` request is the only one. `As of 2026-10` |
311
+ | `seo.robots.disallow` | `string[]` | `[]` | each starts with `/` and contains no line breaks. Added to **every** production group and used to filter the sitemap. A `User-agent: *` group is appended if global rules exist and no declared group names `*`. Other environments block all crawling, as above |
312
+ | `seo.robots.groups` | `{ userAgent, allow?, disallow?, crawlDelay? }[]` | `[]` | per-crawler `User-agent` groups of a production `robots.txt`. Empty: one `User-agent: *` group allowing `/`. Declared, they ARE the groups — a crawler obeys only the one naming it most specifically, so name `*` among them for everyone else. `userAgent` a non-blank string or non-empty list of non-blank strings, with no line breaks; `allow`/`disallow` paths start with `/` and contain no line breaks; `crawlDelay` finite seconds ≥ 0 — anything else is `X_CONFIG_INVALID`, by index. Shapes `robots.txt` only: the sitemap is filtered by `seo.robots.disallow`. `As of 27.16.0` |
313
+ | `seo.sitemap.extra` | `string[]` | `[]` | public pages outside `site/` to list — a path (`/verificar`) answered by an `app/` route with no `policy`; listed per routed locale with hreflang alternates like a `site/` page. No origin, query or fragment (`X_CONFIG_INVALID`); a path no ungated `app/` route answers is `X_SITEMAP_EXTRA_INVALID` when the sitemap is built. `As of 22.10` |
314
+ | `seo.sitemap.lastmod` | `'none' \| 'git' \| 'mtime' \| 'build'` | `'none'` | each `<lastmod>`: `'git'` the last commit touching the route's source file (its mtime where there is no work tree — a container image), `'mtime'` the file's mtime, `'build'` one timestamp for every URL. Read once per file per process. `As of 22.10` |
315
+ | `seo.sitemap.sections` | `{ name, match }[]` | `[]` | any declared: `/sitemap.xml` is an index of `/sitemaps/<name>-<n>.xml`, pages no section matches under the reserved `pages`. `name` a unique lower-case slug (a-z, 0-9, single hyphens, at most 64 characters), never reserved `pages`; `match` a path pattern or non-empty list (`/precios`, `/blog/*`, `/blog/**`), without origin, query or fragment. `*` occupies a whole segment; `**` is allowed only as the final segment. Matched without the locale prefix; first matching section wins. Anything else is `X_CONFIG_INVALID`. [SEO](SEO#sections-an-index-by-configuration). `As of 2026-10` |
316
+ | `seo.sitemap.pageSize` | `number` | `50000` | `<url>` entries per sitemap file, a whole number from 1–50,000 (the sitemaps.org cap); invalid values are `X_CONFIG_INVALID`. A file past 50 MB splits further whatever this says. `As of 2026-10` |
317
+
318
+ ## `pwa`
319
+
320
+ `offline` is a **block**, not a string, `As of 2026-08` — see [Upgrading](Upgrading). Three booleans, two blocks; every field is optional except `pwa.name`, `pwa.colors` and `pwa.offline.fallback`, which `enabled: true` makes required, and `pwa.vapid.subject`, which a `pwa.vapid` block does.
321
+
322
+ ```ts
323
+ pwa: {
324
+ enabled: true,
325
+ offline: { fallback: '/offline' },
326
+ name: 'My App',
327
+ colors: {
328
+ light: { themeColor: '#1b1f3b', backgroundColor: '#ffffff' },
329
+ dark: { themeColor: '#1b1f3b', backgroundColor: '#0b0d1a' },
330
+ },
331
+ // Web Push — the key pair is env (`x vapid create`), never here.
332
+ push: true,
333
+ vapid: { subject: 'mailto:ops@example.com' },
334
+ },
335
+ ```
336
+
337
+ | field | type | default | notes |
338
+ |---|---|---|---|
339
+ | `pwa.enabled` | `boolean` | `false` | **read** — `true` makes `x dev`, the container and `x build --target static` emit `manifest.webmanifest`, `sw.js`, `x-sw-register.js` and the `<head>` that names all three, and requires `pwa.name`, `pwa.colors` and `pwa.offline.fallback` beside it. It generated no service worker until 2026-08-27 ([#390](https://github.com/developerz-ai/ultimate/issues/390)) |
340
+ | `pwa.name` | `string` | `''` | The install title a browser shows a person. **Required when `pwa.enabled` is `true`** — `app.name` is a slug (`^[a-z][a-z0-9-]{1,63}$`), so it is the wrong answer rather than a rough one |
341
+ | `pwa.colors` | `{ light, dark }` of `{ themeColor, backgroundColor }` | `undefined` | `theme_color` and `background_color`, per colour scheme. **Required when `pwa.enabled` is `true`**: a browser paints the install splash and the address bar from these before a stylesheet has loaded, so there is nothing to derive them from and no defensible default. The one place in `app.config.ts` a raw colour is legal (`theme.tokens`, the other, was deleted in 25.0.0) |
342
+ | `pwa.offline.fallback` | `string \| null` | `null` | **read** — the absolute route path of the document an offline navigation gets when the cache has no answer. **Required when `pwa.enabled` is `true`**, and it must start with `/`: a relative path resolves against whatever document registered the worker, so `offline` under `/posts/1` is `/posts/offline` — a 404 cached as the answer to every offline navigation |
343
+ | `pwa.offline.image` | `string \| null` | `null` | **read** — the URL of the placeholder served for an image request neither the network nor the cache can answer. It must be a path on this origin (`/…`, never `https://…`, `//host` or `/\host` — the build refuses with `X_PWA_NO_OFFLINE_FALLBACK`). The build precaches it beside the offline document and on the same terms: its existence is not checked (a URL that does not exist costs that one precache entry, and the request gets the worker's 503), revisioned by the build id unless it is a content-hashed `/assets/…` URL |
344
+ | `pwa.offline.font` | `string \| null` | `null` | **read** — the same, for a font request |
345
+ | `pwa.offline.neverCache` | `string[]` | `[]` | **read** — path prefixes the worker passes straight through. Auth and payments belong here: a stale 200 is worse than a failure |
346
+ | ~~`pwa.offline` as a string~~ | — | — | **Changed in the release that closed [#390](https://github.com/developerz-ai/ultimate/issues/390).** 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. Migration: `offline: 'runtime'` → `offline: { fallback: '/offline' }`, plus a route at that path ([Upgrading](Upgrading)) |
347
+ | `pwa.backgroundSync` | `boolean` | `false` | **read**. The emitted worker's `sync` event posts `OUTBOX_DRAIN_MESSAGE` to every open tab, which drains realtime's outbox (21.0.0) |
348
+ | `pwa.push` | `boolean` | `false` | **read** `As of 26.1.0` — Web Push, end to end: every role's boot resolves the VAPID pair and installs the push runtime (refused with `X_PWA_VAPID_KEY_MISSING` in a deployed environment with no keys), `sw.js` carries the `push` and `notificationclick` handlers, every document carries `<meta name="x-push-key">`, and `pushSubscribe()` / `pushUnsubscribe()` / `webPush()` work. **Wired only with `pwa.vapid`:** `push: true` alone boots as it did on 26.0.0, where the key wired nothing — push stays off and the boot logs `pwa.push unwired` with the fix; a later major refuses it. A static export has no runtime behind it, so it emits no handler and says so in `serviceWorkerWarnings`. [Notify](Notify#push) |
349
+ | `pwa.vapid.subject` | `string` | — | **read** — `mailto:` or an `https:` URL: who a push service writes to about this server (RFC 8292). **What wires `pwa.push`; refused when `pwa.push` is false** (a key with no reader), and a block with no valid subject is refused. The KEYS are never config: `ULTIMATE_VAPID_PUBLIC_KEY` and `ULTIMATE_VAPID_PRIVATE_KEY` are environment variables, sealed together by `x vapid create`, because a public key in committed config and a private key per deploy are two places that can disagree. A development or test process with neither set signs with a published development pair |
350
+ | `pwa.vapid.pushHosts` | `string[]` | `[]` | **read** `As of 26.1.0` — push services this app sends to **beyond** the built-in ones (`PUSH_SERVICE_HOSTS`: `fcm.googleapis.com`, `push.services.mozilla.com`, `push.apple.com`, `notify.windows.com`), for a self-hosted push service. Bare host names — no scheme, port, path or wildcard; each admits its subdomains on a dot boundary; `localhost` and IP literals are refused (`X_CONFIG_INVALID`). A subscription on any other host is refused when it is stored and deleted unsent (`X_PWA_PUSH_HOST_UNLISTED`): an endpoint comes from a request body, and a hostname resolves wherever its owner points it, so the list of services — not the name's shape — is what keeps the sender off private addresses |
351
+ | ~~`pwa.installPrompt`~~ | — | — | **Deleted in 8.0.0.** Refused at boot since 25.0.0 (`X_CONFIG_INVALID`, naming the replacement). Declared, defaulted and merged, and read by nothing — `@ultimat3/pwa`'s `installController` is real and complete and no code ever threaded this flag into it, so both tracked apps and every scaffolded app carried a switch with no wire. Migration: delete the key and call `installController` from your own affordance ([PWA and offline](PWA-And-Offline)) |
352
+
353
+ ## `navigation`
354
+
355
+ Which surfaces move between their pages by client-side navigation over server-rendered documents, and what the browser may prefetch on the rest — [Client navigation](Client-Navigation).
356
+
357
+ ```ts
358
+ navigation: { client: ['app'], speculation: { prefetch: 'moderate', exclude: [] } },
359
+ ```
360
+
361
+ | field | type | default | notes |
362
+ |---|---|---|---|
363
+ | `navigation.client` | `('site' \| 'app')[]` | `[]` | **read** by `x dev`, the container and `x build --target static`: a listed surface's documents carry the router (`/_x/assets/navigation/<hash>.js`, charged to each route's `budget.js`). `api`, `shared`, a duplicate or a non-list → `X_CONFIG_INVALID`. The last layer that lists surfaces wins |
364
+ | `navigation.speculation.prefetch` | `'moderate' \| 'conservative' \| false` | `'moderate'` | **read** by the same three: every document WITHOUT the router carries `<script type="speculationrules">` (prefetch only, admitted to the CSP by hash, 0 bytes against `budget.js`). `false` emits nothing. Any other value → `X_CONFIG_INVALID` |
365
+ | `navigation.speculation.exclude` | `string[]` | `[]` | **read** — URL patterns subtracted from the candidates the route table yields. Each must start with `/`, else `X_CONFIG_INVALID`. The last layer that lists any wins |
366
+
367
+ ## `islands`
368
+
369
+ How the build bundles the app's `*.island.tsx` client entries.
370
+
371
+ ```ts
372
+ islands: { sharedChunks: true },
373
+ ```
374
+
375
+ | field | type | default | notes |
376
+ |---|---|---|---|
377
+ | `islands.sharedChunks` | `boolean` | `false` | **read** by `x dev`, the container and `x build`: `true` builds every island in one split bundle, so a module two islands import is one `/islands/chunk-<hash>.js` a page fetches once and a browser caches across pages; each route's `budget.js` counts it once. `false` builds each island alone, self-contained. Off by default because tree shaking across one split build keeps what ANY importer uses: a small island importing a helper from a module other islands use heavily pays for all of it (`examples/dummy`'s update banner 712 → 16,288 B). Turn it on where pages render several islands over one graph, then weigh the routes with `x verify`. A non-boolean → `X_CONFIG_INVALID` |
378
+
379
+ ## `clientErrors`
380
+
381
+ Whether `app/` documents report browser failures to the app's `ErrorReporter` ([Observability](Observability#browser-failures)). Off unless declared:
382
+
383
+ ```ts
384
+ import { defineConfig } from '@ultimat3/core';
385
+
386
+ export const config = defineConfig({ name: 'my-app', clientErrors: { enabled: true } });
387
+ ```
388
+
389
+ | field | type | default | notes |
390
+ |---|---|---|---|
391
+ | `clientErrors.enabled` | `boolean` | `false` | **read** by `x dev` and the container's web role: on, `GET /x-client-errors.js` and `POST /_client-errors` are mounted and every `app/` document names the script; off, both are 404 and no document names it. A static export never carries it and `x build` warns. A non-boolean → `X_CONFIG_INVALID` |
392
+ | `clientErrors.anonymous` | `boolean` | `false` | `true` accepts a report with no signed-in actor, limited per address. Off, an anonymous report is `401`: crash text is input to whoever reads the monitor |
393
+ | `clientErrors.perMinute` | `number` | `30` | reports one actor (or, anonymous, one address) may file per minute, spent on the app's rate-limit store. A whole number ≥ 1, else `X_CONFIG_INVALID` |
394
+
395
+ ## `failureCapture`
396
+
397
+ ```ts
398
+ import { defineConfig } from '@ultimat3/core';
399
+
400
+ export const config = defineConfig({ name: 'my-app', failureCapture: { enabled: true } });
401
+ ```
402
+
403
+ | Key | Type | Default | Effect |
404
+ |---|---|---|---|
405
+ | `failureCapture.enabled` | `boolean` | `false` | **read** by every role's boot: on, a 5xx at `invoke`/`runQuery` stores a replayable bundle in `x_failures` for `x repro` (input masked, actor, flags, the rows read). `x dev` turns it on whatever this says. Storing user input is the app's decision |
406
+ | `failureCapture.perHour` | `number` | `5` | bundles kept per error fingerprint per hour, per process. A whole number ≥ 1 |
407
+ | `failureCapture.maxRows` | `number` | `50` | captured rows per bundle; past it the bundle says `truncated: true` |
408
+ | `failureCapture.maxBytes` | `number` | `65536` | bytes per bundle; rows go first, never the input |
409
+
410
+ Retention has no default: hand `failurePurgeTarget(postgresFailureStore(), { keepMs })` to a `purge()`.
411
+
412
+ ## `http`
413
+
414
+ **Not an `app.config.ts` block, and never was.** `AppConfigInput` has no `http` key. `@ultimat3/core` is tier 0 and cannot hold `@ultimat3/http`'s types, so an `http` block here would be a **second declaration** of `HttpConfigInput` in a package that can never check it against the real one. An app declares its half with `configureHttp()`, at module scope in a file under `apps/*/` — the same seam `configureAuthenticator()` and `defineStorage()` are, and for the same reason.
415
+
416
+ Until 12.0.0 the whole tuning surface was **unreachable from a shipped app**: the only `HttpConfig` any framework-booted process built was one fixed literal inside the CLI, so `cors.origins` was `[]` in every deployment, `bodyLimitBytes` was 1 MiB for a 4 MB CSV endpoint and `rateLimit.buckets` was 120 burst / 2 rps for a bank and a blog alike. Shipped `fix:` lines across `@ultimat3/http` told the reader to edit `http.<key>` in `app.config.ts`, which has never held one — `bun run scripts/doc-config-keys.ts` is what now refuses that sentence in a doc.
417
+
418
+ ```ts
419
+ // apps/web/http.ts — module scope. The app load imports every `apps/*/*.ts`, so this runs
420
+ // before any listener binds; `apps/web/server.ts` and `prerender.ts` are entry points and
421
+ // are deliberately NOT imported, so the call may not live in either.
422
+ import { configureHttp, DEFAULT_RATE_LIMIT } from '@ultimat3/http';
423
+
424
+ configureHttp({
425
+ requestTimeoutMs: 120_000,
426
+ bodyLimitBytes: 8 * 1_048_576,
427
+ maxInflight: 2_000,
428
+ cors: { origins: ['https://app.example.com'], credentials: true },
429
+ csrf: { mode: 'origin' },
430
+ rateLimit: {
431
+ // The WHOLE table, never a patch: `buckets` replaces the default one rather than merging
432
+ // into it, and a name nothing declares falls through to a built-in 120 / 2.
433
+ buckets: {
434
+ ...DEFAULT_RATE_LIMIT.buckets,
435
+ login: { capacity: 5, refillPerSecond: 0.01 },
436
+ tenant: { capacity: 5_000, refillPerSecond: 100 },
437
+ },
438
+ tenantBucket: 'tenant',
439
+ },
440
+ });
441
+ ```
442
+
443
+ | field | default | notes |
444
+ |---|---|---|
445
+ | `basePath` | `'/'` | stripped before matching, on a segment boundary — a mount at `/api` owns `/api` and `/api/…`, never `/apix` |
446
+ | `bodyLimitBytes` | `1_048_576` | enforced **while** the body streams, so a `transfer-encoding: chunked` payload is cancelled the instant the running total passes it |
447
+ | `requestTimeoutMs` | `30_000` | `0` disables. Bun's socket `idleTimeout` is derived from it — `ceil(ms / 1000) + 2` s, `0` (off) for no deadline or one past Bun's 255 s — so a handler silent for longer than Bun's own 10 s default is no longer cut (`As of 27.16.0`; it was, whatever this said). At most `2_147_483_647` (the longest a timer holds, ~24.8 days) — more is `X_CONFIG_INVALID`, where it used to arm a 1 ms timer and 504 every request. A caller may only **shorten** it, with `x-request-timeout-ms`; an ask above that ceiling is ignored. The framework's own typed clients send what is LEFT of the current request's budget |
448
+ | `maxInflight` | `1_000` | `0` disables. Past it a request is shed `X_OVERLOADED` **before** any work — no route match, no auth, no body |
449
+ | ~~`drainTimeoutMs`~~ | — | **Deleted in 25.0.0.** A second drain budget for the `web` role alone; `drain.deadlineMs` is the one budget for every role. Passing the key to `configureHttp` or `defineHttpConfig` is `X_CONFIG_INVALID` naming `drain.deadlineMs` |
450
+ | `cors` | `origins: []`, `credentials: true` | `origins: ['*']` with `credentials: true` is `X_CORS_CONFIG_INVALID` at boot — no browser accepts the pair. So is `'null'` in `origins`, with or without credentials: every sandboxed iframe sends it, and `URL.parse('localhost:3000').origin` writes it. `['*']` without credentials still answers it |
451
+ | `csrf` | `mode: 'origin'` | `'origin' \| 'off'`. `mode: 'token'` is deliberately not shipped. `'origin'` judges every unsafe request that carries browser evidence (`Origin` or `sec-fetch-site`), signed in or not — a forged sign-in is a forged write. Exempt: an `Authorization: Bearer` header (a cached Basic, Digest or Negotiate credential is ambient and judged like the cookie), and an anonymous request carrying neither header (a webhook, a server-to-server call). `Origin: null` proves nothing: every sandboxed iframe sends it |
452
+ | `trustClientCertHeader` | `false` | read Envoy's `x-forwarded-client-cert` into `ctx.peer`. Its own declaration: `TRUSTED_PROXY_HOPS` says the proxy appends to `x-forwarded-for`, not that it strips a certificate header the client sent. Read at the same hop index, so it does nothing while no proxy is trusted |
453
+ | `healthDetailPeers` | `['loopback']` | who `/healthz` and `/readyz` tell more than `{ state, ready, role }`. Each entry is an address class (`loopback`, `private`, `link-local`, `ula`, `cgnat`, `unspecified`, `reserved`, `public`) or one IP literal; anything else is `X_CONFIG_INVALID`. `[]` tells nobody. Behind a trusted proxy the forwarded caller must be listed as well as the socket. The `sync` role's own listener reads the same list; it trusts no proxy, so a request carrying `Forwarded` or `X-Forwarded-For` gets the verdict only |
454
+ | `security` | HSTS off until https is affirmed, CSP report-only in dev | `security.csp.extend` merges **per directive** with the boot's own hashes, so admitting a CDN source does not evict the hydration runtime's and lock every island out. `connect-src` is `'self' blob:` — no bare `ws:`/`wss:` (a socket to any host); a sync node on another origin (`SYNC_URL`) is added by that origin alone (`As of 22.6.0`). `security.hsts` merges key by key: `{ preload: true }` alone opts into preload over the two-year default, `null` sends none |
455
+ | `locale` / `tz` | header + cookie names | it decides WHERE the request's locale and zone are read from; `@ultimat3/i18n` and `@ultimat3/time` decide what they mean |
456
+ | `rateLimit` | `enabled`, `buckets`, `defaultBucket`, `tenantBucket: null` | `scope` is boot-owned (below). `tenantBucket` names a bucket a whole tenant spends **beside** the caller's own; a name `buckets` does not declare is `X_RATE_LIMIT_TENANT_BUCKET_UNKNOWN` at boot. A request that fails `auth: 'required'` spends `defaultBucket` under one key per client address, across every route — the 401 leaves before the `rate-limit` stage, so it is metered there or nowhere. Once that allowance is spent, the address is refused `X_RATE_LIMITED` on every required route **before** `authenticate` runs — a valid credential included, so a right guess cannot be told from a wrong one — until it refills (`As of 2026-10`) |
457
+
458
+ **Seven keys plus `rateLimit.scope` are boot-owned, and writing one is a compile error** — `AppHttpConfig` is `Omit<HttpConfigInput, BootOwnedHttpKey | 'rateLimit'>` with `rateLimit` re-added minus `scope`, so the refusal is `TS2353` at the call and never a value silently discarded at every boot:
459
+
460
+ | boot-owned key | what decides it |
461
+ |---|---|
462
+ | `port` / `hostname` | `PORT` and the role's binding |
463
+ | `dev` | `x dev`, or `ULTIMATE_ENV` |
464
+ | `buildId` | `BUILD_ID`, stamped by `x build` |
465
+ | `signInPath` | `auth.signInPath` in `app.config.ts` |
466
+ | `trustProxy` / `trustedProxyHops` | `TRUSTED_PROXY_HOPS` in the environment — one image runs behind an ingress in one cluster and behind nothing on a laptop, so an app that hardcoded it would be wrong in one of the two |
467
+ | `rateLimit.scope` | the store the boot installed. A literal here would be a second declaration quietly contradicting it, and `assertRateLimitScope` compares exactly those two halves |
468
+
469
+ An **embedder** that builds its own server — `httpServer({ routes, config: defineHttpConfig({ … }) })` — passes the whole `HttpConfigInput`, boot-owned keys included, and owns every consequence: that is the one path on which `X_RATE_LIMIT_SCOPE_UNSET` and `X_TRUST_PROXY_UNSET` are reachable.
470
+
471
+ ## SEO settings versus Lighthouse thresholds
472
+
473
+ **`site` and `seo` are supported `app.config.ts` blocks.** See the [site and seo reference](#site-and-seo) above for the origin, robots groups and sitemap options, and [SEO](SEO) for generated SEO behavior.
474
+
475
+ **Lighthouse thresholds are not shipped**, `As of 2026-10`: there is no `seo.lighthouse` configuration or Lighthouse threshold gate. The supported robots and sitemap settings do not configure performance scoring. For the shipped per-route JavaScript byte budget, see [budgets](#budgets).
476
+
477
+ ## `budgets`
478
+
479
+ **Not an `app.config.ts` block.** There is no `budgets` key on `AppConfigInput` and no per-surface default table. A budget is declared **per route**, as `budget` on `defineRoute` — `RouteBudget` in [`packages/render/src/route.ts`](https://github.com/developerz-ai/ultimate/blob/main/packages/render/src/route.ts):
480
+
481
+ **Two keys, `As of 2026-08-24`**, and both are optional:
482
+
483
+ | field | type | notes |
484
+ |---|---|---|
485
+ | `budget.js` | `string` | `'40kb'` — measured from the real bundle graph, not the source size. The one budget the framework weighs: `x build --target static` writes a `jsBytes` per prerendered route into `.x/build-stats.json`, counted off the emitted document's own `<script>` tags, and the `budgets` step compares against that |
486
+ | ~~`budget.lcp`~~ | — | **Deleted**, with `budgetLcp` on the route descriptor, the `lcp` in `x routes --json` and `x.manifest.json`, and `lcpMs` in `.x/build-stats.json`. Nothing in the framework observes a paint — the build emits static HTML — so it was published and never weighed. A route still declaring it is a type error, and `registerRoute` refuses one that arrives by a cast (`X_ROUTE_MODE_INVALID`). Migration: delete the key |
487
+ | ~~`budget.css`~~ | — | **Deleted in 12.0.0**, with `budget.cls` and `budget.tbt`. All three were declared on the route contract for four majors, flattened away by `registerRoute` — which projects a budget to `budgetJs` and nothing else — and read by no consumer anywhere, so `budget: { cls: 0.1 }` was accepted, normalised, stored and ignored while `x verify`'s `budgets` step, the one thing that exists to enforce a budget, reported green. Deleted rather than wired, for `pwa.installPrompt`'s reason: the measuring half does not exist either. Migration: delete the key |
488
+ | ~~`budget.cls`~~ | — | as above |
489
+ | ~~`budget.tbt`~~ | — | as above. A new budget key is now a **build error** until the descriptor projects it: `_EveryBudgetKeyIsProjected` in [`packages/render/src/type-pins.tsx`](https://github.com/developerz-ai/ultimate/blob/main/packages/render/src/type-pins.tsx) derives `'js'` from `RouteDescriptor`'s own `budgetJs`, so neither side can move alone |
490
+
491
+ A declared budget with no measurement is itself a failure (`X_BUDGET_UNMEASURED`), never a pass — a route that clears the gate without being weighed is the false green axiom 5 exists to prevent — and the finding names the import chain that blew it, because "your bundle got bigger" is not actionable for a human or an agent. Only `render: 'static'` routes are weighed today; every other mode needs a running process. The **0 kb JS baseline on `site/`** is not a default in a table: a `site/` route off `hydrate: 'never'` with no `budget.js` is refused at registration, which is structural rather than aspirational.
492
+
493
+ The precache warning is its own number and not a budget: `DEFAULT_PRECACHE_WARN_BYTES` is 5 MiB, overridable per build as `warnBytes` ([`packages/pwa/src/precache.ts`](https://github.com/developerz-ai/ultimate/blob/main/packages/pwa/src/precache.ts)).
494
+
495
+ ## `mail`
496
+
497
+ The transport is selected by **environment**, like every other external service — an unset
498
+ variable means the embedded default, so the same image deploys everywhere and no credential is ever
499
+ committed. The `app.config.ts` block says only what the chosen transport keeps:
500
+
501
+ ```ts
502
+ mail: { retainMime: { maxBytes: 262_144 } },
503
+ ```
504
+
505
+ | field | type | default | notes |
506
+ |---|---|---|---|
507
+ | `mail.retainMime` | `boolean \| { maxBytes?: number }` | `false` | **read** by `x dev` and the container at boot, handed to `selectMailDriver`: the SMTP and SES transports keep the exact MIME bytes they sent, on `SendResult.mime`. `true` is mail's default cap (`DEFAULT_RETAIN_MIME_MAX_BYTES`, 256 KiB); a message over the cap keeps its digest and length only. `maxBytes` must be a whole number above 0 (`X_CONFIG_INVALID` at `defineConfig`) and at most `RETAIN_MIME_CEILING_BYTES` (`X_CONFIG_INVALID` at boot). With `RESEND_API_KEY` selected, any retention refuses the boot: Resend builds the MIME on its own side. The durable `onRetained` callback is code, not config: build it with `selectMailDriver(env, { retainMime: { onRetained } })` — a `MailSelection` — and hand its `.driver` over as the `mail` override in `apps/<app>/runtime.ts` (Runtime overrides, below) |
508
+
509
+ | env key | selects | notes |
510
+ |---|---|---|
511
+ | *(none set)* | memory | caught, never sent; the `/_x` mail panel reads this outbox |
512
+ | `SMTP_URL` | SMTP | `smtps://user:pass@host:465`, or `smtp://host:587` for STARTTLS |
513
+ | `RESEND_API_KEY` | Resend | one `POST /emails` per message, with an `Idempotency-Key` |
514
+ | `SES_REGION` | Amazon SES | one SES v2 `SendEmail` with the raw MIME, SigV4-signed, no SDK |
515
+ | `SES_ACCESS_KEY_ID` | — | SES only, required with `SES_REGION` |
516
+ | `SES_SECRET_ACCESS_KEY` | — | SES only, required with `SES_REGION` |
517
+ | `SES_SESSION_TOKEN` | — | SES only, optional: temporary credentials |
518
+ | `SES_ENDPOINT` | — | SES only, optional: a VPC endpoint or a local stand-in. Default `https://email.<region>.amazonaws.com` |
519
+ | `SES_CONFIGURATION_SET` | — | SES only, optional: the configuration set that publishes delivery events |
520
+ | `MAIL_FROM` | — | required by SMTP, Resend and SES; the memory and unconfigured drivers do not read it. `Name <addr>`; also the envelope sender and the `Message-ID` domain |
521
+ | `MAIL_POOL_SIZE` | — | SMTP connections open at once. Default `4`, whole number ≥ 1 |
522
+
523
+ The list is `MAIL_ENV_KEYS` in `packages/mail/src/driver-env.ts`, `As of 2026-10`. More than one of
524
+ `SMTP_URL`, `RESEND_API_KEY` and `SES_REGION` is `X_CONFIG_INVALID`: a process delivers through
525
+ exactly one transport, and picking a winner would send half of an operator's mail the wrong way.
526
+ A transport without `MAIL_FROM` is refused at boot rather than on the first send.
527
+
528
+ `x dev` prints which one it installed — `mail=embedded`, or `mail=external(smtp via SMTP_URL)`.
529
+ The env **key** is reported, never its value, because `SMTP_URL` carries a password.
530
+
531
+ ## `assets`
532
+
533
+ Whether a web pod keeps earlier builds' hashed files reachable across a deploy. Off unless declared:
534
+
535
+ ```ts
536
+ import { defineConfig } from '@ultimat3/core';
537
+
538
+ // `deploys` is a disk from the app's `defineStorage({ disks })`
539
+ export const config = defineConfig({
540
+ name: 'my-app',
541
+ assets: { retain: { disk: 'deploys', builds: 3 } },
542
+ });
543
+ ```
544
+
545
+ | field | type | default | notes |
546
+ |---|---|---|---|
547
+ | `assets.retain` | `{ disk: string; builds: number }` | absent | **read** by the container's web role at boot (`retainBuildAssets`, `packages/cli/src/retained-assets-boot.ts`): it publishes its `/styles`, `/islands`, `/_x/assets` and `/assets` files to `disk` under `x-build-assets/<name>/<environment>/` before `/readyz`, and every asset route answers a miss a retained manifest names from it. Absent: each pod serves only what it built. `x dev` never publishes. Details: [Deployment](Deployment#hashed-assets-across-a-deploy--assetsretain) |
548
+ | `assets.retain.disk` | `string` | — | a disk from the app's `defineStorage({ disks })`. Empty is `X_CONFIG_INVALID` at `defineConfig`; a name the app did not declare is `X_CONFIG_INVALID` at boot |
549
+ | `assets.retain.builds` | `number` | none | how many builds' files stay, newest first. No default — the window is the app's. A whole number ≥ 1, else `X_CONFIG_INVALID` |
550
+
551
+ ## `storage`
552
+
553
+ **Not an `app.config.ts` block.** There is no `storage` key on `AppConfigInput`. Storage is `defineStorage()`, called from `app.config.ts`, and it declares **named disks** (Laravel's model) rather than a driver and a bucket — so `disk('uploads').put(…)` never changes when `local` becomes `s3`:
554
+
555
+ ```ts
556
+ defineStorage({ disks: { uploads: localDriver({ root: '.storage/uploads' }) } });
557
+ ```
558
+
559
+ | `defineStorage` key | shape | notes |
560
+ |---|---|---|
561
+ | `disks` | `Record<string, StorageDriver>` | required and non-empty. Drivers are `localDriver({ root })` and `s3Driver({ … })` (`Bun.s3`) |
562
+ | `default` | `string` | the disk `disk()` resolves with no name. Defaults to the first declared disk; naming one that is not declared is `X_CONFIG_INVALID` |
563
+
564
+ Two disks may not share one driver **instance**: a driver learns its disk name at boot (`registerAs`) and mints signed URLs under it, so one instance told two names would 404 every URL it wrote under the first. Two disks over one root are two `localDriver()` calls. There is no `storage.dir` and no `storage.bucket` field.
565
+
566
+ ## `otel`
567
+
568
+ **Not an `app.config.ts` block.** Tracing is **always on, not a flag**, and the wire is configured by the standard OpenTelemetry environment variables — which is what lets a collector be attached to a running image without a rebuild:
569
+
570
+ | var | notes |
571
+ |---|---|
572
+ | `OTEL_EXPORTER_OTLP_ENDPOINT` | OTLP/HTTP **JSON** only, port `:4318`. Absent = spans still recorded, exported nowhere. Invalid → `X_OTLP_ENDPOINT_INVALID` |
573
+ | `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT` / `..._METRICS_ENDPOINT` | per-signal override |
574
+ | `OTEL_EXPORTER_OTLP_PROTOCOL` | anything but `http/json` → `X_OTLP_PROTOCOL_UNSUPPORTED`, naming `:4318`. gRPC (`:4317`) needs HTTP/2 and protobuf and is out of scope |
575
+ | `OTEL_EXPORTER_OTLP_HEADERS` | `OTEL_EXPORTER_OTLP_TRACES_HEADERS` / `..._METRICS_HEADERS` REPLACES it for that signal. Percent-decoded, so `%zz` is `X_OTLP_HEADERS_INVALID` rather than a bare `URIError` at exporter construction. The header **key** appears in the cause and the fix; the **value** never does — it is the collector's credential |
576
+ | `OTEL_TRACES_SAMPLER` / `OTEL_TRACES_SAMPLER_ARG` | read at the **first span**, never at module scope. `parentbased_always_on` takes no arg and ignores one. `configureTelemetry({ sampler })` is the programmatic form |
577
+
578
+ An empty `spanId` means "no inbound decision" and every reader honours it: a synthesised parent used to make the ratio sampler inherit a bit nobody sent, which exported **every HTTP root span at every ratio**.
579
+
580
+ ## `notify`
581
+
582
+ Retention for `x_notify_inbox`, and **the only framework table whose window the framework refuses to
583
+ pick for you.** Every other one holds bookkeeping whose job ends — an idempotency key, a rate-limit
584
+ bucket, an auth challenge, a delivery claim — so a sweep is unambiguously right. An inbox row is a
585
+ message a person has not read yet, and when that disappears is a product decision (axiom 8).
586
+
587
+ | field | type | default | notes |
588
+ |---|---|---|---|
589
+ | `notify.inboxReadRetentionMs` | `number` | — | age of a row's `read_at`. A read row ages from when it was **read**, never from when it arrived |
590
+ | `notify.inboxUnreadRetentionMs` | `number` | — | age of an unread row's `created_at`. Setting this deletes messages nobody has read |
591
+
592
+ **Absent means never swept, and that is the default for both.** The failure mode of keeping rows is
593
+ a table that grows; the failure mode of guessing a number is a notification the recipient never got
594
+ to read. Two windows rather than one because the objection is specifically about *unread* messages —
595
+ an app that wants read notices gone in a month and unread ones kept forever writes exactly that:
596
+
597
+ ```ts
598
+ notify: { inboxReadRetentionMs: 30 * 24 * 60 * 60 * 1000 }
599
+ ```
600
+
601
+ Milliseconds and not a duration string: `DurationInput` lives in `@ultimat3/jobs` (tier 3) and
602
+ `AppConfig` is tier 0 — the same reason `cache.defaultTtlMs` and `jobs.visibilityTimeoutMs` are
603
+ spelled this way. A value that is not a positive finite number is `X_CONFIG_INVALID` at boot; zero
604
+ is refused rather than read as "immediately", because a sweep at age 0 is an inbox that silently
605
+ receives nothing.
606
+
607
+ **The sweep only runs against the Postgres inbox.** `postgresInboxStore` carries `purgeBefore`; the
608
+ memory inbox does not, and a boot that installed the memory one — or no inbox at all — sweeps
609
+ nothing and logs nothing. Installing the store is `setNotifyStores`, your app's boot line; the
610
+ framework applies the DDL either way.
611
+
612
+ **`x_notify_deliveries` has no config key**, deliberately. Its window is
613
+ `postgresDeliveryLedger({ executor, windowMs })`, stated beside the statement that reads it, and it
614
+ must **never be shorter than your idempotency window**: a job replayed inside the idempotency window
615
+ against a claim that has already been purged claims cleanly and sends the notification a second
616
+ time. Pass `idempotency.windowMs` and the two cannot disagree.
617
+
618
+ **`x_notify_digests` has no config key either.** A digest window is deleted by its own flush; the
619
+ sweep takes only a window CLOSED longer than `postgresDigestStore({ executor, retentionMs })` ago —
620
+ a week by default, longer than any flush's retries — which is a window whose flush dead-lettered on
621
+ a slot that never digests again. Up to 50 batches of 1,000 rows per hourly pass.
622
+
623
+ ## `ai`
624
+
625
+ One live field, and `ai.mcp` is where the app's own MCP surface is configured — there is no top-level `mcp` block.
626
+
627
+ | field | type | default | notes |
628
+ |---|---|---|---|
629
+ | `ai.mcp.expose` | `boolean` | `true` | the app's own MCP surface. Actions still opt in per action `mcp.expose` |
630
+ | ~~`ai.mcp.path`~~ | — | — | **Deleted in 25.0.0**, refused with `X_CONFIG_INVALID`. Endpoint #0 mounted here while every other endpoint mounted at its own `defineAppMcp({ path })`, so the two could disagree. Now every endpoint, #0 included, mounts at its own `defineAppMcp({ path })` (default `/mcp`). Migration: delete the key; if it wasn't `/mcp`, move it to the first `defineAppMcp({ path })` in `apps/<app>/mcp.ts` |
631
+ | ~~`ai.modelEnv`~~ | — | — | **Deleted in 8.0.0.** It named the env key holding the model id "so no model string is baked into the image", and its only reader was `defineConfig`'s own merge: `@ultimat3/ai` reads env for API keys only, and the model was a compile-time default (`DEFAULT_MODEL`, itself deleted in 25.0.0 — an app names its model on the prompt or on `llm({ model })`). The one thing the key existed to prevent is what it delivered. Refused at boot since 25.0.0 (`ai.modelEnv was removed in 8.0.0`). Migration: delete the key and pass `model`, reading your own env key if you want one |
632
+
633
+ `ai.models`, `ai.fallback`, `ai.cache` and `ai.budget` are per-`llm()` declarations, not config ([MCP and AI](MCP-And-AI)). i18n has no config block either: `defineCatalogs({ default, locales })` is the whole surface.
634
+
635
+ ## `time`
636
+
637
+ ```ts
638
+ import { defineConfig } from '@ultimat3/core';
639
+
640
+ export const config = defineConfig({ name: 'my-app', time: { detect: true } });
641
+ ```
642
+
643
+ | Key | Type | Default | Effect |
644
+ |---|---|---|---|
645
+ | `time.detect` | `boolean` | `false` | **read** by `x dev` and the container's web role: on, every `app/` document carries a 153 B inline script (admitted by its CSP hash) writing `x_timezone_detected` from the browser's `Intl` zone, which `@ultimat3/http` reads as the lowest-ranked zone source. Never on `site/`. A non-boolean → `X_CONFIG_INVALID` |
646
+
647
+ No default zone is a key here, and none ever is: the process default is `configureTime({ defaultZone })`, and every format call names its zone → [Timezones and dates](Timezones-And-Dates).
648
+
649
+ ## `drain`
650
+
651
+ How a SIGTERM'd process leaves: `DrainConfig` in [`packages/core/src/config-health.ts`](https://github.com/developerz-ai/ultimate/blob/main/packages/core/src/config-health.ts). The production boot applies it to **every role** (`lifecycleForRole`, `packages/cli/src/serve-boot.ts`), not only to the web server.
652
+
653
+ | field | type | default | notes |
654
+ |---|---|---|---|
655
+ | `drain.readinessGraceMs` | whole ms, `0`–`60000` | `0` in `development`/`test`, `5000` everywhere else (a process naming no environment included) | `/readyz` answers 503 for this long with the listener still open, so endpoints stop routing here before it closes. `web` and `sync` only: every other role drains with no grace |
656
+ | `drain.deadlineMs` | whole ms, `1`–`3600000` | `25000` | the drain budget, after the grace: the time in-flight requests and a **running job** have to finish on a deploy before the lifecycle abandons the rest (`X_SHUTDOWN_TIMEOUT`). Raise it for long jobs. `NaN`, `Infinity`, a fraction, `0` or more than an hour is `X_CONFIG_INVALID` naming the key — past an hour, the job belongs in more steps. `As of 2026-10-05` |
657
+ | `drain.workerDeadlineMs` | whole ms, `1`–`86400000`, or unset | unset | `ROLE=worker`'s budget **in place of** `drain.deadlineMs`, for a job that must finish rather than be cut off and replayed and may run past the hour — a bank login, a payment. Also what bounds a retire (SIGUSR2) that a SIGTERM lands in. Unset: the worker drains on `drain.deadlineMs` like every role. More than a day is `X_CONFIG_INVALID` naming the key. `As of 2026-10` |
658
+ | `drain.workerRetireMs` | whole ms, `0`–`86400000` | `3600000` | how long a retiring worker may finish what it holds on a Kubernetes rollout or scale-down, aborting nothing; `0` turns it off. An idle worker exits at once. A job's `timeout` over it reds `x verify` (`X_JOB_OUTLASTS_DRAIN`) → [Deployment](Deployment#retiring-a-worker-before-sigterm) |
659
+
660
+ ```ts
661
+ export const config = defineConfig({
662
+ name: 'postly',
663
+ // A report job runs up to 8 minutes; a deploy lets it finish.
664
+ drain: { deadlineMs: 600_000 },
665
+ });
666
+ ```
667
+
668
+ The platform's own kill timer must outlast the sum, or SIGKILL truncates the drain:
669
+
670
+ | Rung | Who sizes it | Rule |
671
+ |---|---|---|
672
+ | Helm | `x deploy --method helm` passes `drain.deadlineSeconds`, `drain.workerDeadlineSeconds`, `roles.worker.retireSeconds` and `drain.readinessGraceSeconds` from this section; the chart derives each role's `terminationGracePeriodSeconds` | preStop (web/sync, 1.30+) + grace (web/sync) + deadline (the worker's: `workerDeadlineSeconds` when > 0) + retire (worker, `roles.worker.retireSeconds`) + 10 s margin |
673
+ | Compose | you: `stop_grace_period` in `docker-compose.prod.yml` | ≥ grace + deadline + 10 s (`40s` ships, for the defaults); the worker service's ≥ `workerDeadlineMs` + 10 s when declared — the worker drains with no grace |
674
+
675
+ ```ts
676
+ import { defineConfig } from '@ultimat3/core';
677
+
678
+ export const config = defineConfig({
679
+ name: 'banks',
680
+ // A bank run takes up to 2 h and must never re-login: the worker waits it out on a deploy.
681
+ drain: { deadlineMs: 25_000, workerDeadlineMs: 7_500_000 },
682
+ });
683
+ ```
684
+
685
+ **The boot applies this section after the app's modules load** (`bootRoles`, `packages/cli/src/serve-boot.ts`), so a `configureLifecycle({ deadlineMs })` an app makes at import time or before `serveApp` is overwritten by it — `drain.deadlineMs` always has a value (its default). Declare the budget here, never in code.
686
+
687
+ **`drain.deadlineMs` is the only drain budget, on every role, `web` included** — the worker's alone may be `drain.workerDeadlineMs`. `http.drainTimeoutMs` re-set it on the `web` role after this section was applied; 25.0.0 deleted it, and a `configureHttp({ drainTimeoutMs })` is refused `X_CONFIG_INVALID` with the `drain: { deadlineMs }` edit.
688
+
689
+ ## Runtime overrides — `apps/<app>/runtime.ts`
690
+
691
+ The one place a deployment hands the framework a driver, a middleware or a plain route. Not a
692
+ config key: an object, exported as `runtime` from `apps/<app>/runtime.ts`. `x dev` reads it, and so
693
+ does `runRole` (the container boot) when its caller passed none — one chain in development and in
694
+ production, never two.
695
+
696
+ ```ts
697
+ // apps/web/runtime.ts
698
+ import type { ServeOptions } from '@ultimat3/cli/serve';
699
+ import type { Middleware } from '@ultimat3/http';
700
+
701
+ // `ctx.headers` is merged into whatever response the route produces.
702
+ const servedBy: Middleware = (request, ctx, next) => {
703
+ ctx.headers.set('x-served-by', 'web');
704
+ return next(request, ctx);
705
+ };
706
+
707
+ export const runtime: NonNullable<ServeOptions['runtime']> = { middleware: [servedBy] };
708
+ ```
709
+
710
+ Every field is optional, and every field **replaces** the default the environment would have
711
+ picked — it never sits beside it. The type is `RuntimeOverrides` (`packages/cli/src/runtime-overrides.ts`).
712
+
713
+ | Field | Replaces |
714
+ |---|---|
715
+ | `jobs` | the queue driver every enqueue and every claim uses; installed as the ambient driver too, so the boot refuses a process where the two disagree |
716
+ | `storage` | the `S3_ENDPOINT` / embedded-disk decision, whole: disks, default disk and all |
717
+ | `mail` | the `SMTP_URL` / `RESEND_API_KEY` / `SES_REGION` selection |
718
+ | `transport` | the `NATS_URL` selection. Arrives connected and is **not** closed on stop — whoever built it owns its socket |
719
+ | `purge` | the `CLOUDFLARE_*` / `FASTLY_*` selection behind the `cdn` cache tier |
720
+ | `rateLimitStore` | the Postgres store the boot installs over its own pool. The store's scope decides `rateLimit.scope`; a `'process'` store is legal and warned about — every limit then applies once per replica |
721
+ | `isrStore` | the per-process memory store for regenerated ISR pages (one per replica, so a purge tag reaches one of them) |
722
+ | `middleware` | nothing — prepended to the request pipeline, outermost first |
723
+ | `routes` | nothing — plain HTTP routes at paths no primitive projects to (an OAuth token endpoint, a feed with its own content type; every declared `webhookReceiver()`'s are mounted beside them without a line here), mounted after the framework's routes and before the pages, through the whole pipeline |
724
+ | `images` | `builtinImageDriver`, the `/media/*` transform |
725
+ | `syncAuthenticate` | the adapter over the app's own `configureAuthenticator()` that authenticates a `sync` socket |
726
+
727
+ ### What middleware can and cannot see
728
+
729
+ | Rule | Why |
730
+ |---|---|
731
+ | middleware wraps a **matched** route only | the router runs first (`packages/http/src/stages.ts`). An unmatched path is answered `404` (or `405` with `allow`) before any middleware runs, so middleware cannot rewrite, redirect or serve a URL that no route declares |
732
+ | a redirect from a path nothing serves needs a route | declare a page at the old path that answers the redirect, or add one to `runtime.routes`. `api/` holds no route file: it is actions and queries only ([Project layout](Project-Layout#surfaces)) |
733
+ | `/healthz` and `/readyz` never reach it | they are answered by the listener before the pipeline, so a draining or rate-limited process can still say what it is doing |
734
+ | a `/<locale>/` prefix is stripped before the match | middleware sees the route the stripped path matched, never the prefix as a route of its own |
735
+
736
+ Routes, their files and their render modes → [Routes and render modes](Routes-And-Render-Modes).
737
+
738
+ ## Env vars
739
+
740
+ One typed schema, declared with `defineEnv` at module scope **in `app.config.ts`**, validated at boot. There is no `env.ts` — the one config file is also the one env gate. A missing or malformed key fails in **~40ms** with `X_ENV_MISSING`, every offender named in one error — never a 500 an hour later.
741
+
742
+ | var | roles | required | notes |
743
+ |---|---|---|---|
744
+ | `ROLE` | all | no — default `web`, as is empty or blank | exactly `web \| sync \| worker \| scheduler \| migrate \| replicator`. There is no `all`. Invalid → `X_ROLE_UNKNOWN` from the production boot path, `X_ROLE_INVALID` from `assertRole()` inside the framework |
745
+ | `PORT` | `web`, `sync` | no — default `3000` | the TCP port the role binds. Non-decimal (`0x1F90`, `8e3`, `+80`) or outside 0–65535 → `X_PORT_INVALID`, refused rather than defaulted past, because a web role that quietly bound 3000 fails the platform's health probe with nothing in the log that names the cause |
746
+ | `HOST` | `web`, `sync` | no — default `0.0.0.0` | the interface the role binds, and its metrics endpoint with it. Empty or whitespace is the default. `127.0.0.1` inside a container is unreachable through a port mapping and reachable only where the container shares the host's network namespace (`--network host`, a sidecar, `ssh -L`) — what an app that admits one implicit actor without a login wants. `runRole({ hostname })` overrides it, as `port` overrides `PORT` |
747
+ | `ULTIMATE_ENV` | all | no — default `development` | `development \| test \| staging \| production`, read by `resolveEnvironment()`. `NODE_ENV` is a fallback only, and is never policed |
748
+ | `DATABASE_URL` | all | yes | |
749
+ | `DATABASE_POOLER` | all | no — unset is a direct connection, or a pooler that forwards `options` | `As of 2026-11-02`: `session` or `transaction`, the pooler in the path. Set, the client sends neither bound as libpq `options` and the role's bounds move to `alter role`. `transaction` refuses `migrate` and `replicator` (`X_DB_POOLER_SESSION_REQUIRED`); another value refuses the boot (`X_ENV_MISSING`) — see [Deployment](Deployment.md#through-a-connection-pooler) |
750
+ | `DATABASE_REPLICA_URL` | all | no — unset is one pool | `As of 2026-08-24`: a read-only standby. Set it and `baseClient()` builds a primary + replica client; unset and the client is byte-identical to the single-pool one. Routing is still opt-in per scope (`withReplicaReads`), so setting it alone changes nothing — see [Read replicas](#read-replicas) |
751
+ | `APP_URL` | `web`, `sync` | yes | the canonical origin. An app-read key, not a config field — declare it in `defineEnv`. On `sync` it is also the page origin the node admits a socket from: the node compares the handshake's `Origin` exactly (scheme, host, port), and a declared `APP_URL` is the WHOLE list — the origin the node was reached on is admitted only when none is declared. So it must be RIGHT wherever it is set, and set wherever host or port differ — the Compose rung (page on `:3000`, socket on `:3001`, where the shipped compose file requires it), a `SYNC_URL` on another host. The Helm chart sets it on the sync role from `ingress.host`. Undeclared behind a TLS proxy on the same host and port, a node reached over plain http admits that host's `https` spelling too. `x dev` adds its own web role's origin (and its `localhost`, `127.0.0.1`, `[::1]` spellings) to a declared one, and keeps admitting the origin it was reached on, so `x dev --port 4000` and a forwarded host (a Codespace, a tunnel) beside `APP_URL=http://localhost:3000` connect; a container adds nothing. A socket refused: `X_SOCKET_ORIGIN_REFUSED`, not `APP_URL`'s origin when one is declared; with none declared, not the origin the node was reached on |
752
+ | `SESSION_SECRET` | `web`, `sync` | yes | >=32 chars |
753
+ | `ULTIMATE_STATE_DIR` | all, under `x dev` | no — default `<app>/.x` | relocates the whole of `.x/` (the embedded database, the local disk, the dev lock) for one process tree. The e2e step uses it to boot the app on a throwaway database beside a running `x dev` (`packages/cli/src/runtime-bindings.ts`). 21.0.0 |
754
+ | `SYNC_URL` | `web` | no — unset dials `/_x/sync` on the page's own origin | where the page's one socket dials, rendered into every document as `<meta name="ultimate-sync">`. Must be `ws://` or `wss://`; anything else is `X_CONFIG_INVALID` at boot. **Required on the Compose rung**, where `sync` is published on its own port with no proxy in front: `SYNC_URL=ws://<host>:3001/_x/sync`. The shipped compose file refuses to start `web` without it. `x dev`, a combined-role container and the Helm chart's ingress all serve `/_x/sync` on the page origin, so they need nothing. `As of 2026-09-22` (21.0.0) |
755
+ | `WORKER_QUEUES` | `worker` | no — unset or blank serves `jobs.queues` plus every registered job's queue | comma-separated, trimmed, deduplicated. Set, it is the EXACT list this worker claims — `jobs.queues` and the registered queues are not added (`As of 25.0.0`; documented here before then and read by nothing). An empty entry (`banks,,long`) is `X_CONFIG_INVALID` at boot; an entry that is neither in `jobs.queues` nor a registered job's queue — a typo, or a pattern such as `banks-*`, which is never expanded — is `X_WORKER_QUEUE_UNKNOWN` at worker start (`As of 29.0.0`). See [Splitting queues across Deployments](#jobs) |
756
+ | `NATS_URL` | `sync`, `replicator` | no — unset is in-process fanout | one node only; a second replica shares nothing. Unreachable → `X_TRANSPORT_UNAVAILABLE` at boot, not at readiness |
757
+ | `NATS_KV_BUCKET` | `sync` | no — default `x_presence` | the JetStream KV bucket presence lives in. `[a-zA-Z0-9_-]+`; anything else is `X_TRANSPORT_PROTOCOL` at boot |
758
+ | `REPLICATION_URL` | `replicator` | no — defaults to `DATABASE_URL` | the connection the WAL is read from; this role must have `REPLICATION` privilege |
759
+ | `REPLICATION_SLOT` | `replicator` | no — default `x_replicator` | logical replication slot name |
760
+ | `REPLICATION_PUBLICATION` | `replicator` | no — default `x_changes` | the `pgoutput` publication the slot decodes |
761
+ | `REDIS_URL` | any tier-3 cache user | if `redis` in `cache.tiers` | |
762
+ | `BUILD_ID` | all | set by `x build` | content hash. Never a timestamp, never `latest` |
763
+ | `LOG_LEVEL` | all | no — unset or empty is `info` | `trace \| debug \| info \| warn \| error \| fatal \| silent`, lowercase. Any other value — `DEBUG`, `verbose` — is REFUSED at import (`X_INVARIANT`), never read as `info` |
764
+ | `TRUSTED_PROXY_HOPS` | `web`, `sync` | no — unset trusts no proxy header | how many proxies **append** to `x-forwarded-for` between the client and this process: 1 for a single ingress or ALB, 2 for a CDN in front of one. Decimal digits, 1–64 (`@ultimat3/http`'s `MAX_PROXY_HOPS`); anything else is `X_TRUSTED_PROXY_HOPS_INVALID`, refused rather than defaulted, because reading the header at the wrong index is trusting a value the client typed. Unset means `ctx.ip` is the socket address, `ctx.peer` is `null` and no inbound `x-request-id` is echoed |
765
+ | `OTEL_EXPORTER_OTLP_ENDPOINT` | all | no | the OTLP collector. There is no `otel` config block for it to override — see [`otel`](#otel) |
766
+
767
+ Rules:
768
+
769
+ | Rule | Detail |
770
+ |---|---|
771
+ | Secrets are env or a mounted file | the framework never talks to a vendor secret API ([axiom 7](Home)) |
772
+ | `env.X` reads through `defineEnv`'s schema | a declared key that is missing or malformed is `X_ENV_MISSING` at boot, every offender in one error. A `process.env` read outside the schema is a lint error, never a runtime one |
773
+ | `X_CONFIG_INVALID` is env **and** `app.config.ts` | one code for a configuration that cannot boot: what `defineConfig`'s own validation throws — a bad locale or two spellings of one, an unknown time zone, a section or list of the wrong shape (`jobs: null`), a value outside its closed set (`roles`, `jobs.backoff`, `theme.defaultMode`), a switch that is not a boolean (`database.ssl: 'false'`), `jobs.concurrency < 1`, a `realtime.transport` other than `memory` with no `realtime.urlEnv`, a `cache.tiers` entry the environment cannot supply — and any env **combination** no boot can resolve, thrown by the selector that reads it. Both CDN pairs or half a pair (`selectPurgeDriver`), more than one of `SMTP_URL` / `RESEND_API_KEY` / `SES_REGION`, or a transport with no `MAIL_FROM` (`selectMailDriver`), or `REPLICATION_URL` naming a different host, port or database than `DATABASE_URL` (`selectChangeFeed`) |
774
+ | `X_ENV_MISSING` is one key, `X_CONFIG_INVALID` is the shape | absent or malformed key → `X_ENV_MISSING` at the `defineEnv` gate. Keys that each parse but contradict each other → `X_CONFIG_INVALID`. The two never overlap |
775
+ | No runtime mutation | config is frozen after `defineConfig`; there is no `setConfig` |
776
+ | Same image, all environments | only env differs. That is what makes staging a real rehearsal ([Deployment](Deployment)) |
777
+
778
+ ## The environment — `resolveEnvironment()`
779
+
780
+ Four names, one env var, and a spelling the framework does not use is a refusal rather than a guess.
781
+
782
+ ```ts
783
+ import { isLocal, isProduction, resolveEnvironment, tryResolveEnvironment } from '@ultimat3/core';
784
+
785
+ resolveEnvironment(); // 'development' | 'test' | 'staging' | 'production'
786
+ resolveEnvironment({ fallback: 'production' });
787
+ resolveEnvironment({ env: someRecord });
788
+ tryResolveEnvironment() ?? 'development'; // the same, `undefined` instead of the throw
789
+ ```
790
+
791
+ | Concern | Behaviour |
792
+ |---|---|
793
+ | Precedence | `ULTIMATE_ENV` if set and non-empty → `NODE_ENV` **only if it is one of the four** → `fallback` → `'development'` |
794
+ | Invalid `ULTIMATE_ENV` | throws `X_ENVIRONMENT_INVALID` — `prod`, `dev` and `preview` are all refusals |
795
+ | Invalid `NODE_ENV` | never throws. CI images legitimately set it to anything, so it is a fallback that is silently ignored, not a second gate |
796
+ | `isProduction()` | `=== 'production'` |
797
+ | `isLocal()` | `development` or `test`. **`staging` is deliberately excluded** — staging is a real rehearsal |
798
+ | `tryResolveEnvironment()` | the same resolution, `undefined` instead of the throw — and `undefined` means exactly one thing, an unrecognised `ULTIMATE_ENV`. For a caller that must **answer** rather than fail: `ULTIMATE_ENV` is not in the env schema, so nothing validates it at boot and a `robots.txt` render is routinely its first reader, where a typo would 500 the one response whose body was already going to be `Disallow: /`. It names no fallback of its own; the caller does |
799
+
800
+ `@ultimat3/core` is the **only** reader of `ULTIMATE_ENV` and `Environment` is the only spelling of a
801
+ deploy, `As of 2026-08`. `@ultimat3/seo` exported a second `resolveEnvironment` with its own union
802
+ through 1.2.0; as of 2.0.0 it exports neither that nor `SeoEnvironment`, and its `'preview'` is
803
+ `'staging'` → [Known gaps](Known-Gaps).
804
+
805
+ ## `.env.example` — a projection, never a second list
806
+
807
+ ```sh
808
+ x env example # writes .env.example from the app's envSchema
809
+ ```
810
+
811
+ ```ts
812
+ import { renderEnvExample, ENV_EXAMPLE_PATH } from '@ultimat3/core';
813
+
814
+ await Bun.write(ENV_EXAMPLE_PATH, renderEnvExample(schema)); // '.env.example'
815
+ ```
816
+
817
+ `renderEnvExample(schema, { extras })` returns the whole file, deterministic in declaration order. Per key it writes the description, then an annotation line — `required|optional · <type or enum values> · secret · role a/b` — then `KEY=<example>`. **A `secret: true` key's example is always empty**, even when the declaration has a default. `extras` are appended as commented `# NAME=` lines.
818
+
819
+ **`x env example` writes more than the schema.** After the app's own keys it appends a `# --- Framework` section: the secrets the framework refuses to boot without outside `development`/`test`, whatever `envSchema` declares — `ULTIMATE_CURSOR_SECRET` (cursor signing, `X_CURSOR_SECRET_DEV`) and `STORAGE_SIGNING_SECRET` (the embedded disk's upload grants, `X_ENV_MISSING`; not read when `S3_ENDPOINT`/`S3_BUCKET` select object storage), always; and, once `pwa.push` is wired (`As of 26.1.0`), `ULTIMATE_VAPID_PUBLIC_KEY` and `ULTIMATE_VAPID_PRIVATE_KEY` (`X_PWA_VAPID_KEY_MISSING`), minted together by `x vapid create` — never by `openssl`, and the section header then says which generator mints which. Each is annotated `required when deployed · string` (and `· secret`, all but the public key) with its reason and an empty value, so the file is the whole deploy contract an infra repo builds its Secret from. A key the app declares itself is rendered once, as the app declared it. The list is `FRAMEWORK_SECRETS` in [`packages/cli/src/framework-env.ts`](https://github.com/developerz-ai/ultimate/blob/main/packages/cli/src/framework-env.ts) — `x env check` and `x doctor` read the same one. `renderEnvExample` itself is the app's projection alone.
820
+
821
+ | Command | Framework secrets |
822
+ |---|---|
823
+ | `x env example` | rendered, blank, after the app's keys |
824
+ | `x env check` | checked against the environment it runs in, by the boot's own rule (core's `devSecretsRefused`, `deployed` in `framework-env.ts`): anything but `development`/`test`, **and an environment naming none** — the boot fails closed. `ULTIMATE_ENV=production x env check` answers what the first deployed boot would (`X_CURSOR_SECRET_DEV`, `X_STORAGE_SECRET_DEV`, and `X_PWA_VAPID_KEY_MISSING` for a push app; exit 1); the published development keys count as unset |
825
+ | `x doctor` | the same findings, cause and fix — except the VAPID pair, which needs the app's config and doctor reads none — but about **this machine**: only where `ULTIMATE_ENV` (else `NODE_ENV`) names `staging` or `production` (`namedDeployed`). A developer shell naming no environment gets none; ask `x env check` what a deployed boot would do with it |
826
+
827
+ **`x verify` holds the committed file to it**, on the `manifest` step: `.env.example` must be byte-for-byte the projection of `envSchema` in `app.config.ts`, so a moved description, default or required flag fails the gate as well as a missing key — and so does a file missing the framework section (an example written by 24.x). A miss is `X_ENV_EXAMPLE_DRIFT`, missing keys named first; the fix is `x env example`. An app that exports no `envSchema` has nothing to project and the check is silent.
828
+
829
+ `assertEnvExample(schema, text)`, the older, weaker check (key presence only, called by nothing), was **deleted in 25.0.0** with `EnvExampleDriftError`; the gate above is the one check, and `checkEnvExample(schema, text)` returns the same report as data.
830
+
831
+ Which files are read at boot: `.env` and `.env.<mode>` always, plus `.env.local` unless the mode is `test`. Mode is `production` or `test` verbatim, otherwise `development` — there is no `.env.staging`.
832
+
833
+ ## Secrets in memory — `Secret`
834
+
835
+ A value that redacts **by value**, so it stays redacted under a key nobody thought to add to a deny-list.
836
+
837
+ ```ts
838
+ import { secret, revealSecret, isSecret } from '@ultimat3/core';
839
+
840
+ const token = secret(process.env.API_TOKEN, 'API_TOKEN');
841
+
842
+ `${token}`; // '[redacted]'
843
+ JSON.stringify({ token }); // '{"token":"[redacted]"}'
844
+ logger.info('boot', { token }); // token=[redacted]
845
+ revealSecret(token); // the real string — the one call that unwraps
846
+ ```
847
+
848
+ | Surface | Redacted |
849
+ |---|---|
850
+ | `toString()` | ✅ |
851
+ | `toJSON()` | ✅ |
852
+ | `Symbol.toPrimitive` | ✅ — template literals and coercion |
853
+ | the Node inspect symbol | ✅ — `console.log`, `util.inspect` |
854
+ | the framework logger | ✅ — checked **before** every other branch, at any depth, under any key |
855
+ | spread / `Object.entries` / structured clone | ✅ — only `label` is enumerable, so the value cannot ride out |
856
+
857
+ Frozen and non-configurable. `revealSecret(value)` and `revealOptionalSecret(value)` are the only ways out; `isSecret(value)` is a structural brand check, so it survives two copies of `@ultimat3/core` in one tree.
858
+
859
+ Key-name redaction still exists and is separate: `defineEnv()` registers every `secret: true` key with the logger unless you pass `{ redact: false }`. And `checkEnv()` returns **real** values in `report.values` — anything that *prints* a report must pass them through `maskedEnvValues(schema, values)` first. That is the bug 1.1.0 fixed: `{ dsn: 'postgres://user:pw@host/db' }` printed the credential, because redaction was by key name and `dsn` was not on the list.
860
+
861
+ ```
862
+ x doctor --json # env + connectivity + version checks
863
+ x verify --json # the gate
864
+ ```
865
+
866
+ `x config show` is **planned**, not shipped — it throws `X_NOT_IMPLEMENTED` naming `x manifest --json` as the closest thing today → [CLI reference](CLI-Reference).
867
+
868
+ Error shapes: [Error codes](Error-Codes). Symptom-first fixes: [Troubleshooting](Troubleshooting). Metrics, spans and logs: [Observability](Observability).