@voltro/cli 0.27.0 → 0.29.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 (284) hide show
  1. package/CHANGELOG.md +513 -0
  2. package/THIRD-PARTY-NOTICES.md +88 -2
  3. package/dist/{apiBuild-D22_EpoR.js → apiBuild-BESZbTjA.js} +4 -4
  4. package/dist/apiBuild-C8d74T6g.js +2 -0
  5. package/dist/bin.js +3 -3
  6. package/dist/checkCommand-BPAtyWs8.js +2420 -0
  7. package/dist/checkCommand-DBvZvCh1.js +2 -0
  8. package/dist/cliRuntime-Oh517vCV.js +96 -0
  9. package/dist/{commands-jBX8no1I.js → commands-82BDEktg.js} +6476 -7672
  10. package/dist/dbCommand-CaIVTp-a.js +2 -0
  11. package/dist/{dbCommand-uuNCrFAb.js → dbCommand-bCBbk6tz.js} +3 -3
  12. package/dist/{dev-DcbIJrWg.js → dev-Cg4BKLmi.js} +3034 -2619
  13. package/dist/dev-DeEJj5T4.js +3 -0
  14. package/dist/fileConventions-Cof68_BL.js +33 -0
  15. package/dist/{frameworkTableAssembly-BwHU9Euq.js → frameworkTableAssembly-4Db40V23.js} +2 -2
  16. package/dist/frameworkTableAssembly-BTNirAAk.js +2 -0
  17. package/dist/index.js +1 -1
  18. package/dist/{inspect-CUCCzw2I.js → inspect-BMvQpwqy.js} +211 -190
  19. package/dist/inspect-DHeaMZak.js +2 -0
  20. package/dist/{inspectMetrics-BU90mvJN.js → inspectMetrics-DHYygE4s.js} +999 -961
  21. package/dist/{manifestBuild-BnzAxp2O.js → manifestBuild-C4R1EdHn.js} +1 -1
  22. package/dist/manifestBuild-Ck_90gZy.js +2 -0
  23. package/dist/sdkgen-CW5NbYF0.js +582 -0
  24. package/dist/{seedRunner-D6eu-u5U.js → seedRunner-Bqxgp7HZ.js} +60 -59
  25. package/dist/serveCommand-BXl8mhZD.js +1489 -0
  26. package/dist/serveEntry.js +3 -3
  27. package/dist/{start-BGXIf6zT.js → start-CsCIaW4-.js} +282 -269
  28. package/dist/startEntry.js +2 -2
  29. package/package.json +17 -17
  30. package/templates/AGENTS.md +2 -1
  31. package/templates/agent-docs/_index.md +2 -1
  32. package/templates/agent-docs/_manifest.json +10 -1
  33. package/templates/agent-docs/ai.md +135 -0
  34. package/templates/agent-docs/cli.md +124 -6
  35. package/templates/agent-docs/configuration.md +32 -0
  36. package/templates/agent-docs/data.md +282 -0
  37. package/templates/agent-docs/database/migrations.md +47 -0
  38. package/templates/agent-docs/database/misc.md +57 -0
  39. package/templates/agent-docs/database/schema.md +3 -1
  40. package/templates/agent-docs/deployment.md +56 -0
  41. package/templates/agent-docs/internationalization.md +161 -1
  42. package/templates/agent-docs/local-first-mobile.md +414 -0
  43. package/templates/agent-docs/routing.md +93 -2
  44. package/templates/agent-docs/schema-driven-ui.md +12 -0
  45. package/templates/agent-docs/templates/apibackends.md +1 -1
  46. package/templates/agent-docs/testing.md +42 -0
  47. package/templates/agent-docs/whats-new.md +165 -131
  48. package/templates/agent-docs/workflows.md +11 -0
  49. package/templates/apps/api-ai/package.json +7 -7
  50. package/templates/apps/api-auth/package.json +8 -8
  51. package/templates/apps/api-backend/package.json +7 -7
  52. package/templates/apps/api-backend-deactivation/package.json +7 -7
  53. package/templates/apps/api-backend-mail/package.json +8 -8
  54. package/templates/apps/api-backend-mariadb/package.json +9 -9
  55. package/templates/apps/api-backend-sqlite/.env.example +19 -0
  56. package/templates/apps/api-backend-sqlite/README.md +38 -0
  57. package/templates/apps/api-backend-sqlite/app.config.ts +35 -0
  58. package/templates/apps/api-backend-sqlite/database/schema.ts +54 -0
  59. package/templates/apps/api-backend-sqlite/mutations/notes.create.mutation.server.ts +19 -0
  60. package/templates/apps/api-backend-sqlite/mutations/notes.create.mutation.ts +37 -0
  61. package/templates/apps/api-backend-sqlite/package.json +30 -0
  62. package/templates/apps/api-backend-sqlite/queries/notes.query.server.ts +14 -0
  63. package/templates/apps/api-backend-sqlite/queries/notes.query.ts +20 -0
  64. package/templates/apps/api-backend-sqlite/template.json +6 -0
  65. package/templates/apps/api-backend-sqlite/tests/notes.create.test.ts +50 -0
  66. package/templates/apps/api-backend-sqlite/tsconfig.json +5 -0
  67. package/templates/apps/api-backend-storage/package.json +8 -8
  68. package/templates/apps/api-cms/README.md +104 -0
  69. package/templates/apps/api-cms/actions/content.get.action.server.ts +27 -0
  70. package/templates/apps/api-cms/actions/content.get.action.ts +19 -0
  71. package/templates/apps/api-cms/actions/content.types.action.server.ts +26 -0
  72. package/templates/apps/api-cms/actions/content.types.action.ts +40 -0
  73. package/templates/apps/api-cms/actions/me.action.server.ts +18 -0
  74. package/templates/apps/api-cms/actions/me.action.ts +16 -0
  75. package/templates/apps/api-cms/app.config.ts +61 -0
  76. package/templates/apps/api-cms/content/blogPost.contentType.ts +39 -0
  77. package/templates/apps/api-cms/content/index.ts +18 -0
  78. package/templates/apps/api-cms/content/page.contentType.ts +24 -0
  79. package/templates/apps/api-cms/database/schema.ts +64 -0
  80. package/templates/apps/api-cms/mutations/content.publish.mutation.server.ts +19 -0
  81. package/templates/apps/api-cms/mutations/content.publish.mutation.ts +15 -0
  82. package/templates/apps/api-cms/mutations/content.saveDraft.mutation.server.ts +36 -0
  83. package/templates/apps/api-cms/mutations/content.saveDraft.mutation.ts +32 -0
  84. package/templates/apps/api-cms/mutations/content.unpublish.mutation.server.ts +19 -0
  85. package/templates/apps/api-cms/mutations/content.unpublish.mutation.ts +11 -0
  86. package/templates/apps/api-cms/package.json +32 -0
  87. package/templates/apps/api-cms/queries/content.list.query.server.ts +0 -0
  88. package/templates/apps/api-cms/queries/content.list.query.ts +27 -0
  89. package/templates/apps/api-cms/template.json +6 -0
  90. package/templates/apps/api-cms/tests/content.descriptors.test.ts +64 -0
  91. package/templates/apps/api-cms/tests/content.write.test.ts +85 -0
  92. package/templates/apps/api-cms/tsconfig.json +5 -0
  93. package/templates/apps/api-data-advanced/package.json +8 -8
  94. package/templates/apps/api-durable/package.json +8 -8
  95. package/templates/apps/api-feature-flags/package.json +9 -9
  96. package/templates/apps/api-governance/package.json +8 -8
  97. package/templates/apps/api-kv/package.json +8 -8
  98. package/templates/apps/api-moderation/package.json +8 -8
  99. package/templates/apps/api-observability/package.json +8 -8
  100. package/templates/apps/api-ratelimit/package.json +8 -8
  101. package/templates/apps/api-rbac/package.json +8 -8
  102. package/templates/apps/api-rest/package.json +7 -7
  103. package/templates/apps/api-saas/package.json +11 -11
  104. package/templates/apps/api-saas-starter/README.md +103 -0
  105. package/templates/apps/api-saas-starter/actions/me.action.server.ts +18 -0
  106. package/templates/apps/api-saas-starter/actions/me.action.ts +20 -0
  107. package/templates/apps/api-saas-starter/app.config.ts +87 -0
  108. package/templates/apps/api-saas-starter/database/schema.ts +57 -0
  109. package/templates/apps/api-saas-starter/mutations/invites.create.mutation.server.ts +26 -0
  110. package/templates/apps/api-saas-starter/mutations/invites.create.mutation.ts +18 -0
  111. package/templates/apps/api-saas-starter/mutations/projects.create.mutation.server.ts +29 -0
  112. package/templates/apps/api-saas-starter/mutations/projects.create.mutation.ts +18 -0
  113. package/templates/apps/api-saas-starter/package.json +32 -0
  114. package/templates/apps/api-saas-starter/queries/invites.list.query.server.ts +14 -0
  115. package/templates/apps/api-saas-starter/queries/invites.list.query.ts +17 -0
  116. package/templates/apps/api-saas-starter/queries/projects.list.query.server.ts +14 -0
  117. package/templates/apps/api-saas-starter/queries/projects.list.query.ts +18 -0
  118. package/templates/apps/api-saas-starter/template.json +6 -0
  119. package/templates/apps/api-saas-starter/tests/projects.create.test.ts +62 -0
  120. package/templates/apps/api-saas-starter/tests/session.test.ts +33 -0
  121. package/templates/apps/api-saas-starter/tsconfig.json +5 -0
  122. package/templates/apps/api-search/package.json +8 -8
  123. package/templates/apps/api-status/README.md +88 -0
  124. package/templates/apps/api-status/app.config.ts +36 -0
  125. package/templates/apps/api-status/authz.ts +33 -0
  126. package/templates/apps/api-status/database/schema.ts +70 -0
  127. package/templates/apps/api-status/mutations/components.create.mutation.server.ts +19 -0
  128. package/templates/apps/api-status/mutations/components.create.mutation.ts +16 -0
  129. package/templates/apps/api-status/mutations/incidents.create.mutation.server.ts +25 -0
  130. package/templates/apps/api-status/mutations/incidents.create.mutation.ts +22 -0
  131. package/templates/apps/api-status/mutations/incidents.resolve.mutation.server.ts +24 -0
  132. package/templates/apps/api-status/mutations/incidents.resolve.mutation.ts +19 -0
  133. package/templates/apps/api-status/mutations/incidents.update.mutation.server.ts +26 -0
  134. package/templates/apps/api-status/mutations/incidents.update.mutation.ts +21 -0
  135. package/templates/apps/api-status/package.json +30 -0
  136. package/templates/apps/api-status/queries/components.list.query.server.ts +14 -0
  137. package/templates/apps/api-status/queries/components.list.query.ts +13 -0
  138. package/templates/apps/api-status/queries/incidents.live.query.server.ts +14 -0
  139. package/templates/apps/api-status/queries/incidents.live.query.ts +20 -0
  140. package/templates/apps/api-status/queries/updates.list.query.server.ts +14 -0
  141. package/templates/apps/api-status/queries/updates.list.query.ts +17 -0
  142. package/templates/apps/api-status/template.json +6 -0
  143. package/templates/apps/api-status/tests/status.test.ts +70 -0
  144. package/templates/apps/api-status/tsconfig.json +5 -0
  145. package/templates/apps/api-versioning/package.json +8 -8
  146. package/templates/apps/api-webhooks/package.json +9 -9
  147. package/templates/apps/changelog/package.json +6 -6
  148. package/templates/apps/edge-functions/package.json +2 -2
  149. package/templates/apps/frontend-admin/package.json +8 -8
  150. package/templates/apps/frontend-app/package.json +8 -8
  151. package/templates/apps/frontend-auth/README.md +78 -0
  152. package/templates/apps/frontend-auth/app.config.ts +34 -0
  153. package/templates/apps/frontend-auth/package.json +32 -0
  154. package/templates/apps/frontend-auth/src/components/AuthShell.tsx +35 -0
  155. package/templates/apps/frontend-auth/src/components/PasswordStrength.tsx +33 -0
  156. package/templates/apps/frontend-auth/src/config.ts +11 -0
  157. package/templates/apps/frontend-auth/src/globals.css +105 -0
  158. package/templates/apps/frontend-auth/src/globals.d.ts +6 -0
  159. package/templates/apps/frontend-auth/src/lib/auth.ts +34 -0
  160. package/templates/apps/frontend-auth/src/lib/redirect.test.ts +24 -0
  161. package/templates/apps/frontend-auth/src/lib/redirect.ts +29 -0
  162. package/templates/apps/frontend-auth/src/locales/de.ts +66 -0
  163. package/templates/apps/frontend-auth/src/locales/en.ts +76 -0
  164. package/templates/apps/frontend-auth/src/locales/index.ts +14 -0
  165. package/templates/apps/frontend-auth/src/pages/forgot/page.tsx +51 -0
  166. package/templates/apps/frontend-auth/src/pages/layout.tsx +12 -0
  167. package/templates/apps/frontend-auth/src/pages/login/page.test.tsx +53 -0
  168. package/templates/apps/frontend-auth/src/pages/login/page.tsx +67 -0
  169. package/templates/apps/frontend-auth/src/pages/logout/page.tsx +28 -0
  170. package/templates/apps/frontend-auth/src/pages/magic/page.tsx +51 -0
  171. package/templates/apps/frontend-auth/src/pages/page.tsx +26 -0
  172. package/templates/apps/frontend-auth/src/pages/reset/page.test.tsx +51 -0
  173. package/templates/apps/frontend-auth/src/pages/reset/page.tsx +63 -0
  174. package/templates/apps/frontend-auth/src/pages/signup/page.tsx +60 -0
  175. package/templates/apps/frontend-auth/src/pages/verify/page.tsx +50 -0
  176. package/templates/apps/frontend-auth/template.json +6 -0
  177. package/templates/apps/frontend-auth/tsconfig.json +5 -0
  178. package/templates/apps/frontend-blank/package.json +7 -7
  179. package/templates/apps/frontend-cms/README.md +47 -0
  180. package/templates/apps/frontend-cms/app.config.ts +37 -0
  181. package/templates/apps/frontend-cms/package.json +33 -0
  182. package/templates/apps/frontend-cms/src/config.ts +8 -0
  183. package/templates/apps/frontend-cms/src/globals.css +105 -0
  184. package/templates/apps/frontend-cms/src/globals.d.ts +6 -0
  185. package/templates/apps/frontend-cms/src/lib/api.ts +58 -0
  186. package/templates/apps/frontend-cms/src/locales/de.ts +50 -0
  187. package/templates/apps/frontend-cms/src/locales/en.ts +55 -0
  188. package/templates/apps/frontend-cms/src/locales/index.ts +14 -0
  189. package/templates/apps/frontend-cms/src/pages/(app)/error.tsx +18 -0
  190. package/templates/apps/frontend-cms/src/pages/(app)/layout.test.tsx +31 -0
  191. package/templates/apps/frontend-cms/src/pages/(app)/layout.tsx +62 -0
  192. package/templates/apps/frontend-cms/src/pages/(app)/not-found.tsx +14 -0
  193. package/templates/apps/frontend-cms/src/pages/(app)/page.test.tsx +110 -0
  194. package/templates/apps/frontend-cms/src/pages/(app)/page.tsx +159 -0
  195. package/templates/apps/frontend-cms/src/pages/layout.tsx +12 -0
  196. package/templates/apps/frontend-cms/src/pages/login/page.test.tsx +58 -0
  197. package/templates/apps/frontend-cms/src/pages/login/page.tsx +93 -0
  198. package/templates/apps/frontend-cms/template.json +6 -0
  199. package/templates/apps/frontend-cms/tsconfig.json +5 -0
  200. package/templates/apps/frontend-contact/package.json +7 -7
  201. package/templates/apps/frontend-dashboard/package.json +7 -7
  202. package/templates/apps/frontend-docs/package.json +7 -7
  203. package/templates/apps/frontend-i18n/package.json +6 -6
  204. package/templates/apps/frontend-landing/package.json +7 -7
  205. package/templates/apps/frontend-portal/README.md +71 -0
  206. package/templates/apps/frontend-portal/app.config.ts +37 -0
  207. package/templates/apps/frontend-portal/package.json +32 -0
  208. package/templates/apps/frontend-portal/src/config.ts +8 -0
  209. package/templates/apps/frontend-portal/src/globals.css +93 -0
  210. package/templates/apps/frontend-portal/src/globals.d.ts +6 -0
  211. package/templates/apps/frontend-portal/src/lib/api.ts +62 -0
  212. package/templates/apps/frontend-portal/src/locales/de.ts +86 -0
  213. package/templates/apps/frontend-portal/src/locales/en.ts +93 -0
  214. package/templates/apps/frontend-portal/src/locales/index.ts +14 -0
  215. package/templates/apps/frontend-portal/src/pages/(portal)/api-keys/page.test.tsx +71 -0
  216. package/templates/apps/frontend-portal/src/pages/(portal)/api-keys/page.tsx +124 -0
  217. package/templates/apps/frontend-portal/src/pages/(portal)/billing/page.test.tsx +86 -0
  218. package/templates/apps/frontend-portal/src/pages/(portal)/billing/page.tsx +97 -0
  219. package/templates/apps/frontend-portal/src/pages/(portal)/error.tsx +19 -0
  220. package/templates/apps/frontend-portal/src/pages/(portal)/invoices/page.tsx +45 -0
  221. package/templates/apps/frontend-portal/src/pages/(portal)/layout.test.tsx +31 -0
  222. package/templates/apps/frontend-portal/src/pages/(portal)/layout.tsx +63 -0
  223. package/templates/apps/frontend-portal/src/pages/(portal)/not-found.tsx +15 -0
  224. package/templates/apps/frontend-portal/src/pages/(portal)/page.tsx +39 -0
  225. package/templates/apps/frontend-portal/src/pages/(portal)/profile/page.tsx +79 -0
  226. package/templates/apps/frontend-portal/src/pages/layout.tsx +12 -0
  227. package/templates/apps/frontend-portal/src/pages/login/page.test.tsx +58 -0
  228. package/templates/apps/frontend-portal/src/pages/login/page.tsx +96 -0
  229. package/templates/apps/frontend-portal/template.json +6 -0
  230. package/templates/apps/frontend-portal/tsconfig.json +5 -0
  231. package/templates/apps/frontend-saas/README.md +73 -0
  232. package/templates/apps/frontend-saas/app.config.ts +46 -0
  233. package/templates/apps/frontend-saas/package.json +32 -0
  234. package/templates/apps/frontend-saas/src/config.ts +8 -0
  235. package/templates/apps/frontend-saas/src/globals.css +85 -0
  236. package/templates/apps/frontend-saas/src/globals.d.ts +6 -0
  237. package/templates/apps/frontend-saas/src/lib/api.ts +47 -0
  238. package/templates/apps/frontend-saas/src/locales/de.ts +72 -0
  239. package/templates/apps/frontend-saas/src/locales/en.ts +81 -0
  240. package/templates/apps/frontend-saas/src/locales/index.ts +14 -0
  241. package/templates/apps/frontend-saas/src/pages/(marketing)/layout.tsx +30 -0
  242. package/templates/apps/frontend-saas/src/pages/(marketing)/login/page.test.tsx +58 -0
  243. package/templates/apps/frontend-saas/src/pages/(marketing)/login/page.tsx +96 -0
  244. package/templates/apps/frontend-saas/src/pages/(marketing)/page.tsx +27 -0
  245. package/templates/apps/frontend-saas/src/pages/dashboard/billing/page.tsx +72 -0
  246. package/templates/apps/frontend-saas/src/pages/dashboard/error.tsx +20 -0
  247. package/templates/apps/frontend-saas/src/pages/dashboard/layout.test.tsx +32 -0
  248. package/templates/apps/frontend-saas/src/pages/dashboard/layout.tsx +69 -0
  249. package/templates/apps/frontend-saas/src/pages/dashboard/not-found.tsx +17 -0
  250. package/templates/apps/frontend-saas/src/pages/dashboard/page.test.tsx +113 -0
  251. package/templates/apps/frontend-saas/src/pages/dashboard/page.tsx +76 -0
  252. package/templates/apps/frontend-saas/src/pages/dashboard/team/page.tsx +73 -0
  253. package/templates/apps/frontend-saas/src/pages/layout.tsx +12 -0
  254. package/templates/apps/frontend-saas/template.json +6 -0
  255. package/templates/apps/frontend-saas/tsconfig.json +5 -0
  256. package/templates/apps/frontend-spa/package.json +7 -7
  257. package/templates/apps/frontend-ssr/package.json +7 -7
  258. package/templates/apps/frontend-ssr-api/package.json +8 -8
  259. package/templates/apps/frontend-static-blog/package.json +6 -6
  260. package/templates/apps/frontend-status/README.md +51 -0
  261. package/templates/apps/frontend-status/app.config.ts +39 -0
  262. package/templates/apps/frontend-status/package.json +32 -0
  263. package/templates/apps/frontend-status/src/config.ts +7 -0
  264. package/templates/apps/frontend-status/src/globals.css +70 -0
  265. package/templates/apps/frontend-status/src/globals.d.ts +6 -0
  266. package/templates/apps/frontend-status/src/lib/status.ts +81 -0
  267. package/templates/apps/frontend-status/src/locales/de.ts +41 -0
  268. package/templates/apps/frontend-status/src/locales/en.ts +45 -0
  269. package/templates/apps/frontend-status/src/locales/index.ts +13 -0
  270. package/templates/apps/frontend-status/src/pages/layout.tsx +27 -0
  271. package/templates/apps/frontend-status/src/pages/page.test.tsx +123 -0
  272. package/templates/apps/frontend-status/src/pages/page.tsx +136 -0
  273. package/templates/apps/frontend-status/template.json +6 -0
  274. package/templates/apps/frontend-status/tsconfig.json +11 -0
  275. package/templates/baselines/compose/docker-compose.prod.yml +15 -0
  276. package/templates/baselines/compose-mariadb/docker-compose.prod.yml +15 -0
  277. package/dist/apiBuild-BrjrVJJh.js +0 -2
  278. package/dist/dbCommand-DrzXimKf.js +0 -2
  279. package/dist/dev-DNkso403.js +0 -3
  280. package/dist/fileConventions-3bffWssN.js +0 -30
  281. package/dist/frameworkTableAssembly-lrjZtk0G.js +0 -2
  282. package/dist/inspect-gt8bq-Tz.js +0 -2
  283. package/dist/manifestBuild-ifczArzr.js +0 -2
  284. package/dist/serveCommand-DfkisVWP.js +0 -1310
@@ -1024,6 +1024,67 @@ const message = matchError(err, {
1024
1024
  }, () => 'Something went wrong')
1025
1025
  ```
1026
1026
 
1027
+ ## Cross-table business rules — `.rule()`
1028
+
1029
+ A [typed error](#typed-errors) is declared on ONE mutation. A **rule** is declared on a TABLE: an invariant the runtime enforces on EVERY mutation that writes that table, no matter which one did the write. Declare it in the schema with `.rule(name, predicate)`:
1030
+
1031
+ ```ts
1032
+ import { table, id, integer, eq } from '@voltro/database'
1033
+ import { database } from '../database/schema'
1034
+
1035
+ export const invoices = table('invoices', {
1036
+ id: id(),
1037
+ total: integer(),
1038
+ }).rule(
1039
+ 'totalMatchesLineItems',
1040
+ async (row, { store }) => {
1041
+ const items = await store.query(
1042
+ database.lineItems.where(eq('invoiceId', row.id)).descriptor,
1043
+ )
1044
+ const sum = items.reduce((acc, l) => acc + Number(l.amount), 0)
1045
+ return sum === row.total || { params: { computed: sum, declared: row.total } }
1046
+ },
1047
+ )
1048
+ ```
1049
+
1050
+ The predicate receives the **post-write row** and a `context` whose `store` is the SAME transactional store the mutation wrote through — so a cross-table read shares the write's MVCC snapshot and cannot race it. It runs INSIDE the mutation transaction, after the write and before commit, and it is dialect-neutral (the same predicate is correct on all four dialects — no per-dialect code). Return `true` (or nothing) when the invariant holds; return `false` or a `RuleViolationDetail` (`{ params?, field?, message? }`) to signal a violation for THIS row.
1051
+
1052
+ Unlike [`.check()`](/docs/database/columns#db-level-checks) — a single-row SQL `CHECK` the DATABASE enforces as DDL — a rule is a PREDICATE the runtime evaluates, so it can read OTHER tables. Use `.check()` when the database itself must guarantee a single-row constraint; reach for `.rule()` for a cross-table invariant (an invoice total matching its line items, a booking not exceeding a resource's capacity).
1053
+
1054
+ ### The violation is a typed error — automatically
1055
+
1056
+ A violated `error`-severity rule rolls the whole mutation back and fails with the typed, wire-preserved **`BusinessRuleViolation`**. You do **not** declare it on the mutation's `error:` — the runtime auto-merges it into every mutation's error union at the wire boundary, exactly like `ScopeError`. A rule declared on a SCHEMA table can fail ANY mutation that writes that table, so no single descriptor could know to declare it; the auto-merge is what keeps the violation a typed error the client decodes by `_tag` rather than an untyped defect.
1057
+
1058
+ Match it on the client the same way as any [typed error](#matching-typed-errors-on-the-client):
1059
+
1060
+ ```ts
1061
+ import { errorTag } from '@voltro/protocol'
1062
+
1063
+ try {
1064
+ await createInvoice(input)
1065
+ } catch (err) {
1066
+ if (errorTag(err) === 'BusinessRuleViolation') {
1067
+ // err.rule — the rule name; err.params — the offending values; err.field — the pointer
1068
+ }
1069
+ }
1070
+ ```
1071
+
1072
+ `BusinessRuleViolation` carries `rule` (the declared name), optional `params` (i18n params for the offending values), an optional `field` pointer, and `severity` (always `'error'` on the wire — a warning-severity rule never reaches the client as an error).
1073
+
1074
+ ### `severity: 'warning'` — log without blocking
1075
+
1076
+ ```ts
1077
+ export const invoices = table('invoices', { id: id(), total: integer() }).rule(
1078
+ 'totalMatchesLineItems',
1079
+ async (row, { store }) => true,
1080
+ { severity: 'warning' },
1081
+ )
1082
+ ```
1083
+
1084
+ A `warning`-severity rule LOGS and audits the violation but lets the write commit — useful while backfilling data that does not yet satisfy a newly-added invariant. The default is `'error'` (roll back).
1085
+
1086
+ Rules run on `voltro dev` and `voltro serve` through the same mutation runner, so the two boot paths cannot disagree about whether a rule fires. Inserts and updates on your own tables are re-validated; a delete is not (there is no post-write row to check).
1087
+
1027
1088
  ## `internal: true` — off the wire entirely
1028
1089
 
1029
1090
  Every discovered `*.mutation.ts` / `*.query.ts` / `*.action.ts` / `*.stream.ts` is
@@ -1472,6 +1533,88 @@ A subscription that was live and is then skipped goes idle — it does **not**
1472
1533
  keep serving the snapshot it still holds. Otherwise `skip: !open` would show
1473
1534
  last time's data the moment a dialog reopens.
1474
1535
 
1536
+ ## SSR preload — first paint with data
1537
+
1538
+ By default a `useSubscription` on an SSR page flashes its empty/loading state on
1539
+ mount, THEN opens the WebSocket and fetches the first snapshot — even though the
1540
+ server could have fetched that value during the render. **`usePreloadedSubscription`**
1541
+ closes that gap: it reads its first value from the SSR hydration payload, renders
1542
+ real data on the first paint, then upgrades to the live stream the instant its
1543
+ first snapshot lands.
1544
+
1545
+ Two things wire it up: the hook, and a `preload` export on the page.
1546
+
1547
+ ```tsx
1548
+ import { usePreloadedSubscription } from '@voltro/client'
1549
+
1550
+ export const preload = ['notes.list']
1551
+
1552
+ export default function NotesPage() {
1553
+ const { data } = usePreloadedSubscription<Note[]>('app', 'notes.list')
1554
+ // `data` is present on the first paint — no loading flash
1555
+ return <NotesTable notes={data} />
1556
+ }
1557
+ ```
1558
+
1559
+ `export const preload` lists the subscriptions the page's tree needs at first
1560
+ paint. During the SSR render (`voltro dev` and `voltro start`) the framework runs
1561
+ each one server-side — the SAME `ctx.query(tag, input)` a loader receives — and
1562
+ seeds the result into the hydration payload. The client's
1563
+ `usePreloadedSubscription` finds the seed by the SAME cache key `useSubscription`
1564
+ uses, so the server markup and the client hydration render read an identical
1565
+ value — there is no hydration mismatch. The live subscription still opens and
1566
+ takes over; the seed is only the first value, never the source of truth.
1567
+
1568
+ ### `preload` entries
1569
+
1570
+ An entry is either a bare tag or a tag plus an `input` derived from the route
1571
+ params:
1572
+
1573
+ ```tsx
1574
+ export const preload = [
1575
+ 'teams.list', // no input
1576
+ { tag: 'project.detail', input: (params) => ({ id: params.id }) },
1577
+ ]
1578
+ ```
1579
+
1580
+ The `input` you derive here MUST match the `input` you pass the hook — both
1581
+ address the same cache entry:
1582
+
1583
+ ```tsx
1584
+ export default function ProjectPage({ params }: { params: { id: string } }) {
1585
+ const { data } = usePreloadedSubscription('app', 'project.detail', { id: params.id })
1586
+ return <ProjectView project={data} />
1587
+ }
1588
+ ```
1589
+
1590
+ ### Falls back to `useSubscription`
1591
+
1592
+ When no seed exists for the key — a client-side SPA navigation the server never
1593
+ rendered, or the static prerender (which has no live api origin) —
1594
+ `usePreloadedSubscription` behaves EXACTLY like `useSubscription`: it loads until
1595
+ the stream answers. So it is always safe to reach for; the preload is a
1596
+ first-paint optimization, never a correctness dependency. A failed preload is
1597
+ likewise non-fatal — the live subscription still delivers the value on the
1598
+ client, the only loss is the first-paint seed.
1599
+
1600
+ ### Seeding by hand
1601
+
1602
+ `export const preload` is sugar over an explicit seed. When a loader ALREADY has
1603
+ the value — you fetched it for the `<title>`, a breadcrumb, or the row name — seed
1604
+ it directly with `seedPreloadedSubscription` (server-side only) instead of
1605
+ fetching it a second time:
1606
+
1607
+ ```ts
1608
+ import { seedPreloadedSubscription } from '@voltro/client'
1609
+
1610
+ // inside a loader / layout loader, server-side
1611
+ const notes = await ctx.query('notes.list', {})
1612
+ seedPreloadedSubscription('app', 'notes.list', {}, notes)
1613
+ ```
1614
+
1615
+ Calling it outside a server render throws — on the client the live subscription
1616
+ already provides the value, so a client-side seed would be meaningless.
1617
+
1475
1618
  ## Streams Are Different
1476
1619
 
1477
1620
  For non-database or transient element feeds, use [streams](/docs/data/streams), not subscriptions:
@@ -2697,6 +2840,145 @@ export default defineAggregate({
2697
2840
 
2698
2841
  When no analytics sink is configured the framework provides the no-op sink: the read methods fail with `AnalyticsCapabilityNotSupported({ provider: 'noop' })`, which surfaces as a refresh error (last-good rows are preserved). Provider-specific queries beyond the four-method contract (raw SQL, HyperLogLog) live outside the sink — there is no raw-client escape hatch; query the provider with your own client instance inside the build function where you need them.
2699
2842
 
2843
+ ## Standing IVM siblings — expectations, cost budgets, experiments
2844
+
2845
+ Three more primitives are built on the same engine as `defineAggregate({ incremental })` — they reduce a table to a value maintained incrementally from `store.onChange` CDC deltas (O(1) per write, no re-query). Unlike an aggregate you never `read()` a materialised table; you **observe a live signal** through a framework registry and a `useX(def)` handler-sugar. Each is discovered by its own file convention and wired into both `voltro dev` and `voltro serve`.
2846
+
2847
+ | Primitive | File | Reduces a table to | Observe with |
2848
+ |---|---|---|---|
2849
+ | `defineExpectation` | `*.expectation.ts` | a `holding`/`violated` data-quality signal | `useExpectation` / `ExpectationRegistry` |
2850
+ | `defineCostBudget` | `*.budget.ts` | per-tenant compute-cost attribution + `ok`/`warn`/`exceeded` budgets | `useCostBudget` / `CostRegistry` |
2851
+ | `defineExperiment` | `*.experiment.ts` | per-variant metric + lift (live A/B / holdout) | `useExperiment` / `ExperimentRegistry` |
2852
+
2853
+ They are **soft, observability-grade signals** — none of them ever blocks a write. Rejecting a write is a business rule's job (`table().validate(Schema)`); running an agent/workflow on a change is [`*.reaction.tsx`](/docs/data/reactions). These three only *watch* and *report*.
2854
+
2855
+ ### Data-quality expectations (`*.expectation.ts`)
2856
+
2857
+ A `defineExpectation` is a standing data-quality contract over a table. Where dbt tests / Great Expectations run in BATCH and catch bad data hours later, an expectation re-evaluates on every write and tips `holding`↔`violated` the instant an invariant breaks — tracing the violation to the write that caused it (the CDC event's `traceId` / `subjectId` / `procedure`).
2858
+
2859
+ ```ts
2860
+ // apps/api/expectations/orderFreshness.expectation.ts
2861
+ import { defineExpectation } from '@voltro/runtime'
2862
+
2863
+ export default defineExpectation({
2864
+ name: 'orders-fresh',
2865
+ on: { table: 'orders' }, // its CDC deltas drive re-evaluation
2866
+ invariant: { kind: 'freshness', column: 'createdAt', maxAgeMs: 10 * 60_000 },
2867
+ severity: 'critical', // 'info' | 'warn' (default) | 'critical' — alerting priority only
2868
+ })
2869
+ ```
2870
+
2871
+ The default export must be the return value of `defineExpectation({...})` — the cli discovers `*.expectation.ts` files by that branded shape.
2872
+
2873
+ Four invariant kinds, each reducing the table to one incrementally-maintained metric:
2874
+
2875
+ ```ts
2876
+ { kind: 'freshness', column: 'createdAt', maxAgeMs: 600_000 } // newest row no older than X (re-checked on a clock tick too)
2877
+ { kind: 'nullRate', column: 'email', maxRate: 0.01 } // ≤ 1% of rows null in `column`
2878
+ { kind: 'rowCount', min: 1, max: 100_000 } // COUNT(*) within [min, max]
2879
+ { kind: 'valueBounds', column: 'price', min: 0, max: 1000, maxViolationRate: 0.05 } // ≤ 5% of rows out of [min, max]
2880
+ ```
2881
+
2882
+ An optional `on.where` predicate narrows the population the invariant is asserted over — it runs server-side per row and never leaves the server, so it can be any predicate:
2883
+
2884
+ ```ts
2885
+ on: { table: 'orders', where: (row) => (row['status'] as string) === 'paid' }
2886
+ ```
2887
+
2888
+ Observe one expectation from a handler with `useExpectation`, or read the whole surface off `ExpectationRegistry`:
2889
+
2890
+ ```ts
2891
+ import { Effect } from 'effect'
2892
+ import { useExpectation } from '@voltro/runtime'
2893
+ import ordersFresh from '../expectations/orderFreshness.expectation'
2894
+
2895
+ export default (input, _ctx) =>
2896
+ Effect.gen(function* () {
2897
+ const state = yield* useExpectation(ordersFresh)
2898
+ // state: { status: 'holding' | 'violated' | 'unknown', metric, threshold,
2899
+ // since, lastEvaluatedAt, lastCause, ... } | null (null = not registered)
2900
+ return { degraded: state?.status === 'violated' }
2901
+ })
2902
+ ```
2903
+
2904
+ `ExpectationRegistry` exposes `snapshot()` (every expectation's state — the inspect feed), `get(name)`, `violations()` (the alerting view), and `subscribe(listener)` for `violated`/`recovered` transitions. A transition carries the causing write's provenance; a `freshness` SLA aging out with no write reports `cause: null` — the honest "no write caused this; the ABSENCE of writes did".
2905
+
2906
+ ### Cost budgets (`*.budget.ts`)
2907
+
2908
+ A `defineCostBudget` is a reactive-FinOps primitive: per-tenant / per-subscription compute-cost attribution plus a budget every tenant is held to independently. It is to reactive compute what AI's `requireAiBudget` is to USD spend — a standing per-tenant ceiling that crosses `ok`→`warn`→`exceeded` and recovers on a window rollover.
2909
+
2910
+ ```ts
2911
+ // apps/api/budgets/tenantCompute.budget.ts
2912
+ import { defineCostBudget } from '@voltro/runtime'
2913
+
2914
+ export default defineCostBudget({
2915
+ name: 'tenant-compute',
2916
+ limit: 100_000, // the per-tenant ceiling, in the budget's unit
2917
+ unit: 'recompute', // which cost unit to meter; omit ⇒ the tenant's TOTAL across every unit
2918
+ warnAt: 0.8, // fraction of `limit` at which it goes 'warn' (default 0.8; set 1 to disable)
2919
+ window: '24h', // tumbling window — the counter resets each boundary; omit ⇒ cumulative since boot
2920
+ severity: 'warn', // 'info' | 'warn' (default) | 'critical'
2921
+ })
2922
+ ```
2923
+
2924
+ Observe one budget for one tenant with `useCostBudget(def, tenantId)`:
2925
+
2926
+ ```ts
2927
+ import { Effect } from 'effect'
2928
+ import { useCostBudget } from '@voltro/runtime'
2929
+ import tenantCompute from '../budgets/tenantCompute.budget'
2930
+
2931
+ export default (input, ctx) =>
2932
+ Effect.gen(function* () {
2933
+ const state = yield* useCostBudget(tenantCompute, ctx.tenantId)
2934
+ // state: { status: 'ok' | 'warn' | 'exceeded', spent, limit, warnThreshold, ... } | null
2935
+ return { overBudget: state?.status === 'exceeded' }
2936
+ })
2937
+ ```
2938
+
2939
+ `CostRegistry` exposes `attribution()` (per-tenant chargeback/showback rows — `total` + `byUnit` + `bySubscription`), `tenant(tenantId)`, `budgets()`, `budget(name, tenantId)`, `breaches()` (the alerting view), and `subscribe(listener)` for threshold crossings.
2940
+
2941
+ > **Reactive recomputes now feed attribution automatically.** Declaring any `*.budget.ts` wires the dispatcher's `recordCost` tap in both boot paths: every time a source-row change re-runs an affected subscription and pushes it a delta, one `{ unit: 'recompute', amount: 1 }` cost event is attributed to that subscription's tenant (and traceId). So a tenant's `total`/`spent` populates from real reactive work — a `unit: 'recompute'` budget crosses `ok`→`warn`→`exceeded` as deliveries accrue, and recovers on a window rollover. The emission is **per delivered recompute** — one event per subscription the change fanned out to, on both the row-set and computed-query delivery paths. An app that declares NO cost budget wires no tap and allocates nothing on the reactive hot path.
2942
+
2943
+ ### Online experiments (`*.experiment.ts`)
2944
+
2945
+ A `defineExperiment` is a live A/B / holdout experiment expressed as IVM aggregates. Assignment is a deterministic salted hash (subject → variant, no stored assignment table, reproducible on the client); the success metric is maintained PER VARIANT from the watched table's CDC — so lift vs a baseline is real-time, with no batch pipeline. It differs from `plugin-flags` (which GATES a code path) — an experiment MEASURES the outcome.
2946
+
2947
+ ```ts
2948
+ // apps/api/experiments/checkoutColor.experiment.ts
2949
+ import { defineExperiment } from '@voltro/runtime'
2950
+
2951
+ export default defineExperiment({
2952
+ name: 'checkout-button-color',
2953
+ on: { table: 'orders' }, // its CDC deltas drive the live recompute
2954
+ subject: 'userId', // stable per-subject bucketing (a column name, or a (row) => key fn)
2955
+ variants: [{ name: 'control' }, { name: 'green', weight: 1 }], // ≥ 2 arms; `weight` skews the split (default 1)
2956
+ holdout: 0.1, // 10% held out entirely, for a clean untouched baseline
2957
+ metric: { kind: 'conversionRate', column: 'completed' }, // per-variant success metric
2958
+ baseline: 'control', // which variant lift is measured against (default: the first)
2959
+ })
2960
+ ```
2961
+
2962
+ Metric kinds: `{ kind: 'count' }`, `{ kind: 'sum', column }`, `{ kind: 'avg', column }`, and `{ kind: 'conversionRate', column, equals? }` (converted iff `row[column]` is truthy, or `=== equals` when given). `avg` / `conversionRate` are per-subject rates that a lift reads honestly; `count` / `sum` reflect exposure too, so their cross-variant comparison only means "more/less total".
2963
+
2964
+ Observe the live result with `useExperiment`, or off `ExperimentRegistry`:
2965
+
2966
+ ```ts
2967
+ import { Effect } from 'effect'
2968
+ import { useExperiment } from '@voltro/runtime'
2969
+ import checkoutColor from '../experiments/checkoutColor.experiment'
2970
+
2971
+ export default (input, _ctx) =>
2972
+ Effect.gen(function* () {
2973
+ const result = yield* useExperiment(checkoutColor)
2974
+ // result: { totalExposure, baseline, variants: [{ variant, isBaseline, isHoldout,
2975
+ // exposure, metric, lift, diff }, ...], lastUpdatedAt, ... } | null
2976
+ return { arms: result?.variants ?? [] }
2977
+ })
2978
+ ```
2979
+
2980
+ Each variant row carries its `exposure` (sample size), the maintained `metric`, and — for non-baseline arms — `lift` (`(metric − baseMetric) / baseMetric`) and `diff` (absolute). `ExperimentRegistry` exposes `snapshot()`, `get(name)`, and `subscribe(listener)` for the live recompute stream that a results view redraws from. The same-subject-same-variant assignment is a pure function (`assignVariant(def, subject)`) a client can reproduce.
2981
+
2700
2982
 
2701
2983
 
2702
2984
  ---
@@ -658,6 +658,53 @@ export const users = table('users', {
658
658
 
659
659
  The cleanup is a separate PR after the migration has rolled out. Don't mix the rollout PR with the cleanup PR — the markers ARE the migration's audit trail until it's applied everywhere.
660
660
 
661
+ ## `voltro evolve` — the schema-evolution copilot
662
+
663
+ Adding the `.renamedFrom()` marker by hand is easy for one column. The hard part of changing an EXISTING schema is the *rest*: which handlers read or write that column, whether a rename is safe or needs a backfill, and getting the annotation onto the entity AND every call site without missing one. `voltro evolve` does that reconnaissance and proposes a reviewable plan.
664
+
665
+ Given a change, it reads the **observed graph** (`app.graph.observed.generated.json`, recorded by `voltro dev` — see [`voltro check`](/docs/cli/inspect)) plus the declared manifest, enumerates the real blast radius, and prints three things: a proposed **codemod**, a branch-verified **backfill** plan, and a `voltro check` **verify** step.
666
+
667
+ ```bash
668
+ voltro evolve rename-column notes.title --to heading # dry-run: plan + codemod preview
669
+ voltro evolve rename-column notes.title --to heading --write # apply the codemod
670
+ voltro evolve retype-column orders.total --to numeric # reshape → manual codemod + steps
671
+ voltro evolve split-column users.name --into firstName,lastName
672
+ voltro evolve rename-table note --to notes
673
+ voltro evolve drop-column notes.legacy
674
+ voltro evolve rename-column notes.title --to heading --json # for CI / an agent loop
675
+ ```
676
+
677
+ It is **dry-run by default** (mirrors `voltro generate`); `--write` applies the codemod through the same `runCodemods` toolkit as `voltro update`. `--json` emits the whole plan for CI or an agent.
678
+
679
+ ### The blast radius is observed, not guessed
680
+
681
+ ```text
682
+ change: rename-column notes.title → heading
683
+
684
+ blast radius (observed + declared):
685
+ query(notes.list) — observed read
686
+ mutation(notes.update) — observed update
687
+ ⚠ 1 declared but NEVER exercised — column use UNKNOWN: notes.archive
688
+
689
+ codemod:
690
+ entity: rename 'notes.title' → 'heading' and add .renamedFrom('title') (catalog rename — data preserved)
691
+ annotate 2 handler site(s) that reference the old field
692
+
693
+ backfill (dry-runs on branch notes-pr-0):
694
+ [safe] catalog RENAME — no data movement
695
+
696
+ verify: voltro check
697
+ ```
698
+
699
+ A handler that a `voltro dev` session or a test actually ran is reported with what it did (`observed read` / `observed update`). A handler that is *declared* to touch the table but was **never exercised** is flagged `UNKNOWN` and listed separately — it is never folded into "safe", because no run proves what it does with the column. That honesty is the point: the tool tells you exactly where it cannot vouch for the change.
700
+
701
+ ### What the codemod does per kind
702
+
703
+ - **`rename-column`** gets a real `transform`: it renames the field in the `*.entity.ts` AND chains **`.renamedFrom('old')`** (so the differ plans a catalog RENAME, not the lossy drop+create described [above](#renamedfromoldname)), then annotates the handler sites the blast radius found.
704
+ - **`retype-column` / `split-column` / `drop-column` / `rename-table`** are reshaping changes with no single mechanical rewrite, so they get a **`manual`** codemod: a generated, numbered checklist of the edits + the annotation to add, printed for you to apply.
705
+
706
+ `voltro evolve` produces the plan; it does not apply the schema change. **`voltro check` is the gate on the result**, and `voltro db apply` lands it — after `--write`, review the annotated handlers, then run those two.
707
+
661
708
 
662
709
 
663
710
  ---
@@ -296,6 +296,63 @@ who may receive the plaintext — a private note you decrypt *for its owner* is
296
296
  valid case, so treating "encrypted" as "never to a client" would be wrong. State
297
297
  the exposure policy explicitly.
298
298
 
299
+ ## `.readableBy(...scopes)` — visible only to scoped subjects
300
+
301
+ The **graded middle of the same wire-exposure axis** as `.serverOnly()` — not a
302
+ fourth axis. A plain column is visible to everyone who can read the row;
303
+ `.serverOnly()` hides it from every client; `.readableBy(...)` sits between them —
304
+ the column reaches a subject only if it holds the scope:
305
+
306
+ ```ts
307
+ import { id, integer, text, table } from '@voltro/database'
308
+
309
+ export const invoices = table('invoices', {
310
+ id: id(),
311
+ number: text(), // visible to everyone
312
+ amountCents: integer().readableBy('billing:read'), // only billing-scoped subjects
313
+ taxNote: text().readableBy('billing:read', 'admin:pii'), // ANY of the two scopes
314
+ })
315
+ ```
316
+
317
+ **What it does:** a column marked `.readableBy(...scopes)` is stripped from the
318
+ wire OUTPUT for any subject holding NONE of the listed scopes, and present for
319
+ one holding **at least one** of them. Like `.serverOnly()` it changes nothing at
320
+ rest and nothing in the DDL — it is a wire concern only, so a server-internal
321
+ read (`ctx.store.query`) still sees the value; the strip applies only on the way
322
+ out to a client.
323
+
324
+ **Scope semantics:**
325
+
326
+ - **≥ 1 scope, OR-matched.** A subject sees the column iff it holds at least one
327
+ of the listed scopes — `.readableBy('a', 'b')` means "a OR b", not both.
328
+ - **Effective scopes.** The check is the framework's `hasEffectiveScope` — the
329
+ subject's RAW scopes **∪** its rbac role-derived scopes — so a role that grants
330
+ `billing:read` unlocks the column even when the scope is not listed directly on
331
+ the subject.
332
+ - **`admin:full` bypasses it.** A subject holding `admin:full` sees every
333
+ `.readableBy(...)` column, exactly as it satisfies every guard.
334
+ - **At least one scope is required.** `.readableBy()` with no scope would mean
335
+ "readable by nobody" — that is `.serverOnly()` — so both the empty call and a
336
+ blank scope string are rejected at declaration.
337
+
338
+ **`.serverOnly()` wins when both are present.** `.serverOnly()` is the all-hidden
339
+ end of the axis, so a column carrying both is hidden from EVERYONE — `admin:full`
340
+ included. `.readableBy()` only ever *narrows* an otherwise-visible column; it can
341
+ never re-expose a `.serverOnly()` one:
342
+
343
+ ```ts
344
+ amountCents: integer().readableBy('billing:read'), // scoped subjects see it; admin:full does too
345
+ secretKey: text().serverOnly().readableBy('billing:read'), // serverOnly wins → hidden from EVERYONE
346
+ ```
347
+
348
+ Enforcement shares the SAME chokepoint as `.serverOnly()` — the Dispatcher's read
349
+ boundary — but resolves **per subject**: it applies to a query's initial snapshot
350
+ AND every reactive subscription delta, and to the one-shot
351
+ [`publicApi`](/docs/data/rest-routes) REST GET, in both boot paths (`voltro dev`,
352
+ `voltro serve`). A table that declares no `.readableBy(...)` column pays nothing —
353
+ the strip collapses to the subject-independent `.serverOnly()` set and the
354
+ dispatcher's read/diff memo is still shared across all subscribers of a change.
355
+
299
356
  ## Worked example
300
357
 
301
358
  ```ts
@@ -280,7 +280,9 @@ Two layers enforce row invariants, and they're complementary:
280
280
 
281
281
  - **`table().validate(Schema)` (below)** — app-level `effect/Schema` validation, run before the INSERT/UPDATE. Use it for the rich stuff a portable `CHECK` can't express: regex / email / structured strings, length ranges, numeric bounds, cross-field rules with custom typed errors. It's the single validation surface — don't reach for a pile of `.min()` / `.max()` / `.pattern()` column modifiers (there aren't any, by design).
282
282
 
283
- Rule of thumb: **`.check()` when the DATABASE must guarantee it; `validate(Schema)` for everything else.** There is no `.comment()` / `.index()` column modifier indexes are table-level.
283
+ - **`table().rule(name, predicate)` (cross-table)** neither of the above can read ANOTHER table. A [business rule](/docs/data/mutations#cross-table-business-rules-rule) is a predicate the runtime evaluates inside the mutation transaction, so it can compare the written row against rows in other tables (an invoice total vs. its line items) and rolls the mutation back with a typed `BusinessRuleViolation` on failure.
284
+
285
+ Rule of thumb: **`.check()` when the DATABASE must guarantee a single-row constraint; `table().rule()` for a cross-table invariant; `validate(Schema)` for everything else.** There is no `.comment()` / `.index()` column modifier — indexes are table-level.
284
286
 
285
287
  ## Default with callback
286
288
 
@@ -1150,6 +1150,59 @@ KV_BACKEND=redis
1150
1150
 
1151
1151
  Schedules and aggregates auto-coordinate via an advisory lock on SQL stores — no extra config to keep them from double-firing across replicas.
1152
1152
 
1153
+ ### The prerequisites you learn at the SECOND pod
1154
+
1155
+ Every item below is invisible on one replica and breaks on two. They are
1156
+ collected here because an operator reported each of them separately, each found
1157
+ the same way: the first pod proved the configuration worked.
1158
+
1159
+ **The connection pool multiplies, the database limit does not.**
1160
+
1161
+ ```sh
1162
+ DB_MAX_CONNECTIONS=10 # per replica — the fleet opens up to this × replicaCount
1163
+ ```
1164
+
1165
+ The framework opens ONE pool per process. At 4 replicas a pool of 10 is 40
1166
+ connections against a database that still allows whatever it allowed before you
1167
+ scaled. An operator's second pod died on `Connection timed out` for exactly
1168
+ this. `voltro serve` now prints the number and the arithmetic at boot:
1169
+
1170
+ ```
1171
+ db pool: max=10 per replica (DB_MAX_CONNECTIONS) × 4 replicas = up to 40 connections.
1172
+ ```
1173
+
1174
+ Set `REPLICA_COUNT` from your deployment (Helm: `{{ .Values.replicaCount }}`) and
1175
+ the line does the multiplication for you; without it the line still names the
1176
+ formula. `voltro dev` deliberately does not print it — one process, no replicas.
1177
+
1178
+ **`POD_IP` is each replica's identity, not only a workflow setting.**
1179
+
1180
+ ```yaml
1181
+ env:
1182
+ - name: POD_IP
1183
+ valueFrom:
1184
+ fieldRef:
1185
+ fieldPath: status.podIP
1186
+ ```
1187
+
1188
+ Without it every replica registers under the same host, so they are one runner
1189
+ as far as the cluster is concerned. The boot warning for it fires only on SQL
1190
+ cluster storage, so a deployment that has not adopted durable workflows yet gets
1191
+ no signal at all — inject it as a matter of course.
1192
+
1193
+ **Derive the broadcast namespace from something that cannot be forgotten.**
1194
+
1195
+ ```yaml
1196
+ - name: VOLTRO_BROADCAST_NAMESPACE
1197
+ value: {{ .Release.Namespace }}
1198
+ ```
1199
+
1200
+ Staging and production of the same app share a name, code and fingerprint, so
1201
+ the auto-derived namespace does NOT separate them — only this variable does. An
1202
+ operator's own guidance, and better than ours was: a value taken from the
1203
+ release namespace cannot be left out of one environment's config file, because
1204
+ there is no file to forget.
1205
+
1153
1206
  ### Workflow failover across replicas
1154
1207
 
1155
1208
  On a **SQL store** (postgres / mysql / mariadb / mssql), durable workflows survive a replica crash: completed `step({...})` activities are checkpointed in the cluster journal, so when a replica dies mid-run, a **surviving replica takes over the run and continues it from the last completed step** — it replays the completed steps rather than re-running them. (On sqlite the engine is single-process — durable within one replica, no cross-replica failover.) Two requirements:
@@ -1181,6 +1234,9 @@ Lower the lease for **faster failover**, at the cost of **false-positive reclaim
1181
1234
  - [ ] `VOLTRO_LOG_FORMAT=json`; `sentryPlugin()` + `SENTRY_DSN` for errors
1182
1235
  - [ ] `terminationGracePeriodSeconds` generous for graceful drain; `VOLTRO_SHUTDOWN_GRACE_MS` set just under it (minus the preStop sleep)
1183
1236
  - [ ] `CACHE_BACKEND` / `KV_BACKEND` + a cross-replica change bus when running >1 replica
1237
+ - [ ] `DB_MAX_CONNECTIONS` set so `pool × replicaCount` fits your database's limit — read the `db pool:` boot line before raising `replicaCount`
1238
+ - [ ] `POD_IP` injected via the downward API on EVERY multi-replica deployment, not only for durable workflows
1239
+ - [ ] `VOLTRO_BROADCAST_NAMESPACE` derived from the release namespace — staging and production do not separate themselves
1184
1240
  - [ ] For durable workflows on >1 replica: SQL store + `POD_IP` injected; tune `VOLTRO_WORKFLOW_FAILOVER_LEASE` if a 35s takeover is too slow; step side effects idempotent
1185
1241
 
1186
1242