@voltro/cli 0.33.0 → 0.34.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 (337) hide show
  1. package/CHANGELOG.md +1801 -0
  2. package/bin/nodeEnvironment.d.mts +30 -0
  3. package/bin/nodeEnvironment.mjs +158 -0
  4. package/bin/voltro.mjs +69 -5
  5. package/dist/addCommand-BNeoeSxe.js +124 -0
  6. package/dist/addCommand-aXSQveak.js +2 -0
  7. package/dist/agentsMd-BTchIZku.js +2 -0
  8. package/dist/agentsMd-mhQMF1bx.js +254 -0
  9. package/dist/apiBuild-BICVGnEC.js +2 -0
  10. package/dist/{apiBuild-h9VHtnlw.js → apiBuild-DZF_f0_I.js} +46 -46
  11. package/dist/appPort-B_HpJ_ck.js +48 -0
  12. package/dist/baselineCommand-C2ClWZN3.js +2 -0
  13. package/dist/baselineCommand-DIttzO8A.js +227 -0
  14. package/dist/bin.js +71 -28
  15. package/dist/build-CE7Ca9gm.js +711 -0
  16. package/dist/cacheCommand-DA4OH9xt.js +42 -0
  17. package/dist/capabilitiesCommand-nq_pz5xd.js +123 -0
  18. package/dist/checkCommand-Dv8r9tGO.js +231 -0
  19. package/dist/checkCommand-YZDmkAGd.js +2 -0
  20. package/dist/{cliArgs-qdZSElM3.js → cliArgs-D4p8n7EE.js} +12 -1
  21. package/dist/cliError-BmdYnghb.js +10 -0
  22. package/dist/cliOutput-D1tSBoRM.js +15 -0
  23. package/dist/{cliRuntime-Oh517vCV.js → cliRuntime-Dh7UDinH.js} +20 -20
  24. package/dist/cloudClient-DWL-Hw_T.js +67 -0
  25. package/dist/cloudCmd-Cvv5HGaZ.js +364 -0
  26. package/dist/clusterCmd-CNXKlcvD.js +54 -0
  27. package/dist/codegen-CYM3Zqrf.js +605 -0
  28. package/dist/codegen-ChBi_hVa.js +2 -0
  29. package/dist/codegenCommand-DC6w2tNZ.js +30 -0
  30. package/dist/codemodRunner-DRRqXR74.js +5243 -0
  31. package/dist/commandRunner-BLAEFLjp.js +47 -0
  32. package/dist/commands-gutsz-Ac.js +808 -0
  33. package/dist/connectionConfig-UFlIEiys.js +66 -0
  34. package/dist/dashboardCommand-3YG8p-UA.js +25 -0
  35. package/dist/dataCommand-qL0r7fPO.js +535 -0
  36. package/dist/dataProfile-dW-PsfLB.js +15 -0
  37. package/dist/{dbCommand-DTLKAfbA.js → dbCommand-B6X0FZbq.js} +551 -401
  38. package/dist/dbCommand-CpdKLeQq.js +2 -0
  39. package/dist/{dev-C_P8FLSx.js → dev-CerMd0mW.js} +3122 -3129
  40. package/dist/dev-CoG-ZPx8.js +3 -0
  41. package/dist/devActivity-Dx_3nnGv.js +100 -0
  42. package/dist/devActivity.js +1 -1
  43. package/dist/dialectDriver-CgXnDfec.js +39 -0
  44. package/dist/discover-C9XKJDco.js +25 -0
  45. package/dist/doctorCommand-BvqGBwNG.js +2 -0
  46. package/dist/{checkCommand-xGhRFFg2.js → doctorCommand-CnDXQxfa.js} +520 -1278
  47. package/dist/dormancyCommand-Dszo57d6.js +69 -0
  48. package/dist/e2eCmd-BRabZww-.js +147 -0
  49. package/dist/embeddingsCommand-C0sKVRo1.js +73 -0
  50. package/dist/envCommand-DPgdV1Bq.js +60 -0
  51. package/dist/evalCommand-6RUfPen4.js +118 -0
  52. package/dist/evolveCommand-DHpkgjgH.js +281 -0
  53. package/dist/fileTaxonomy-CJfgOllU.js +457 -0
  54. package/dist/frameworkTableAssembly-BGHmck-x.js +2 -0
  55. package/dist/{frameworkTableAssembly-BwIrO5nv.js → frameworkTableAssembly-DkkP6BgC.js} +184 -148
  56. package/dist/generateCommand-oibemh97.js +147 -0
  57. package/dist/index.d.ts +45 -0
  58. package/dist/index.js +4 -3
  59. package/dist/infoCommand-BJw9nLUR.js +60 -0
  60. package/dist/{inspect-BUUjt773.js → inspect-CBqFtAKA.js} +82 -40
  61. package/dist/inspect-C_T_WGvl.js +2 -0
  62. package/dist/inspectCmd-Bppy-GGw.js +224 -0
  63. package/dist/inspectFetch-Cm8_wVvp.js +151 -0
  64. package/dist/inspectMetrics-CfdKLh6t.js +72 -0
  65. package/dist/loadEnv-D9nEOClM.js +44 -0
  66. package/dist/logFileSink-C_D2wRN1.js +105 -0
  67. package/dist/logsCmd-CCca3KRZ.js +260 -0
  68. package/dist/manifestBuild-ChsKAhmn.js +2 -0
  69. package/dist/{manifestBuild-BLrVuSlM.js → manifestBuild-sxpwdKY1.js} +1 -1
  70. package/dist/metaCommands-7MJfZ5cf.js +196 -0
  71. package/dist/migrate-CBwOt_iV.js +83 -0
  72. package/dist/mssqlClusterPatch-_4cE_nun.js +44 -0
  73. package/dist/newCommand-COWOJ1_E.js +156 -0
  74. package/dist/nodeEnvironment-cGFAj1J8.js +28 -0
  75. package/dist/packageCommand-Cug_3Ogl.js +271 -0
  76. package/dist/pageConvention-cEiRxdab.js +5 -0
  77. package/dist/privacyCommand-C-Df56U_.js +146 -0
  78. package/dist/projectScaffold-DmzEKHib.js +2 -0
  79. package/dist/projectScaffold-LMMtaavR.js +814 -0
  80. package/dist/renderModeScan-D7J1B7Kw.js +105 -0
  81. package/dist/renderProfile-1OWWAAtx.js +81 -0
  82. package/dist/runtimeRegistry-DMeKfTHP.js +81 -0
  83. package/dist/runtimeTrace-CRxalXTs.js +91 -0
  84. package/dist/scheduleCmd--jksTrf6.js +69 -0
  85. package/dist/scheduleManifestCmd-D2x0CTTY.js +249 -0
  86. package/dist/schemaIr-UJybUUZW.js +103 -0
  87. package/dist/{sdkgen-C81QIkiL.js → sdkgen-CYJscZC7.js} +111 -209
  88. package/dist/seedRunner-TFHHiToI.js +329 -0
  89. package/dist/serveCommand-B_isw7q4.js +1647 -0
  90. package/dist/serveCommand-DOvbgRnQ.js +2 -0
  91. package/dist/serveEntry.js +5 -5
  92. package/dist/serverlessCommand-CfJZy6dS.js +482 -0
  93. package/dist/start-9LiUOfES.js +1087 -0
  94. package/dist/start-B-9Nsp-S.js +3 -0
  95. package/dist/startEntry.js +2 -2
  96. package/dist/staticCommand-Dr2M6tpU.js +304 -0
  97. package/dist/storageCommand-Co6NfLqN.js +42 -0
  98. package/dist/templates-De8IR5-c.js +102 -0
  99. package/dist/test-CI6iDsYc.js +115 -0
  100. package/dist/tracesCmd-CkEZQrtt.js +232 -0
  101. package/dist/tsconfigPaths-BWXBWgcl.js +107 -0
  102. package/dist/tsxLoader-EuXmSJ1K.js +51 -0
  103. package/dist/typecheckCommand-BlsWiCNq.js +61 -0
  104. package/dist/updateCommand-Bkptutss.js +585 -0
  105. package/dist/updateCommand-us1_hdIC.js +2 -0
  106. package/dist/{inspectMetrics-1xzTKAFx.js → webDev-CBYvPqQr.js} +975 -1559
  107. package/dist/webDev-Cg-fFiyd2.js +2 -0
  108. package/dist/webhookDiscovery-CrGAfhIG.js +2 -0
  109. package/dist/webhookDiscovery-D7VaeMlz.js +51 -0
  110. package/dist/webhooksCommand-CID96Rga.js +267 -0
  111. package/dist/workflowsCmd-D1VTmLMY.js +608 -0
  112. package/package.json +179 -17
  113. package/templates/AGENTS.core.md +58 -3
  114. package/templates/AGENTS.md +64 -7
  115. package/templates/agent-docs/_index.md +6 -4
  116. package/templates/agent-docs/_manifest.json +22 -5
  117. package/templates/agent-docs/ai.md +370 -0
  118. package/templates/agent-docs/authentication.md +265 -31
  119. package/templates/agent-docs/caching.md +6 -0
  120. package/templates/agent-docs/cli.md +794 -50
  121. package/templates/agent-docs/data.md +550 -11
  122. package/templates/agent-docs/database/migrations.md +174 -25
  123. package/templates/agent-docs/database/misc.md +156 -40
  124. package/templates/agent-docs/database/querying.md +19 -1
  125. package/templates/agent-docs/database/scaling.md +60 -0
  126. package/templates/agent-docs/database/schema.md +1 -0
  127. package/templates/agent-docs/database/seedsdialects.md +208 -19
  128. package/templates/agent-docs/database/transactions.md +68 -0
  129. package/templates/agent-docs/deployment.md +69 -3
  130. package/templates/agent-docs/introduction.md +87 -16
  131. package/templates/agent-docs/local-first-mobile.md +79 -4
  132. package/templates/agent-docs/multi-tenancy.md +95 -20
  133. package/templates/agent-docs/observability.md +58 -3
  134. package/templates/agent-docs/plugins/ai-flows.md +161 -2
  135. package/templates/agent-docs/plugins/analytics-postgres.md +1 -1
  136. package/templates/agent-docs/plugins/audit.md +37 -1
  137. package/templates/agent-docs/plugins/auth-social.md +143 -0
  138. package/templates/agent-docs/plugins/auth-workos.md +4 -2
  139. package/templates/agent-docs/plugins/auth.md +131 -6
  140. package/templates/agent-docs/plugins/billing.md +132 -15
  141. package/templates/agent-docs/plugins/cdc-out.md +46 -7
  142. package/templates/agent-docs/plugins/clickhouse.md +1 -1
  143. package/templates/agent-docs/plugins/duckdb.md +1 -1
  144. package/templates/agent-docs/plugins/flags.md +132 -0
  145. package/templates/agent-docs/plugins/governance.md +105 -7
  146. package/templates/agent-docs/plugins/multitenancy.md +9 -4
  147. package/templates/agent-docs/plugins/presence.md +13 -2
  148. package/templates/agent-docs/plugins/ratelimit.md +9 -0
  149. package/templates/agent-docs/plugins/search.md +157 -6
  150. package/templates/agent-docs/plugins/sso-saml.md +47 -8
  151. package/templates/agent-docs/plugins/webhooks.md +105 -0
  152. package/templates/agent-docs/plugins.md +150 -18
  153. package/templates/agent-docs/reference.md +60 -3
  154. package/templates/agent-docs/releases.md +1117 -0
  155. package/templates/agent-docs/routing.md +43 -25
  156. package/templates/agent-docs/scheduling.md +10 -0
  157. package/templates/agent-docs/schema-driven-ui.md +92 -12
  158. package/templates/agent-docs/security.md +426 -0
  159. package/templates/agent-docs/templates/apibackends.md +87 -18
  160. package/templates/agent-docs/templates/appshells.md +32 -14
  161. package/templates/agent-docs/templates/overview.md +13 -8
  162. package/templates/agent-docs/testing.md +211 -14
  163. package/templates/agent-docs/whats-new.md +1700 -104
  164. package/templates/agent-docs/workflows.md +130 -14
  165. package/templates/apps/api-ai/actions/summarize.action.ts +11 -0
  166. package/templates/apps/api-ai/package.json +8 -7
  167. package/templates/apps/api-auth/actions/me.action.ts +13 -0
  168. package/templates/apps/api-auth/package.json +9 -8
  169. package/templates/apps/api-backend/mutations/notes.create.mutation.ts +9 -0
  170. package/templates/apps/api-backend/package.json +12 -8
  171. package/templates/apps/api-backend/queries/notes.query.ts +30 -8
  172. package/templates/apps/api-backend-deactivation/actions/users.get.action.ts +14 -0
  173. package/templates/apps/api-backend-deactivation/mutations/users.create.mutation.ts +8 -0
  174. package/templates/apps/api-backend-deactivation/mutations/users.deactivate.mutation.ts +10 -0
  175. package/templates/apps/api-backend-deactivation/package.json +8 -7
  176. package/templates/apps/api-backend-mail/actions/sendWelcome.action.ts +17 -0
  177. package/templates/apps/api-backend-mail/mutations/notes.create.mutation.ts +9 -0
  178. package/templates/apps/api-backend-mail/package.json +9 -8
  179. package/templates/apps/api-backend-mail/queries/notes.query.ts +30 -8
  180. package/templates/apps/api-backend-mariadb/.env.example +14 -0
  181. package/templates/apps/api-backend-mariadb/mutations/notes.create.mutation.ts +9 -0
  182. package/templates/apps/api-backend-mariadb/package.json +10 -9
  183. package/templates/apps/api-backend-mariadb/queries/notes.query.ts +30 -8
  184. package/templates/apps/api-backend-sqlite/.env.example +14 -0
  185. package/templates/apps/api-backend-sqlite/mutations/notes.create.mutation.ts +9 -0
  186. package/templates/apps/api-backend-sqlite/package.json +9 -8
  187. package/templates/apps/api-backend-sqlite/queries/notes.query.ts +30 -8
  188. package/templates/apps/api-backend-storage/actions/uploadAvatar.action.ts +13 -0
  189. package/templates/apps/api-backend-storage/actions/uploadDocument.action.ts +12 -0
  190. package/templates/apps/api-backend-storage/mutations/notes.create.mutation.ts +9 -0
  191. package/templates/apps/api-backend-storage/package.json +9 -8
  192. package/templates/apps/api-backend-storage/queries/notes.query.ts +30 -8
  193. package/templates/apps/api-cms/actions/content.get.action.ts +7 -0
  194. package/templates/apps/api-cms/actions/content.types.action.ts +6 -0
  195. package/templates/apps/api-cms/actions/me.action.ts +13 -0
  196. package/templates/apps/api-cms/app.config.ts +19 -0
  197. package/templates/apps/api-cms/authz.ts +63 -0
  198. package/templates/apps/api-cms/mutations/content.publish.mutation.ts +10 -0
  199. package/templates/apps/api-cms/mutations/content.saveDraft.mutation.ts +10 -0
  200. package/templates/apps/api-cms/mutations/content.unpublish.mutation.ts +5 -0
  201. package/templates/apps/api-cms/package.json +11 -10
  202. package/templates/apps/api-cms/queries/content.list.query.ts +23 -7
  203. package/templates/apps/api-cms/tests/accessDecisions.test.ts +121 -0
  204. package/templates/apps/api-cms/tests/content.descriptors.test.ts +8 -4
  205. package/templates/apps/api-collab/mutations/documents.create.mutation.ts +8 -0
  206. package/templates/apps/api-collab/mutations/documents.setBody.mutation.ts +13 -0
  207. package/templates/apps/api-collab/package.json +9 -8
  208. package/templates/apps/api-collab/queries/documents.query.ts +19 -7
  209. package/templates/apps/api-data-advanced/package.json +9 -8
  210. package/templates/apps/api-data-advanced/queries/authors.withBooks.query.ts +12 -0
  211. package/templates/apps/api-data-advanced/queries/books.search.query.ts +9 -0
  212. package/templates/apps/api-durable/mutations/orders.approve.mutation.ts +17 -0
  213. package/templates/apps/api-durable/mutations/orders.place.mutation.ts +9 -0
  214. package/templates/apps/api-durable/package.json +9 -8
  215. package/templates/apps/api-feature-flags/actions/notes.export.action.ts +16 -0
  216. package/templates/apps/api-feature-flags/mutations/notes.create.mutation.ts +12 -0
  217. package/templates/apps/api-feature-flags/package.json +10 -9
  218. package/templates/apps/api-governance/README.md +30 -8
  219. package/templates/apps/api-governance/actions/profiles.get.action.server.ts +17 -1
  220. package/templates/apps/api-governance/actions/profiles.get.action.ts +29 -4
  221. package/templates/apps/api-governance/database/schema.ts +16 -4
  222. package/templates/apps/api-governance/mutations/profiles.create.mutation.ts +12 -0
  223. package/templates/apps/api-governance/package.json +9 -8
  224. package/templates/apps/api-kv/actions/sync.pull.action.ts +15 -0
  225. package/templates/apps/api-kv/actions/sync.reset.action.ts +13 -0
  226. package/templates/apps/api-kv/actions/sync.status.action.ts +7 -0
  227. package/templates/apps/api-kv/package.json +9 -8
  228. package/templates/apps/api-kv/queries/events.list.query.ts +19 -8
  229. package/templates/apps/api-moderation/mutations/comments.create.mutation.ts +11 -0
  230. package/templates/apps/api-moderation/mutations/posts.create.mutation.ts +13 -0
  231. package/templates/apps/api-moderation/package.json +9 -8
  232. package/templates/apps/api-observability/mutations/notes.create.mutation.ts +8 -0
  233. package/templates/apps/api-observability/package.json +9 -8
  234. package/templates/apps/api-observability/queries/notes.list.query.ts +13 -0
  235. package/templates/apps/api-ratelimit/mutations/notes.create.mutation.ts +14 -0
  236. package/templates/apps/api-ratelimit/package.json +9 -8
  237. package/templates/apps/api-rbac/package.json +9 -8
  238. package/templates/apps/api-rest/package.json +8 -7
  239. package/templates/apps/api-saas/mutations/projects.create.mutation.ts +13 -0
  240. package/templates/apps/api-saas/package.json +12 -11
  241. package/templates/apps/api-saas/queries/projects.list.query.ts +11 -0
  242. package/templates/apps/api-saas-starter/actions/me.action.ts +13 -0
  243. package/templates/apps/api-saas-starter/app.config.ts +19 -0
  244. package/templates/apps/api-saas-starter/authz.ts +75 -0
  245. package/templates/apps/api-saas-starter/mutations/invites.create.mutation.ts +10 -0
  246. package/templates/apps/api-saas-starter/mutations/projects.create.mutation.ts +9 -0
  247. package/templates/apps/api-saas-starter/package.json +15 -11
  248. package/templates/apps/api-saas-starter/queries/invites.list.query.ts +19 -8
  249. package/templates/apps/api-saas-starter/queries/projects.list.query.ts +18 -7
  250. package/templates/apps/api-saas-starter/tests/accessDecisions.test.ts +135 -0
  251. package/templates/apps/api-search/mutations/articles.create.mutation.ts +14 -0
  252. package/templates/apps/api-search/package.json +9 -8
  253. package/templates/apps/api-search/queries/articles.list.query.ts +21 -8
  254. package/templates/apps/api-status/README.md +10 -3
  255. package/templates/apps/api-status/app.config.ts +8 -3
  256. package/templates/apps/api-status/authz.ts +5 -3
  257. package/templates/apps/api-status/package.json +9 -8
  258. package/templates/apps/api-status/queries/components.list.query.ts +14 -6
  259. package/templates/apps/api-status/queries/incidents.live.query.ts +23 -10
  260. package/templates/apps/api-status/queries/updates.list.query.ts +16 -9
  261. package/templates/apps/api-status/tests/status.test.ts +9 -1
  262. package/templates/apps/api-versioning/actions/documents.asOf.action.ts +12 -0
  263. package/templates/apps/api-versioning/actions/documents.history.action.ts +13 -0
  264. package/templates/apps/api-versioning/mutations/documents.create.mutation.ts +9 -0
  265. package/templates/apps/api-versioning/mutations/documents.update.mutation.ts +12 -0
  266. package/templates/apps/api-versioning/package.json +9 -8
  267. package/templates/apps/api-webhooks/mutations/orders.fulfill.mutation.ts +16 -0
  268. package/templates/apps/api-webhooks/package.json +10 -9
  269. package/templates/apps/api-webhooks/queries/orders.list.query.ts +10 -0
  270. package/templates/apps/changelog/package.json +8 -6
  271. package/templates/apps/edge-functions/package.json +2 -2
  272. package/templates/apps/frontend-admin/package.json +10 -8
  273. package/templates/apps/frontend-admin/src/lib/admin.ts +20 -9
  274. package/templates/apps/frontend-admin/src/locales/de.ts +11 -1
  275. package/templates/apps/frontend-admin/src/locales/en.ts +13 -1
  276. package/templates/apps/frontend-admin/src/pages/admin/[entity]/page.tsx +65 -22
  277. package/templates/apps/frontend-admin/src/pages/admin/entity.test.tsx +130 -23
  278. package/templates/apps/frontend-admin/src/pages/admin/layout.tsx +3 -2
  279. package/templates/apps/frontend-admin/src/pages/admin/page.test.tsx +19 -2
  280. package/templates/apps/frontend-admin/src/pages/admin/page.tsx +9 -4
  281. package/templates/apps/frontend-app/app.config.ts +4 -3
  282. package/templates/apps/frontend-app/package.json +11 -8
  283. package/templates/apps/frontend-app/src/lib/api.ts +25 -0
  284. package/templates/apps/frontend-app/src/pages/page.test.tsx +130 -82
  285. package/templates/apps/frontend-app/src/pages/page.tsx +14 -18
  286. package/templates/apps/frontend-auth/package.json +10 -8
  287. package/templates/apps/frontend-blank/package.json +9 -7
  288. package/templates/apps/frontend-cms/package.json +11 -9
  289. package/templates/apps/frontend-collab/package.json +12 -9
  290. package/templates/apps/frontend-collab/src/pages/page.test.tsx +122 -78
  291. package/templates/apps/frontend-contact/package.json +9 -7
  292. package/templates/apps/frontend-dashboard/package.json +9 -7
  293. package/templates/apps/frontend-docs/package.json +9 -7
  294. package/templates/apps/frontend-i18n/package.json +8 -6
  295. package/templates/apps/frontend-landing/package.json +9 -7
  296. package/templates/apps/frontend-portal/package.json +10 -8
  297. package/templates/apps/frontend-saas/app.config.ts +10 -6
  298. package/templates/apps/frontend-saas/package.json +10 -8
  299. package/templates/apps/frontend-saas/src/lib/api.ts +27 -32
  300. package/templates/apps/frontend-saas/src/pages/dashboard/billing/page.tsx +7 -8
  301. package/templates/apps/frontend-saas/src/pages/dashboard/page.test.tsx +27 -3
  302. package/templates/apps/frontend-saas/src/pages/dashboard/page.tsx +4 -4
  303. package/templates/apps/frontend-saas/src/pages/dashboard/team/page.tsx +3 -4
  304. package/templates/apps/frontend-spa/package.json +9 -7
  305. package/templates/apps/frontend-ssr/package.json +9 -7
  306. package/templates/apps/frontend-ssr-api/package.json +10 -8
  307. package/templates/apps/frontend-static-blog/package.json +8 -6
  308. package/templates/apps/frontend-status/package.json +10 -8
  309. package/templates/apps/mobile-app/README.md +1 -0
  310. package/templates/apps/mobile-app/package.json +4 -2
  311. package/templates/apps/mobile-app/src/app/index.tsx +22 -12
  312. package/templates/apps/mobile-app/src/app/orders/[id].tsx +1 -1
  313. package/templates/apps/mobile-app/src/lib/api.ts +34 -0
  314. package/templates/apps/mobile-app/voltro.mobile.ts +4 -2
  315. package/templates/baselines/bare/.env.example +14 -0
  316. package/templates/baselines/bare/baseline.json +4 -4
  317. package/templates/baselines/compose/.env.example +14 -0
  318. package/templates/baselines/compose/README.md +1 -1
  319. package/templates/baselines/compose/baseline.json +5 -5
  320. package/templates/baselines/compose-mariadb/.env.example +14 -0
  321. package/templates/baselines/compose-mariadb/README.md +1 -1
  322. package/templates/baselines/compose-mariadb/baseline.json +5 -5
  323. package/templates/baselines/helm/.env.example +14 -0
  324. package/templates/baselines/helm/baseline.json +4 -4
  325. package/dist/apiBuild-C-x9YacA.js +0 -2
  326. package/dist/checkCommand-DRovTKza.js +0 -2
  327. package/dist/commands-CJfepbm4.js +0 -11541
  328. package/dist/dbCommand-b1gum4td.js +0 -2
  329. package/dist/dev-iiMtlkfs.js +0 -3
  330. package/dist/devActivity-BhIu6ncs.js +0 -159
  331. package/dist/frameworkTableAssembly-D-EebUQX.js +0 -2
  332. package/dist/inspect-mmBuRXmy.js +0 -2
  333. package/dist/manifestBuild-Dj8Jjoto.js +0 -2
  334. package/dist/seedRunner-Bqxgp7HZ.js +0 -230
  335. package/dist/serveCommand-DdaM4Hup.js +0 -1608
  336. package/dist/start-C0koT0UO.js +0 -1084
  337. /package/templates/apps/api-ai/actions/{summarize.action.server.tsx → summarize.action.server.ts} +0 -0
@@ -168,6 +168,7 @@ import { Schema } from 'effect'
168
168
  export const listNotes = defineQuery({
169
169
  name: 'notes.list',
170
170
  source: 'notes',
171
+ guards: [{ scope: 'notes:read' }],
171
172
  input: Schema.Struct({}),
172
173
  output: Schema.Array(Schema.Struct({
173
174
  id: Schema.String,
@@ -188,6 +189,13 @@ export default () =>
188
189
 
189
190
  Save both files and `notes.list` becomes a streaming query in the typed client.
190
191
 
192
+ > **`guards:` is not decoration here — it is what makes the file boot.** Every
193
+ > wire-exposed procedure must declare exactly one of `guards:`,
194
+ > `openAccess: '<reason>'` or `internal: true`; a descriptor that declares none
195
+ > is refused at boot, naming the file. Which one is right is a real decision,
196
+ > and both of the other two appear on this page below. Full rules:
197
+ > [Authorization](/docs/authentication/authorization#every-procedure-decides-guards-or-openaccess).
198
+
191
199
  ## Consuming a query
192
200
 
193
201
  ```tsx no-check
@@ -219,6 +227,7 @@ import { Schema } from 'effect'
219
227
  export const listMessages = defineQuery({
220
228
  name: 'messages.list',
221
229
  source: 'messages',
230
+ guards: [{ scope: 'messages:read' }],
222
231
  input: Schema.Struct({
223
232
  channelId: Schema.String,
224
233
  limit: Schema.Number,
@@ -273,6 +282,7 @@ import { Schema } from 'effect'
273
282
  export const notesSummary = defineQuery({
274
283
  name: 'notes.summary',
275
284
  source: 'notes',
285
+ guards: [{ scope: 'notes:read' }],
276
286
  input: Schema.Struct({}),
277
287
  output: Schema.Struct({
278
288
  open: Schema.Number,
@@ -323,8 +333,11 @@ It's a runtime identity (returns the handler unchanged) — the whole value is t
323
333
  `source` also connects query caches to mutation `target` metadata:
324
334
 
325
335
  ```ts
326
- defineQuery({ name: 'notes.list', source: 'notes', /* ... */ })
327
- defineMutation({ name: 'notes.create', target: { table: 'notes', op: 'insert' }, /* ... */ })
336
+ defineQuery({ name: 'notes.list', source: 'notes', guards: [{ scope: 'notes:read' }], /* ... */ })
337
+ defineMutation({
338
+ name: 'notes.create', target: { table: 'notes', op: 'insert' },
339
+ guards: [{ scope: 'notes:write' }], /* ... */
340
+ })
328
341
  ```
329
342
 
330
343
  With that pairing, `useMutation('app', 'notes.create')` can stage an optimistic row in active `notes.list` caches without client-side cache plumbing.
@@ -340,7 +353,11 @@ export const teamBoard = defineQuery({
340
353
  name: 'boards.forTeam',
341
354
  input: Schema.Struct({ teamId: Schema.String }),
342
355
  output: BoardRows,
343
- // `boards` alone is wrong here`requireTeamAccess` reads `teamMembers`.
356
+ // The guard resolves the caller's membership of THIS team a read of
357
+ // `teamMembers` that happens before the executor runs.
358
+ guards: [{ action: 'view', resourceType: 'team', resource: (input) => input.teamId }],
359
+ // `boards` alone is wrong here — that membership read is part of what the
360
+ // result depends on.
344
361
  source: ['boards', 'teamMembers'],
345
362
  })
346
363
  ```
@@ -390,6 +407,7 @@ import { Schema } from 'effect'
390
407
  export const listProjects = defineQuery({
391
408
  name: 'projects.list',
392
409
  source: 'projects',
410
+ guards: [{ scope: 'projects:read' }],
393
411
  input: Schema.Struct({}),
394
412
  output: Schema.Array(
395
413
  Schema.Struct({
@@ -542,9 +560,12 @@ Queries are streaming RPCs whose elements are **subscription events**: an initia
542
560
  defineQuery({ name: 'q', guards: [], … }) // ✗ enforces nothing
543
561
  defineQuery({ name: 'q', source: '', … }) // ✗ reactive, subscribed to nothing
544
562
  defineQuery({ name: 'q', internal: true, overridesPlugin: true, … }) // ✗ removes, replaces nothing
563
+ defineQuery({ name: 'q', openAccess: '', … }) // ✗ a marker with no reason
564
+ defineQuery({ name: 'q', guards: [{ scope: 'x' }], openAccess: 'open', … }) // ✗ two decisions
565
+ defineQuery({ name: 'q', internal: true, openAccess: 'open', … }) // ✗ no wire to decide about
545
566
  ```
546
567
 
547
- The same three shapes `defineEvent` refuses, for the same reasons — a rule that
568
+ The first three are the shapes `defineEvent` refuses too, for the same reasons — a rule that
548
569
  holds for one primitive and not another is worse than no rule, because the
549
570
  answer then depends on which file you happened to open.
550
571
 
@@ -561,6 +582,22 @@ something unreachable in its place: callers get a 404 for something that used to
561
582
  work, with no diff that says so. Joins the existing refusals of `internal` with
562
583
  `publicApi` or `exposeAsTool`.
563
584
 
585
+ **`openAccess` without a reason** is a marker that says nothing. The reason is
586
+ what a reviewer reads to decide whether this really should be callable without a
587
+ check — `openAccess: 'public pricing, no caller data'`.
588
+
589
+ **`openAccess` + `guards`** is two different access decisions at once: the
590
+ procedure is protected AND open. Keep the guards if a caller must hold a scope;
591
+ drop them if anyone may call it.
592
+
593
+ **`openAccess` + `internal: true`** decides about a surface that does not exist —
594
+ `internal` takes the procedure off the wire. Drop one of the two.
595
+
596
+ > Every wire-exposed procedure must carry ONE of `guards:` / `openAccess:` /
597
+ > `internal: true`, or the boot refuses it. See
598
+ > [Authorization](/docs/authentication/authorization) for the gate and the
599
+ > `security.defaultDeny` field that governs it.
600
+
564
601
 
565
602
  ## Loading vs empty — don't conflate them
566
603
 
@@ -658,6 +695,7 @@ import { Schema } from 'effect'
658
695
  export const createNote = defineMutation({
659
696
  name: 'notes.create',
660
697
  target: { table: 'notes', op: 'insert' },
698
+ guards: [{ scope: 'notes:write' }],
661
699
  input: Schema.Struct({
662
700
  title: Schema.NonEmptyString,
663
701
  body: Schema.String,
@@ -668,6 +706,13 @@ export const createNote = defineMutation({
668
706
  })
669
707
  ```
670
708
 
709
+ > **`guards:` is what makes this file boot.** A wire-exposed mutation must
710
+ > declare exactly one of `guards:`, `openAccess: '<reason>'` or `internal: true`
711
+ > — a descriptor with none of them is refused at boot, naming the file. A write
712
+ > is also where a rubber-stamp guard costs the most, so name the scope the write
713
+ > actually needs rather than one every caller already holds. Full rules:
714
+ > [Authorization](/docs/authentication/authorization#every-procedure-decides-guards-or-openaccess).
715
+
671
716
  Server executor:
672
717
 
673
718
  ```ts
@@ -872,6 +917,7 @@ The default path is declarative:
872
917
  defineQuery({
873
918
  name: 'notes.list',
874
919
  source: 'notes',
920
+ guards: [{ scope: 'notes:read' }],
875
921
  input: Schema.Struct({}),
876
922
  output: Schema.Array(Note),
877
923
  })
@@ -879,6 +925,7 @@ defineQuery({
879
925
  defineMutation({
880
926
  name: 'notes.create',
881
927
  target: { table: 'notes', op: 'insert' },
928
+ guards: [{ scope: 'notes:write' }],
882
929
  input,
883
930
  output,
884
931
  })
@@ -972,6 +1019,7 @@ class NoteQuotaExceeded extends Schema.TaggedError<NoteQuotaExceeded>()('NoteQuo
972
1019
  export const createNote = defineMutation({
973
1020
  name: 'notes.create',
974
1021
  target: { table: 'notes', op: 'insert' },
1022
+ guards: [{ scope: 'notes:write' }],
975
1023
  input,
976
1024
  output,
977
1025
  error: NoteQuotaExceeded,
@@ -1087,10 +1135,16 @@ Rules run on `voltro dev` and `voltro serve` through the same mutation runner, s
1087
1135
 
1088
1136
  ## `internal: true` — off the wire entirely
1089
1137
 
1090
- Every discovered `*.mutation.ts` / `*.query.ts` / `*.action.ts` / `*.stream.ts` is
1091
- callable over the WebSocket by any authenticated browser session. `publicApi` and
1092
- `exposeAsTool` opt IN to wider surfaces; `internal: true` opts OUT of the default
1093
- one:
1138
+ Every discovered `*.mutation.ts` / `*.query.ts` / `*.action.ts` / `*.stream.ts`
1139
+ gets a route on the WebSocket which is why each one has to declare **who may
1140
+ call it**, and why an app carrying an undecided procedure does not boot at all.
1141
+ `internal: true` is the third answer to that question, beside `guards:` and
1142
+ `openAccess:`: there is no wire surface to make a decision about, because the
1143
+ procedure never gets a route. (`publicApi` and `exposeAsTool` go the other way
1144
+ and opt IN to *wider* surfaces — which is why neither combines with `internal`.)
1145
+
1146
+ Reach for it when the caller is other **server** code — a workflow step, a
1147
+ schedule, another executor — and never a browser:
1094
1148
 
1095
1149
  ```ts
1096
1150
  export const createFromAction = defineMutation({
@@ -1113,6 +1167,13 @@ anyone logged in. If the only thing keeping a procedure off the wire is that
1113
1167
  nobody wrote a client call for it, it is on the wire — the same reasoning as
1114
1168
  `.serverOnly()` on a column, one level up.
1115
1169
 
1170
+ That app is why the boot gate exists: all 18 declared no access decision, so
1171
+ today it does not start until each of them says `guards:`, `openAccess:` or
1172
+ `internal: true`. The gate turns "reachable and nobody looked" into a refusal
1173
+ naming every file — but it only forces the question, it cannot answer it, and
1174
+ `internal: true` is the right answer only when no browser is meant to call the
1175
+ procedure at all.
1176
+
1116
1177
  **It is not a substitute for a guard.** An internal procedure still runs with
1117
1178
  whatever authority its caller has. This removes the wire surface, not the need to
1118
1179
  check who is asking; `voltro doctor`'s authz scan still covers it.
@@ -1172,6 +1233,7 @@ a mutation do:
1172
1233
  ```ts
1173
1234
  export const syncIssue = defineAction({
1174
1235
  name: 'jira.syncIssue',
1236
+ guards: [{ scope: 'jira:sync' }],
1175
1237
  input: Schema.Struct({ key: Schema.String }),
1176
1238
  output: Schema.Struct({ ok: Schema.Boolean }),
1177
1239
  source: 'jiraIssueCache',
@@ -1195,6 +1257,7 @@ import { Schema } from 'effect'
1195
1257
 
1196
1258
  export const pingExternal = defineAction({
1197
1259
  name: 'support.ping',
1260
+ guards: [{ scope: 'support:diagnostics' }],
1198
1261
  input: Schema.Struct({ url: Schema.String }),
1199
1262
  output: Schema.Struct({
1200
1263
  status: Schema.Number,
@@ -1203,6 +1266,15 @@ export const pingExternal = defineAction({
1203
1266
  })
1204
1267
  ```
1205
1268
 
1269
+ > **`guards:` is what makes this file boot** — a wire-exposed action must declare
1270
+ > exactly one of `guards:`, `openAccess: '<reason>'` or `internal: true`, or the
1271
+ > boot refuses it, naming the file. This one earns a real scope rather than
1272
+ > `openAccess`: it fetches a **caller-supplied URL** from your server. The
1273
+ > [SSRF guard](/docs/security/overview#outbound-http-is-ssrf-guarded-by-default)
1274
+ > keeps that off your internal network, but "anyone on the internet may make this
1275
+ > server issue requests" is still not a claim to make by accident. Full rules:
1276
+ > [Authorization](/docs/authentication/authorization#every-procedure-decides-guards-or-openaccess).
1277
+
1206
1278
  Server executor:
1207
1279
 
1208
1280
  ```ts
@@ -1461,6 +1533,7 @@ import { Schema } from 'effect'
1461
1533
  export const listNotes = defineQuery({
1462
1534
  name: 'notes.list',
1463
1535
  source: 'notes',
1536
+ guards: [{ scope: 'notes:read' }], // re-checked on every delivery, not just at open
1464
1537
  input: Schema.Struct({ archived: Schema.Boolean }),
1465
1538
  output: Schema.Array(Schema.Struct({
1466
1539
  id: Schema.String,
@@ -1471,6 +1544,84 @@ export const listNotes = defineQuery({
1471
1544
 
1472
1545
  `source` declares which table re-runs computed queries and lets mutations with matching `target` metadata patch the client cache optimistically.
1473
1546
 
1547
+ ## Reactivity channels
1548
+
1549
+ `source:` usually names a table. It does not have to, and when the state a query
1550
+ reads is **not in the database**, naming one is the wrong answer.
1551
+
1552
+ ```ts
1553
+ import { defineQuery, reactivityChannel } from '@voltro/protocol'
1554
+ import { Schema } from 'effect'
1555
+
1556
+ // Declared once, in a browser-safe module both sides import.
1557
+ export const jobQueue = reactivityChannel('job-queue')
1558
+
1559
+ export const queueDepth = defineQuery({
1560
+ name: 'jobs.depth',
1561
+ source: jobQueue, // ← a channel, not a table
1562
+ input: Schema.Struct({}),
1563
+ output: Schema.Struct({ pending: Schema.Number }),
1564
+ })
1565
+ ```
1566
+
1567
+ Then push it from wherever the state actually changes:
1568
+
1569
+ ```ts
1570
+ import { publishReactivity } from '@voltro/protocol'
1571
+
1572
+ publishReactivity(ctx.store, jobQueue) // every subscriber re-runs its executor
1573
+ ```
1574
+
1575
+ Everything else is unchanged: the executor returns a plain value, the framework
1576
+ re-runs it and pushes the result over the same subscription transport a
1577
+ table-backed reactive query uses. There is no second client concept and no
1578
+ second push mechanism — `useSubscription` does not know the difference.
1579
+
1580
+ ### Why not just declare a table
1581
+
1582
+ Because the alternatives are worse, and the framework shipped one of them for a
1583
+ release. Presence held its roster in memory and still declared a
1584
+ `_voltro_presence` table it never wrote a row to, purely to own a name the
1585
+ reactivity layer would route on — an empty table in every user's database,
1586
+ created by every migration and diffed on every boot.
1587
+
1588
+ The other tempting option is to point `source:` at a name that resolves to
1589
+ nothing. That is worse than the empty table: the [stale-`source` boot
1590
+ warning](#fan-out--how-many-subscribers-may-one-change-wake) is the only signal
1591
+ for a subscription that has gone permanently quiet, and an exemption for a name
1592
+ you invented disables it for the one case it was built for.
1593
+
1594
+ ### Pass the channel, not its key
1595
+
1596
+ A channel's routing key is `channel:<name>`, and you can read it off
1597
+ `jobQueue.key`. **Do not write that string into `source:`.** Passing the object
1598
+ creates an import edge from the query to the declaration, which removes the
1599
+ entire class of stale-source bugs for channels: a table `source:` is a string,
1600
+ so a rename leaves the old one behind and `tsc` cannot see it, while a channel
1601
+ that is not imported does not exist to be named. The boot audit reports a
1602
+ `channel:` key nothing declared, for the two ways round it.
1603
+
1604
+ Names are lowercase kebab segments separated by dots — `presence`, `job-queue`,
1605
+ `billing.usage`. A `:` is refused (it is the namespace separator) and so is an
1606
+ uppercase letter (a key that differs only by case reads as one channel and
1607
+ routes as two). Declaring the same name twice returns the same channel.
1608
+
1609
+ ### What a channel is not
1610
+
1611
+ - **Not an event.** [`defineEvent`](/docs/data/subscriptions#streams-are-different)
1612
+ carries a PAYLOAD to subscribers with replay, ordering and gap detection. A
1613
+ channel carries nothing — it says "re-read", and the subscriber's own executor
1614
+ decides what that means. Reach for an event when the message matters; a channel
1615
+ when the state does.
1616
+ - **Not cross-replica.** `publishReactivity` wakes subscribers on the node that
1617
+ called it. Fanning a change out to other replicas is
1618
+ [`@voltro/plugin-broadcast`](/docs/plugins/broadcast)'s job, exactly as it is
1619
+ for table changes on a dialect without CDC.
1620
+ - **Not free per subscriber.** A publish wakes every subscriber of that channel
1621
+ and re-runs each one's executor; the channel is one routing key, so
1622
+ subscribers looking at different slices of the state are woken too. Publish on
1623
+ a real change, not on a timer — see [Fan-out](#fan-out--how-many-subscribers-may-one-change-wake).
1624
+
1474
1625
  ## Query Executor
1475
1626
 
1476
1627
  ```ts
@@ -1628,7 +1779,50 @@ Queries/subscriptions are for live state. Streams are for one-shot element flows
1628
1779
 
1629
1780
  ## Reconnect
1630
1781
 
1631
- On reconnect, the client re-subscribes to active queries and receives a fresh snapshot. Optimistic patches are client-local and are reverted when their mutation settles.
1782
+ A dropped WebSocket rebuilds the whole client stack new socket, new RPC
1783
+ client, new subscription cache — and re-subscribes every active query, each of
1784
+ which answers with a fresh snapshot.
1785
+
1786
+ **What is on screen while that happens is your last-known-good data, not a
1787
+ skeleton.** The replacement cache is seeded from the one it retires, so `data`
1788
+ keeps its previous value and `loading` stays `false` across the gap; the first
1789
+ snapshot on the new stream replaces the stale rows. There is nothing to opt
1790
+ into:
1791
+
1792
+ ```tsx
1793
+ const { data, loading } = useSubscription('app', 'notes.list', {})
1794
+ if (loading) return <Skeleton/> // does NOT fire on a reconnect
1795
+ return <NoteList notes={data}/>
1796
+ ```
1797
+
1798
+ Use [`useConnectionStatus`](/docs/ui/client-utilities/use-connection-status) if
1799
+ you want to tell the user the rows may be a few seconds old — the data itself
1800
+ never disappears from under them.
1801
+
1802
+ Three things are deliberately NOT carried across:
1803
+
1804
+ - **Optimistic patches.** They are client-local and belong to mutations that
1805
+ died with the old connection, so nothing could ever retract them. They are
1806
+ reverted when their mutation settles.
1807
+ - **A cold-start error.** The new connection re-establishes the truth.
1808
+ - **Entries nothing re-subscribes to.** A screen that unmounted during the
1809
+ reconnect does not pin its rows; the seed evicts on the normal inactive TTL.
1810
+
1811
+ ### An auth change still blanks — on purpose
1812
+
1813
+ When the rebuild happens because the connection's *subject* changed — a cookie
1814
+ login, a logout, a tenant switch, i.e. `useReconnect()` — **nothing** is carried
1815
+ over and the screen does go back to its loading state.
1816
+
1817
+ That is not a gap in the feature, it is the point of the gate. The next subject
1818
+ may be entitled to strictly less than the previous one, so painting the previous
1819
+ subject's rows into their session, even for the moment before the first snapshot
1820
+ lands, would be a data exposure. The same rule applies to
1821
+ [`useRefreshSubscriptions`](/docs/ui/client-utilities/use-refresh-subscriptions),
1822
+ which clears each entry's data on the same-socket re-auth path.
1823
+
1824
+ The short version: **a dropped connection keeps your screen, a change of
1825
+ identity clears it.**
1632
1826
 
1633
1827
  ## Tenant Scoping
1634
1828
 
@@ -1670,6 +1864,82 @@ a bigger machine.
1670
1864
  These numbers are asserted by `rowPatch.perf.test.ts`, so they are current
1671
1865
  rather than a note somebody wrote down once.
1672
1866
 
1867
+ ## Fan-out — how many subscribers may one change wake?
1868
+
1869
+ A change wakes every subscription that reads the changed table, and the framework
1870
+ already collapses the work they share: one READ per distinct query, one DIFF per
1871
+ distinct `(query, base)`, one no-op comparison per distinct `(query, base)`. Fifty
1872
+ screens on one query cost one of each, not fifty.
1873
+
1874
+ What does NOT collapse is what is genuinely per subscriber: re-running the query's
1875
+ `guards:` and re-resolving row-level visibility. Those are re-run for every
1876
+ subscriber on every delivery, on purpose — a role revoked or a share withdrawn has
1877
+ to end the stream on the very NEXT delivery, not whenever a cache happens to
1878
+ expire — and each of them can be a database round-trip.
1879
+
1880
+ So deliveries run **concurrently, up to a bound**. The default is 8 in flight.
1881
+ Measured with 50 subscribers behind a 5 ms guard: 517 ms to serve all of them
1882
+ serially, 72 ms at 8 lanes.
1883
+
1884
+ Set it with `VOLTRO_REACTIVE_DELIVERY_CONCURRENCY`:
1885
+
1886
+ ```bash
1887
+ VOLTRO_REACTIVE_DELIVERY_CONCURRENCY=16 voltro serve
1888
+ ```
1889
+
1890
+ Raise it when your guards or row filters hit the database and you have pool
1891
+ headroom; set it to `1` for strictly one-at-a-time delivery. A value that is not
1892
+ a positive integer is ignored rather than honoured — a concurrency of `0` is a
1893
+ fan-out that delivers to nobody, and that is reachable through a typo in a values
1894
+ file. Unbounded is deliberately not an option: one round-trip per subscriber at
1895
+ the same instant starves the connection pool the request path shares, which is
1896
+ slower than serial.
1897
+
1898
+ Per-subscriber ordering is unaffected: a change touches each subscription exactly
1899
+ once. Order BETWEEN subscribers was never guaranteed.
1900
+
1901
+ ### How many subscribers fit on one node?
1902
+
1903
+ There is a number, it is not a constant, and which number you get depends on a
1904
+ property of your **queries** rather than of your scale. Re-derive it on your own
1905
+ hardware with `node packages/runtime/scripts/fanout-ceiling.mjs`; the figures
1906
+ below are the spread across three runs on a busy developer machine at 10 matched
1907
+ writes per second, against a budget of 100 ms of event-loop time per second (10%
1908
+ of one core).
1909
+
1910
+ | Subscriber population | Marginal CPU per subscriber | Subscribers per node |
1911
+ | --- | --- | --- |
1912
+ | **Shared** — N clients on the SAME query (a leaderboard, a shared board) | 0.5–0.9 µs | ≈ 11 000–20 000 |
1913
+ | **Distinct** — N clients each on their OWN query (`where userId = me`) | 22–29 µs | ≈ 350–450 |
1914
+
1915
+ Ranges rather than single numbers, deliberately: that is the spread three runs
1916
+ produced, and a ceiling quoted to three significant figures from one run is a
1917
+ number somebody will hold you to.
1918
+
1919
+ The shared case is cheap because the memoisation above applies: one read, one
1920
+ diff, N emits. The distinct case gets no sharing at all — the read, the diff and
1921
+ the emit are all per subscriber — and a per-user dashboard is exactly that shape.
1922
+ **Plan against the distinct number**, and note that it scales inversely with your
1923
+ write rate: at 1 matched write per second it is ten times higher.
1924
+
1925
+ Two things that are easy to assume and are not true:
1926
+
1927
+ - **A more selective `where` buys no headroom.** Measured: 200 of 200
1928
+ subscribers whose predicate matched *nothing* were still woken by one write on
1929
+ their table. Every subscription is a dependent of its own table, so a change
1930
+ wakes all of them and each re-queries. The ceiling counts subscribers **on the
1931
+ table**, not subscribers whose predicate matches.
1932
+ - **It is not 512.** That constant bounds `onChange` LISTENERS — one per declared
1933
+ subscription file, reaction or aggregate, bound once at boot. Every client
1934
+ subscription in a process shares the dispatcher's single listener, so ten
1935
+ thousand of them move that count by zero.
1936
+
1937
+ Past the ceiling the lever is horizontal: more nodes, each carrying fewer
1938
+ subscribers. Change fan-out is already fleet-wide on postgres (LISTEN/NOTIFY) and
1939
+ mysql/mariadb (binlog), so a second node needs no extra wiring — the cost being
1940
+ budgeted here is the matcher and re-query CPU each node spends on ITS OWN
1941
+ clients.
1942
+
1673
1943
  ## See also
1674
1944
 
1675
1945
  - [Subscribers (`*.subscribe.ts`)](/docs/data/subscribers) — server-side, best-effort post-commit reactivity to a table (NOT the client hook on this page).
@@ -1707,6 +1977,8 @@ import { Schema } from 'effect'
1707
1977
 
1708
1978
  export const ticker = defineStream({
1709
1979
  name: 'ticker.watch',
1980
+ openAccess: 'public market prices from an upstream feed — reads no table and '
1981
+ + 'nothing derived from the caller',
1710
1982
  input: Schema.Struct({ symbol: Schema.String }),
1711
1983
  element: Schema.Struct({
1712
1984
  price: Schema.Number,
@@ -1715,6 +1987,15 @@ export const ticker = defineStream({
1715
1987
  })
1716
1988
  ```
1717
1989
 
1990
+ > **The access decision is what makes this file boot.** A wire-exposed stream
1991
+ > must declare exactly one of `guards:`, `openAccess: '<reason>'` or
1992
+ > `internal: true`, or the boot refuses it, naming the file. This one is
1993
+ > genuinely open, so it says so — and the reason is the point: `openAccess`
1994
+ > takes a sentence, not a boolean, because a reviewer has to be able to check
1995
+ > the claim. A stream carrying *your* rows (an import log, an export feed) wants
1996
+ > `guards:` instead. Full rules:
1997
+ > [Authorization](/docs/authentication/authorization#every-procedure-decides-guards-or-openaccess).
1998
+
1718
1999
  Server executor:
1719
2000
 
1720
2001
  ```ts
@@ -2438,6 +2719,7 @@ export default defineQuery({
2438
2719
  - **Relations** need nothing extra: eager loading is resolved server-side by the executor, so `include` works identically over HTTP.
2439
2720
  - **Authorization is the same code** as the WebSocket path — the declarative `guards:`, the row filter, and tenant scoping all run before the handler. A procedure that denies on the socket denies here.
2440
2721
  - Also available per endpoint: `scopes` (extra API-key scopes), `rateLimit`, `idempotent`.
2722
+ - **`idempotency:` covers these routes too**, on the same terms as a hand-written `restRoutes` entry above: one binding, one `Idempotency-Key` header, one `_voltro_idempotency` table, and identical behaviour under `voltro dev` and `voltro serve`. Projected routes and hand-written ones go through the same single projection, so it is not possible for one to deduplicate and the other not to.
2441
2723
 
2442
2724
  This pairs with [`crud.list`](/docs/data/crud): `filter` / `paginate` / `sort` / `include` on the executor plus `publicApi: {}` on the descriptor is a complete, filtered, paginated REST list endpoint in one declaration.
2443
2725
 
@@ -3004,6 +3286,8 @@ import { Schema } from 'effect'
3004
3286
 
3005
3287
  export default defineQuery({
3006
3288
  name: 'accounts.list',
3289
+ // the authorization lives here, not in the helper — crud.* adds no guard
3290
+ guards: [{ scope: 'accounts:read' }],
3007
3291
  input: Schema.Struct({}),
3008
3292
  // note: the wire output OMITS apiSecret, so it never reaches the client
3009
3293
  output: Schema.Array(Schema.Struct({ id: Schema.String, name: Schema.String })),
@@ -3014,6 +3298,8 @@ export default defineQuery({
3014
3298
 
3015
3299
  - **Tenant scope.** `crud.list` and `crud.getById` read through `ctx.store`, which auto-scopes a `tenant()` table. They never call `.unscoped()`, so a cross-tenant read is impossible through them — `payslips.list` cannot return another tenant's rows.
3016
3300
  - **Redaction.** A column a generated read must never ship — a credential, a token hash, a salary — is stripped from every returned row (reads and the row a `create` / `update` echoes). Two sources: a column marked [`.serverOnly()`](/docs/database/sensitivity) is stripped **automatically** (declare the exposure policy once at the schema and every crud read respects it — the single-source form), plus the per-call `redact: [...]` option for anything not worth a schema marker. Declare the same omission in the descriptor's `output` schema so the column never reaches the client at all; the helper is the runtime guarantee that it doesn't, whatever the schema says.
3301
+ - **No mass assignment of a `.serverOnly()` column.** The same marker is enforced on the **input** side: `crud.create` and `crud.update` **refuse** a payload that sets one, with `ServerOnlyColumnWrite` naming the offending columns, and nothing is written. `.serverOnly()` means *never crosses the wire*, so accepting it inbound is the same violation as leaking it, mirrored — a client could SET a column it is not allowed to READ. It is a refusal rather than a silent strip because a stripped field makes an attack look like a no-op and leaves an honest caller debugging a value that quietly did not land. A key present with the value `undefined` does not count as sent, so optional schema fields are unaffected. When the *server* legitimately needs to write one, do it from the handler with `ctx.store.insert` / `ctx.store.update` — those are unchanged; the refusal is on the generated path, which is the one fed straight from client input.
3302
+ - **Keyed writes stay inside the caller's tenant.** `crud.update` and `crud.remove` address the row by `input.id`, which is client-supplied. On a [`tenant()`](/docs/multi-tenancy/mixin) table the store resolves that id inside `subject.tenantId`, so another tenant's id fails with `TenantRowNotFound` instead of writing. The error is the same whether the row is missing or foreign, on purpose — the pair would otherwise be a cross-tenant existence oracle.
3017
3303
  - **`getById` returns `null`, never throws.** A reactive getter that throws takes its shared-WebSocket siblings down with it. `crud.getById` resolves `null` for an absent row.
3018
3304
 
3019
3305
  ## The helpers
@@ -3022,8 +3308,8 @@ export default defineQuery({
3022
3308
  |---|---|
3023
3309
  | `crud.list(table, { redact? })` | tenant-scoped list of every row, redacted |
3024
3310
  | `crud.getById(table, { redact? })` | one row by `input.id`, or `null` — redacted |
3025
- | `crud.create(table, { redact? })` | insert `input`; id/tenant/audit auto-stamped; echoes the redacted row |
3026
- | `crud.update(table, { redact? })` | patch `{ id, ...patch }`; returns the updated row or `null` |
3311
+ | `crud.create(table, { redact? })` | insert `input`; id/tenant/audit auto-stamped; echoes the redacted row; refuses a `.serverOnly()` field in the input |
3312
+ | `crud.update(table, { redact? })` | patch `{ id, ...patch }`; returns the updated row or `null`; tenant-resolved by id; refuses a `.serverOnly()` field in the patch |
3027
3313
  | `crud.remove(table)` | delete `input.id`; returns `{ deleted }` |
3028
3314
  | `crud.count(table, { filter? })` | `COUNT(*)` of the filtered, tenant-scoped set — the total for page-based UIs |
3029
3315
 
@@ -3428,6 +3714,223 @@ into the agent's prompt.
3428
3714
 
3429
3715
 
3430
3716
 
3717
+ ---
3718
+
3719
+ <!-- source: en/data/approvals.md -->
3720
+ ## Approvals
3721
+
3722
+ _requiresApproval — a mutation or action that needs a second human before it takes effect, with the pending intent in a durable row and self-approval refused._
3723
+
3724
+ A mutation can declare that **one person is not enough**:
3725
+
3726
+ ```ts
3727
+ // apps/api/mutations/invoices.refund.mutation.ts
3728
+ import { defineMutation } from '@voltro/protocol'
3729
+ import { Schema } from 'effect'
3730
+
3731
+ export const refundInvoice = defineMutation({
3732
+ name: 'invoices.refund',
3733
+ input: Schema.Struct({ invoiceId: Schema.String, amountCents: Schema.Number }),
3734
+ output: Schema.Struct({ ok: Schema.Boolean }),
3735
+ // The requester still has to be allowed to ASK.
3736
+ guards: [{ scope: 'invoices:refund' }],
3737
+ requiresApproval: {
3738
+ approvers: [{ scope: 'invoices:approve' }],
3739
+ expiresIn: '4h',
3740
+ reason: 'refunds move money out of the account',
3741
+ },
3742
+ })
3743
+ ```
3744
+
3745
+ That is the whole declaration. The framework does the rest: the first call is
3746
+ recorded and refused, a second human decides, and the identical call then
3747
+ succeeds exactly once.
3748
+
3749
+ Human-in-the-loop already existed *inside* a durable workflow
3750
+ ([`awaitSignal`](/docs/workflows/definition), AI-Flows' human step). This is the
3751
+ same idea for an **ordinary rpc call** — no workflow around it, no status column
3752
+ to hand-roll, and the "who may approve" rule expressed in the same `guards:`
3753
+ vocabulary as everything else.
3754
+
3755
+ ## What the caller sees
3756
+
3757
+ The first call does **not** run. It fails with a typed `ApprovalRequired`:
3758
+
3759
+ ```ts
3760
+ import { errorTag } from '@voltro/protocol'
3761
+
3762
+ const result = await refund({ invoiceId, amountCents })
3763
+ // throws:
3764
+ // {
3765
+ // _tag: 'ApprovalRequired',
3766
+ // approvalId: 'apv_01j…',
3767
+ // procedure: 'invoices.refund',
3768
+ // expiresAt: '2026-08-12T14:00:00.000Z',
3769
+ // requiredScopes: ['invoices:approve'],
3770
+ // reason: 'refunds move money out of the account',
3771
+ // created: true, // false when an earlier identical call already asked
3772
+ // }
3773
+ ```
3774
+
3775
+ It is a typed failure rather than a success with a status field on purpose: a
3776
+ mutation that returned its normal output shape when nothing happened is the
3777
+ easiest thing in the world for a client to mis-handle, and every client already
3778
+ branches on `_tag`.
3779
+
3780
+ For a mutation the **transaction never opens**. For an action the executor's
3781
+ external I/O never happens — which is the only point at which nothing has
3782
+ happened yet, since there is no rollback for an outbound HTTP call.
3783
+
3784
+ ## The two built-in procedures
3785
+
3786
+ `__voltro.approvals.pending` is a reactive query over the approvals table, so
3787
+ both sides of the exchange are live with no polling:
3788
+
3789
+ ```tsx
3790
+ import { useSubscription, useMutation } from '@voltro/client'
3791
+ import type { PendingApproval } from '@voltro/protocol'
3792
+
3793
+ export const ApprovalQueue = () => {
3794
+ const { data } = useSubscription<ReadonlyArray<PendingApproval>>(
3795
+ 'app', '__voltro.approvals.pending', {},
3796
+ )
3797
+ const decide = useMutation('app', '__voltro.approvals.decide')
3798
+
3799
+ return (
3800
+ <ul>
3801
+ {(data ?? []).map((a) => (
3802
+ <li key={a.id}>
3803
+ {a.procedure} — {a.relation === 'to-decide' ? 'awaiting you' : `you asked · ${a.status}`}
3804
+ {a.relation === 'to-decide' && (
3805
+ <button onClick={() => decide.mutate({ approvalId: a.id, decision: 'approve' })}>
3806
+ Approve
3807
+ </button>
3808
+ )}
3809
+ </li>
3810
+ ))}
3811
+ </ul>
3812
+ )
3813
+ }
3814
+ ```
3815
+
3816
+ Each row carries a `relation`: `'to-decide'` (you may act on it) or
3817
+ `'requested'` (you asked for it). The requester watches their own row flip
3818
+ `pending → approved` and re-fires the mutation; the approver's queue appears
3819
+ without a refresh.
3820
+
3821
+ The feed is **scoped in the handler, not by a descriptor guard** — a row appears
3822
+ only if you requested it or satisfy its recorded approver scopes, so an
3823
+ anonymous caller sees an empty list. There is no scope that means "may see my own
3824
+ approval work", and inventing one would be a guard that reads as protection and
3825
+ enforces nothing.
3826
+
3827
+ ## Where the pending intent lives, and why its identity matters
3828
+
3829
+ Between the request and the decision the intent is a row in `_voltro_approvals`
3830
+ — durable, so it survives a restart, a rolling deploy and a replica switch.
3831
+
3832
+ Its identity is **content-addressed**: a digest over the procedure, the
3833
+ requester, the tenant, the canonicalised input, and an optional caller `nonce`.
3834
+ Both directions of getting that wrong are real bugs:
3835
+
3836
+ - too **coarse** (keying on the procedure, say) and two different pending
3837
+ intents share one row, so approving one executes the other's payload;
3838
+ - too **fine** (a fresh id per attempt) and every page refresh, client re-send or
3839
+ transaction replay mints a second approval, asking the human twice for one
3840
+ decision.
3841
+
3842
+ Content-addressing is the only spelling that is stable across a retry and
3843
+ distinct across intents. A `UNIQUE` on that key enforces **at most one live
3844
+ intent per content**. Two deliberately identical requests — the same user really
3845
+ does want to refund the same invoice twice — are expressible by passing a
3846
+ different `nonce`, which is a decision you state rather than one the framework
3847
+ guesses.
3848
+
3849
+ An approval is **consumed** when it admits a call. A replay after that is a new
3850
+ request, not a free second execution.
3851
+
3852
+ ## The refusals
3853
+
3854
+ These are the point of the feature, so they are worth reading as a list.
3855
+
3856
+ **Self-approval is refused, unconditionally.** There is no opt-out flag. The
3857
+ whole content of "a second human" is that it is a second one, and a framework
3858
+ that shipped `allowSelfApproval: true` would be shipping a control every team
3859
+ turns off under deadline pressure — with the audit row still reading "approved".
3860
+
3861
+ The check runs **before** the authority check, deliberately: a requester who
3862
+ happens to hold the approver scope is told they cannot approve their own request,
3863
+ which is the accurate reason, instead of being quietly let through.
3864
+
3865
+ **An unauthorised approver** gets `ApprovalForbidden`, naming the scope they
3866
+ lack. The check uses the same guard evaluator the dispatch spine runs, against
3867
+ the scopes recorded **on the row** — including a resource-scoped guard's resolved
3868
+ resource id, so `approvers: [{ scope: 'invoices:approve', resource: (i) => i.invoiceId }]`
3869
+ stays scoped to that invoice at decision time rather than widening into a global
3870
+ scope check.
3871
+
3872
+ **An anonymous decider** is refused. Every anonymous caller compares equal to
3873
+ every other, so the identity the control rests on does not exist. For the same
3874
+ reason, `requiresApproval` combined with `openAccess:` is refused at declaration.
3875
+
3876
+ **Expiry fails closed.** Past `expiresAt` the intent can be neither approved nor
3877
+ executed — including an intent that was approved and then aged out before the
3878
+ requester came back. The requester re-submits and a fresh decision is asked for.
3879
+
3880
+ **One intent, one verdict.** A second decision on the same intent gets
3881
+ `ApprovalNotPending`.
3882
+
3883
+ A rejection is reported to the requester once, as `ApprovalRejected`, on their
3884
+ next attempt. A further attempt after that opens a genuinely new decision — a
3885
+ rejection is a verdict on one request, not a permanent ban on the operation.
3886
+
3887
+ ## Declaration-time refusals
3888
+
3889
+ Two shapes read like a control and enforce nothing, so they throw where you can
3890
+ still see both fields:
3891
+
3892
+ ```ts
3893
+ // ✗ nobody can approve this — every call would park forever
3894
+ requiresApproval: { approvers: [] }
3895
+
3896
+ // ✗ an unauthenticated requester has no identity, so the self-approval
3897
+ // refusal cannot compare anything and the control degrades to nothing
3898
+ openAccess: 'public', requiresApproval: { approvers: [{ scope: 'x' }] }
3899
+ ```
3900
+
3901
+ ## Expiry as a tunable
3902
+
3903
+ Precedence: the descriptor's own `expiresIn`, then the app default, then the
3904
+ environment, then 24 hours.
3905
+
3906
+ ```ts
3907
+ // app.config.ts
3908
+ export default {
3909
+ approvals: { expiresIn: '4h' },
3910
+ }
3911
+ ```
3912
+
3913
+ `VOLTRO_APPROVAL_EXPIRY_HOURS` overrides the built-in default. There is
3914
+ deliberately no "never expires": an approval queue with no floor is a list of
3915
+ decisions nobody made.
3916
+
3917
+ ## Agent tools
3918
+
3919
+ `exposeAsTool: { confirm: true }` used to be a report — the inventory showed it
3920
+ and nothing enforced it. It is now backed by this primitive: a confirm tool is
3921
+ mountable only if its descriptor also declares `requiresApproval`, and then the
3922
+ agent's call parks in your approval queue and returns `ApprovalRequired` to the
3923
+ model. See [Agent tools](/docs/ai/tools).
3924
+
3925
+ ## The table
3926
+
3927
+ `_voltro_approvals` is created for every app (it is one small table, and the
3928
+ alternative would be a surprise `CREATE TABLE` on the production boot after
3929
+ somebody adds `requiresApproval:` to a mutation). It is bounded by the retention
3930
+ sweep — 30 days by default, `VOLTRO_APPROVALS_TTL_HOURS` to change it.
3931
+
3932
+
3933
+
3431
3934
  ---
3432
3935
 
3433
3936
  <!-- source: en/data/client-state.md -->
@@ -3698,6 +4201,20 @@ enqueued by another replica, and rows waiting out a backoff. Without it the
3698
4201
  guarantee degrades to "delivered unless something went wrong" — which is the
3699
4202
  exact case a durable outbox exists for.
3700
4203
 
4204
+ **The 5 seconds is a floor, not a rate.** Those three reasons are taken one at a
4205
+ time, and only the last needs a clock:
4206
+
4207
+ | the poll exists for… | what brings the loop back |
4208
+ | --- | --- |
4209
+ | a nudge lost to a dead process | the first pass at boot, which is not deferred behind a tick |
4210
+ | a row another replica enqueued | a change event on `_voltro_outbox` |
4211
+ | a row waiting out a backoff | the runner arms for that row's own `nextAttemptAt` |
4212
+
4213
+ So on an empty queue the timer **stops entirely** and the loop waits to be woken.
4214
+ Where no change channel is available it keeps the fixed 5-second tick instead —
4215
+ the poll is then the only thing that can notice another replica's row, and a
4216
+ durable outbox that stops looking is worse than one that polls.
4217
+
3701
4218
  ## Options
3702
4219
 
3703
4220
  ```ts
@@ -3989,6 +4506,7 @@ Wire them into a mutation / action / query via the `error` field:
3989
4506
  ```ts
3990
4507
  export const transfer = defineMutation({
3991
4508
  name: 'wallet.transfer',
4509
+ guards: [{ scope: 'wallet:transfer' }],
3992
4510
  input: Schema.Struct({ to: Schema.String, amount: Schema.Number }),
3993
4511
  output: Schema.Struct({ txId: Schema.String }),
3994
4512
  error: Schema.Union(InsufficientFunds, TitleTooLong),
@@ -4020,6 +4538,25 @@ if (result._tag === 'InsufficientFunds') {
4020
4538
  }
4021
4539
  ```
4022
4540
 
4541
+ ### What a rejected write actually rejects with
4542
+
4543
+ **The value is the typed error itself** — the same value `useSubscription` reports on the read side. Not a wrapper around it, and not an Effect `FiberFailure` (whose `_tag` would be `undefined`, so every branch above would silently fall through to the generic one).
4544
+
4545
+ That guarantee covers every write hook, not just `mutate`:
4546
+
4547
+ | Hook | Rejecting call |
4548
+ |---|---|
4549
+ | `useMutation` | `mutate(input)` |
4550
+ | `useAction` | `run(input)` |
4551
+ | `useWorkflow` | `start` · `cancel` · `resume` · `signal` · `update` |
4552
+ | `useWorkflowSignal` / `useWorkflowUpdate` | `signal` · `update` |
4553
+ | `useUpload` | `upload` · `uploadMany` |
4554
+
4555
+ Two consequences worth knowing:
4556
+
4557
+ - A **defect** (an undeclared `throw` server-side, a transport drop) rejects too, but with a plain `Error` — so an `_tag` check on it is `undefined` and falls to your generic branch, which is the intended split. Check `_tag` for the outcomes you declared; treat everything else as unexpected.
4558
+ - Passing `onError` (or `notify.error`) instead **resolves** with `undefined` and hands that same typed value to your handler. See [Mutations](/docs/data/mutations).
4559
+
4023
4560
  Tagged errors:
4024
4561
 
4025
4562
  - Are wire-safe — they serialise as JSON (the rpc transport is `RpcSerialization.layerJson`) and restore on the client with the correct `_tag` + payload.
@@ -4053,6 +4590,8 @@ To distinguish "we know about this" from "this surprised us":
4053
4590
  | `ScopeError` | `@voltro/protocol` | `requireScope(subject, scope)` failed — `{ required, message }`. |
4054
4591
  | `Unauthenticated` | `@voltro/protocol` | The resolved Subject is anonymous but a signed-in caller was required — optional `{ reason }`. |
4055
4592
  | `TenantScopeViolation` | `@voltro/runtime` | A tenant-scoped `EffectStore` write had no authenticated subject. |
4593
+ | `TenantRowNotFound` | `@voltro/runtime` | A keyed-by-id write (`store.update(t, id, …)`, `delete`, `hardDelete`, `patchJson`) on a `tenant()` table found no such row **in the caller's tenant**. Raised identically whether the row is missing or belongs to another tenant — the distinction would be a cross-tenant existence oracle. |
4594
+ | `ServerOnlyColumnWrite` | `@voltro/runtime` | A `crud.create` / `crud.update` input tried to set a [`.serverOnly()`](/docs/database/sensitivity) column — `{ table, columns }`. |
4056
4595
  | `StoreOperationFailed` | `@voltro/runtime` | The underlying store operation failed (transient). |
4057
4596
  | `TableValidationFailed` | `@voltro/runtime` | A `table().validate(Schema)` row check rejected the write. |
4058
4597
  | `CacheError` | `@voltro/cache` | A cache backend op failed — `{ operation, key, cause }`. |