@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
@@ -42,13 +42,14 @@ export default {
42
42
 
43
43
  What it provides:
44
44
 
45
- - The `_voltro_billing_*` tables (customers, subscriptions, invoices, usage, flush_claims) via `extendSchema.tables`.
45
+ - The `_voltro_billing_*` tables (customers, subscriptions, invoices, usage, flush_claims, dunning_notices) via `extendSchema.tables`.
46
46
  - The `BillingService` Context.Tag — yield it in any handler.
47
47
  - The `requireEntitlement(ctx, key, cost)` in-handler quota guard + the declarative `enforce` map.
48
48
  - A webhook receiver at `POST /billing/webhook`.
49
- - The rpc routes `billing.startCheckout`, `billing.portalUrl`, `billing.subscription`, `billing.reportUsage`, `billing.changePlan`, `billing.changeSeats`.
49
+ - The rpc routes `billing.startCheckout`, `billing.portalUrl`, `billing.subscription`, `billing.entitlementStatus`, `billing.reportUsage`, `billing.changePlan`, `billing.changeSeats`.
50
50
  - **Seat-based billing**; proration and failed-payment retries are Stripe's.
51
- - The typed `BillingError` + `EntitlementExceeded` errors, merged into every procedure's wire error union.
51
+ - **Dunning** a past-due notification sequence, a grace period and a lockout, composed on Stripe's outcomes.
52
+ - The typed `BillingError`, `EntitlementExceeded` + `SubscriptionLocked` errors, merged into every procedure's wire error union.
52
53
  - A browser-safe `useStartCheckout()` hook on the `/web` subpath.
53
54
 
54
55
  ## The BillingService
@@ -68,7 +69,7 @@ export default (input: { tenantId: string }, _ctx) =>
68
69
  })
69
70
  ```
70
71
 
71
- The subscription row lives in your DB; the provider is the source of truth and webhooks keep the row in sync. `plan()` returns `'free'` when there is no active (or trialing) subscription.
72
+ The subscription row lives in your DB; the provider is the source of truth and webhooks keep the row in sync. `plan()` returns the plan whose **limits apply right now**: `'free'` with no subscription, the paid plan while active or trialing, and — because a bounced card should not downgrade a customer on the same second — the paid plan for the whole [grace period](#failed-payments--stripe-retries-dunning-composes-on-the-outcome) after a failed payment, falling back only once the lockout is real.
72
73
 
73
74
  ## Entitlement checks
74
75
 
@@ -142,6 +143,8 @@ billingPlugin({
142
143
 
143
144
  `onEvent` keys are the normalized `BillingEvent` tags (`subscriptionUpserted`, `invoicePaid`, `invoicePaymentFailed`, `customerLinked`, `subscriptionCanceled`), not raw Stripe types.
144
145
 
146
+ Every event carries an `occurredAt` (Stripe's `event.created`). Webhook delivery is at-least-once **and unordered**, so the subscription and invoice rows compare it against the timestamp of the state they already reflect and **drop anything older** — a redelivered `active` from before a decline cannot un-do the past-due, and a late `payment_failed` cannot flip a paid invoice back to `open`.
147
+
145
148
  ## Checkout + upgrade flows
146
149
 
147
150
  The `/web` subpath ships a browser-safe hook. It imports nothing from the server module — no secret, no node-only lib ever reaches the browser bundle. Pass it the generated `billing.startCheckout` rpc binding:
@@ -196,13 +199,14 @@ yield* billing.flushUsage()
196
199
 
197
200
  ## Tables
198
201
 
199
- All five are `_voltro_`-prefixed and built from the cross-dialect schema DSL (no raw SQL, no pg-only types, no `TEXT` defaults). Money is `integer` minor units + a `currency` text column:
202
+ All six are `_voltro_`-prefixed and built from the cross-dialect schema DSL (no raw SQL, no pg-only types, no `TEXT` defaults). Money is `integer` minor units + a `currency` text column:
200
203
 
201
204
  - `_voltro_billing_customers` — tenant ↔ provider customer link.
202
205
  - `_voltro_billing_subscriptions` — one subscription per tenant (plan, status, seat `quantity`, period start + end, cancel-at).
203
206
  - `_voltro_billing_invoices` — invoice history (`amountMinor` integer + `currency`).
204
207
  - `_voltro_billing_usage` — per-tenant metered counters keyed by `(tenantId, entitlementKey, period)`.
205
208
  - `_voltro_billing_flush_claims` — INSERT-wins flush-window claims (multi-instance autopilot coordination); short-lived, retention defaults to 1 hour via `VOLTRO_BILLING_FLUSH_CLAIM_TTL_HOURS`.
209
+ - `_voltro_billing_dunning_notices` — the sent-notice ledger, `UNIQUE (tenantId, episode, stepId)`. It is the **send gate**, not a report: a notice is claimed here before it goes out, so a duplicated webhook sends nothing. Retention defaults to ~400 days via `VOLTRO_BILLING_DUNNING_TTL_HOURS` — deliberately generous, because pruning a row belonging to a still-open episode would let its notice go out a second time.
206
210
 
207
211
  `_voltro_billing_usage` is append-only — one upserted counter row per `(tenant, key, period)` — so a closed period's row would otherwise live forever. The plugin registers a retention sweep on the row's `updatedAt`: a row is only touched while its window is current, so once a period closes it ages out, while the live period's row stays fresh and survives regardless. The bound defaults to ~400 days (a conservative window with headroom for end-of-period flush + back-dated reads) and is tunable via the `VOLTRO_BILLING_USAGE_TTL_HOURS` env var; the boot retention sweep drains rows past the TTL.
208
212
 
@@ -285,9 +289,9 @@ const quote = yield* billing.previewChange(tenantId, { quantity: 40 })
285
289
 
286
290
  Never quote a locally estimated number. The one Stripe previews is the one it charges.
287
291
 
288
- ## Failed payments — Stripe retries, you read the status
292
+ ## Failed payments — Stripe retries, dunning composes on the outcome
289
293
 
290
- There is no dunning subsystem here. Stripe Smart Retries runs the retry schedule (configured in the Stripe Dashboard, where it can use Stripe's own timing models) and reports the outcome as a subscription status change:
294
+ Stripe Smart Retries runs the retry schedule (configured in the Stripe Dashboard, where it can use Stripe's own timing models) and reports the outcome as a subscription status change:
291
295
 
292
296
  | From | On | To |
293
297
  | --- | --- | --- |
@@ -295,21 +299,134 @@ There is no dunning subsystem here. Stripe Smart Retries runs the retry schedule
295
299
  | `pastDue` | payment recovers | `active` |
296
300
  | `pastDue` | Stripe gives up | `canceled` |
297
301
 
298
- Those transitions arrive as `customer.subscription.updated` webhooks and land on the subscription row. **Key your UI on `subscription.status`**, not on a retry record:
302
+ The framework does not reimplement that cadence and never will a local retry schedule ran here once, on a fixed `[1,3,5,7]`-day rhythm, and drifted from Stripe's the moment the two disagreed. What the plugin adds is the part Stripe does not do for your app: a **past-due notification sequence**, a **grace period**, and a **lockout** your entitlement checks can read.
303
+
304
+ ### The grace clock is a column, not a timer
305
+
306
+ When the provider confirms a subscription is past due, the plugin stamps `pastDueSince` on the subscription row. Everything else is derived from it at read time — so there is no job to miss a tick, fire twice, or run on two replicas at once, and nothing "expires" a grace period in the background.
307
+
308
+ Two rules keep it honest, and both exist because sending an email and locking a customer out cannot be undone:
309
+
310
+ - **`pastDueSince` is only written after the provider confirms it.** A failed-payment event triggers a *reconcile* that reads the subscription's current status from Stripe (`subscriptions.retrieve`) and writes the row from that — never from the event body. Webhook delivery is at-least-once **and unordered**, so a `payment_failed` genuinely can arrive after the retry that succeeded; reconciling against the object settles it.
311
+ - **An unreachable provider never escalates.** If the reconcile cannot reach Stripe, `pastDueSince` stays unset — and without it there is no clock to expire, so the tenant stays in grace. Failing open is the only safe direction: the alternative is locking a paying customer out because your network was down.
312
+
313
+ ### Asking the truthful question
314
+
315
+ `entitlementStatus()` is the one answer to "is this tenant entitled right now". It is a pure read of the local row — no provider call — so it is safe on a hot path:
299
316
 
300
317
  ```ts
301
- const sub = yield* billing.subscription(tenantId)
302
- if (sub?.status === 'pastDue') {
303
- // Show a "update your payment method" banner + a link to the billing portal.
304
- const { url } = yield* billing.portalUrl(tenantId, returnUrl)
305
- }
318
+ import { Effect } from 'effect'
319
+ import { BillingService } from '@voltro/plugin-billing'
320
+
321
+ export default (input: { tenantId: string }, _ctx) =>
322
+ Effect.gen(function* () {
323
+ const billing = yield* BillingService
324
+ const status = yield* billing.entitlementStatus(input.tenantId)
325
+ // { plan, billedPlan, status, entitled, inGrace, graceEndsAt, lockedSince, lockout }
326
+ return status
327
+ })
306
328
  ```
307
329
 
308
- `pastDue` deliberately keeps the customer's entitlements: a bounced card is a payment problem, and locking an organisation out of its own data over a bank decline is a support incident, not enforcement. Stripe cancels when it has genuinely given up, and `canceled` is what removes access.
330
+ - `entitled: false` means **dunning has locked this tenant out**. A canceled subscription is not a lockout it is simply the free tier.
331
+ - `inGrace: true` with a `graceEndsAt` is the "your payment failed, you have until …" state. Show the banner and a billing-portal link.
332
+ - `plan` is the plan whose **limits apply right now**; `billedPlan` is what they are subscribed to. Under a hard lockout the two differ.
333
+
334
+ The same shape is available to the browser as the `billing.entitlementStatus` rpc query (timestamps as ISO strings).
335
+
336
+ For a feature with no numeric quota to degrade — an export, an admin action — guard it directly:
337
+
338
+ ```ts
339
+ import { Effect } from 'effect'
340
+ import { requireEntitled } from '@voltro/plugin-billing'
341
+
342
+ export default (input: { id: string }, ctx) =>
343
+ Effect.gen(function* () {
344
+ yield* requireEntitled(ctx) // fails SubscriptionLocked once grace expired
345
+ return { ok: true }
346
+ })
347
+ ```
348
+
349
+ `SubscriptionLocked` is a typed `Schema.TaggedError` carrying `{ tenantId, status, lockedSince, lockout }`, decoded on the client like every other framework error — so the UI can route to the billing portal instead of showing a generic failure.
350
+
351
+ ### The sequence
352
+
353
+ Steps are declared with an `afterHours` measured from `pastDueSince`, so a step cannot be pulled forward by how often the provider happens to retry. Each provider event is the tick that evaluates whichever steps have come due:
354
+
355
+ ```ts
356
+ import { billingPlugin, dunningMailNotifier } from '@voltro/plugin-billing'
357
+ import { MailService } from '@voltro/plugin-mail'
358
+
359
+ billingPlugin({
360
+ plans: { /* … */ },
361
+ dunning: {
362
+ graceHours: 168, // default: 7 days
363
+ steps: [ // default: exactly these three
364
+ { id: 'payment-failed', afterHours: 0 },
365
+ { id: 'reminder', afterHours: 72 },
366
+ { id: 'final-warning', afterHours: 144 },
367
+ ],
368
+ lockout: 'hard', // default | 'soft'
369
+ portalReturnUrl: 'https://acme.com/billing',
370
+ notify: (notice) => Effect.sync(() => { /* your transport */ }),
371
+ },
372
+ })
373
+ ```
374
+
375
+ Each notice carries `{ stepId, tenantId, to, plan, status, pastDueSince, graceEndsAt, locked, lockout, portalUrl }` **plus** ready-to-send default `subject` / `html` / `text` — plain and unbranded, so the sequence works the moment `notify` is wired without inviting you to ship it unchanged.
376
+
377
+ **With no `notify`, nothing is sent.** The sequence claims and logs. That is the deliberate default: the framework cannot address a customer on your behalf, and an email is irreversible.
378
+
379
+ To send with `@voltro/plugin-mail`, bridge it — the plugin types the mail service structurally, so it takes on no dependency:
380
+
381
+ ```ts
382
+ const mail = yield* MailService
383
+ billingPlugin({ dunning: { notify: dunningMailNotifier(mail) } })
384
+ ```
385
+
386
+ The recipient is resolved as: your `dunning.resolveRecipient(tenantId)` first, then the provider's customer email. A notice with no resolvable recipient still reaches `notify` (with `to: null`) so you can route it in-product.
387
+
388
+ ### Idempotency, and what happens when delivery lies
389
+
390
+ Every send is claimed in `_voltro_billing_dunning_notices` under a `UNIQUE (tenantId, episode, stepId)` **before** it goes out. The episode key IS the clock — the epoch-ms of `pastDueSince` — so:
391
+
392
+ - a **duplicated** webhook lands on a claimed key and sends nothing;
393
+ - a **recovery** clears `pastDueSince`, which cancels every step still pending for that episode — a later failure opens a genuinely new episode and legitimately starts over;
394
+ - a **stale** `customer.subscription.updated` cannot un-do a past-due, and a stale `invoice.payment_failed` cannot flip a paid invoice back to `open`: both rows carry the event's timestamp and drop anything older than the state they already reflect.
395
+
396
+ Claim-then-send is on purpose. Its failure mode is one notice that never arrives; send-then-claim's is a customer receiving the same dunning email twice.
397
+
398
+ ### The lockout
399
+
400
+ Once `graceEndsAt` passes, `entitled` turns false and one `locked` notice fires on the next reconcile.
401
+
402
+ - **`lockout: 'hard'`** (default) — entitlement limits fall to the `'free'` plan's. Every existing `requireEntitlement` / `enforce` check starts answering with the free tier's numbers; you write no new code.
403
+ - **`lockout: 'soft'`** — limits stay on the paid plan and only `entitlementStatus()` reports the lockout, so your app decides what to withhold.
404
+
405
+ Recovery at any point — including after the lockout — restores the paid entitlements on the next reconcile.
406
+
407
+ ### Tunables
408
+
409
+ Every number the framework picked on your behalf is a field with a default and an env override:
410
+
411
+ | Option | Env | Default |
412
+ | --- | --- | --- |
413
+ | `dunning.enabled` | `VOLTRO_BILLING_DUNNING` (`on` / `off`) | `true` |
414
+ | `dunning.graceHours` | `VOLTRO_BILLING_GRACE_HOURS` | `168` (7 days) |
415
+ | `dunning.steps[].afterHours` | `VOLTRO_BILLING_DUNNING_STEP_HOURS` (positional, comma-separated) | `0,72,144` |
416
+ | `dunning.lockout` | `VOLTRO_BILLING_LOCKOUT` (`hard` / `soft`) | `hard` |
417
+ | notice-ledger retention | `VOLTRO_BILLING_DUNNING_TTL_HOURS` | `9600` (~400 days) |
418
+
419
+ The env parsers **fail the boot** on a value they cannot read, and `VOLTRO_BILLING_DUNNING_STEP_HOURS` refuses a list whose length differs from the declared sequence — an operator re-timing a sequence they are not looking at is exactly the quiet wrong number this refuses to become.
420
+
421
+ With `dunning.enabled: false` there is no grace clock and no reconcile: `pastDue` degrades entitlements at once, which is what this plugin did before dunning existed.
422
+
423
+ ### The optional sweep
424
+
425
+ Provider events drive the sequence, and that covers the normal case — Stripe emits an event per retry attempt. `billing.dunningSweep()` reconciles every past-due tenant in one pass, for a step configured at an hour the provider happens not to emit an event at, or a reconcile missed during a provider outage. **Nothing schedules it for you**; wire it from your own `*.cron.tsx` if you want it. It is idempotent, it never charges anything, and it never asks the provider to retry.
309
426
 
310
427
  ## Provider portability
311
428
 
312
- The surface (`BillingService`, the entitlement engine, the DB rows) is provider-agnostic. A `BillingProvider` is a dumb adapter: checkout/portal URL minting, usage push, and a pure `normalizeEvent` mapping the provider's payload to a `BillingEvent`. Stripe and an in-memory mock ship in the box; a new provider is a new adapter against the same contract — pass it directly:
429
+ The surface (`BillingService`, the entitlement engine, the DB rows) is provider-agnostic. A `BillingProvider` is a dumb adapter: checkout/portal URL minting, usage push, a `fetchSubscription` direct read (the current status dunning refuses to lock a customer out without), and a pure `normalizeEvent` mapping the provider's payload to a `BillingEvent`. Stripe and an in-memory mock ship in the box; a new provider is a new adapter against the same contract — pass it directly:
313
430
 
314
431
  ```ts
315
432
  import { billingPlugin } from '@voltro/plugin-billing'
@@ -44,6 +44,11 @@ export default {
44
44
  maxAttempts: 5, // per-record delivery attempts before dead-letter
45
45
  backoffBaseMs: 200, // first retry delay; doubles per attempt, jittered
46
46
  deliveryTimeoutMs: 10_000, // per-attempt timeout — aborts the sink call
47
+ leaseTtlMs: 15_000, // leader lease; heartbeat renews at ttl/3
48
+ // Fleet-scope handoff (see "Delivery guarantees"):
49
+ dedupWindowMs: 60_000, // how long an enqueue claim is kept — must exceed leaseTtlMs
50
+ handoffBufferMs: 60_000, // how far back each replica buffers for a takeover to drain
51
+ handoffBufferSize: 10_000, // hard ceiling on buffered changes per replica
47
52
  }),
48
53
  ],
49
54
  }
@@ -73,12 +78,44 @@ via `extendSchema`, migrated by `voltro dev`). The row's TypeID id **is** the
73
78
  record's `deliveryKey` — unique across replicas, stable across restarts and
74
79
  retries.
75
80
 
76
- - **Exactly-once ENQUEUE, fleet-wide.** On `changeScope: 'local'` stores each
77
- replica enqueues only its OWN commits (injected cross-replica events are
78
- skipped). On `'fleet'` stores (postgres `changeStrategy: 'cdc'`
79
- LISTEN/NOTIFY, mysql binlog) only the holder of the leader lease
80
- (`_voltro_cdcout_leases`, TTL-heartbeat) enqueues. Fail closed: no lease,
81
- no enqueue.
81
+ - **Enqueue is de-duplicated per observed change, fleet-wide, across leadership
82
+ handovers with one hole: a replica that dies between winning a change's
83
+ claim and inserting its outbox row loses that change, because the claim
84
+ survives and nothing rescans orphan claims.** (Those are two statements with
85
+ no transaction around them, which is why this does not say "exactly-once".)
86
+
87
+ On `changeScope: 'local'` stores each replica enqueues only its OWN commits
88
+ (injected cross-replica events are skipped), so a change is observed by
89
+ exactly one process and needs nothing further.
90
+
91
+ On `'fleet'` stores (postgres `changeStrategy: 'cdc'` LISTEN/NOTIFY, mysql
92
+ binlog) EVERY replica sees the full stream, so every replica **buffers** it
93
+ in memory (`handoffBufferMs`, capped at `handoffBufferSize` entries, oldest
94
+ dropped first). The holder of the leader lease (`_voltro_cdcout_leases`,
95
+ TTL-heartbeat) enqueues as it goes; a replica that WINS the lease drains the
96
+ window its predecessor never got to. Enqueue is **idempotent per change
97
+ identity**: each change is keyed by a `changeKey` every replica computes
98
+ alike — a digest of `(pipe, op, row id, new image, old image)` plus an
99
+ occurrence counter that keeps two byte-identical changes to one row apart —
100
+ and claimed in `_voltro_cdcout_claims` under `unique(pipe, changeKey)`. So
101
+ the rows the dying leader already wrote collapse instead of duplicating, and
102
+ the ones it never reached are written by its successor.
103
+
104
+ A replica that boots into a fleet that is **already running** adopts the
105
+ fleet's occurrence counters from the claims already in the database before it
106
+ keys anything — otherwise its first sighting of an already-claimed change
107
+ would key occurrence 0, collide, and be dropped as a duplicate it is not.
108
+ `GET /_voltro/inspect/plugins/cdc-out/sinks` reports `handoff.seeded` and
109
+ `handoff.awaitingSeed` so you can see that happen rather than assume it.
110
+
111
+ What still bounds it, stated plainly: a change **no surviving replica
112
+ observed** is gone (the transport delivered it only to the dead process); a
113
+ handoff that takes longer than `handoffBufferMs` loses whatever aged out of
114
+ the buffer, and that count is reported as `handoff.dropped` on
115
+ `GET /_voltro/inspect/plugins/cdc-out/sinks` rather than dropped silently.
116
+ `dedupWindowMs` (default `max(60_000, 4 × leaseTtlMs)`) is how long a claim
117
+ is kept; it **must exceed `leaseTtlMs`** — a claim that expires mid-handoff
118
+ is a duplicate window, and the plugin refuses to boot with one.
82
119
  - **At-least-once FROM ENQUEUE.** The tap is post-commit — a crash in the
83
120
  narrow window between commit and the outbox insert loses that one event;
84
121
  the plugin does not claim better. From enqueue on, delivery survives
@@ -92,7 +129,9 @@ retries.
92
129
  `GET /_voltro/inspect/plugins/cdc-out/dead-letter` — and unblocks the pipe.
93
130
  - **Bounded storage.** Delivered/dead rows are purged by the framework
94
131
  retention sweep after `retentionHours` (default 72, env
95
- `CDCOUT_RETENTION_HOURS`); pending rows are never purged.
132
+ `CDCOUT_RETENTION_HOURS`); pending rows are never purged. Enqueue claims are
133
+ short-lived by design — purged after `dedupWindowMs`, which only has to
134
+ outlive a leadership handoff.
96
135
 
97
136
  ## Multi-tenancy
98
137
 
@@ -61,7 +61,7 @@ on graceful shutdown.
61
61
  | `password` | `string` | — | |
62
62
  | `database` | `string` | `'default'` | The plugin creates the events table inside it on first boot. |
63
63
  | `table` | `string` | `'events'` | Override the events-table name. |
64
- | `mirrorTables` | `ReadonlyArray<string>` | `[]` (events-only) | Reactive tables to CDC-mirror into `voltro_mirror_<table>` (a `ReplacingMergeTree`) so analytical queries JOIN events against live user data. |
64
+ | `mirrorTables` | `ReadonlyArray<string>` | `[]` (events-only) | Reactive tables to CDC-mirror into `voltro_mirror_<table>` (a `ReplacingMergeTree(version)` over `{ id, data, version, is_deleted }`) so analytical queries JOIN events against live user data. The version is the framework's commit-order stamp, so a change that arrives late loses the collapse. |
65
65
  | `mirrorPrimaryKey` | `string` | `'id'` | Primary-key column on the mirrored source rows. |
66
66
 
67
67
  `database`, `table`, and each `mirrorTables` entry are validated as SQL
@@ -53,7 +53,7 @@ connection + instance are closed on graceful shutdown.
53
53
  | Option | Type | Default | Notes |
54
54
  |---|---|---|---|
55
55
  | `path` | `string` | in-memory | A file path (`.voltro/analytics.duckdb`) → durable across restarts, single-process. Omit or pass `:memory:` → events live in process memory, lost on restart (ephemeral dev / tests). |
56
- | `mirrorTables` | `ReadonlyArray<string>` | `[]` (events-only) | Reactive tables to CDC-mirror into `voltro_mirror_<table>` inside DuckDB so analytical queries JOIN events against live user data. |
56
+ | `mirrorTables` | `ReadonlyArray<string>` | `[]` (events-only) | Reactive tables to CDC-mirror into `voltro_mirror_<table>` (`{ id, data, version, is_deleted }`) inside DuckDB so analytical queries JOIN events against live user data. Deletes write a tombstone — filter `is_deleted = false`. |
57
57
  | `mirrorPrimaryKey` | `string` | `'id'` | Primary-key column on the mirrored source rows. |
58
58
 
59
59
  ```ts
@@ -109,6 +109,138 @@ GET /_voltro/inspect/plugins/flags/audit?flag=beta # one flag's history
109
109
 
110
110
  The `POST /toggle` body accepts an optional `actor` (the acting admin id) that is stored on the audit row. On the `memory` tier there is no durable audit (the endpoint returns an empty trail with a note).
111
111
 
112
+ ## Typed flags — `defineFlag()`
113
+
114
+ A flag's VALUE had no type. `FlagVariant.value` is the `FlagVariantValue` union (`string | number | boolean | null | array | object`), so `{ name: 'big', value: 'lots' }` on a flag every reader treats as a number typechecked, and the mistake showed up at the call site as `NaN`.
115
+
116
+ `defineFlag()` gives a flag a value **Schema**, and the flag is browser-safe by construction — ONE declaration, imported by `app.config.ts` and by the component that reads it.
117
+
118
+ ```ts
119
+ // apps/api/lib/flags.ts
120
+ import { Schema } from 'effect'
121
+ import { defineFlag } from '@voltro/plugin-flags'
122
+
123
+ export const checkoutButton = defineFlag({
124
+ key: 'checkout.button',
125
+ value: Schema.Literal('blue', 'green'),
126
+ default: 'blue',
127
+ variants: [{ name: 'control', value: 'blue' }, { name: 'green', value: 'green' }],
128
+ })
129
+
130
+ export const pageSize = defineFlag({
131
+ key: 'search.pageSize',
132
+ value: Schema.Number,
133
+ default: 20,
134
+ // default: 'twenty', ← Type 'string' is not assignable to type 'number'
135
+ })
136
+ ```
137
+
138
+ Register them with `flagsPlugin({ typedFlags: [checkoutButton, pageSize] })`. Keys share one namespace with `flags: { … }`; declaring a key in both is refused at construction.
139
+
140
+ Read them typed on either side:
141
+
142
+ ```ts
143
+ import { flagValue, flagVariant } from '@voltro/plugin-flags'
144
+ const size: number = flagValue(ctx, pageSize) // server
145
+ const arm = flagVariant(ctx, checkoutButton) // the served variant NAME, or null
146
+ ```
147
+
148
+ ```tsx
149
+ import { useFlagValue } from '@voltro/plugin-flags/web'
150
+ const colour = useFlagValue(checkoutButton) // 'blue' | 'green'
151
+ ```
152
+
153
+ ### Which half a Schema reaches, precisely
154
+
155
+ **Authored** values — `default`, every `variants[].value` — are checked by `tsc`. That is the headline and it is enforced by tests that fail `typecheck` if those lines ever start compiling.
156
+
157
+ **Runtime** values cannot be. A postgres-tier override row, or a dashboard edit, is JSON long after `tsc` ran. So the same Schema is the runtime gate: an override whose variant values do not decode is **refused whole**, the code-declared definition stands, and the refusal is logged and shown in the dashboard panel. Not partially applied — dropping the one bad arm re-normalises the weights of the rest, silently reallocating every subject.
158
+
159
+ Declaration also DECODES the authored default, which catches what a type cannot: `Schema.Int`'s TypeScript type is `number`, so `default: 20.5` typechecks and is a value the flag could never legally serve.
160
+
161
+ ## Dead-flag detection
162
+
163
+ Every flag system accumulates flags nobody removes. `GET /_voltro/inspect/plugins/flags/list` carries a lifecycle report (rendered by the dashboard panel) built on two independent axes — and only some of it is a proof.
164
+
165
+ **`shape`** is decided from the DEFINITION alone. No observation, no window:
166
+
167
+ | shape | meaning |
168
+ |---|---|
169
+ | `constantOn` | `rollout` 100, no targeting, no variants, no live schedule — resolves `true` for everyone, forever |
170
+ | `constantOff` | `enabled: false`, or `rollout: 0` |
171
+ | `expired` | a `schedule.deactivateAt` that has passed — it can never be on again |
172
+ | `notYetActive` | a future `activateAt` — pending, not dead |
173
+ | `conditional` | genuinely selects between callers |
174
+
175
+ **`usage`** is decided from observed evaluations, and exactly one state is a proof:
176
+
177
+ | usage | meaning |
178
+ |---|---|
179
+ | `evaluated` | read server-side inside the threshold |
180
+ | `stale` | reads EXIST in the window and the newest is older than the threshold. **Provable** |
181
+ | `neverObserved` | no server-side read at all. Consistent with "dead" AND with "declared last Tuesday" — reported, never asserted, **never a removal candidate** |
182
+ | `untracked` | nothing is recording |
183
+
184
+ `removalCandidate` is set only by a proof: a constant/expired shape, or `stale`.
185
+
186
+ ### What it cannot see
187
+
188
+ Shipped in the payload and rendered in the panel, not buried here:
189
+
190
+ - **Reachability is not decided.** "Not evaluated since \<date>" is a measurement; "this code path is dead" is not decidable in general. A Black-Friday flag, a flag behind an admin route nobody visited this month, and a flag whose last call site was deleted are indistinguishable.
191
+ - **Only SERVER-side reads count** — `isFlagEnabled` / `requireFlag` / `flagValue` / a `gatedBy` interception. `useFlag()` in the browser reads from the bulk set the server already sent, so the key never arrives as a named read. Bulk deliveries are recorded separately and never counted as use: one `useFlags()` poll evaluates the whole registry and would otherwise mark every flag in the app alive forever.
192
+ - **The window is finite**, bounded by the observation table's retention. A flag last read BEFORE the window has no observation at all and reads `neverObserved` — exactly what a flag declared this morning reads.
193
+ - On the day you turn tracking on, nothing has been observed, so every flag is `neverObserved` and nothing is proposed for removal. It is that SPLIT that prevents the day-one "everything is dead" report, not a coverage gate on top of it: such a gate is unreachable, because the observation that dates a stale flag is itself inside the window.
194
+
195
+ ### Tunables
196
+
197
+ ```ts
198
+ flagsPlugin({
199
+ store: 'postgres',
200
+ usage: {
201
+ track: true, // default: on with store:'postgres' (there is nowhere to write on memory)
202
+ flushIntervalMs: 300_000, // the report resolves to a DAY, so a tighter interval buys nothing
203
+ staleAfterDays: 30,
204
+ retentionDays: 90, // also VOLTRO_FLAG_USAGE_TTL_HOURS; the report's observation ceiling
205
+ },
206
+ })
207
+ ```
208
+
209
+ Observations land in `_voltro_feature_flag_usage`, one row per (flag, UTC day, source), retention-swept. `track: true` on the `memory` tier is refused at construction rather than silently observing nothing, and `staleAfterDays > retentionDays` is refused because staleness could then never be proven.
210
+
211
+ ## A flag can carry an experiment
212
+
213
+ `defineExperiment` (`@voltro/runtime`) maintains standing A/B results as **live IVM aggregates recomputed per-write from CDC deltas** — real-time uplift with no batch pipeline. A flag can name one:
214
+
215
+ ```ts
216
+ export const checkoutButton = defineFlag({
217
+ key: 'checkout.button',
218
+ value: Schema.Literal('blue', 'green'),
219
+ default: 'blue',
220
+ variants: [{ name: 'control', value: 'blue' }, { name: 'green', value: 'green' }],
221
+ experiment: 'checkout-colour',
222
+ })
223
+
224
+ // apps/api/experiments/checkoutColour.experiment.ts
225
+ export default defineExperiment({
226
+ name: 'checkout-colour',
227
+ on: { table: 'orders' },
228
+ variantFrom: 'checkoutArm', // ← the arm the FLAG served
229
+ variants: [{ name: 'control' }, { name: 'green' }],
230
+ metric: { kind: 'conversionRate', column: 'completed' },
231
+ })
232
+ ```
233
+
234
+ Persist the served arm on the row you want to measure:
235
+
236
+ ```ts
237
+ await ctx.store.insert('orders', { …, checkoutArm: flagVariant(ctx, checkoutButton) })
238
+ ```
239
+
240
+ **Why `variantFrom` and not just a name.** The flag assigns by hashing FNV-1a over `${key}:variant`; an experiment in `subject` mode hashes over the EXPERIMENT name. Both are stable and uniform, and they are INDEPENDENT — roughly half the subjects served `green` land in the experiment's `control` arm. The uplift would be live, precise, and measuring a split nobody experienced. One assignment, persisted, read by the experiment.
241
+
242
+ `flagsPlugin({ typedFlags, experiments })` **refuses to construct** when the link is wrong: a missing experiment, an experiment still in `subject` mode, an experiment-side holdout (carved AT assignment, which this experiment does not do), or arm names that do not match. Each of those is otherwise a wrong number rather than an error — a name mismatch shows up as a permanently-empty arm beside a permanently-full one, which reads as "the treatment has no effect".
243
+
112
244
  ## Three ways to use a flag
113
245
 
114
246
  **1. Declarative gate** — `gatedBy: { '<rpcTag>': '<flag>' }` (exact tag or `/regex/`). An off flag fails the call with typed `FlagDisabled` before the handler runs (merged into every procedure's wire-error union → typed on the client).
@@ -29,11 +29,11 @@ export default {
29
29
  { table: 'events', ttlMs: 90 * 86_400_000 }, // delete after 90 days
30
30
  { table: 'users', ttlMs: 365 * 86_400_000, action: 'anonymize', anonymizeFields: ['email', 'name'] },
31
31
  ],
32
- // GDPR: which tables reference a subject + by which column.
32
+ // GDPR: DERIVE which tables hold a subject's data from the schema.
33
+ deriveSubjectScopes: { subjectTable: 'users' },
34
+ // …plus anything the schema cannot encode (see below).
33
35
  subjectScopes: [
34
- { table: 'users', subjectField: 'id' },
35
- { table: 'posts', subjectField: 'userId' },
36
- { table: 'comments', subjectField: 'authorId' },
36
+ { table: 'audit_trail', subjectField: 'actorRef' },
37
37
  ],
38
38
  sweepIntervalMs: 3_600_000, // default 1h
39
39
  }),
@@ -47,13 +47,111 @@ export default {
47
47
 
48
48
  ## GDPR — export + erasure (admin-gated)
49
49
 
50
- Two admin-only routes (guarded by `requireScope(ADMIN_SCOPE)`) walk `subjectScopes`:
50
+ Two admin-only routes (guarded by `requireScope(ADMIN_SCOPE)`):
51
51
 
52
- - **`governance.export`** `{ subjectId }` → a portable bundle `{ [table]: rows[] }` of everything referencing the subject.
53
- - **`governance.erase`** `{ subjectId, mode? }` → deletes (or anonymises) the subject across every scope; returns an immutable `ErasureLogEntry` (`{ subjectId, at, mode, affected: [{ table, count }] }`).
52
+ - **`governance.export`** `{ subjectId }` → a portable bundle `{ [table]: rows[] }` of everything belonging to the subject.
53
+ - **`governance.erase`** `{ subjectId, mode? }` → deletes (or anonymises) the subject across every scope; returns an immutable `ErasureLogEntry` (`{ subjectId, at, mode, affected: [{ table, count }], truncated? }`).
54
54
 
55
55
  The same operations are available in-handler via `GovernanceService` (`exportSubject` / `eraseSubject`).
56
56
 
57
+ Erasure runs **deepest-first** — children before parents — so a real foreign key
58
+ neither refuses the delete nor cascades through rows the log entry never counted.
59
+
60
+ ### The subject scope is DERIVED from your schema
61
+
62
+ A hand-written list of "every table holding this person's data" is wrong the day
63
+ after someone adds a table — and that list *is* the compliance claim. So
64
+ `deriveSubjectScopes: { subjectTable: 'users' }` walks the schema instead: the
65
+ relations registry plus the `reference()` column graph, outward from the subject.
66
+
67
+ That reaches rows a flat `{ table, subjectField }` entry cannot even express —
68
+ `users → posts → comments` is two hops, so a comment on the subject's post is in
69
+ the export without anyone listing `comments`.
70
+
71
+ **Only CHILD edges are followed** — a table holding a reference to the subject's
72
+ row. Never a parent or lookup edge, and a `manyToMany` follows the JUNCTION only.
73
+ Getting that backwards would not be an over-broad export; it would be an erasure
74
+ that walks from one member into their organisation and deletes everybody else's
75
+ rows.
76
+
77
+ | declaration | followed? |
78
+ |---|---|
79
+ | `many(posts, { foreignKey: 'authorId' })` | ✓ the target carries the FK |
80
+ | `one(profile)` with the FK on the target | ✓ a 1:1 child |
81
+ | `one(country, { sourceKey: 'countryId' })` | — a lookup this row points AT |
82
+ | `manyToMany(orgs, { through: memberships })` | ✓ `memberships` only, never `orgs` |
83
+ | a `reference()` column with no `relations()` block | ✓ found in the column graph |
84
+
85
+ `subjectScopes` is still first-class and is **unioned on top**, never replaced.
86
+ It is the only way to reach a link the schema does not encode: a subject id in a
87
+ plain (non-`reference()`) column, a polymorphic `(ownerType, ownerId)` pair, an
88
+ id inside JSON.
89
+
90
+ ### What the derivation cannot see — and says so
91
+
92
+ A list of reachable tables, printed alone, reads as a completeness claim. So the
93
+ blind spots ship in the same payload:
94
+
95
+ ```ts
96
+ const gov = yield* GovernanceService
97
+ const { paths, limitations } = gov.subjectGraph()
98
+ ```
99
+
100
+ …and at `GET /_voltro/inspect/plugins/governance/subject-graph`. `limitations`
101
+ names every table nothing links to the subject (`unreachable`), everything cut by
102
+ the depth ceiling (`depth-truncated`, default 4 hops) and everything you excluded.
103
+ Structurally outside the graph in every case: **object storage and uploaded
104
+ files, external processors, log and metric sinks, backups, and any subject id
105
+ embedded in JSON or free text.**
106
+
107
+ The plugin also warns at boot if a derived scope reaches one table or fewer —
108
+ that is what a typo'd `subjectTable` looks like, and it is otherwise
109
+ indistinguishable from a working configuration until the first DSAR.
110
+
111
+ ### `voltro privacy`
112
+
113
+ ```bash
114
+ voltro privacy scope --subject-table users # the graph + its blind spots, OFFLINE
115
+ voltro privacy scope --json # { reachable, unreachable, depthTruncated }
116
+ voltro privacy export usr_123 --url https://api.example.com --out bundle.json
117
+ voltro privacy erase usr_123 --url https://api.example.com --confirm
118
+ ```
119
+
120
+ `scope` needs no database and no running app — schema only, so it belongs in CI
121
+ and in a PR review, where "does our erasure still reach every table" is a question
122
+ somebody can still act on.
123
+
124
+ `export` / `erase` deliberately go through the **running app's** admin-gated
125
+ governance endpoint rather than opening their own connection. A CLI that erased
126
+ directly would bypass your configured `anonymizeFields` and exclusions, bypass the
127
+ erasure log (which is the compliance artefact, not a nicety), and work against a
128
+ schema the deployed app may not be running. `erase` refuses without `--confirm`,
129
+ without `--url`, and without an inspect credential.
130
+
131
+ ### Scale
132
+
133
+ Every read is `WHERE <column> IN (<keys>)` on an indexed column, chunked at 500
134
+ keys and memoised across paths that share a prefix — not a full table scan per
135
+ scope. Per table, 50 000 rows is the ceiling; hitting it sets `truncated` on the
136
+ erasure-log entry and exits non-zero from the CLI, because a short erasure
137
+ presented as complete is exactly the failure this is built to prevent.
138
+
139
+ ### Crypto-shredding is NOT supported — and should not be faked
140
+
141
+ "Erase a subject by destroying their key" needs a key **per subject**. The shipped
142
+ cipher is one app-wide passphrase-derived key, so there is nothing subject-shaped
143
+ to destroy: deleting it would make *every* subject's `.encrypted()` columns
144
+ unreadable, which is an outage, not an erasure. Per-subject shredding needs
145
+ envelope encryption — a DEK per subject, wrapped by a KEK, with every existing
146
+ ciphertext re-wrapped — which is a re-architecture of the cipher rather than a
147
+ mode of `eraseSubject`.
148
+
149
+ What makes its absence cost less than it sounds: `.encrypted()` columns are
150
+ decrypted transparently on read, so `delete` removes the ciphertext row and
151
+ `anonymize` overwrites the ciphertext with a null. Both erase the data itself
152
+ rather than the key guarding it. Crypto-shredding is an optimisation for erasure
153
+ at rest across backups; it is not the only route to Art. 17.
154
+
57
155
  ## Consent ledger
58
156
 
59
157
  `governance.consent` `{ purpose, granted }` records the calling subject's decision; `governance.hasConsent` `{ purpose }` reads the latest (latest-write-wins per `(subject, purpose)`).
@@ -1,6 +1,6 @@
1
1
  # Multi-tenancy
2
2
 
3
- > The tenant() schema mixin (auto-scope reads + auto-fill writes), the assertOwnTenant write-guard, and the typed TenantMismatch error.
3
+ > The tenant() schema mixin (auto-scoped reads, auto-filled inserts, tenant-resolved keyed writes), the assertOwnTenant write-guard, and the typed TenantMismatch error.
4
4
 
5
5
 
6
6
 
@@ -9,7 +9,7 @@
9
9
  <!-- source: en/plugins/multitenancy.md -->
10
10
  ## Multi-tenancy
11
11
 
12
- _The tenant() schema mixin (auto-scope reads + auto-fill writes), the assertOwnTenant write-guard, and the typed TenantMismatch error._
12
+ _The tenant() schema mixin (auto-scoped reads, auto-filled inserts, tenant-resolved keyed writes), the assertOwnTenant write-guard, and the typed TenantMismatch error._
13
13
 
14
14
  `@voltro/plugin-multitenancy` is the first-party multi-tenancy primitive. It has two surfaces: a **schema mixin** (`tenant()`) and a **write-time guard** (`assertOwnTenant` + the typed `TenantMismatch` error).
15
15
 
@@ -37,11 +37,13 @@ for projects that don't want the full barrel.
37
37
  What the runtime does for a `tenant()`-marked table:
38
38
 
39
39
  - **Reads are auto-scoped.** The runtime AND-merges `eq('tenantId', subject.tenantId)` into every subscription predicate against the table — tenant A never sees tenant B's rows, and a write in tenant A never wakes a subscription in tenant B.
40
- - **Writes are auto-filled.** On insert, `tenantId` is stamped from `ctx.request.subject.tenantId` when the caller didn't pass it explicitly. The wrapper never overrides a value the caller did pass.
40
+ - **Inserts are auto-filled.** On insert, `tenantId` is stamped from `ctx.request.subject.tenantId` when the caller didn't pass it explicitly. The wrapper never overrides a value the caller did pass, and it refuses the insert outright when the subject has no tenant.
41
+ - **Set-based writes are auto-scoped.** `updateMany` / `deleteMany` and the fluent `update(t).where(...)` / `delete(t).where(...)` builders get the same `eq('tenantId', …)` AND-merged onto their `WHERE`, so a tenant-blind predicate is confined rather than executed as written.
42
+ - **Keyed-by-id writes are resolved inside the tenant.** `update(t, id, patch)`, `delete(t, id)`, `hardDelete(t, id)` and `patchJson(t, id, …)` address a row by primary key, so the runtime resolves that key within `subject.tenantId` first and fails with `TenantRowNotFound` (`@voltro/runtime`) when there is no such row there. The error is raised identically whether the row is missing or belongs to another tenant — reporting the two differently would let a caller probe for row ids in other tenants.
41
43
 
42
44
  ## The write-guard — `assertOwnTenant`
43
45
 
44
- Read isolation is automatic. **Writes are not** a mutation receives raw, user-supplied input including `tenantId`, so without an explicit check a client authenticated as tenant A could submit `tenantId: 'B'` and land the row in B's data. Guard every tenant-scoped mutation:
46
+ Isolation itself is enforced by the runtime on every path above. What `assertOwnTenant` covers is the one question the framework deliberately does not answer for you: a mutation whose input carries an explicit `tenantId` it intends to USE. Without a check, a client authenticated as tenant A could submit `tenantId: 'B'` and your handler would happily read that claim. Guard such a mutation:
45
47
 
46
48
  ```ts
47
49
  import { assertOwnTenant, TenantMismatch } from '@voltro/plugin-multitenancy'
@@ -54,6 +56,8 @@ const execute = async (input: { tenantId: string }, ctx) => {
54
56
 
55
57
  `assertOwnTenant(inputTenantId, subject)` throws `TenantMismatch` when `inputTenantId` doesn't equal the subject's `tenantId`. Anonymous subjects have no tenant scope at all, so the guard always throws for them — anonymous + tenant-scoped writes need an `apiKey` / `serviceAccount` subject instead.
56
58
 
59
+ It checks a **claimed** `input.tenantId`, so a mutation whose input carries none never reaches it. That is why it is an early, typed convenience and not the boundary — the boundary is the store enforcement listed above.
60
+
57
61
  ## The typed error — `TenantMismatch`
58
62
 
59
63
  `TenantMismatch` is a `Schema.TaggedError` carrying `inputTenantId` + `subjectTenantId` (empty string for anonymous subjects). Declare it on the mutation's `error:` schema so the rpc layer surfaces the rejection typed. **Import it from the browser-safe `@voltro/plugin-multitenancy/guard` subpath in the descriptor (`*.mutation.ts`)** — the package root also re-exports the schema mixin, which pulls `@voltro/database` into the client rpcGroup bundle (a browser-safety violation):
@@ -65,6 +69,7 @@ import { Schema } from 'effect'
65
69
 
66
70
  export const createProject = defineMutation({
67
71
  name: 'projects.create',
72
+ guards: [{ scope: 'projects:write' }], // WHO may call; TenantMismatch bounds WHICH tenant
68
73
  input: Schema.Struct({ tenantId: Schema.String, name: Schema.String }),
69
74
  output: Schema.Struct({ id: Schema.String }),
70
75
  error: TenantMismatch,