@voltro/cli 0.28.0 → 0.30.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 (354) hide show
  1. package/CHANGELOG.md +655 -0
  2. package/THIRD-PARTY-NOTICES.md +90 -4
  3. package/dist/{apiBuild-eUM32r1u.js → apiBuild-BceikpQf.js} +3 -3
  4. package/dist/apiBuild-CxvR1Dwt.js +2 -0
  5. package/dist/bin.js +3 -3
  6. package/dist/checkCommand-BMr-X48w.js +2427 -0
  7. package/dist/checkCommand-BOk9y264.js +2 -0
  8. package/dist/cliRuntime-Oh517vCV.js +96 -0
  9. package/dist/{commands-CSkrUI1h.js → commands-D4Y1-Nlu.js} +8575 -9366
  10. package/dist/{dbCommand-CpYgmSw4.js → dbCommand-C7hbl5pk.js} +345 -266
  11. package/dist/dbCommand-D8nhyppR.js +2 -0
  12. package/dist/{dev-DlBWWnJQ.js → dev-Cr-teYsz.js} +1 -1
  13. package/dist/{dev-CEZwJhmb.js → dev-DmnkH-wr.js} +2939 -1832
  14. package/dist/fileConventions-Cof68_BL.js +33 -0
  15. package/dist/{frameworkTableAssembly-BwHU9Euq.js → frameworkTableAssembly-BzAajuUv.js} +115 -99
  16. package/dist/frameworkTableAssembly-DU0aOxjL.js +2 -0
  17. package/dist/index.d.ts +7 -2
  18. package/dist/index.js +1 -1
  19. package/dist/{inspect-CUCCzw2I.js → inspect-BUUjt773.js} +349 -192
  20. package/dist/inspect-mmBuRXmy.js +2 -0
  21. package/dist/{inspectMetrics-BU90mvJN.js → inspectMetrics-BeJdP_3b.js} +1068 -978
  22. package/dist/{manifestBuild-BnzAxp2O.js → manifestBuild-BLrVuSlM.js} +1 -1
  23. package/dist/manifestBuild-Dj8Jjoto.js +2 -0
  24. package/dist/sdkgen-CqpOq_fy.js +582 -0
  25. package/dist/serveCommand-Ciq_wIY8.js +1577 -0
  26. package/dist/serveEntry.js +2 -2
  27. package/dist/{start-BGXIf6zT.js → start-ocqB1seX.js} +283 -270
  28. package/dist/startEntry.js +2 -2
  29. package/package.json +17 -17
  30. package/templates/AGENTS.core.md +50 -0
  31. package/templates/AGENTS.md +54 -2
  32. package/templates/agent-docs/_index.md +4 -2
  33. package/templates/agent-docs/_manifest.json +26 -5
  34. package/templates/agent-docs/ai.md +191 -0
  35. package/templates/agent-docs/cli.md +143 -6
  36. package/templates/agent-docs/configuration.md +32 -0
  37. package/templates/agent-docs/data.md +282 -0
  38. package/templates/agent-docs/database/migrations.md +47 -0
  39. package/templates/agent-docs/database/misc.md +57 -0
  40. package/templates/agent-docs/database/schema.md +3 -1
  41. package/templates/agent-docs/internationalization.md +169 -8
  42. package/templates/agent-docs/local-first-mobile.md +426 -0
  43. package/templates/agent-docs/routing.md +94 -3
  44. package/templates/agent-docs/schema-driven-ui.md +12 -0
  45. package/templates/agent-docs/security.md +17 -0
  46. package/templates/agent-docs/templates/apibackends.md +210 -1
  47. package/templates/agent-docs/templates/appshells.md +144 -1
  48. package/templates/agent-docs/templates/mobile.md +60 -0
  49. package/templates/agent-docs/templates/overview.md +17 -7
  50. package/templates/agent-docs/whats-new.md +238 -158
  51. package/templates/agent-docs/workflows.md +376 -0
  52. package/templates/apps/api-ai/package.json +7 -7
  53. package/templates/apps/api-auth/package.json +8 -8
  54. package/templates/apps/api-backend/package.json +7 -7
  55. package/templates/apps/api-backend-deactivation/package.json +7 -7
  56. package/templates/apps/api-backend-mail/package.json +8 -8
  57. package/templates/apps/api-backend-mariadb/package.json +9 -9
  58. package/templates/apps/api-backend-sqlite/.env.example +19 -0
  59. package/templates/apps/api-backend-sqlite/README.md +38 -0
  60. package/templates/apps/api-backend-sqlite/app.config.ts +35 -0
  61. package/templates/apps/api-backend-sqlite/database/schema.ts +54 -0
  62. package/templates/apps/api-backend-sqlite/mutations/notes.create.mutation.server.ts +19 -0
  63. package/templates/apps/api-backend-sqlite/mutations/notes.create.mutation.ts +37 -0
  64. package/templates/apps/api-backend-sqlite/package.json +30 -0
  65. package/templates/apps/api-backend-sqlite/queries/notes.query.server.ts +14 -0
  66. package/templates/apps/api-backend-sqlite/queries/notes.query.ts +20 -0
  67. package/templates/apps/api-backend-sqlite/template.json +6 -0
  68. package/templates/apps/api-backend-sqlite/tests/notes.create.test.ts +50 -0
  69. package/templates/apps/api-backend-sqlite/tsconfig.json +5 -0
  70. package/templates/apps/api-backend-storage/package.json +8 -8
  71. package/templates/apps/api-cms/README.md +104 -0
  72. package/templates/apps/api-cms/actions/content.get.action.server.ts +27 -0
  73. package/templates/apps/api-cms/actions/content.get.action.ts +19 -0
  74. package/templates/apps/api-cms/actions/content.types.action.server.ts +26 -0
  75. package/templates/apps/api-cms/actions/content.types.action.ts +40 -0
  76. package/templates/apps/api-cms/actions/me.action.server.ts +18 -0
  77. package/templates/apps/api-cms/actions/me.action.ts +16 -0
  78. package/templates/apps/api-cms/app.config.ts +61 -0
  79. package/templates/apps/api-cms/content/blogPost.contentType.ts +39 -0
  80. package/templates/apps/api-cms/content/index.ts +18 -0
  81. package/templates/apps/api-cms/content/page.contentType.ts +24 -0
  82. package/templates/apps/api-cms/database/schema.ts +64 -0
  83. package/templates/apps/api-cms/mutations/content.publish.mutation.server.ts +19 -0
  84. package/templates/apps/api-cms/mutations/content.publish.mutation.ts +15 -0
  85. package/templates/apps/api-cms/mutations/content.saveDraft.mutation.server.ts +36 -0
  86. package/templates/apps/api-cms/mutations/content.saveDraft.mutation.ts +32 -0
  87. package/templates/apps/api-cms/mutations/content.unpublish.mutation.server.ts +19 -0
  88. package/templates/apps/api-cms/mutations/content.unpublish.mutation.ts +11 -0
  89. package/templates/apps/api-cms/package.json +32 -0
  90. package/templates/apps/api-cms/queries/content.list.query.server.ts +0 -0
  91. package/templates/apps/api-cms/queries/content.list.query.ts +27 -0
  92. package/templates/apps/api-cms/template.json +6 -0
  93. package/templates/apps/api-cms/tests/content.descriptors.test.ts +64 -0
  94. package/templates/apps/api-cms/tests/content.write.test.ts +85 -0
  95. package/templates/apps/api-cms/tsconfig.json +5 -0
  96. package/templates/apps/api-collab/README.md +70 -0
  97. package/templates/apps/api-collab/app.config.ts +28 -0
  98. package/templates/apps/api-collab/database/schema.ts +63 -0
  99. package/templates/apps/api-collab/mutations/documents.create.mutation.server.ts +19 -0
  100. package/templates/apps/api-collab/mutations/documents.create.mutation.ts +36 -0
  101. package/templates/apps/api-collab/mutations/documents.setBody.mutation.server.ts +20 -0
  102. package/templates/apps/api-collab/mutations/documents.setBody.mutation.ts +30 -0
  103. package/templates/apps/api-collab/package.json +30 -0
  104. package/templates/apps/api-collab/queries/documents.query.server.ts +16 -0
  105. package/templates/apps/api-collab/queries/documents.query.ts +23 -0
  106. package/templates/apps/api-collab/template.json +6 -0
  107. package/templates/apps/api-collab/tests/documents.setBody.test.ts +89 -0
  108. package/templates/apps/api-collab/tsconfig.json +5 -0
  109. package/templates/apps/api-data-advanced/package.json +8 -8
  110. package/templates/apps/api-durable/package.json +8 -8
  111. package/templates/apps/api-feature-flags/package.json +9 -9
  112. package/templates/apps/api-governance/package.json +8 -8
  113. package/templates/apps/api-kv/package.json +8 -8
  114. package/templates/apps/api-moderation/package.json +8 -8
  115. package/templates/apps/api-observability/package.json +8 -8
  116. package/templates/apps/api-ratelimit/package.json +8 -8
  117. package/templates/apps/api-rbac/package.json +8 -8
  118. package/templates/apps/api-rest/package.json +7 -7
  119. package/templates/apps/api-saas/package.json +11 -11
  120. package/templates/apps/api-saas-starter/README.md +103 -0
  121. package/templates/apps/api-saas-starter/actions/me.action.server.ts +18 -0
  122. package/templates/apps/api-saas-starter/actions/me.action.ts +20 -0
  123. package/templates/apps/api-saas-starter/app.config.ts +87 -0
  124. package/templates/apps/api-saas-starter/database/schema.ts +57 -0
  125. package/templates/apps/api-saas-starter/mutations/invites.create.mutation.server.ts +26 -0
  126. package/templates/apps/api-saas-starter/mutations/invites.create.mutation.ts +18 -0
  127. package/templates/apps/api-saas-starter/mutations/projects.create.mutation.server.ts +29 -0
  128. package/templates/apps/api-saas-starter/mutations/projects.create.mutation.ts +18 -0
  129. package/templates/apps/api-saas-starter/package.json +32 -0
  130. package/templates/apps/api-saas-starter/queries/invites.list.query.server.ts +14 -0
  131. package/templates/apps/api-saas-starter/queries/invites.list.query.ts +17 -0
  132. package/templates/apps/api-saas-starter/queries/projects.list.query.server.ts +14 -0
  133. package/templates/apps/api-saas-starter/queries/projects.list.query.ts +18 -0
  134. package/templates/apps/api-saas-starter/template.json +6 -0
  135. package/templates/apps/api-saas-starter/tests/projects.create.test.ts +62 -0
  136. package/templates/apps/api-saas-starter/tests/session.test.ts +33 -0
  137. package/templates/apps/api-saas-starter/tsconfig.json +5 -0
  138. package/templates/apps/api-search/package.json +8 -8
  139. package/templates/apps/api-status/README.md +88 -0
  140. package/templates/apps/api-status/app.config.ts +36 -0
  141. package/templates/apps/api-status/authz.ts +33 -0
  142. package/templates/apps/api-status/database/schema.ts +70 -0
  143. package/templates/apps/api-status/mutations/components.create.mutation.server.ts +19 -0
  144. package/templates/apps/api-status/mutations/components.create.mutation.ts +16 -0
  145. package/templates/apps/api-status/mutations/incidents.create.mutation.server.ts +25 -0
  146. package/templates/apps/api-status/mutations/incidents.create.mutation.ts +22 -0
  147. package/templates/apps/api-status/mutations/incidents.resolve.mutation.server.ts +24 -0
  148. package/templates/apps/api-status/mutations/incidents.resolve.mutation.ts +19 -0
  149. package/templates/apps/api-status/mutations/incidents.update.mutation.server.ts +26 -0
  150. package/templates/apps/api-status/mutations/incidents.update.mutation.ts +21 -0
  151. package/templates/apps/api-status/package.json +30 -0
  152. package/templates/apps/api-status/queries/components.list.query.server.ts +14 -0
  153. package/templates/apps/api-status/queries/components.list.query.ts +13 -0
  154. package/templates/apps/api-status/queries/incidents.live.query.server.ts +14 -0
  155. package/templates/apps/api-status/queries/incidents.live.query.ts +20 -0
  156. package/templates/apps/api-status/queries/updates.list.query.server.ts +14 -0
  157. package/templates/apps/api-status/queries/updates.list.query.ts +17 -0
  158. package/templates/apps/api-status/template.json +6 -0
  159. package/templates/apps/api-status/tests/status.test.ts +70 -0
  160. package/templates/apps/api-status/tsconfig.json +5 -0
  161. package/templates/apps/api-versioning/package.json +8 -8
  162. package/templates/apps/api-webhooks/package.json +9 -9
  163. package/templates/apps/changelog/package.json +6 -6
  164. package/templates/apps/edge-functions/package.json +2 -2
  165. package/templates/apps/frontend-admin/app.config.ts +1 -1
  166. package/templates/apps/frontend-admin/package.json +8 -8
  167. package/templates/apps/frontend-admin/src/locales/index.ts +1 -1
  168. package/templates/apps/frontend-app/app.config.ts +1 -1
  169. package/templates/apps/frontend-app/package.json +8 -8
  170. package/templates/apps/frontend-app/src/locales/index.ts +1 -1
  171. package/templates/apps/frontend-app/src/pages/layout.tsx +1 -1
  172. package/templates/apps/frontend-app/src/pages/page.tsx +1 -1
  173. package/templates/apps/frontend-auth/README.md +78 -0
  174. package/templates/apps/frontend-auth/app.config.ts +34 -0
  175. package/templates/apps/frontend-auth/package.json +32 -0
  176. package/templates/apps/frontend-auth/src/components/AuthShell.tsx +35 -0
  177. package/templates/apps/frontend-auth/src/components/PasswordStrength.tsx +33 -0
  178. package/templates/apps/frontend-auth/src/config.ts +11 -0
  179. package/templates/apps/frontend-auth/src/globals.css +105 -0
  180. package/templates/apps/frontend-auth/src/globals.d.ts +6 -0
  181. package/templates/apps/frontend-auth/src/lib/auth.ts +34 -0
  182. package/templates/apps/frontend-auth/src/lib/redirect.test.ts +24 -0
  183. package/templates/apps/frontend-auth/src/lib/redirect.ts +29 -0
  184. package/templates/apps/frontend-auth/src/locales/de.ts +66 -0
  185. package/templates/apps/frontend-auth/src/locales/en.ts +76 -0
  186. package/templates/apps/frontend-auth/src/locales/index.ts +14 -0
  187. package/templates/apps/frontend-auth/src/pages/forgot/page.tsx +51 -0
  188. package/templates/apps/frontend-auth/src/pages/layout.tsx +12 -0
  189. package/templates/apps/frontend-auth/src/pages/login/page.test.tsx +53 -0
  190. package/templates/apps/frontend-auth/src/pages/login/page.tsx +67 -0
  191. package/templates/apps/frontend-auth/src/pages/logout/page.tsx +28 -0
  192. package/templates/apps/frontend-auth/src/pages/magic/page.tsx +51 -0
  193. package/templates/apps/frontend-auth/src/pages/page.tsx +26 -0
  194. package/templates/apps/frontend-auth/src/pages/reset/page.test.tsx +51 -0
  195. package/templates/apps/frontend-auth/src/pages/reset/page.tsx +63 -0
  196. package/templates/apps/frontend-auth/src/pages/signup/page.tsx +60 -0
  197. package/templates/apps/frontend-auth/src/pages/verify/page.tsx +50 -0
  198. package/templates/apps/frontend-auth/template.json +6 -0
  199. package/templates/apps/frontend-auth/tsconfig.json +5 -0
  200. package/templates/apps/frontend-blank/app.config.ts +1 -1
  201. package/templates/apps/frontend-blank/package.json +7 -7
  202. package/templates/apps/frontend-blank/src/locales/index.ts +1 -1
  203. package/templates/apps/frontend-blank/src/pages/layout.tsx +1 -1
  204. package/templates/apps/frontend-blank/src/pages/page.tsx +1 -1
  205. package/templates/apps/frontend-cms/README.md +47 -0
  206. package/templates/apps/frontend-cms/app.config.ts +37 -0
  207. package/templates/apps/frontend-cms/package.json +33 -0
  208. package/templates/apps/frontend-cms/src/config.ts +8 -0
  209. package/templates/apps/frontend-cms/src/globals.css +105 -0
  210. package/templates/apps/frontend-cms/src/globals.d.ts +6 -0
  211. package/templates/apps/frontend-cms/src/lib/api.ts +58 -0
  212. package/templates/apps/frontend-cms/src/locales/de.ts +50 -0
  213. package/templates/apps/frontend-cms/src/locales/en.ts +55 -0
  214. package/templates/apps/frontend-cms/src/locales/index.ts +14 -0
  215. package/templates/apps/frontend-cms/src/pages/(app)/error.tsx +18 -0
  216. package/templates/apps/frontend-cms/src/pages/(app)/layout.test.tsx +31 -0
  217. package/templates/apps/frontend-cms/src/pages/(app)/layout.tsx +62 -0
  218. package/templates/apps/frontend-cms/src/pages/(app)/not-found.tsx +14 -0
  219. package/templates/apps/frontend-cms/src/pages/(app)/page.test.tsx +110 -0
  220. package/templates/apps/frontend-cms/src/pages/(app)/page.tsx +159 -0
  221. package/templates/apps/frontend-cms/src/pages/layout.tsx +12 -0
  222. package/templates/apps/frontend-cms/src/pages/login/page.test.tsx +58 -0
  223. package/templates/apps/frontend-cms/src/pages/login/page.tsx +93 -0
  224. package/templates/apps/frontend-cms/template.json +6 -0
  225. package/templates/apps/frontend-cms/tsconfig.json +5 -0
  226. package/templates/apps/frontend-collab/README.md +80 -0
  227. package/templates/apps/frontend-collab/app.config.ts +38 -0
  228. package/templates/apps/frontend-collab/package.json +33 -0
  229. package/templates/apps/frontend-collab/src/locales/de.ts +29 -0
  230. package/templates/apps/frontend-collab/src/locales/en.ts +31 -0
  231. package/templates/apps/frontend-collab/src/locales/index.ts +15 -0
  232. package/templates/apps/frontend-collab/src/pages/layout.tsx +39 -0
  233. package/templates/apps/frontend-collab/src/pages/page.test.tsx +150 -0
  234. package/templates/apps/frontend-collab/src/pages/page.tsx +186 -0
  235. package/templates/apps/frontend-collab/template.json +6 -0
  236. package/templates/apps/frontend-collab/tsconfig.json +11 -0
  237. package/templates/apps/frontend-contact/package.json +7 -7
  238. package/templates/apps/frontend-dashboard/app.config.ts +1 -1
  239. package/templates/apps/frontend-dashboard/package.json +7 -7
  240. package/templates/apps/frontend-dashboard/src/locales/index.ts +1 -1
  241. package/templates/apps/frontend-docs/package.json +7 -7
  242. package/templates/apps/frontend-i18n/package.json +6 -6
  243. package/templates/apps/frontend-landing/package.json +7 -7
  244. package/templates/apps/frontend-portal/README.md +71 -0
  245. package/templates/apps/frontend-portal/app.config.ts +37 -0
  246. package/templates/apps/frontend-portal/package.json +32 -0
  247. package/templates/apps/frontend-portal/src/config.ts +8 -0
  248. package/templates/apps/frontend-portal/src/globals.css +93 -0
  249. package/templates/apps/frontend-portal/src/globals.d.ts +6 -0
  250. package/templates/apps/frontend-portal/src/lib/api.ts +62 -0
  251. package/templates/apps/frontend-portal/src/locales/de.ts +86 -0
  252. package/templates/apps/frontend-portal/src/locales/en.ts +93 -0
  253. package/templates/apps/frontend-portal/src/locales/index.ts +14 -0
  254. package/templates/apps/frontend-portal/src/pages/(portal)/api-keys/page.test.tsx +71 -0
  255. package/templates/apps/frontend-portal/src/pages/(portal)/api-keys/page.tsx +124 -0
  256. package/templates/apps/frontend-portal/src/pages/(portal)/billing/page.test.tsx +86 -0
  257. package/templates/apps/frontend-portal/src/pages/(portal)/billing/page.tsx +97 -0
  258. package/templates/apps/frontend-portal/src/pages/(portal)/error.tsx +19 -0
  259. package/templates/apps/frontend-portal/src/pages/(portal)/invoices/page.tsx +45 -0
  260. package/templates/apps/frontend-portal/src/pages/(portal)/layout.test.tsx +31 -0
  261. package/templates/apps/frontend-portal/src/pages/(portal)/layout.tsx +63 -0
  262. package/templates/apps/frontend-portal/src/pages/(portal)/not-found.tsx +15 -0
  263. package/templates/apps/frontend-portal/src/pages/(portal)/page.tsx +39 -0
  264. package/templates/apps/frontend-portal/src/pages/(portal)/profile/page.tsx +79 -0
  265. package/templates/apps/frontend-portal/src/pages/layout.tsx +12 -0
  266. package/templates/apps/frontend-portal/src/pages/login/page.test.tsx +58 -0
  267. package/templates/apps/frontend-portal/src/pages/login/page.tsx +96 -0
  268. package/templates/apps/frontend-portal/template.json +6 -0
  269. package/templates/apps/frontend-portal/tsconfig.json +5 -0
  270. package/templates/apps/frontend-saas/README.md +73 -0
  271. package/templates/apps/frontend-saas/app.config.ts +46 -0
  272. package/templates/apps/frontend-saas/package.json +32 -0
  273. package/templates/apps/frontend-saas/src/config.ts +8 -0
  274. package/templates/apps/frontend-saas/src/globals.css +85 -0
  275. package/templates/apps/frontend-saas/src/globals.d.ts +6 -0
  276. package/templates/apps/frontend-saas/src/lib/api.ts +47 -0
  277. package/templates/apps/frontend-saas/src/locales/de.ts +72 -0
  278. package/templates/apps/frontend-saas/src/locales/en.ts +81 -0
  279. package/templates/apps/frontend-saas/src/locales/index.ts +14 -0
  280. package/templates/apps/frontend-saas/src/pages/(marketing)/layout.tsx +30 -0
  281. package/templates/apps/frontend-saas/src/pages/(marketing)/login/page.test.tsx +58 -0
  282. package/templates/apps/frontend-saas/src/pages/(marketing)/login/page.tsx +96 -0
  283. package/templates/apps/frontend-saas/src/pages/(marketing)/page.tsx +27 -0
  284. package/templates/apps/frontend-saas/src/pages/dashboard/billing/page.tsx +72 -0
  285. package/templates/apps/frontend-saas/src/pages/dashboard/error.tsx +20 -0
  286. package/templates/apps/frontend-saas/src/pages/dashboard/layout.test.tsx +32 -0
  287. package/templates/apps/frontend-saas/src/pages/dashboard/layout.tsx +69 -0
  288. package/templates/apps/frontend-saas/src/pages/dashboard/not-found.tsx +17 -0
  289. package/templates/apps/frontend-saas/src/pages/dashboard/page.test.tsx +113 -0
  290. package/templates/apps/frontend-saas/src/pages/dashboard/page.tsx +76 -0
  291. package/templates/apps/frontend-saas/src/pages/dashboard/team/page.tsx +73 -0
  292. package/templates/apps/frontend-saas/src/pages/layout.tsx +12 -0
  293. package/templates/apps/frontend-saas/template.json +6 -0
  294. package/templates/apps/frontend-saas/tsconfig.json +5 -0
  295. package/templates/apps/frontend-spa/app.config.ts +1 -1
  296. package/templates/apps/frontend-spa/package.json +7 -7
  297. package/templates/apps/frontend-spa/src/locales/index.ts +1 -1
  298. package/templates/apps/frontend-spa/src/pages/layout.tsx +1 -1
  299. package/templates/apps/frontend-spa/src/pages/page.tsx +1 -1
  300. package/templates/apps/frontend-ssr/app.config.ts +1 -1
  301. package/templates/apps/frontend-ssr/package.json +7 -7
  302. package/templates/apps/frontend-ssr/src/locales/index.ts +1 -1
  303. package/templates/apps/frontend-ssr/src/pages/layout.tsx +1 -1
  304. package/templates/apps/frontend-ssr/src/pages/page.tsx +1 -1
  305. package/templates/apps/frontend-ssr-api/app.config.ts +1 -1
  306. package/templates/apps/frontend-ssr-api/package.json +8 -8
  307. package/templates/apps/frontend-ssr-api/src/locales/index.ts +1 -1
  308. package/templates/apps/frontend-ssr-api/src/pages/layout.tsx +1 -1
  309. package/templates/apps/frontend-static-blog/package.json +6 -6
  310. package/templates/apps/frontend-status/README.md +51 -0
  311. package/templates/apps/frontend-status/app.config.ts +39 -0
  312. package/templates/apps/frontend-status/package.json +32 -0
  313. package/templates/apps/frontend-status/src/config.ts +7 -0
  314. package/templates/apps/frontend-status/src/globals.css +70 -0
  315. package/templates/apps/frontend-status/src/globals.d.ts +6 -0
  316. package/templates/apps/frontend-status/src/lib/status.ts +81 -0
  317. package/templates/apps/frontend-status/src/locales/de.ts +41 -0
  318. package/templates/apps/frontend-status/src/locales/en.ts +45 -0
  319. package/templates/apps/frontend-status/src/locales/index.ts +13 -0
  320. package/templates/apps/frontend-status/src/pages/layout.tsx +27 -0
  321. package/templates/apps/frontend-status/src/pages/page.test.tsx +123 -0
  322. package/templates/apps/frontend-status/src/pages/page.tsx +136 -0
  323. package/templates/apps/frontend-status/template.json +6 -0
  324. package/templates/apps/frontend-status/tsconfig.json +11 -0
  325. package/templates/apps/mobile-app/README.md +58 -0
  326. package/templates/apps/mobile-app/app.config.ts +33 -0
  327. package/templates/apps/mobile-app/babel.config.js +5 -0
  328. package/templates/apps/mobile-app/metro.config.js +20 -0
  329. package/templates/apps/mobile-app/package.json +33 -0
  330. package/templates/apps/mobile-app/src/app/_layout.tsx +61 -0
  331. package/templates/apps/mobile-app/src/app/index.tsx +62 -0
  332. package/templates/apps/mobile-app/src/app/orders/[id].tsx +19 -0
  333. package/templates/apps/mobile-app/src/app/settings.tsx +51 -0
  334. package/templates/apps/mobile-app/src/client.ts +74 -0
  335. package/templates/apps/mobile-app/src/lib/deeplinks.ts +39 -0
  336. package/templates/apps/mobile-app/src/lib/notifications.ts +31 -0
  337. package/templates/apps/mobile-app/src/lib/sync.ts +29 -0
  338. package/templates/apps/mobile-app/src/persistence.ts +38 -0
  339. package/templates/apps/mobile-app/template.json +6 -0
  340. package/templates/apps/mobile-app/tests/deeplinks.test.ts +32 -0
  341. package/templates/apps/mobile-app/tests/notifications.test.ts +26 -0
  342. package/templates/apps/mobile-app/tests/sync.test.ts +34 -0
  343. package/templates/apps/mobile-app/tsconfig.json +10 -0
  344. package/templates/apps/mobile-app/voltro.mobile.ts +11 -0
  345. package/templates/baselines/compose/docker-compose.prod.yml +15 -0
  346. package/templates/baselines/compose-mariadb/docker-compose.prod.yml +15 -0
  347. package/templates/patches/@effect__cluster@0.60.0.patch +6 -6
  348. package/dist/apiBuild-DgBS9ayv.js +0 -2
  349. package/dist/dbCommand-DvguqlzF.js +0 -2
  350. package/dist/fileConventions-3bffWssN.js +0 -30
  351. package/dist/frameworkTableAssembly-lrjZtk0G.js +0 -2
  352. package/dist/inspect-gt8bq-Tz.js +0 -2
  353. package/dist/manifestBuild-ifczArzr.js +0 -2
  354. package/dist/serveCommand-ZTn-dPFa.js +0 -1425
@@ -1,4 +1,4 @@
1
- # What's new in 0.27.0
1
+ # What's new in 0.30.0
2
2
 
3
3
  Read this FIRST when a task touches an area you have not worked in recently.
4
4
  It is the cheapest way to notice that the framework grew the thing you were
@@ -7,295 +7,375 @@ workaround for something that shipped two versions ago.
7
7
 
8
8
  BREAKING entries name a codemod; run `voltro update` to apply it.
9
9
 
10
- ### Added
10
+ ### ⚠ BREAKING
11
11
 
12
- - **@voltro/plugin-audit, @voltro/plugin-versioning** — `scope` the app's own scoping dimension on `_voltro_audit_log` **and** `_voltro_row_history`, supplied by a `resolveScope` option on each plugin.
12
+ - **@voltro/ui-shadcn, @voltro/i18n, @voltro/web, @voltro/cli** — The language-preference cookie is **`voltro:locale`**. It was `voltro:lang`. The exported constant is `LOCALE_COOKIE` (was `LANG_COOKIE`), and `parsePreferenceCookies()` returns `{ theme, locale }` (was `{ theme, lang }`).
13
13
 
14
- The last thing between a consumer and deleting a 2900-row, 300-call-site hand-rolled audit trail. Their trail and its retention are per-TEAM; a tenant has many teams, so `.with(tenant())` is one level too coarse and every view they render filters by team first. It is the same column `_voltro_webhook_targets.scope` already carries: opaque json in, opaque json out, equality filtering.
14
+ Every other name in the framework says `locale` `resolveLocale`, `defaultLocale`, `config.locales`, `[locale]/…` routes, `meta({ locale })`, `RouteContext.locale`. The cookie was the one place the vocabulary broke, while holding a full IETF tag (`fr-CA`) which is a locale, not a language.
15
15
 
16
- **Deliberately not `metadata`.** They offered to carry `teamId` there and filter in memory, and were right to dislike it: `metadata` is documented as the app's free-form note the noun a diff cannot contain — so filtering on it builds a read path against a column whose contract says it is not one. Two columns, two jobs.
16
+ That inconsistency was not cosmetic. `voltro dev` shipped for eleven weeks reading `voltro:locale` while every writer wrote `voltro:lang`, so `<html lang>` was the literal `"en"` on every page of a German-default app. A reader and a writer that disagree on a string are invisible to `tsc`; a name nobody types the same way twice is what produced the disagreement.
17
17
 
18
- The app supplies the value, because the framework does not know what a team is which is the whole reason the column is opaque. `auditPlugin` derives it from the call (`(ctx) => ({ teamId: ctx.subject.metadata?.teamId })`); `versioningPlugin` from the changed ROW (`(row) => ({ teamId: row.teamId })`), because that is what that plugin has and where a per-table dimension lives. Configure both or half of every view is unfiltered.
18
+ **Your source is migrated by `voltro update`. Your users' browsers are not.** The old cookie in an already-visited browser is no longer read, so each user falls through to `Accept-Language` and then `defaultLocale` once and re-picks their language. Nothing errors and nothing else is lost. There is deliberately no dual-read fallback: a framework that keeps reading the old name forever is one that never finished the rename, which is the exact condition this change removes. If the one-time reset is unacceptable for your users, copy the value forward at your own boot and delete the bridge once they have cycled through:
19
19
 
20
- Neither resolver can fail the write it annotates: an underivable scope is `null`, the same answer as not configuring one.
20
+ ```ts
21
+ import { LOCALE_COOKIE, getCookie, setCookie } from '@voltro/ui-shadcn'
22
+
23
+ const legacy = getCookie('voltro:lang')
24
+ if (legacy && !getCookie(LOCALE_COOKIE)) setCookie(LOCALE_COOKIE, legacy)
25
+ ```
21
26
 
22
- codemod: none
23
- - **@voltro/plugin-notifications** — `notificationsPlugin({ resolveSubjectId })` — the app names its own addressing unit.
27
+ `voltro:theme` is unchanged.
24
28
 
25
- An inbox belonged to `subject.id`. That is the framework's answer and not always the app's: a shift change, an absence request or a task reminder is addressed to a PERSON, and a person does not necessarily have an auth user. A reporter measured it on 14 670 rows — 4 677 addressable through a user, and **668 live, read rows belonging to four people who have none**.
29
+ **`voltro update` carries you across this** codemod `0.30.0/01_locale-cookie-rename`.
30
+ - **@voltro/plugin-webhooks, @voltro/cli** — A deferred `ctx.webhooks.emit(...)` is a **transactional outbox row** now, not an in-memory after-commit callback. And `EmitOptions` gains **`immediate: true`** as the named way to opt out.
26
31
 
27
- The worse half is what follows a migration without it: every producer resolves person user and **silently delivers nothing** for anyone missing one. That is the failure this plugin's own docstring warns about, one level up and structural rather than accidental.
32
+ The commit-ordering half shipped in 0.29.0: an emit inside a mutation rides the commit, so a mutation that emits and then throws no longer tells a subscriber about a change that did not happen. That fix was correct about ORDER and silent about DURABILITY — a process dying between COMMIT and the callback dropped the delivery with nothing recorded as owed, which is the at-least-once-FROM-ENQUEUE weakness `@voltro/plugin-cdc-out` documents about itself, arrived at by accident.
28
33
 
29
- One function, thirteen call sites — the same seam `auth.resolveScopes` already offers for this shape. Absent keeps `subject.id`, so nothing changes for an app whose units line up. A resolver that returns `undefined`, an empty string, or throws falls back to the subject rather than failing the read: an inbox must not go down because one caller has no employee record, or because a lookup hit a database that was briefly unavailable.
34
+ The enqueue writes through `ctx.store` inside a mutation, the transactional view so the intent to deliver commits with the domain write or not at all. A crash is a retry instead of a loss. Delivery stays at-least-once, which is the strongest guarantee available without distributed transactions into the receiver.
30
35
 
31
- **`useEvent` already returns what the same report asked for.** `{ status, missed, lastMiss }`, where `status === 'live'` is the connected flag the ask was for discoverability, not an API, so the docs now name the case that motivated it: a wall display nobody is standing at keeps rendering the last thing it received, and from across the room stale and current look identical.
36
+ **What changes for you:** an emit inside a mutation returns `{ event, deliveries: [], deferred: true }` and its delivery rows appear after commitas it already did in 0.29.0. New is that the deferral survives a crash, and that `{ immediate: true }` exists for the cases that genuinely want the POST now. `immediate` does not make the emit safe; it makes the trade visible at the call site, which the old un-transactional behaviour never did.
32
37
 
33
- codemod: none
34
- - **@voltro/plugin-presence** — The presence tracker gets a perf suite — the last of the four realtime surfaces without pinned numbers — and the four are now documented side by side.
38
+ The framework registers its own `voltro.webhook.emit` outbox handler in BOTH boot paths, gated by one shared `hasWebhookSurface` predicate — a deferral that is durable under `voltro dev` and not under `voltro serve` is exactly the drift the parity guard exists for. An app with no outbox wiring keeps the in-memory callback: ordered, not durable, and it says so.
35
39
 
36
- The presence figures existed in the docs (a heartbeat, a 10 k roster) and were measured once by hand, which cannot fail. Same gap the event bus had, closed the same way: a `*.perf.test.ts` that prints what it measured and asserts the SHAPE rather than the microseconds.
40
+ **`voltro update` carries you across this** codemod `0.30.0/03_webhook-emit-durable-deferral`.
41
+ - **@voltro/protocol, @voltro/runtime, @voltro/cli, @voltro/plugin-webhooks** — **`WorkflowRunHandle.executionId` is nullable and `status` has three more members, because a start no longer always becomes a run.**
37
42
 
38
- Measured across all four, each asserted by a test:
43
+ With declarative flow control a start can be QUEUED (debounce / batch / throttle / concurrency / paused), DROPPED (over a `rateLimit` cap) or SKIPPED (a `singleton: { mode: 'skip' }` key was held). None of those has an execution id, and two of them may never have one.
39
44
 
40
- | primitive | operation | cost | scales with | | --- | --- | --- | --- | | Events | `ctx.events.publish` | 4.3 µs (~232 k/s) | nothing | | Events | delivery to a subscriber | 0.027 µs | subscribers, cheaply | | Presence | a heartbeat | 0.16 µs | nothing | | Presence | a roster read, 10 k members | 547 µs | the ROOM | | Records | a live-query re-diff, 5 000 rows | 3 062 µs | the RESULT SET | | Broadcast | cross-replica over real Redis | p50 1.1 ms · p99 11.2 ms | the network |
45
+ ```ts
46
+ status: 'running' | 'queued' | 'dropped' | 'skipped' // was: 'running'
47
+ executionId: string | null // was: string
48
+ deferral?: { mode, dueAt, retryAfterMs, intentId } // new
49
+ ```
41
50
 
42
- **The comparison is what was missing, not the numbers.** Publishing an event costs about a thousandth of re-diffing a large live query, and that ratio is what should decide between them a 60 Hz value belongs in an event, because the same value written to a table wakes every subscriber of every query reading it and each pays the full walk.
51
+ Keeping `executionId` a required string was considered and rejected. It would have meant inventing a value — an empty string, or the id the run WOULD have had and both produce a handle that polls `status: 'unknown'` forever: a wait that never resolves and never errors, which is the worst of the three answers. For a `skipped` singleton it carries the INCUMBENT's execution id, which is a real, pollable run and the entire point of that mode.
43
52
 
44
- Two of the four are flat and two are not. That is the property the tests assert: the per-member cost of a roster read must not grow with the room, and the per-row cost of a diff must not grow with the result set. Either one growing is the difference between expensive and unusable.
53
+ `ctx.workflows.wait(...)` on a handle with no execution id now throws with a message naming the status and, for `queued`, its `dueAt` instead of polling forever.
45
54
 
46
- codemod: none
47
- - **@voltro/cli** — A capability matrix for the realtime surface — fifteen things people build, each mapped to the primitive that carries it, each asserted by a test.
55
+ Two structural copies of the old shape went stale and are now the protocol type itself rather than hand-copies: `@voltro/plugin-webhooks`' `IncomingWorkflowFacade` and the CLI's `inspectStartWorkflow`. An incoming webhook that starts a debounced workflow gets a `queued` handle, which both copies said could not happen.
48
56
 
49
- "Nothing is missing" is not a checkable sentence. This turns it into one: `realtimeCapabilities.test.ts` asserts every row's primitive is still exported, so a capability that loses its primitive to a rename goes red in CI rather than being discovered by whoever tries to build it.
57
+ **`voltro update` carries you across this** codemod `0.30.0/04_workflow-run-handle-nullable-execution`.
50
58
 
51
- It caught one on its first run — the matrix claimed `useUpload` lived in `@voltro/plugin-storage` and it is in `@voltro/client`. A row pointing at the wrong package is exactly what a table in a document does silently.
59
+ ### Added
52
60
 
53
- It asserts EXPORTS rather than behaviour on purpose. Behaviour is what the other suites are for, and duplicating them here would make this a slower copy of them. What it catches is the gap between "we support that" and "the thing that supports it still exists".
61
+ - **@voltro/ai, @voltro/workflow** **`@voltro/ai/workflow` `aiStep` / `aiObjectStep`, a model call as a durable step that records what it cost.**
54
62
 
55
- The same table is in the docs, with the three capabilities people usually reach for wrongly called out: a value changing many times a second is an EVENT and not a row (writing it to a table wakes every subscriber of every query reading that table, each paying a full re-diff); "who is online" is presence rather than a table; and "did anything get lost" has a computed answer in `missed`, so nobody needs to build a heartbeat of their own to find out.
63
+ ```ts
64
+ import { aiStep } from '@voltro/ai/workflow'
65
+
66
+ const summary = yield* aiStep({
67
+ name: 'summarise-thread',
68
+ prompt: `Summarise:\n${thread}`,
69
+ store: ctx.store,
70
+ tenantId: payload.tenantId,
71
+ offload: true,
72
+ })
73
+ ```
56
74
 
57
- codemod: none
58
- - **@voltro/cli** — The ten hard questions a realtime system is judged on, with this framework's answer and — enforced by a test — the proof behind each.
75
+ Journaling is NOT the difference, and saying otherwise would be selling something the framework already gives away: every `step()` is journaled, so a replay of a plain wrapped `generateText` already returns the recorded completion rather than re-calling the model. Four things are genuinely new:
59
76
 
60
- `realtimeProperties.test.ts` fails if a row's proof disappears: a property may not be CLAIMED without something in the repository that demonstrates it. Red-verified by re-pointing one row at a test that does not exist.
77
+ 1. **What did this run cost?** A model call inside a workflow was invisible to `_voltro_ai_usage` unless the app remembered to call `recordAiUsage` by hand — so the spend ledger was systematically missing exactly the calls that run unattended. `aiStep` records it, attributed to the workflow and the step. 2. **The prompt is not silently copied into a second table.** `step({ input })` is written to `_voltro_workflow_run_steps` and rendered in the dashboard; for a prompt built from customer data that is a plaintext copy outside whatever boundary the app established for the source. The default records a DIGEST plus the length; `recordPrompt: 'full'` exists and has to be typed out. 3. **Provider failures retry like provider failures.** The default policy handles a 429 with its `Retry-After` and a 5xx, rather than every app rediscovering that a bare call fails the whole durable run on a rate limit.
61
78
 
62
- The questions, because they are the deliverable rather than the mechanism: is a missed delivery reported or silently dropped; can a late arrival tell "nothing happened" from "I was not listening"; is a SUBSCRIPTION authorized or only the connection; does a subscription outlive its credential; does the link heal itself after an outage; does a degraded network lose messages or only slow them; does fan-out cost grow with subscribers; are channels typed or strings; is a declared event nobody publishes reported; is cross-replica traffic separated per app by default.
79
+ 4. **`offload: true` frees the worker while the model thinks.** The run SUSPENDS on a durable deferred, the wait lives as a row in `_voltro_ai_inferences`, and a dispatcher owns the socket. Two hundred waiting runs become two hundred rows and four in-flight requests instead of two hundred parked fibers.
63
80
 
64
- **Why this replaces a benchmark against hosted competitors.** A table of our measured numbers beside someone else's published ones is not a comparison, it is two things in a row. Measuring a hosted product honestly needs its accounts, regions, tiers and retry policies, and a wrong number about someone else's product is worse than no number. What decides a choice is not the microseconds anyway — it is whether the system answers these questions at all, and every answer above is checkable against this repository by anyone.
81
+ Nothing here needs a third party to operate an inference tier it needs something to own the socket while the run sleeps, and a server process is something. Both pieces already existed: durable suspend/resume (`awaitSignalSuspending`, built for human-in-the-loop waits) and a leased work queue with a coordinated drainer (the admission queue's own shape).
65
82
 
66
- codemod: none
67
- - **@voltro/cli** — A real competitive measurement — against socket.io, on this machine, in the same topology.
83
+ The cost is stated rather than buried: a suspend/resume round trip adds the dispatcher's poll interval plus one engine wake, so it is a MODE. Under 5% on a six-second call; a doubling on a 200 ms one.
68
84
 
69
- This was declined twice on the grounds that a benchmark needs the competitor's accounts and regions. That reasoning holds for hosted products and **does not hold for socket.io**, which is an npm package: it can be installed, run and measured here with the same method. Declining it was over-broad.
85
+ Four guarantees, each ruling out a specific way this goes wrong:
70
86
 
71
- Back to back, two server instances sharing one Redis, client on B, emits on A:
87
+ - the enqueue is idempotent (the row id derives from execution + step, so a replay cannot queue — and pay for — the same call twice); - the claim is a conditional update, so two dispatchers cannot both bill one call; - the order is perform → RESUME the run → mark the row, because a crash the other way round leaves a run waiting for a signal nobody will send again; - a give-up resumes the run WITH the failure — an abandoned queued call that never told its run is the one unrecoverable outcome here.
72
88
 
73
- | | p50 | p99 | delivered | | --- | --- | --- | --- | | Voltro cross-replica | **1.29 ms** | 6.80 ms | 200/200 | | socket.io + redis-adapter | 1.89 ms | **3.81 ms** | 200/200 |
89
+ `aiObjectStep({ offload: true })` renders the schema to JSON Schema for the dispatcher (a JavaScript Schema cannot be journaled) and still decodes on the awaiting side, where the real schema exists.
74
90
 
75
- **~32% faster at the median, ~44% worse at the tail.** Both lossless. The p99 is ours to improve and is published rather than omitted, because a benchmark you only show when you win is advertising.
91
+ The dispatcher rides the ONE shared builder both boot paths call, with a red-verified parity guard: the gap it prevents is the worst variant this repo catalogues in production every offloaded call would suspend its run and never resume it, with no error and no log line.
76
92
 
77
- **The topology is what makes it a comparison.** The first attempt measured socket.io on a plain localhost websocket with no adapter and came out 3x faster which proved nothing: that is one hop, ours is two through a broker. It would have flattered socket.io and been dishonest in their favour, which is the same defect as flattering ourselves.
93
+ The Flow tab renders the queue: what is waiting and for how long, calls waiting past two minutes, claims whose dispatcher died, and the dispatcher's own last tick. A run parked on an offloaded call reads `suspended` with no step row yet, so this is the only view of the wait while it is happening.
78
94
 
79
- Also measured and NOT published as a headline: socket.io's `emit` to 100 subscribers costs 13.7 µs against our 2.7 µs, but at that point ours has already run every listener while socket.io has only enqueued to 100 sockets — zero had arrived when the measurement ended. Two different quantities; comparing them would have been the same mistake in the other direction.
95
+ `StepRetryPolicy` is now re-exported from `@voltro/workflow/define` (type-only, so the browser bundle is unaffected): it is the type of a `step()` option, and anything defining a step has to be able to name it.
96
+ - **@voltro/cli** — **`voltro db scan-credentials`** — the credential scan as a command instead of a SQL snippet in an upgrade note.
80
97
 
81
- `scripts/bench/socketio-cross-replica.mjs` carries the method and the numbers so they can be re-taken. Deliberately a script, not a test: keeping a competitor in the dependency tree to hold a number green is the wrong trade.
98
+ It counts rows whose Subject carries a credential-shaped key (`token` / `secret` / `password` / `apikey` / `credential` / `privatekey`) in `_voltro_audit_log` and `_voltro_row_history`, plus any `--table <name>[:<column>]` you add. Exit `1` on a hit so CI can gate on it.
82
99
 
83
- codemod: none
84
- - **@voltro/plugin-webhooks** — **A subscription is a SET of events, and the service now has a word for it.**
100
+ Why it is a command: the same check shipped as documented SQL (`subject::text ILIKE '%token%'`), which is postgres-only. Readers on MySQL/MariaDB translated it to a bare `LIKE` — case-SENSITIVE against the `utf8mb4_bin` collation our own migrator emits for a `json()` column, so `'%token%'` does not match `jiraToken`. A team ran it over 141 rows, got `0`, and nearly filed themselves clean; 117 held a working credential. Every dialect now casts to its own text type before `LOWER`, in code.
85
101
 
86
- `subscribe({ events: [...] })` creates the rows in one call; a `{ scope }` selector addresses them as a group wherever a target id is accepted `pauseTarget`, `resumeTarget`, `updateTarget`, `deleteTarget`, `rotateSecret`, `listDeliveries`.
102
+ **And a `0` can no longer mean two things.** Every line prints the number of rows SCANNED beside the number of hits; an empty table says "EMPTY this is not a clean bill of health"; a missing table reports as missing rather than as zero; and a run that examined nothing exits `2`, not `0`.
103
+ - **@voltro/workflow, @voltro/runtime, @voltro/cli, @voltro/plugin-ai-flows** — **`apiSurface: compatible`, and the reason.** Making `workflow()` a SINGLE call signature (see below — it is what lets a `key` lambda receive the payload type) also means every result is now intersected with its message carrier, including the empty one. `@voltro/plugin-ai-flows`' golden therefore reads
87
104
 
88
- A row is one event, but a subscription — as every webhook UI models it, ours included — is one URL with a list of event checkboxes. Without a name for the group, five checkboxes are five rows and every operation a user thinks of as single becomes a fan-out the app writes by hand: N pauses, N updates, N delivery reads merged and re-sorted, and a rotate that is delete + re-subscribe.
105
+ Workflow<"flow.run", …, typeof Schema.Never> & WorkflowMessagesCarrier<{ signals: {}, updates: {}, queries: {} }>
89
106
 
90
- **The shared secret is why this is correctness and not ergonomics.** The receiver verifies ONE signature for ONE url, so N rows for one endpoint must sign identically and there was no way to say so. `subscribe` mints a secret per call, `SubscribeResult` surfaces it once, `TargetPatch` cannot set it. So ticking a sixth event meant reading the secret column back out of `_voltro_webhook_targets` through the app's own database handle. That is exactly the coupling `listDeliveries` was added to remove, re-entered through a different door one release later.
107
+ where it used to stop at the first line. That is an ADDED intersection member, not a narrowing: a value of `T & M` is usable everywhere a `T` was, and nothing outside the package produces a value of that type. The gate flags it because one golden LINE was rewritten, which is the right thing for it to be blunt about it cannot tell an addition spelled as a rewrite from a removal.
91
108
 
92
- `subscribe` now mints one secret for the whole set, and `rotateSecret({ scope })` rotates every row to the same new value — which also replaces the delete-and-re-subscribe that minted new target ids and orphaned the delivery history.
109
+ It is also an improvement worth naming: before this, a workflow declaring `messages` fell through to the second overload and its payload/success/error types erased to `any`. That erasure is gone.
93
110
 
94
- **`secret` is deliberately still not patchable.** Adding it to `TargetPatch` would close the same gap by making a live credential app-writable, trading a coupling for a weaker invariant. The reporter proposed the constraint and declined that shortcut themselves.
111
+ **Flow control is a declaration now `debounce`, `singleton`, `concurrency`, `throttle`, `rateLimit`, `batch`, `priority`, `timeouts`, `onFailure`, `encryptSteps` on `workflow({...})`.**
95
112
 
96
- A scope matching no row is an error rather than a no-op: "pause the endpoint" that pauses nothing and reports success is the silent shape this selector exists to avoid.
113
+ Every one of these could already be hand-rolled, and that was the problem. A downstream app shipped "fifteen minutes after the last edit, narrate what settled" as ~120 lines: an idempotency key carrying the edit timestamp so every edit minted its own durable run, a re-check loop asking "what is due now and when should I wake next", a round cap so a run could not live forever, and an idempotent round so the superseded runs cost a diff instead of a model call. It works. It costs **twenty sleeping cluster entities to express "one job, latest deadline"**. It is now one line:
97
114
 
98
- codemod: none
115
+ ```ts
116
+ debounce: { key: (p) => `tour:${p.rowId}`, period: '15 minutes' }
117
+ ```
99
118
 
100
- ### Fixed
119
+ The reason the obvious version is wrong is the same for all of them: **the decision has to be made before the run exists.** Once a run is enqueued the only tools left are cancel and sleep, and neither un-spends the entity. So this is not a primitive you call inside the body — it is a property of the declaration, evaluated at the ONE boundary every start funnels through (`start`, `child`, `run`, a trigger, a reaction, a cron).
120
+
121
+ **One decision function, two callers.** `decideAdmission` is pure — no store, no clock, no service. The arrival path and the drainer call it with state read by the same two queries, so they cannot disagree; a disagreement would surface as a workflow running twice under a limit of one, on a replica nobody is attached to, under load.
122
+
123
+ **Nothing is silent.** Every decision is a row in `_voltro_workflow_admissions` with its key, reason, `collapsed` count and `waitedMs`. A debounce that collapses nineteen starts into one is indisputably correct AND indistinguishable from nineteen starts vanishing unless something writes it down. `voltro workflows flow` and `GET /_voltro/inspect/workflows/flow-control` show it.
124
+
125
+ Also in this change set:
126
+
127
+ - **`awaitEvent({ event, schema, match })`** — wait on a CORRELATION rather than on an execution id. `awaitSignal` requires the sender to already know which run to wake, so a workflow waiting on a webhook that carries an issue key needed an app-maintained lookup table. The predicate is ordinary JavaScript over the decoded event, and the compiler checks it. - **`sleepUntil({ name, until })`** — the instant is journaled first, so a run that suspends and replays does not recompute the delta against a now that is already past the target and sleep the whole period again. - **`onFailure`** fires for every way a run fails to deliver, including the two that produce no run row at all (`timeouts.start` expiring a queued start; the workflow renamed away while starts were queued) — which is exactly why polling `listRuns({ status: 'failed' })` could never see them. - **`encryptSteps: true`** encrypts the journaled step `input` / `output` / `errorCause` with the cipher `governancePlugin({ fieldEncryption })` already registers. Declaring it without that plugin is a boot refusal, not a warning: a plaintext fallback would leave the declaration reading as protection. - **`voltro workflows pause|unpause <name>`** — a paused workflow COLLECTS. Never discards.
128
+
129
+ A workflow that declares no control takes exactly the path it took before this existed, and an undeclared control costs zero round trips.
130
+ - **@voltro/cli** — A failed `voltro dev` SSR render now reports how many hot updates the process has absorbed since boot, and every failure carries `x-voltro-ssr-generation`.
131
+
132
+ Not telemetry — PROVENANCE for a measurement. A consumer filed and unfiled the same item twice in one afternoon, in both directions, because the same route on the same code answered 200 and 500 depending only on which edits the watcher had processed since boot. Their conclusion is the right one and it belongs to both sides: otherwise two parties judge one item against two different module graphs and each concludes the other was careless.
133
+
134
+ A hard restart on every edit would trade one broken feedback loop for a slower one — a 224-page app is not free to reboot. What costs nothing is letting every failing response say which graph produced it. `0` means nothing has changed since this process started, which is the only state in which a dev-SSR measurement is worth reporting; anything else prints an explicit instruction to restart and measure once from a fresh boot.
101
135
 
102
- - **@voltro/cli** Cross-replica delivery is now tested over a network that is not loopback.
136
+ Counted for suppressed hot updates too: a module we chose not to reload is still one whose bytes on disk no longer match what this process serves, which is precisely the divergence the number exists to expose.
137
+ - **@voltro/cli** — `voltro doctor` flags a framework cookie name written as a string literal (`voltro:locale`, `voltro:theme`, or the pre-0.30.0 `voltro:lang`) and names the constant to import instead.
103
138
 
104
- This closes the one item repeatedly written off as needing external infrastructure"two real pods over a real network". That was the wrong variable. What a loopback number cannot show is a path with LATENCY, JITTER and a bandwidth ceiling, and injecting those is not only possible in the test stack, it is BETTER than a real network for a test: reproducible, and degradable on purpose.
139
+ A cookie name the FRAMEWORK reads and the APP writes is a public API and the only kind where both sides can disagree with nothing failing. Nothing throws, no page breaks: the resolver finds nothing and falls back to `Accept-Language`, so the symptom is a language preference that quietly stops working for the subset of users whose browser language differs from their choice. The least likely thing anyone tests.
105
140
 
106
- `toxiproxy-test` joins `test/docker-compose.yml` as a degradable path to `redis-test`. Measured through it:
141
+ Raised by a consumer ahead of the `voltro:lang` `voltro:locale` rename, in their words: *"your codemod will presumably rewrite the literal. Ours were two bare strings in two components, which is exactly the shape a codemod misses one of."* The codemod does rewrite every literal it can see. This rule covers what a codemod structurally cannot — and, more usefully, the NEXT rename, for which no codemod has been written yet.
142
+ - **@voltro/cli** — New `mobile` template kind + scaffolder support for Expo (React Native) apps. `voltro create-project <name> --mobile` (defaults to the `mobile-app` template) and `voltro add-app <name> --template=mobile-app` scaffold an Expo app that consumes your api with the same typed hooks. A `mobile` app deliberately gets NO port and is NOT part of `voltro dev`'s orchestration — Expo owns Metro (`expo start` / `expo run:ios`); the app connects to the sibling api over the network. `list-templates` shows the new kind; the template validation harness (`test-templates.mjs`) skips `kind: mobile` from its default sweep LOUDLY (the Expo/RN toolchain is heavy and simulator-bound — the template's pure logic is covered by its own tests). codemod: none — additive, no user-authored code changes. (The forward-looking design + the M0 gap — no RN-safe client boot yet — are in `plans/open/mobile/`.)
143
+ - **@voltro/client, @voltro/web** — `@voltro/client` now exports `buildApiRuntime` — the transport-level construction of one api's client stack (an rpc-client-over-WebSocket, its ManagedRuntime, a SubscriptionCache, an error bus, per-connection auth-header seeding). The WebSocket constructor is an INJECTED dependency, so React Native can build the SAME `ApiHandle` pieces the web client uses without pulling in `@voltro/web` — the keystone for mobile support (plans/open/mobile M0). `@voltro/web`'s `buildRuntimeAndClient` now DELEGATES to it (one implementation, no duplicate path; the web client-builder test suite stays green), and its `ResolvableHeaders` type is re-exported from `@voltro/client` (the owning lower layer) rather than defined locally. Also exported: `BuildApiRuntimeOptions`, `BuiltApiRuntime`, `ResolvableHeaders`. Additive — no consumer migration.
144
+ - **@voltro/cli** — `voltro dev` now tells you WHICH of two causes produced *"[React Intl] Could not find required `intl` object"*.
107
145
 
108
- | condition | p50 | p99 | delivered | | --- | --- | --- | --- | | 20 ms ± 10 jitter | 26 ms | 89 ms | 200/200 | | + a 50 KB/s ceiling | 188 ms | 354 ms | 200/200 |
146
+ That error is byte-identical whether there is no `<I18nProvider>` above the consumer or a provider built from a SECOND physical `react-intl` copy React contexts are identified by object identity, so a duplicate library has a duplicate context and the provider is present and invisible. The two causes have opposite fixes, and no red/green experiment in the app can separate them: the app's own provider comes from the app's own import, i.e. the instance its `useT()` already uses.
109
147
 
110
- Seven times slower at the median under the second, and not one envelope lost. That is the property the new suite asserts: **degradation costs latency, never messages.**
148
+ The dev server knows something the error does not — whether it supplied an `outerWrap` for that request. When it did, the 500 body and the log line now carry the duplicate-copy diagnosis and the one command that confirms it (`pnpm ls -r --depth 10 @voltro/i18n react-intl`), plus an explicit statement that the diagnosis is wrong if both resolve to a single version. It stays silent for an app that configures no locales, where "no provider" is the correct state.
149
+ - **@voltro/cli** — `voltro update --dry-run` now lists the codemods the target version puts **in range**, without installing anything and without touching your tree.
111
150
 
112
- The latency BUDGET is deliberately left in the healthy-path suite. Asserting it here would produce a test that goes red when the network is bad rather than when the code is and the second row above is exactly that case.
151
+ The obstacle was not the one we thought. The codemods for a jump ship INSIDE the target `@voltro/cli`, which is not installed when the preview runs — so the target VERSION is known before installing and the target REGISTRY is not. A preview that confused the two would list the codemods of the version you are leaving.
113
152
 
114
- codemod: none
115
- - **@voltro/plugin-audit, @voltro/cli, @voltro/runtime** — **`@voltro/plugin-audit` could not boot — a release blocker, reported within a day.** 0.26.0 attached `interceptAction` and `interceptQuery` and declared neither scope, so the boot permission audit (`level: fatal`) refused to start EVERY app carrying the plugin, whether or not it had opted into query auditing. The audit inspects the presence of a hook, not what it does, so the identity passthrough counted.
153
+ The registry is therefore republished as package METADATA (`voltro.codemods` in the published `package.json`, generated by `scripts/gen-codemod-manifest.mjs`, drift-checked in CI) and read with the SAME registry query that already resolves the latest version — project package manager first, `npm view` last. No tarball fetch, no temp install, no second package-manager surface. yarn and bun fall straight through to npm on purpose: `yarn npm info …` parses as `yarn run npm` on yarn classic and executes a same-named script, and that risk is not worth taking for a preview.
116
154
 
117
- The manifest declares both now — and `interceptQuery` is **attached** only when `recordQueries` is on, with its scope declared conditionally the way `store:write` already is. That is the reporter's suggestion and it is the better half of the fix: listing the scope unconditionally clears the boot while making every deployment DECLARE that it intercepts queries when almost none do, and a permission manifest is worth reading only if it describes what the plugin actually touches.
155
+ Two honesty properties, both load-bearing:
118
156
 
119
- Their diagnosis of why it escaped is what the guard is built from: *a plugin's own test suite exercises the plugin, not a boot with the plugin installed*. The same shape as the `gc-snapshots` dialect bug one round earlier the check that would have caught it is the one nobody ran on the affected path. There is now a test over the WHOLE `packages/plugin-*` set asserting that every hook a plugin ships has its scope named in its source, red-verified by reproducing 0.26.0.
157
+ - **"In range" is not "will apply".** `appliesTo` is a function and cannot cross a registry query, so the list is the upper bound on what a run can touch. The output says so. - **"Could not look" never prints as "nothing to do".** A target published before this field existed, or an unreachable registry, produces an explicit *"This is NOT the same as no codemods"* because the whole reason to preview is to decide whether to stash a dirty tree.
120
158
 
121
- **The stale-`source:` warning fired on the framework's own tables.** It resolved against the app's discovered entities, so every table the framework contributes conditionally — `_voltro_agent_messages` / `_voltro_agent_threads` behind a `*.agent.tsx`, and every plugin's `extendSchema.tables` — read as missing. The reporter got two warnings on every boot, for two sources that were correct, about a table the framework itself had created.
159
+ Asked for twice by a consumer who established the answer by grepping their own call sites instead.
160
+ - **@voltro/plugin-webhooks, @voltro/cli, @voltro/devtools-ui** — The Webhooks panel's Events tab shows **two** facts side by side: whether the event was ever DELIVERED, and whether `emit(...)` ever RAN.
122
161
 
123
- Their argument for why that is worse than cosmetic is the one that shaped the fix: this warning exists because a stale `source` is otherwise silent, so its entire value is being trusted. Firing on correct rows teaches the reader it is noise, and the next real one arrives into a warning nobody reads.
162
+ `everDelivered: false` conflates three different things no emit call site, a call site that ran before anyone subscribed, and one whose payload every target's filter excluded (or every target was paused). Only the first is a defect, and it is the one a consumer spent a week finding by hand: seven of eleven advertised events had no emit call site anywhere. Delivery history also ages out at 90 days, so a quiet-but-working event decays into looking dead.
124
163
 
125
- It resolves against the full live set nowapp entities + plugin `extendSchema.tables` + framework tables, the same set auto-migrate emits DDL for which means it runs after that set is assembled rather than inside `loadDiscovered`. Both boot paths do it, pinned by an ordering test.
164
+ `_voltro_webhook_event_stats` carries one row per event, stamped on every emit **regardless of whether any target matched** the axis delivery history structurally cannot see. Not tenant-scoped (the question is whether the CODE has a live call site, not whether a tenant has triggered it) and not retention-swept (a quarterly event must not read as dead). The write is best-effort and silent on failure: this is telemetry for a dashboard column and must never be the reason a delivery does not go out.
126
165
 
127
- codemod: none
128
- - **@voltro/plugin-broadcast, @voltro/cli** — The broadcast namespace is normalised silently, and the silence reintroduces the hazard the namespace removes.
166
+ **An unreadable stats table reports as UNKNOWN, never as "never".** "We did not look" and "it never fired" are different answers and only one is a finding.
129
167
 
130
- Found by probing the broadcast surface the way the events and records surfaces were probed. `broadcastPlugin` accepted all nine bad shapes tried — whitespace, a bare `>`, a trailing dot, an empty string, no options at all — and the sanitiser handles every one of them correctly. **No declaration-time refusal is warranted, and that is the finding**, not a gap.
168
+ Two corrections rode along:
131
169
 
132
- What the probe surfaced is one step on: `my app` and `my.app` BOTH resolve to `my-app`. Two deployments configured DIFFERENTLY therefore share a channel, which is precisely what this option exists to prevent arrived at by way of the option itself. The docs already say that staging and production of one app share a name and only this variable separates them, which is exactly the case where someone types two values believing they differ.
170
+ - The existing activity label read *"{n} subscribed · NEVER emitted"* while being derived from delivery history. It says *never DELIVERED* now it may only claim what it actually knows. - **The cloud dashboard never received `eventActivity` at all.** The proxy's output schema did not name the field, so Effect's decode dropped it silently and the column rendered locally but not in the cloud the four-layer drift the maintainer rule exists to prevent, shipped since 0.29.0. Both dashboards now get it.
171
+ - **@voltro/cli, @voltro/devtools-ui** — **Bulk cancel and bulk replay — `voltro workflows cancel-many` / `replay-many`, plus a dashboard panel.**
133
172
 
134
- Nothing refuses: the resolved value is broker-safe either way, and failing a boot over a dot would be worse than the collapse. Both boot paths log the substitution when it changes what was written, and the message names the COLLAPSE rather than only the substitution the substitution alone reads as cosmetic. Silent when the value survives unchanged, and silent for the derived app-name default, which is not something an operator can act on.
173
+ A bad deploy leaves four thousand runs that must all stop, or four thousand that must all be re-driven once the downstream is fixed. Doing that one run at a time through a dashboard is not a workflow, and doing it with raw SQL is how a `_voltro_workflow_runs` row ends up marked `cancelled` while the engine keeps executing it.
135
174
 
136
- codemod: none
137
- - **@voltro/plugin-broadcast, @voltro/cli** — **`broadcastPlugin()` with `REDIS_URL` set no longer stays silently on `memory`.** `REDIS_URL` counted for RESOLUTION but not for INFERENCE — it took an explicit `connection` option to be considered — so the plugin fell through to the in-process bus while the branch that would have read the variable sat directly below. Two doc strings promised the fallback ("inferred from … `REDIS_URL`", "falls back to `REDIS_URL`").
175
+ ```
176
+ voltro workflows cancel-many --workflow tourNarration --reason "bad deploy"
177
+ voltro workflows cancel-many --workflow tourNarration --reason "bad deploy" --commit
178
+ voltro workflows replay-many --status failed --mode redrive --limit 200 --commit
179
+ ```
138
180
 
139
- The asymmetry is what made it expensive rather than merely wrong: cache, kv and ratelimit all follow `<NAME>_REDIS_URL` → `REDIS_URL`, so an operator sets one variable, reads `cache backend resolved: redis` in the boot log, and concludes the bus did the same. A reporter did exactly that, on a single-replica deployment where the difference is unobservable — it appears on scale-up, as "some screens miss some events".
181
+ Three decisions are deliberately stricter than the obvious design:
140
182
 
141
- The caution the opt-in encoded is obsolete: every channel now carries the app-derived namespace, so attaching to a shared server no longer means two apps read each other's traffic. The test that pinned the old decision is reversed with that reasoning in it rather than deleted.
183
+ - **`--limit` is required and there is no "all".** The cap IS the blast radius, and it costs one number. `truncated` in the result says whether more matched, so "did I get all of them" stays answerable without an unbounded verb ever existing. A result of "1000 cancelled" reads as "all of them" otherwise, at the exact moment that mistake is most expensive. - **It is a DRY RUN unless `--commit` is passed.** That is the opposite of the usual `--dry-run` flag, and deliberate: the default for a verb that can stop a thousand runs should be the one that stops none. The dashboard panel enforces the same order — the apply button does not exist until a preview has returned a number, because "this will cancel 412 runs" is a different sentence from "412 runs were cancelled". - **`--reason` is required for a cancel.** It lands on every affected run's `run-cancelled` event, so "why did four thousand runs stop on the 8th" has an answer in the same table an operator is already reading.
142
184
 
143
- **The producer scan sees a locally bound publisher.** `\.publish\s*\(` misses
185
+ The result is per-run, not a count: `succeeded`, `failed` (with the reason for each) and `skipped` (with what made each ineligible) are three different outcomes. A bulk op that reports "4000 cancelled" while forty failed is how people learn not to trust bulk ops.
144
186
 
145
- const publish = ctx.publish if (publish === undefined) return await publish(descriptor, {}, payload)
187
+ Eligibility follows the verb rather than a flag: a cancel acts on `running` and `suspended`; `replay --mode redrive` on `failed` only (redrive resumes from the step that died, which only exists for a failure); `replay --mode retry` on `failed` and `cancelled`. `--mode` has no default because the two cost very different amounts.
146
188
 
147
- which is not a corner case but the shape a handler writes when it guards the optional publisher. A reporter spent a quarter hour hunting for a missing publish they had just written, because the warning said their working event was dead. A false negative here is a missed warning; a false POSITIVE is a warning that lies about working code, and that is the expensive direction.
189
+ Each verb delegates to the SINGLE-run operation beside it the shared canceller, `retry`, `redrive` so a bulk path cannot end up performing a different set of side effects from the button next to it.
148
190
 
149
- A bare `publish(` now counts, but only in a file that mentions `ctx.publish` or `ctx.events` `publish` is too common a name to accept unqualified, and the qualifier also covers the `async ({ publish })` destructuring the dotted form misses for the same reason. Both directions tested.
191
+ The dashboard panel is gated on a NEW capability, `canBulkOperateRuns`, rather than on `canPauseWorkflow`. The argument that made pause safe to expose is exactly why: a pause COLLECTS starts and never discards one, so its worst outcome is a backlog. A bulk cancel destroys work already in flight. In the cloud dashboard it is `owner`-only.
192
+ - **@voltro/workflow, @voltro/cli, @voltro/devtools-ui** — **`cancelOn` — stop a workflow's live work when a correlated event arrives.**
150
193
 
151
- codemod: none
152
- - **@voltro/plugin-broadcast** — `broadcastPlugin` refuses a request it cannot honour instead of downgrading it silently.
194
+ ```ts
195
+ cancelOn: [{
196
+ event: 'jira.issue.deleted',
197
+ schema: JiraIssueDeleted,
198
+ match: (event, payload) => event.issueKey === payload.issueKey,
199
+ }]
200
+ ```
153
201
 
154
- Probed the way the events, records and presence surfaces were: five plausible mistakes, **five accepted**, and every one produced the same outcome the in-process memory bus with a successful boot.
202
+ Both sides are typed: the event from the entry's own `schema`, the payload from the workflow's.
155
203
 
156
- | written | got | said | | --- | --- | --- | | `provider: 'redes'` (typo) | memory | nothing | | `url: 'http://x'` | memory | nothing | | `url: ''` | memory | nothing | | `provider: 'redis'`, no url anywhere | memory | nothing |
204
+ **Why a declaration rather than a race inside the body.** "Stop when the issue is deleted" is expressible with `awaitEvent` and an interrupt, and that works while the body is RUNNING. It does not work while the run is sleeping for six hours, suspended on a signal, or still sitting in the admission queue — which is the case cancellation was wanted for. The event has to reach a run whose fiber is not executing anything, and only something outside the body can do that. So it is swept: a coordinated tick reads events published since a durable watermark (`_voltro_workflow_watermarks`), resolves each declaring workflow's live runs, and cancels the ones that correlate.
157
205
 
158
- On one replica each of these is indistinguishable from working. They appear on the second, as "some screens miss some events" which is the report that led here, and it cost a consumer a deployment.
206
+ **It also discards QUEUED starts of the same workflow.** Cancelling only the running one leaves a debounced or concurrency-queued duplicate to start seconds later against the row that was just deleted — the exact outcome the declaration was meant to prevent, arriving late enough that nobody connects the two.
159
207
 
160
- The asymmetry that decides it: **an app that configures nothing has taken a default, and memory is the honest answer. An app that writes `provider: 'redis'` has stated a requirement**, and answering a requirement with a downgrade is the shape removed everywhere else in this codebase.
208
+ Three rules that are stricter than they look, each protecting against a way this would otherwise be silently wrong:
161
209
 
162
- So configuring nothing still takes memory, an explicit `provider: 'memory'` is still honoured saying it out loud must not be worse than saying nothing and a bare redis url still resolves without naming the provider. What throws is only the case where the request cannot be met: an unknown name (listing the valid ones, so the fix does not need the docs), a url whose scheme names no provider, and a named provider with no url anywhere (naming the variables that would satisfy it).
210
+ - **`match` is required.** The omitted case would mean "cancel every live run of this workflow", which is a legitimate thing to want and a catastrophic thing to acquire by forgetting a line. `match: () => true` says it out loud. - **A run that started AFTER the event is never cancelled.** A sweep catching up after a deployment gap reads an hour of history; without this it kills runs that started in the meantime, and the symptom looks nothing like the cause. - **An event that fails to decode is REPORTED and never matched.** Cancelling on an event you could not read is cancelling blind.
163
211
 
164
- Red-verified: with the refusal removed, the two tests that assert it go red.
212
+ The cancel itself goes through the same code the operator's cancel button uses — engine interrupt, row flipped, `run-cancelled` recorded with the event name, children closed because a second implementation would inevitably have done three of those four.
165
213
 
166
- codemod: none
167
- - **@voltro/cli** — Cross-replica delivery is now tested across a broker OUTAGE, not only a healthy or a degraded link.
214
+ Wired through the one shared builder both `voltro dev` and `voltro serve` call, and shown in the dashboard as a `cancelOn:<event>` badge on the declaring workflow, so "which event stops this" is answerable without reading the source.
168
215
 
169
- The suites here proved delivery on a working link, and one proved it on a throttled one. None broke the link. That is the failure an operator actually meets — a redis restart, a failover, a partition that heals — and it was the last untested shape in the realtime stack.
216
+ Also in this change set, from a review of the above:
170
217
 
171
- **The property asserted is recovery, not delivery.** A broker that is down cannot carry messages, and claiming otherwise would be exactly the sort of guarantee this repo keeps removing. What must hold is that the link heals BY ITSELF: after the outage, delivery resumes with no process restart, no app-side retry and no resubscribe. A subscriber that silently stays dead after a blip is the worst realtime failure there is, because the screen keeps rendering and nothing reports it.
218
+ **A DISCARDED queued start now writes a ledger row.** Both paths that drop one an operator's discard button and a `cancelOn` event deleted the pending row and recorded nothing. That is precisely the failure `_voltro_workflow_admissions` exists to prevent, committed by the feature that argues against it: from the outside, a start deliberately discarded and one that silently vanished are the same observation, a row that is no longer there. `outcome: 'discarded'` is a new member of the ledger's enum (a `_voltro_*` column change, so it rides the declarative differ on `voltro db apply` and on a `voltro dev` boot, on every dialect — no codemod).
172
219
 
173
- The test proves the link worked BEFORE it breaks it, so a zero at the end cannot be blamed on a link that never worked. Red-verified: leaving the proxy disabled gives 0 recovered deliveries instead of 10.
220
+ **The `cancelOn` sweep reports its own health**, in the Flow tab rather than only in a log line. `problems` is the field that matters: an event whose SHAPE changed makes cancellation silently stop firing — the run keeps going, which is the safe direction, and nothing about the run says a cancellation was attempted and could not be evaluated.
174
221
 
175
- Also probed, and correct as found: a throwing listener does not kill the publish, does not stop its healthy siblings receiving, and does not leave the bus unusable afterwards. The 5 MB payload the bus accepts is fine the size gate sits at the public seam (`ctx.events.publish`) and measures the ENCODED wire form, which is the representation that can actually be rejected downstream.
222
+ Two bounds the first version was missing: the live-run read is paged (oldest-first, so a bounded sweep makes progress instead of re-reading the same page) and reports when it filled up; and a LISTING failure now HOLDS the watermark, because a tick that never evaluated those events must not advance past them. A cancel that was attempted and refused still advances those are different failures and only one of them is worth retrying.
223
+ - **@voltro/workflow, @voltro/cli, @voltro/devtools-ui** — **`concurrency.pool` — one budget shared across workflows.** Without it, a concurrency limit bounds one workflow's runs; five workflows that each call a rate-limited provider hold five separate budgets nobody meant to multiply. Declaring the same pool name makes them compete for ONE:
176
224
 
177
- codemod: none
178
- - **@voltro/protocol, @voltro/runtime, @voltro/cli** — **The credential bound covered one auth shape and the sentence did not say so.**
225
+ ```ts
226
+ // embeddings.workflow.tsx AND summarize.workflow.tsx
227
+ concurrency: { limit: 10, pool: 'openai' }
228
+ ```
179
229
 
180
- We wrote that "an event subscription can no longer outlive the credential that authorized it" and, a release later, that "the bound now covers EVERY realtime primitive". Both were true only for the `voltro:session` cookie: `sessionExpiryFromHeaders` read that cookie and nothing else, so for an app authenticating with Bearer JWTs the bound was always `undefined` a no-op that reads as a guarantee.
230
+ `key` still partitions WITHIN the pool (`(p) => p.tenantId` in each member a per-tenant shared budget). Every member must declare the SAME `limit` the boot fails on a disagreement, naming every workflow involved, because two numbers for one budget is a contradiction and silently picking either would enforce a limit somebody did not write.
181
231
 
182
- A reporter found it by expecting black screens an hour after a deploy and getting none. Their framing is the one to keep: **the guarantee was not false, it was scoped to an auth shape the sentence did not name** and we had corrected a different sentence in the same release for exactly that reason.
232
+ Mechanically, the pool is spelled into the stored concurrency key (`pool<NUL><name><NUL><key>` — NUL separators so an app key function cannot collide with it by accident), so the pending row, the ledger row and the drainer all group pool-wide without any of them knowing pools exist. The count query drops its per-workflow filter exactly when a pool is declared; two UN-pooled workflows with a coincidentally-equal key stay separate budgets, and a test pins that boundary in both directions. The dashboard renders the pooled spelling as `pool:<name> · <key>`.
183
233
 
184
- There was also no seam to close it with. `StrategyResolution` was `{ matched, subject }`, so the strategythe only place in the system that verified the token and holds its `exp` could not report it.
234
+ Also in this change: the unreleased `concurrency.scope: 'replica'` option is GONE before ever shipping. It was resolved and then read by nothing a knob that did nothing distinguishable and it cannot be coherent in this model: deferred starts queue in the SHARED pending table and are drained by whichever replica has capacity, so a per-process count has no meaning. The limit is deployment-wide, enforced through the shared admissions ledger, full stop.
185
235
 
186
- It can now: `{ kind: 'matched', subject, credentialExpiresAt? }`, optional, with absent still meaning no bound. The shared JWT strategy reports its verified `exp`, which covers all six catalog providers (auth0, clerk, kinde, oidc, supabase, workos) in one place rather than six near-identical lines that drift.
236
+ codemod: none `pool` is additive and `scope` never appeared in a published release.
237
+ - **@voltro/runtime, @voltro/cli, @voltro/devtools-ui** — **Server-side run filtering + a throughput/failure chart, across both dashboards.**
187
238
 
188
- The expiry rides WITH the subject through the chain and is recorded on the per-connection channel that already carries subject overrides, so `ConnectionInfo` reads it instead of re-deriving from headers. Two sites deriving one fact is what let the cookie path and the bearer path disagree. Both boot paths do it, in the same change.
239
+ The runs surface used to fetch the newest N rows and filter in the browser fine at a hundred runs, useless at a hundred thousand, where the five failed runs you are hunting have long scrolled out of the fetched page.
189
240
 
190
- Tested for both shapes including that `resolveScopes`, which rebuilds the subject, does not drop it. That would have reopened the hole for every app using the seam we point people at for this kind of augmentation.
241
+ - **`ctx.workflows.listRuns(...)` and `GET /_voltro/inspect/workflows/runs`** gain composable server-side filters: `statuses` (several at once), `source`, `tagContains` (`q=` the search-box semantic, where `tag` stays exact), `idPrefix` (matches the run id OR the execution id, so an operator never has to know which kind their log line carried), and a `startedAfter`/ `startedBefore` time range. The dashboards' filter bars send exactly these; the shared `WorkflowsPage` keeps its client-side filtering as a second layer, so an older api that ignores the params still renders a correctly-filtered page just off a larger fetch.
242
+
243
+ - **`GET /_voltro/inspect/workflows/stats`** returns ~48 buckets of run activity over a trailing window (`hours` up to 168, optional `tag`), each with started/succeeded/failed/cancelled counts plus per-workflow totals. Computed by the app itself and PROXIED to the cloud dashboard, so both dashboards render the same aggregation instead of two derivations that drift. When the window exceeded the scan cap the response says `truncated: true`, and the chart renders that as a warning — a silently-truncated chart shows throughput dropping at exactly the moment it spiked.
244
+
245
+ - **`WorkflowThroughputChart`** (devtools-ui) — a dependency-free SVG stacked-bar chart (green delivered / red failed / grey cancelled / blue in-flight), rendered on the Runs tab and in per-workflow detail mode in the local AND cloud dashboards.
246
+
247
+ The cloud runs subscription (`apps.inspectWorkflowRuns`) accepts the same filters — time range included — and applies them inside the reactive predicate, so deltas for filtered-out runs never reach the browser.
248
+
249
+ - **The filter bar grows a TIME RANGE** (two `datetime-local` inputs), URL-persisted like the other filters. Deliberately NOT part of saved views: an absolute range goes stale the moment it is saved — "last Tuesday" is a moment, not a view — and silently re-applying it later filters to an empty page that reads as "no runs".
250
+
251
+ - **The overview chart lists the busiest workflows** in the window (per-tag started/ok/failed), each linking into that workflow's detail view.
252
+
253
+ - **`voltro workflows list`** gains the same triage flags (`--statuses a,b`, `--q`, `--source`, `--id-prefix`, `--since`/`--until` — an unparseable instant fails loudly at the flag rather than returning an empty page), and **`voltro workflows stats`** renders the chart in the terminal: a unicode sparkline for started/failed plus per-workflow totals, with the same never-silent truncation warning.
254
+
255
+ codemod: none — all additive.
256
+
257
+ ### Fixed
191
258
 
192
- codemod: none
193
- - **@voltro/cli** — **Records and presence are now PROVEN cross-replica, not asserted.**
259
+ - **@voltro/cli** — **Re-issued the credential-purge query, because the correction to it could not reach the people who ran the wrong one.**
194
260
 
195
- Asking one question across the whole surface *which primitive is proven cross-replica against a real broker?* gave an answer no amount of bug-fixing had:
261
+ `0.28.0/04_audit-redacts-subject-metadata` originally printed `subject::text ILIKE '%token%'`postgres-only, and its natural MySQL/MariaDB translation (`LIKE`) is case-SENSITIVE against the `utf8mb4_bin` collation our own migrator emits for a `json()` column. `'%token%'` therefore does not match `jiraToken`. A team ran it over 141 rows, got `0`, and nearly filed themselves clean; 117 of those rows held a working credential.
196
262
 
197
- | primitive | before | | --- | --- | | events | seven suites: partition, broker outage, degraded network | | records | **none** | | presence | **none** zero broker use in all three of its suites |
263
+ The 0.28.0 note was corrected and that correction is unreachable for everyone it concerns. `selectCodemods` picks `from < version <= to`, so a project that has already crossed 0.28.0 never runs a 0.28.0 codemod again, however wrong its note turned out to be. **A codemod note is delivered once, at a version boundary, and is not a document you can revise.** When one is found wrong after its version ships, the correction has to be re-issued under a version users have not yet landed on. `0.30.0/02_audit-purge-query-recheck` is that re-issue.
264
+ - **@voltro/database** — A `bytes()` / `crdtText()` column read over a reactive subscription or query threw on the CLIENT: `rowSchema`'s wire mapping used `Schema.Uint8ArrayFromSelf`, whose encode leaves a raw `Uint8Array` — `JSON.stringify` turns that into a numeric-keyed object (`{"0":1,…}`) the decoder then rejects. Every other column type in that module already crosses in a JSON-safe form (timestamp → epoch-ms number, bigint → decimal string); bytes was the outlier. It now crosses as a base64 string (Uint8Array in the handler, string on the wire), regression-covered by a full JSON round-trip for both `bytes()` and nullable `crdtText()`. codemod: none — the prior behaviour threw, so there is no working consumer to migrate. (Surfaced while building the api-collab/frontend-collab CRDT templates.)
265
+ - **@voltro/cli** — The `@effect/cluster@0.60.0` patch cast a message's `deliver_at` to `BigInt` for EVERY dialect (the fix was for mssql's tedious driver, which infers INT and overflows post-2001 epochs). But `@effect/sql-sqlite-node` runs `safeIntegers(true)`, where a bigint `deliver_at` breaks the due-message comparison — the cluster workflow engine polls forever, never delivers the message, and the workflow HANGS. This silently broke every cluster/workflow integration path on sqlite since the 0.60.0 bump (the whole sql-sqlite cluster suite timed out at ~95s and read as "flaky under load"). The cast is now dialect-conditional — `BigInt` only for mssql, plain number elsewhere (the pre-0.60.0 behaviour pg/mysql/sqlite always accepted). sql-sqlite: 86/86 in 12s (was 7 hanging at 96s); mssql's overflow fix preserved.
266
+ - **@voltro/cli** — `voltro dev`'s console capture no longer destroys the error it is passing through.
198
267
 
199
- "Multi-replica works" was proven for events and asserted for the other two, and they run through different code: events go bus → bridge → subscriber, records go `store.onChange` broadcast the peer's `injectExternalChange` dispatcher subscription. Only one had been driven end to end.
268
+ Node's `console.error` formats every argument with `util.inspect`. A React SSR failure carries the element/props graph, inspecting it can exceed V8's string cap, and `inspect` then throws `RangeError: Invalid string length` from `markNodeModules` which REPLACES the error being reported.
200
269
 
201
- **Presence** now proves what a consumer had to measure by hand with `redis-cli PUBSUB NUMSUB` because the framework was telling them the opposite: a member tracked on A appears in B's roster, opaque `meta` survives the hop, and a leave on A removes it from B. A roster that only ever GROWS across instances is the failure that looks like success.
270
+ **The framework is what made that fatal rather than merely ugly.** `voltro dev` installs a console wrapper on every boot and its first act was an unguarded pass-through, so the RangeError propagated out of `console.error` itself. A consumer chased a one-line dev-SSR i18n bug across two rounds through this mask and only recovered the real message by neutralising `console.error` from their own app code.
202
271
 
203
- **Records** cost three wrong attempts, and the reason is worth more than the test. Two independent in-memory stores cannot model this: `injectExternalChange` NOTIFIES without persisting deliberately, because replicas share a DATABASE and the peer re-reads storage they have in common. With separate stores the notification arrives (measured: called exactly once) and the re-read finds nothing, so no delta is emitted. Correct behaviour against an incorrect topology — and reported as a defect it would have sent someone hunting the bus for a bug that is not there. The suite runs one postgres, two stores, two dispatchers.
272
+ The pass-through now retries with bounded arguments and says that it did. Truncation that announces itself is the point: a message that silently stops looks like a short message, and the reader draws conclusions from it. Ordinary console output is untouched the guard is a fallback, not a filter, and a wrapper that reshaped every line would be the mask with extra steps.
204
273
 
205
- Two harness errors along the way are recorded in the files rather than quietly fixed: `tracker.track()` alone is a LOCAL write (the route calls `announce(track(...))`), and a predicate literal is `{ column, op, value }` — using `kind` instead of `op` matched nothing, so the missing delta was correct. Both would have been reported as framework defects.
274
+ Red-verified: restoring the unguarded call turns two of the three new tests red.
275
+ - **@voltro/runtime** — A malformed CRDT update written to a `crdtText()` column no longer crashes the mutation with a cryptic `Unexpected end of array` from deep inside Yjs, and can no longer be stored raw to poison later reads. The server merge now validates every incoming update — folding it against the stored state, or an EMPTY state on a first write (previously a first write stored the bytes unchecked) — and a non-decodable update throws a clear, column-named error naming what a client must send. Surfaced while exercising the api-collab CRDT template.
276
+ - **@voltro/cli** — The `events: declared but not wired` check no longer calls every webhook event dead when an app emits through a shared helper.
206
277
 
207
- codemod: none
208
- - **@voltro/cli** — The `no-consumer` half of the event audit sees sibling apps.
278
+ Two independent defects produced that, both fixed:
209
279
 
210
- It read the API app's own tree, and in a monorepo the `useEvent` calls are not there they are in the web apps beside it. A reporter had ten declared events, all ten consumed, all ten calls in ONE file in a sibling app, and got ten `no-consumer` warnings. A check that is wrong ten times out of ten carries no signal, and they ranked the two halves themselves: the producer half found them a dead trigger node that had not fired since a migration; the consumer half found nothing and spent the attention the producer half needed.
280
+ - **The emitter test required the webhooks service within 400 CHARACTERS of the `emit(`.** That is a claim about file layout, not about code. An app that funnels every emit through one helper has `import { useWebhooks as webhooks }` at the top and the call a hundred lines below. A consumer's only `.emit(` in their entire api reads `webhooks(ctx).emit(descriptor, payload)` and matched neither alternative. The qualifier now has to appear anywhere in the file, the same shape the bare `publish(` rule already used, plus the package specifier for the aliased-import case where no service identifier survives into the body.
211
281
 
212
- The siblings are not guessed from directory layout. `pnpm-workspace.yaml` declares them, so this reads what the workspace already says a project outside a workspace costs nothing, which is the common single-app case.
282
+ - **A funnel names no event, because the descriptor arrives as a VALUE.** A text scan cannot follow a value across a call boundary. That is not weak evidence of a dead event it is no evidence, in either direction, and the check reported it as the strongest kind: 29 of 29 events flagged "never published" on every boot, for an app where all 29 were live.
213
283
 
214
- Bounded at 4000 files, and LOUDLY: hitting the bound logs that a `no-consumer` line below may mean "we stopped looking" rather than "nothing consumes it". A silently truncated scan is the same false confidence one layer down, which is the defect this whole audit exists to remove.
284
+ The producer half now **abstains** for webhook events once an indirect emitter is found, and says so: `N webhook event(s) NOT verified Not a warning, and not a pass either.` Abstaining silently would be its own defect a check that stops reporting is indistinguishable from a codebase that got fixed.
215
285
 
216
- codemod: none
217
- - **@voltro/protocol** — `defineEvent` refuses four authoring mistakes it used to accept.
286
+ The abstention is scoped to the webhook audience. An in-app event still has `publish(` to find, and a genuinely dead webhook event is still reported in a project whose emit sites name their events.
287
+ - **@voltro/cli** — `react-intl` joins `react` / `react-dom` in Vite's `resolve.dedupe`, in `voltro dev` and in every `voltro build` SSR config.
218
288
 
219
- Found by probing what it lets through rather than by reading it: nine plausible mistakes were tried, nine were accepted. The surface had exactly two refusals, one of which (`latest` + `webhook`) is a model for the rest.
289
+ It carries a React CONTEXT whose two ends resolve from different roots: the framework builds `<I18nProvider>` by loading `@voltro/i18n` through Vite's SSR loader from its own dir, while the app's `useT()` imports it from the app root. Two physical copies means the provider is present and INVISIBLE — `useIntl` reads the other instance's context and throws *"[React Intl] Could not find required `intl` object"*, byte for byte the error you get when there is no provider at all.
220
290
 
221
- **Whitespace in a name is the severe one a production-only silence.** The name becomes a broker SUBJECT segment, and NATS refuses a subject containing whitespace and delivers nothing, with no error on the publishing side. An app that works on Redis stops working when the transport changes: silently, on one broker only. Refused at declaration, where the author can still see the string, and the message names the dot form to use instead.
291
+ A single-app fixture cannot surface this (only one copy ever exists), which is why the guard is the config rather than a test. Dev and build dedupe the same set on purpose an app that renders in one and not the other is the boot-path divergence class.
292
+ - **@voltro/runtime** — **`column(...)` in an `.aggregate({})` spec crashed the memory store.** The documented way to project a grouped key (`groupBy(['status']).aggregate({ status: column('status'), n: count() })`) has always compiled on every SQL dialect — and threw `computeAggregates: unknown op 'column'` on `store: 'memory'`. Worse, only once the table held a row: an empty table never reaches the evaluator, so the aggregate looked healthy exactly until it had data. Found live against the reference app's `orderStats` aggregate; the memory evaluator now answers the op from the group's key (every row in the bucket shares it by construction), pinned by a parity test.
222
293
 
223
- **`guards: []`** is refused because the enforcement in `bindEvent` runs only for a non-empty list — so it reads at the call site as if the event were protected and secures nothing. That is the declared-and-inert shape this codebase keeps finding; an omitted field is the honest spelling for unguarded.
294
+ codemod: none.
295
+ - **@voltro/database** — **In-memory Date predicates compared by REFERENCE, so every range boundary was off by one row.** `evaluatePredicate`'s comparator checked `lhs === rhs` before `>` — reference equality for objects — so two Date objects holding the SAME instant compared as "less than". `gte(startedAt, T)` EXCLUDED a row whose value was exactly T, `lt(startedAt, T)` INCLUDED it, and `eq`/`neq`/`in`/`notIn` never matched a Date at all unless it was literally the same object. SQL never had the bug (the compiler emits `>=`/`<`), which is what kept it invisible: the same query returned different rows on the memory store than on postgres, only at the boundary millisecond.
224
296
 
225
- **`webhook.rateLimit.perMinute: 0`** defers every delivery forever, and there is no "unlimited" spelling for the field, so 0 is almost always someone reaching for one. **`webhook.version: 0`** would make a subscriber pinned to 1 read the event as *behind* the opposite of what a version bump means.
297
+ Same defect class as the analytics sink that lost same-millisecond events an instant-boundary comparison whose failure is one row, at one millisecond, in one store. All comparators now normalise Dates to their instant (`equalsValue` / `compareNumeric`), and `datePredicateBoundary.test.ts` pins every operator on both sides of the boundary.
226
298
 
227
- Each message says what is wrong, why, and what to write instead; a test asserts that every refusal is more than one line, because a message that only names the rule leaves the reader guessing at the reason, and the reason is usually what they needed.
299
+ Affects everything the in-memory evaluator serves: the `store: 'memory'` store, the reactive engine's pre-filter, and unit-test fixtures which also means a test that "passed" against a memory fixture and failed against SQL at a time boundary was this, not your code.
228
300
 
229
- codemod: none
230
- - **@voltro/database, @voltro/runtime, @voltro/cli, @voltro/plugin-presence** — **`pluginRef` declarations survive `table()` and are readable as `table.appliedPluginRefs`.** They did not, and the consequence reached further than the reporter could see.
301
+ codemod: none.
302
+ - **@voltro/cli** — **An event published from a MUTATION never reached its durable audience — no event-log row, no triggered workflow, while the mutation reported success.** Two independent defects, one symptom, both boot paths:
231
303
 
232
- `pluginRefSpecOf` reads a column BUILDER; `table()` materialises builders into plain field descriptors. So the declaration vanished the instant the table existed, and the column read as an ordinary `text()`.
304
+ 1. **The events facade wrote through the mutation's TRANSACTION.** `publish` correctly defers the durable half to `lifecycle.afterCommit` but by then the transaction is closed, so the `_voltro_workflow_events` insert failed (or vanished into a discarded overlay) and the deliberate `.catch(() => {})` on the emit hid it. The facade writes through the BASE store now: post-commit facts do not belong to a closed transaction. (The OUTBOX intent stays on the transactional view on purpose — it is written DURING the handler and must die with a rollback.)
233
305
 
234
- A consumer's CRUD generator and their contract test both derive "which column carries the tenant" from the schema, both asked `type === 'reference'`, and a `pluginRef` column answered no — so generated junction handlers dropped the tenant sub-query and a favourite could point at another tenant's row. Their test missed it for the same reason the generator did: **a checker sharing the assumption of the thing it checks.** They caught it only because they happened to teach discovery about `pluginRef` before the generator; the other order ships the regression.
306
+ 2. **The trigger's workflow start was deferred TWICE.** The start closure wrapped itself in the post-commit facade even though it is only ever reached post-commit — so it pushed its real `start` onto an afterCommit drain that had already finished. The delivery row optimistically said `started` with a minted execution id, and the engine never saw the run: no run row, no admission entry, no error.
235
307
 
236
- **On our side it was worse and they could not have known.** The framework's own orphan-rule collector walked the column bag asking `pluginRefSpecOf`, got `undefined` every time, and produced ZERO rules on every real schemaso `orphanPolicy: 'delete'` did nothing, for the second release running. Its wiring test stayed green because it asserted the collector was CALLED, never that it returned anything.
308
+ Found LIVE, not by a test: the reference durable app's advertised chain (mutation `order.placed` trigger `orders.fulfill`) placed orders that never fulfilled. The action-shaped bridge tests stayed green throughout, because outside a transaction both stores are the same object and nothing defers which is exactly the shape the new regression test builds: a mutation-formed context with a lifecycle and a tx store that refuses writes after commit, asserting the event row exists AND the workflow really started. Both halves red-verified.
237
309
 
238
- A test against a real `table()` then found a THIRD defect immediately: the collector read `spec.target().name`, and a table's property is `tableName`, so every target resolved to undefined and the boot refusal fired for every `pluginRef`. The fixtures returned `{ name }` — confirming the wrong assumption rather than testing it.
310
+ Publishes from actions, schedules, startup hooks and workflow bodies were never affected.
239
311
 
240
- That confusion had spread. The stale-`source:` resolver in BOTH boot paths built its table set the same way, producing an empty set and `unresolvedSources` returns nothing for an empty set by design, so the warning silently stopped firing. **The fix for one false positive had turned the other into silence.** A narrow source guard now catches the shape; its own first run flagged a correct workflow read, which is recorded in the file, because a guard that opens with a false positive gets muted.
312
+ codemod: noneno user-authored code changes; the fix restores the documented behavior.
313
+ - **@voltro/runtime, @voltro/cli** — **`guards.rateLimit` on a reaction was neither per-key nor a limit — two defects, both reported from production.**
241
314
 
242
- **The presence broker warning fires after the bus attaches.** It ran at plugin activation, and the broadcast bus attaches later — the reporter measured 634 ms, then confirmed with `PUBSUB NUMSUB` that presence was cross-instance while the log said otherwise. The check is deferred and re-reads the transport at fire time. This exact warning had just found them a real misconfiguration and then kept reporting the fault after the repair, which is how a warning spends the credibility it earned.
315
+ It reads as a per-key cap. The runner keyed the limiter on the **reaction name**, so one cap covered every row and every tenant that reaction watched: an app with a hundred tenants got a hundredth of the throughput it declared, and the busiest tenant starved the rest.
243
316
 
244
- codemod: none
245
- - **@voltro/plugin-presence** — `presencePlugin` refuses a `timeoutMs` that expires members between heartbeats.
317
+ And the limiter was **in-memory, per process**. With three replicas the effective cap was 3×, and nothing in the declaration said so — the same config produced a different limit depending on how many pods happened to be running.
246
318
 
247
- The presence surface, probed the way events, records and broadcast were: five plausible mistakes tried, five accepted. Zero and a negative are the obvious two; the one worth the rule is a value SMALLER than the client's heartbeat, because that is the mistake with a plausible motive ("expire people quickly") and a silent failure.
319
+ ```ts
320
+ rateLimit: { limit: 10, windowMs: 60_000, key: (e) => e.new.tenantId }
321
+ ```
248
322
 
249
- `timeoutMs` is one half of a contract whose other half lives in the client. A member is online for `timeoutMs` after its last heartbeat, and `usePresence` beats every 15s by default. Below that, every member expires between beats the roster flaps empty and nothing reports it, because an empty roster is also what "nobody is here" looks like.
323
+ `key` partitions the cap; omitting it keeps the GLOBAL meaning, which is a legitimate thing to want (a cap on a scarce downstream) just not what the field appeared to offer. The limiter is now a claim in the shared store, using the same INSERT-wins arbiter the cron scheduler relies on, so the cap holds across replicas. A read-then-write would not: two replicas both read N-1, both fire, and the cap is exceeded by exactly the number of concurrent replicas.
250
324
 
251
- A consumer wrote that pairing down themselves ("our 10s heartbeat is the other half of the contract"), which is evidence the rule is real AND that it was left to the reader to work out. It is stated in both languages now, and the message names the CLIENT side, since a message naming only the server value sends the reader looking for the number in another package.
325
+ That forces a FIXED window (a sliding one needs prior timestamps, i.e. a read), with the standard artefact: up to the limit can fire across a bucket boundary. Stated rather than hidden, and a far smaller error than the it replaces transiently at a boundary versus permanently.
252
326
 
253
- A long window is still fine a signage terminal beating once a minute is a real deployment. The rule is a floor, not a range.
327
+ Where no durable claimer is wired (dev on the memory store) the per-process fallback remains, and `attachReactions` now says so ONCE at boot rather than leaving it to be discovered. The partition key applies there too, so the per-entity half of the fix survives.
254
328
 
255
- codemod: none
256
- - **@voltro/protocol** — `defineQuery` refuses three contradictions it used to accept — the same probe that found four on `defineEvent`, run against the records surface.
329
+ Also: **`act` can shape the workflow's payload.**
257
330
 
258
- That symmetry is the point rather than a coincidence. `guards: []` was refused on events an hour after it was accepted on queries, and a rule that holds for one primitive and not another is worse than no rule: the framework's answer then depends on which file the author happened to open.
331
+ ```ts
332
+ act: { kind: 'workflow', workflow: 'tourNarration', payload: (e) => ({ rowId: e.new.id }) }
333
+ ```
259
334
 
260
- - **`guards: []`** reads at the call site as if the procedure were protected and enforces nothing the check runs only for a non-empty list. - **An empty `source`** (`''`, `[]`, or a blank entry) declares reactivity and subscribes to nothing: one snapshot, never an update, indistinguishable from "nothing changed". It is worse than a STALE source, which the boot warning can at least name — this one names no table at all, so nothing can report it. - **`internal: true` + `overridesPlugin`** removes the plugin's route and puts something not wire-reachable in its place, so callers get a 404 for something that used to work with no diff that says so. It extends the existing `assertWireSurfaceConsistent` contract rather than adding a second rule beside it.
335
+ Without it the workflow's payload schema was dictated by the watched TABLE's row shape every column travelling whether the workflow wanted it or not, and a `timestamp()` column arriving as a `Date` on MariaDB and a number elsewhere, so apps were normalising on both sides of an idempotency key. Omitting `payload` keeps the changed row, exactly as before.
336
+ - **@voltro/runtime, @voltro/voltro, @voltro/cli** — **`apiSurface: compatible`, and the reason:** `bindMutation` gained a seventh parameter and it is OPTIONAL. Every existing call site compiles and behaves exactly as before — omitting it skips the new check entirely, which is the deliberate default for a caller that cannot name a schema. `@voltro/voltro`'s golden churns only because it re-exports runtime. Nothing was removed, narrowed, or renamed.
261
337
 
262
- Also settled, by reading the runtime rather than declining again: **`rewind` needs no rule.** It replays the pruned ring on attach — with `each` that is "catch up on what you missed", with `latest` it is "here is the current value". Both are meaningful, so the combination that looked suspicious is fine, and a test now pins that decision so the next reader does not re-open it.
338
+ A TAGGED error a procedure does not DECLARE no longer reaches the browser as the full `ExitEncoded<…>` decode tree.
263
339
 
264
- codemod: none
265
- - **@voltro/cli** — The one multi-replica scenario with no test: a replica that goes away, misses traffic, and comes back. The existing suites prove two replicas REACH each other, not what happens when one stops being able to.
340
+ It was a third category neither guard could see: the untagged-failure catch skips it (it has a `_tag`), `INFRA_ERROR_TAGS` skips it (it is not on a curated list), and the rpc encoder then cannot match it against the descriptor's `error:` union and ships the whole tree — ~2 KB for a one-line cause, with the message at the END so every tool that truncates shows the useless half. A consumer met it with `TenantScopeViolation`.
266
341
 
267
- It covers both directions of the claim that `missed` is COMPUTED and never estimated. **Under-reporting** is the silence this primitive exists to remove. **Over-reporting** is the freshly-started replica announcing a loss for messages it was never owed measured once at 5000, and the reason every delivery carries `prior`.
342
+ **Adding that tag to the infra list would have been wrong**, and that is the interesting part. `effectStore.ts` documents `error: Schema.Union(TenantScopeViolation, StoreOperationFailed, MyDomainError)` as a supported declaration, so an app that DECLARES it must still receive it typed. Collapsing unconditionally would break that app to fix the other one.
268
343
 
269
- The accounting identity is the assertion: every envelope owed after the resume point is either replayed or reported, and the two must sum to what was owed.
344
+ So the rule is a predicate, not a longer list: **tagged AND not representable by THIS descriptor's declared union** — `Schema.is(descriptor.error)`. The union IS the contract, so asking it directly cannot drift from what the encoder accepts. A call site that supplies no schema keeps the old behaviour exactly, rather than collapsing errors it cannot classify. Wired in dev AND serve: a sanitiser active on one boot path only is the drift class the parity guard exists for.
270
345
 
271
- **The first version of that test was vacuous, and the reason is worth recording because it is the fourth instance this session.** It published six envelopes into the default ring of 64, so the ring held everything, `missed` was always 0, and the identity was true by arithmetic for any implementation at all — sabotaging the computation to under-report by one left it green. The ring is now deliberately SMALLER than the traffic (`ringSize: 3`, ten publishes), the non-vacuity assertions come FIRST, and the same sabotage now fails it 8-to-9.
346
+ `defectMessage` now prefixes the `_tag` when there is one. A `Schema.TaggedError` with no `message` field rendered as an empty string, so the collapsed `InternalError` arrived correct, small AND useless half a fix for the tree it replaces.
347
+ - **@voltro/plugin-webhooks, @voltro/cli** — **`webhooks.subscribe(...)` could not write its own table.** From any authenticated executor it died with:
272
348
 
273
- `describeIfReachable`, verified both ways: with `REDIS_PORT=1` it reports two named skips rather than returning green having tested nothing.
349
+ ```
350
+ TenantScopeViolation: cannot insert into tenant-scoped table without an
351
+ authenticated tenant — subject.tenantId is null. Either authenticate first or
352
+ pass tenantId explicitly in the row (admin tooling).
353
+ ```
274
354
 
275
- codemod: none
276
- - **@voltro/runtime** — A resume replay could be **overtaken** by live traffic, delivering serials out of order.
355
+ `_voltro_webhook_targets` carries `.with(tenant())`; the mixin scopes by the REQUEST subject; the service is built once at boot with the app-level store and no subject. The READ path got its binding in 0.29.0 (`EmitOptions.tenantId`, from the acting subject). The WRITE path had the identical gap and no equivalent — and **both escapes the error message named were unreachable**: you cannot "authenticate first" against a subject-less service, and `SubscribeInput` had no `tenantId` to pass.
277
356
 
278
- Found by testing the case a busy app produces and the reconnect tests do not: a backlog being replayed at the same moment new envelopes are accepted, because a real reconnect does not pause the publisher. Measured resuming from `n=3` over a ring of 8, with an ordinary re-entrant publish from the listener at `n=6`, delivered `[4, 5, 6, 9, 7, 8]`.
357
+ `ctx.webhooks.subscribe(...)` now binds the acting subject's tenant, exactly as `emit` does, and `SubscribeInput.tenantId` exists for the admin tooling the message mentions. An explicit value at the call site wins; an explicit `null` survives (a deliberate system-wide subscription) rather than being replaced.
279
358
 
280
- The cause is an ordering that is right for a different reason. The listener is registered BEFORE the replay on purpose: it closes the window between reading the ring and going live, so nothing published in between is lost. What it does not do on its own is keep the two streams in sequencea live delivery reaches the listener immediately and jumps ahead of the entries still queued behind it.
359
+ **What this invalidates, and it cuts both ways:** a `count(*) FROM _voltro_webhook_targets` of `0` did not mean "unused". It meant "never worked". A consumer read their zero as "the feature is unannounced"; we read it as "not exposed". Neither was true, and the empty table looked like evidence to both of us. Anything downstream that rested on that zero an exposure assessment, a "nothing to purge" has to be re-asked now that a subscription can exist.
281
360
 
282
- Out-of-order is worse than loss for anything that folds state: a display applying an older frame after a newer one shows the past and stays there. And `n` arriving non-monotonically undermines the serial every gap number is computed from.
361
+ ### Internal (no consumer-facing effect)
283
362
 
284
- Live deliveries are now buffered for the duration of the replay and flushed after it, in arrival order, synchronously before `subscribe` returnsan async flush would reopen the window the early registration exists to close. Both properties hold: nothing is missed, and nothing overtakes.
363
+ - **@voltro/cli** Guard test (`clusterPatchDialectGuard.test.ts`) that fails fast if a dependency bump re-vendors the `@effect/cluster` patch with an unconditional `BigInt(deliver_at_in)` castthe exact shape that hung the sql-sqlite cluster/workflow suite for a week (safeIntegers(true) + a bigint deliver_at message never delivers → workflow hangs, misread as flakiness). Self-tested: the negative matcher catches the buggy line and passes the mssql-only conditional. Test-only, no consumer effect.
364
+ - **@voltro/cli** — One shared `walkSourceFiles`, and a guard that makes source-tree guards use it.
285
365
 
286
- The new tests also pin two things the quiet reconnect case cannot see: no serial is delivered twice when an envelope is in the ring at the moment of attach, and traffic arriving during a replay is not reported as a gap.
366
+ Our codegen and agent suites create scratch fixtures INSIDE `packages/cli/src` (`mkdtemp(join(here, '.agent-fixtures-…'))`) because the codegen imports them through vite's module graph, which is rooted at the package. A guard that walks `src/` concurrently races them, and the failure is always the same shape: the whole FILE dies at COLLECTION time with `ENOENT` on a path nobody recognises, and it is green when re-run alone — the signature people write off as flake.
287
367
 
288
- codemod: none
289
- - **@voltro/cli** — The socket.io comparison published one run per side. Both its numbers were noise, and it is corrected here with five runs each.
368
+ **Third occurrence, and that is why this is a function rather than another paragraph.** `ledgerReadPortability` hit it with `readdirSync` + `statSync` (two syscalls, one gap) and `packages/cli/CLAUDE.md` gained "any new guard that walks a source tree must do both". `broadcastNamespaceCoverage` then hit it while that rule was written down and current: it had `withFileTypes` — half the rule — and descended into a `.scan-fixtures-…` directory another suite had just removed.
290
369
 
291
- | | p50 median | p50 range | p99 median | p99 range | | --- | --- | --- | --- | --- | | Voltro cross-replica | **0.68 ms** | 0.58–0.86 | 7.01 ms | 4.50–13.36 | | socket.io + redis-adapter | 1.31 ms | 1.16–1.73 | **4.52 ms** | 4.33–7.83 |
370
+ `walkSourceFiles` has three properties, each load-bearing: one syscall per entry, dot-directories skipped (a scratch dir is never source, so this is right on its own terms), and a directory that vanishes mid-walk is skipped rather than fatal.
292
371
 
293
- The earlier table claimed "32% faster at the median, 44% worse at the tail". The median advantage is nearer **2x** the p50 ranges do not overlap at all and the tail gap sits INSIDE the overlap, so it is weaker evidence than a single pair of numbers made it look.
372
+ Six guards migrated one of which still carried the ORIGINAL `readdirSync` + `statSync` shape. `sourceWalkDiscipline.test.ts` fails if a file reads a package `src/` without importing the shared walker; its first version flagged four files that had just been migrated correctly (a single-level `readdirSync` enumerating package directories is the shape we WANT), so the rule is "import the walker", not "never call readdirSync".
373
+ - **@voltro/cli** — The cross-replica latency test can now tell a dropped MESSAGE from a dropped CONNECTION.
294
374
 
295
- **A single measurement presented as a fact is the defect this framework spends its time removing, and it was committed in its own benchmark.** The correction is the finding.
375
+ It asserted zero loss over a raw subscribe — a stronger claim than the transport makes. Redis pub/sub has no retention, so when a subscriber's broker connection blips, everything published during the blip is gone by design. The shortfall looks identical to real loss, and the assertion reported the first as the second: `expected 163 to be 200` inside a full gate run (80 packages plus an 11-service docker stack on 12 cores), while the same test passed 8/8 in isolation — including under 12 busy loops.
296
376
 
297
- **Where the tail comes from, measured rather than guessed.** Splitting the publish path: our own code — building the envelope, the Effect fiber per message, the handoff costs p50 **0.056 ms** / p99 **0.444 ms**. Waiting for Redis to acknowledge costs p50 1.17 ms / p99 6.43 ms.
377
+ **A loss check that a contended machine can trip cannot be trusted about loss, which is the only thing it exists to say.**
298
378
 
299
- So roughly 0.4 ms of a 7 ms tail is ours and the rest is the broker round-trip, which socket.io pays too. The `Effect.runPromise` per message was the leading hypothesis and the measurement cleared it. There is no code-level tail defect to fix on this machine the number is dominated by Docker's network stack.
379
+ The bus already publishes the fact needed to separate them: `kind: 'gap'` with a PROVEN `missed` count. The test now records gaps and, when any occurred, skips the loss assertion LOUDLY with the count a run that could not measure must not read like a run that measured nothing wrong. With no gap, a shortfall IS loss and still fails; red-verified by dropping every fifth delivery. The latency budget applies either way, guarded by a floor so the percentiles are never computed over a sample too small to mean anything.
300
380
 
301
- codemod: none
381
+ Production recovery for a real gap is unchanged and covered elsewhere (`busGapDetection.test.ts`): a live query is idempotent, so the bus detects the gap and re-runs.