@voltro/cli 0.27.0 → 0.29.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (284) hide show
  1. package/CHANGELOG.md +513 -0
  2. package/THIRD-PARTY-NOTICES.md +88 -2
  3. package/dist/{apiBuild-D22_EpoR.js → apiBuild-BESZbTjA.js} +4 -4
  4. package/dist/apiBuild-C8d74T6g.js +2 -0
  5. package/dist/bin.js +3 -3
  6. package/dist/checkCommand-BPAtyWs8.js +2420 -0
  7. package/dist/checkCommand-DBvZvCh1.js +2 -0
  8. package/dist/cliRuntime-Oh517vCV.js +96 -0
  9. package/dist/{commands-jBX8no1I.js → commands-82BDEktg.js} +6476 -7672
  10. package/dist/dbCommand-CaIVTp-a.js +2 -0
  11. package/dist/{dbCommand-uuNCrFAb.js → dbCommand-bCBbk6tz.js} +3 -3
  12. package/dist/{dev-DcbIJrWg.js → dev-Cg4BKLmi.js} +3034 -2619
  13. package/dist/dev-DeEJj5T4.js +3 -0
  14. package/dist/fileConventions-Cof68_BL.js +33 -0
  15. package/dist/{frameworkTableAssembly-BwHU9Euq.js → frameworkTableAssembly-4Db40V23.js} +2 -2
  16. package/dist/frameworkTableAssembly-BTNirAAk.js +2 -0
  17. package/dist/index.js +1 -1
  18. package/dist/{inspect-CUCCzw2I.js → inspect-BMvQpwqy.js} +211 -190
  19. package/dist/inspect-DHeaMZak.js +2 -0
  20. package/dist/{inspectMetrics-BU90mvJN.js → inspectMetrics-DHYygE4s.js} +999 -961
  21. package/dist/{manifestBuild-BnzAxp2O.js → manifestBuild-C4R1EdHn.js} +1 -1
  22. package/dist/manifestBuild-Ck_90gZy.js +2 -0
  23. package/dist/sdkgen-CW5NbYF0.js +582 -0
  24. package/dist/{seedRunner-D6eu-u5U.js → seedRunner-Bqxgp7HZ.js} +60 -59
  25. package/dist/serveCommand-BXl8mhZD.js +1489 -0
  26. package/dist/serveEntry.js +3 -3
  27. package/dist/{start-BGXIf6zT.js → start-CsCIaW4-.js} +282 -269
  28. package/dist/startEntry.js +2 -2
  29. package/package.json +17 -17
  30. package/templates/AGENTS.md +2 -1
  31. package/templates/agent-docs/_index.md +2 -1
  32. package/templates/agent-docs/_manifest.json +10 -1
  33. package/templates/agent-docs/ai.md +135 -0
  34. package/templates/agent-docs/cli.md +124 -6
  35. package/templates/agent-docs/configuration.md +32 -0
  36. package/templates/agent-docs/data.md +282 -0
  37. package/templates/agent-docs/database/migrations.md +47 -0
  38. package/templates/agent-docs/database/misc.md +57 -0
  39. package/templates/agent-docs/database/schema.md +3 -1
  40. package/templates/agent-docs/deployment.md +56 -0
  41. package/templates/agent-docs/internationalization.md +161 -1
  42. package/templates/agent-docs/local-first-mobile.md +414 -0
  43. package/templates/agent-docs/routing.md +93 -2
  44. package/templates/agent-docs/schema-driven-ui.md +12 -0
  45. package/templates/agent-docs/templates/apibackends.md +1 -1
  46. package/templates/agent-docs/testing.md +42 -0
  47. package/templates/agent-docs/whats-new.md +165 -131
  48. package/templates/agent-docs/workflows.md +11 -0
  49. package/templates/apps/api-ai/package.json +7 -7
  50. package/templates/apps/api-auth/package.json +8 -8
  51. package/templates/apps/api-backend/package.json +7 -7
  52. package/templates/apps/api-backend-deactivation/package.json +7 -7
  53. package/templates/apps/api-backend-mail/package.json +8 -8
  54. package/templates/apps/api-backend-mariadb/package.json +9 -9
  55. package/templates/apps/api-backend-sqlite/.env.example +19 -0
  56. package/templates/apps/api-backend-sqlite/README.md +38 -0
  57. package/templates/apps/api-backend-sqlite/app.config.ts +35 -0
  58. package/templates/apps/api-backend-sqlite/database/schema.ts +54 -0
  59. package/templates/apps/api-backend-sqlite/mutations/notes.create.mutation.server.ts +19 -0
  60. package/templates/apps/api-backend-sqlite/mutations/notes.create.mutation.ts +37 -0
  61. package/templates/apps/api-backend-sqlite/package.json +30 -0
  62. package/templates/apps/api-backend-sqlite/queries/notes.query.server.ts +14 -0
  63. package/templates/apps/api-backend-sqlite/queries/notes.query.ts +20 -0
  64. package/templates/apps/api-backend-sqlite/template.json +6 -0
  65. package/templates/apps/api-backend-sqlite/tests/notes.create.test.ts +50 -0
  66. package/templates/apps/api-backend-sqlite/tsconfig.json +5 -0
  67. package/templates/apps/api-backend-storage/package.json +8 -8
  68. package/templates/apps/api-cms/README.md +104 -0
  69. package/templates/apps/api-cms/actions/content.get.action.server.ts +27 -0
  70. package/templates/apps/api-cms/actions/content.get.action.ts +19 -0
  71. package/templates/apps/api-cms/actions/content.types.action.server.ts +26 -0
  72. package/templates/apps/api-cms/actions/content.types.action.ts +40 -0
  73. package/templates/apps/api-cms/actions/me.action.server.ts +18 -0
  74. package/templates/apps/api-cms/actions/me.action.ts +16 -0
  75. package/templates/apps/api-cms/app.config.ts +61 -0
  76. package/templates/apps/api-cms/content/blogPost.contentType.ts +39 -0
  77. package/templates/apps/api-cms/content/index.ts +18 -0
  78. package/templates/apps/api-cms/content/page.contentType.ts +24 -0
  79. package/templates/apps/api-cms/database/schema.ts +64 -0
  80. package/templates/apps/api-cms/mutations/content.publish.mutation.server.ts +19 -0
  81. package/templates/apps/api-cms/mutations/content.publish.mutation.ts +15 -0
  82. package/templates/apps/api-cms/mutations/content.saveDraft.mutation.server.ts +36 -0
  83. package/templates/apps/api-cms/mutations/content.saveDraft.mutation.ts +32 -0
  84. package/templates/apps/api-cms/mutations/content.unpublish.mutation.server.ts +19 -0
  85. package/templates/apps/api-cms/mutations/content.unpublish.mutation.ts +11 -0
  86. package/templates/apps/api-cms/package.json +32 -0
  87. package/templates/apps/api-cms/queries/content.list.query.server.ts +0 -0
  88. package/templates/apps/api-cms/queries/content.list.query.ts +27 -0
  89. package/templates/apps/api-cms/template.json +6 -0
  90. package/templates/apps/api-cms/tests/content.descriptors.test.ts +64 -0
  91. package/templates/apps/api-cms/tests/content.write.test.ts +85 -0
  92. package/templates/apps/api-cms/tsconfig.json +5 -0
  93. package/templates/apps/api-data-advanced/package.json +8 -8
  94. package/templates/apps/api-durable/package.json +8 -8
  95. package/templates/apps/api-feature-flags/package.json +9 -9
  96. package/templates/apps/api-governance/package.json +8 -8
  97. package/templates/apps/api-kv/package.json +8 -8
  98. package/templates/apps/api-moderation/package.json +8 -8
  99. package/templates/apps/api-observability/package.json +8 -8
  100. package/templates/apps/api-ratelimit/package.json +8 -8
  101. package/templates/apps/api-rbac/package.json +8 -8
  102. package/templates/apps/api-rest/package.json +7 -7
  103. package/templates/apps/api-saas/package.json +11 -11
  104. package/templates/apps/api-saas-starter/README.md +103 -0
  105. package/templates/apps/api-saas-starter/actions/me.action.server.ts +18 -0
  106. package/templates/apps/api-saas-starter/actions/me.action.ts +20 -0
  107. package/templates/apps/api-saas-starter/app.config.ts +87 -0
  108. package/templates/apps/api-saas-starter/database/schema.ts +57 -0
  109. package/templates/apps/api-saas-starter/mutations/invites.create.mutation.server.ts +26 -0
  110. package/templates/apps/api-saas-starter/mutations/invites.create.mutation.ts +18 -0
  111. package/templates/apps/api-saas-starter/mutations/projects.create.mutation.server.ts +29 -0
  112. package/templates/apps/api-saas-starter/mutations/projects.create.mutation.ts +18 -0
  113. package/templates/apps/api-saas-starter/package.json +32 -0
  114. package/templates/apps/api-saas-starter/queries/invites.list.query.server.ts +14 -0
  115. package/templates/apps/api-saas-starter/queries/invites.list.query.ts +17 -0
  116. package/templates/apps/api-saas-starter/queries/projects.list.query.server.ts +14 -0
  117. package/templates/apps/api-saas-starter/queries/projects.list.query.ts +18 -0
  118. package/templates/apps/api-saas-starter/template.json +6 -0
  119. package/templates/apps/api-saas-starter/tests/projects.create.test.ts +62 -0
  120. package/templates/apps/api-saas-starter/tests/session.test.ts +33 -0
  121. package/templates/apps/api-saas-starter/tsconfig.json +5 -0
  122. package/templates/apps/api-search/package.json +8 -8
  123. package/templates/apps/api-status/README.md +88 -0
  124. package/templates/apps/api-status/app.config.ts +36 -0
  125. package/templates/apps/api-status/authz.ts +33 -0
  126. package/templates/apps/api-status/database/schema.ts +70 -0
  127. package/templates/apps/api-status/mutations/components.create.mutation.server.ts +19 -0
  128. package/templates/apps/api-status/mutations/components.create.mutation.ts +16 -0
  129. package/templates/apps/api-status/mutations/incidents.create.mutation.server.ts +25 -0
  130. package/templates/apps/api-status/mutations/incidents.create.mutation.ts +22 -0
  131. package/templates/apps/api-status/mutations/incidents.resolve.mutation.server.ts +24 -0
  132. package/templates/apps/api-status/mutations/incidents.resolve.mutation.ts +19 -0
  133. package/templates/apps/api-status/mutations/incidents.update.mutation.server.ts +26 -0
  134. package/templates/apps/api-status/mutations/incidents.update.mutation.ts +21 -0
  135. package/templates/apps/api-status/package.json +30 -0
  136. package/templates/apps/api-status/queries/components.list.query.server.ts +14 -0
  137. package/templates/apps/api-status/queries/components.list.query.ts +13 -0
  138. package/templates/apps/api-status/queries/incidents.live.query.server.ts +14 -0
  139. package/templates/apps/api-status/queries/incidents.live.query.ts +20 -0
  140. package/templates/apps/api-status/queries/updates.list.query.server.ts +14 -0
  141. package/templates/apps/api-status/queries/updates.list.query.ts +17 -0
  142. package/templates/apps/api-status/template.json +6 -0
  143. package/templates/apps/api-status/tests/status.test.ts +70 -0
  144. package/templates/apps/api-status/tsconfig.json +5 -0
  145. package/templates/apps/api-versioning/package.json +8 -8
  146. package/templates/apps/api-webhooks/package.json +9 -9
  147. package/templates/apps/changelog/package.json +6 -6
  148. package/templates/apps/edge-functions/package.json +2 -2
  149. package/templates/apps/frontend-admin/package.json +8 -8
  150. package/templates/apps/frontend-app/package.json +8 -8
  151. package/templates/apps/frontend-auth/README.md +78 -0
  152. package/templates/apps/frontend-auth/app.config.ts +34 -0
  153. package/templates/apps/frontend-auth/package.json +32 -0
  154. package/templates/apps/frontend-auth/src/components/AuthShell.tsx +35 -0
  155. package/templates/apps/frontend-auth/src/components/PasswordStrength.tsx +33 -0
  156. package/templates/apps/frontend-auth/src/config.ts +11 -0
  157. package/templates/apps/frontend-auth/src/globals.css +105 -0
  158. package/templates/apps/frontend-auth/src/globals.d.ts +6 -0
  159. package/templates/apps/frontend-auth/src/lib/auth.ts +34 -0
  160. package/templates/apps/frontend-auth/src/lib/redirect.test.ts +24 -0
  161. package/templates/apps/frontend-auth/src/lib/redirect.ts +29 -0
  162. package/templates/apps/frontend-auth/src/locales/de.ts +66 -0
  163. package/templates/apps/frontend-auth/src/locales/en.ts +76 -0
  164. package/templates/apps/frontend-auth/src/locales/index.ts +14 -0
  165. package/templates/apps/frontend-auth/src/pages/forgot/page.tsx +51 -0
  166. package/templates/apps/frontend-auth/src/pages/layout.tsx +12 -0
  167. package/templates/apps/frontend-auth/src/pages/login/page.test.tsx +53 -0
  168. package/templates/apps/frontend-auth/src/pages/login/page.tsx +67 -0
  169. package/templates/apps/frontend-auth/src/pages/logout/page.tsx +28 -0
  170. package/templates/apps/frontend-auth/src/pages/magic/page.tsx +51 -0
  171. package/templates/apps/frontend-auth/src/pages/page.tsx +26 -0
  172. package/templates/apps/frontend-auth/src/pages/reset/page.test.tsx +51 -0
  173. package/templates/apps/frontend-auth/src/pages/reset/page.tsx +63 -0
  174. package/templates/apps/frontend-auth/src/pages/signup/page.tsx +60 -0
  175. package/templates/apps/frontend-auth/src/pages/verify/page.tsx +50 -0
  176. package/templates/apps/frontend-auth/template.json +6 -0
  177. package/templates/apps/frontend-auth/tsconfig.json +5 -0
  178. package/templates/apps/frontend-blank/package.json +7 -7
  179. package/templates/apps/frontend-cms/README.md +47 -0
  180. package/templates/apps/frontend-cms/app.config.ts +37 -0
  181. package/templates/apps/frontend-cms/package.json +33 -0
  182. package/templates/apps/frontend-cms/src/config.ts +8 -0
  183. package/templates/apps/frontend-cms/src/globals.css +105 -0
  184. package/templates/apps/frontend-cms/src/globals.d.ts +6 -0
  185. package/templates/apps/frontend-cms/src/lib/api.ts +58 -0
  186. package/templates/apps/frontend-cms/src/locales/de.ts +50 -0
  187. package/templates/apps/frontend-cms/src/locales/en.ts +55 -0
  188. package/templates/apps/frontend-cms/src/locales/index.ts +14 -0
  189. package/templates/apps/frontend-cms/src/pages/(app)/error.tsx +18 -0
  190. package/templates/apps/frontend-cms/src/pages/(app)/layout.test.tsx +31 -0
  191. package/templates/apps/frontend-cms/src/pages/(app)/layout.tsx +62 -0
  192. package/templates/apps/frontend-cms/src/pages/(app)/not-found.tsx +14 -0
  193. package/templates/apps/frontend-cms/src/pages/(app)/page.test.tsx +110 -0
  194. package/templates/apps/frontend-cms/src/pages/(app)/page.tsx +159 -0
  195. package/templates/apps/frontend-cms/src/pages/layout.tsx +12 -0
  196. package/templates/apps/frontend-cms/src/pages/login/page.test.tsx +58 -0
  197. package/templates/apps/frontend-cms/src/pages/login/page.tsx +93 -0
  198. package/templates/apps/frontend-cms/template.json +6 -0
  199. package/templates/apps/frontend-cms/tsconfig.json +5 -0
  200. package/templates/apps/frontend-contact/package.json +7 -7
  201. package/templates/apps/frontend-dashboard/package.json +7 -7
  202. package/templates/apps/frontend-docs/package.json +7 -7
  203. package/templates/apps/frontend-i18n/package.json +6 -6
  204. package/templates/apps/frontend-landing/package.json +7 -7
  205. package/templates/apps/frontend-portal/README.md +71 -0
  206. package/templates/apps/frontend-portal/app.config.ts +37 -0
  207. package/templates/apps/frontend-portal/package.json +32 -0
  208. package/templates/apps/frontend-portal/src/config.ts +8 -0
  209. package/templates/apps/frontend-portal/src/globals.css +93 -0
  210. package/templates/apps/frontend-portal/src/globals.d.ts +6 -0
  211. package/templates/apps/frontend-portal/src/lib/api.ts +62 -0
  212. package/templates/apps/frontend-portal/src/locales/de.ts +86 -0
  213. package/templates/apps/frontend-portal/src/locales/en.ts +93 -0
  214. package/templates/apps/frontend-portal/src/locales/index.ts +14 -0
  215. package/templates/apps/frontend-portal/src/pages/(portal)/api-keys/page.test.tsx +71 -0
  216. package/templates/apps/frontend-portal/src/pages/(portal)/api-keys/page.tsx +124 -0
  217. package/templates/apps/frontend-portal/src/pages/(portal)/billing/page.test.tsx +86 -0
  218. package/templates/apps/frontend-portal/src/pages/(portal)/billing/page.tsx +97 -0
  219. package/templates/apps/frontend-portal/src/pages/(portal)/error.tsx +19 -0
  220. package/templates/apps/frontend-portal/src/pages/(portal)/invoices/page.tsx +45 -0
  221. package/templates/apps/frontend-portal/src/pages/(portal)/layout.test.tsx +31 -0
  222. package/templates/apps/frontend-portal/src/pages/(portal)/layout.tsx +63 -0
  223. package/templates/apps/frontend-portal/src/pages/(portal)/not-found.tsx +15 -0
  224. package/templates/apps/frontend-portal/src/pages/(portal)/page.tsx +39 -0
  225. package/templates/apps/frontend-portal/src/pages/(portal)/profile/page.tsx +79 -0
  226. package/templates/apps/frontend-portal/src/pages/layout.tsx +12 -0
  227. package/templates/apps/frontend-portal/src/pages/login/page.test.tsx +58 -0
  228. package/templates/apps/frontend-portal/src/pages/login/page.tsx +96 -0
  229. package/templates/apps/frontend-portal/template.json +6 -0
  230. package/templates/apps/frontend-portal/tsconfig.json +5 -0
  231. package/templates/apps/frontend-saas/README.md +73 -0
  232. package/templates/apps/frontend-saas/app.config.ts +46 -0
  233. package/templates/apps/frontend-saas/package.json +32 -0
  234. package/templates/apps/frontend-saas/src/config.ts +8 -0
  235. package/templates/apps/frontend-saas/src/globals.css +85 -0
  236. package/templates/apps/frontend-saas/src/globals.d.ts +6 -0
  237. package/templates/apps/frontend-saas/src/lib/api.ts +47 -0
  238. package/templates/apps/frontend-saas/src/locales/de.ts +72 -0
  239. package/templates/apps/frontend-saas/src/locales/en.ts +81 -0
  240. package/templates/apps/frontend-saas/src/locales/index.ts +14 -0
  241. package/templates/apps/frontend-saas/src/pages/(marketing)/layout.tsx +30 -0
  242. package/templates/apps/frontend-saas/src/pages/(marketing)/login/page.test.tsx +58 -0
  243. package/templates/apps/frontend-saas/src/pages/(marketing)/login/page.tsx +96 -0
  244. package/templates/apps/frontend-saas/src/pages/(marketing)/page.tsx +27 -0
  245. package/templates/apps/frontend-saas/src/pages/dashboard/billing/page.tsx +72 -0
  246. package/templates/apps/frontend-saas/src/pages/dashboard/error.tsx +20 -0
  247. package/templates/apps/frontend-saas/src/pages/dashboard/layout.test.tsx +32 -0
  248. package/templates/apps/frontend-saas/src/pages/dashboard/layout.tsx +69 -0
  249. package/templates/apps/frontend-saas/src/pages/dashboard/not-found.tsx +17 -0
  250. package/templates/apps/frontend-saas/src/pages/dashboard/page.test.tsx +113 -0
  251. package/templates/apps/frontend-saas/src/pages/dashboard/page.tsx +76 -0
  252. package/templates/apps/frontend-saas/src/pages/dashboard/team/page.tsx +73 -0
  253. package/templates/apps/frontend-saas/src/pages/layout.tsx +12 -0
  254. package/templates/apps/frontend-saas/template.json +6 -0
  255. package/templates/apps/frontend-saas/tsconfig.json +5 -0
  256. package/templates/apps/frontend-spa/package.json +7 -7
  257. package/templates/apps/frontend-ssr/package.json +7 -7
  258. package/templates/apps/frontend-ssr-api/package.json +8 -8
  259. package/templates/apps/frontend-static-blog/package.json +6 -6
  260. package/templates/apps/frontend-status/README.md +51 -0
  261. package/templates/apps/frontend-status/app.config.ts +39 -0
  262. package/templates/apps/frontend-status/package.json +32 -0
  263. package/templates/apps/frontend-status/src/config.ts +7 -0
  264. package/templates/apps/frontend-status/src/globals.css +70 -0
  265. package/templates/apps/frontend-status/src/globals.d.ts +6 -0
  266. package/templates/apps/frontend-status/src/lib/status.ts +81 -0
  267. package/templates/apps/frontend-status/src/locales/de.ts +41 -0
  268. package/templates/apps/frontend-status/src/locales/en.ts +45 -0
  269. package/templates/apps/frontend-status/src/locales/index.ts +13 -0
  270. package/templates/apps/frontend-status/src/pages/layout.tsx +27 -0
  271. package/templates/apps/frontend-status/src/pages/page.test.tsx +123 -0
  272. package/templates/apps/frontend-status/src/pages/page.tsx +136 -0
  273. package/templates/apps/frontend-status/template.json +6 -0
  274. package/templates/apps/frontend-status/tsconfig.json +11 -0
  275. package/templates/baselines/compose/docker-compose.prod.yml +15 -0
  276. package/templates/baselines/compose-mariadb/docker-compose.prod.yml +15 -0
  277. package/dist/apiBuild-BrjrVJJh.js +0 -2
  278. package/dist/dbCommand-DrzXimKf.js +0 -2
  279. package/dist/dev-DNkso403.js +0 -3
  280. package/dist/fileConventions-3bffWssN.js +0 -30
  281. package/dist/frameworkTableAssembly-lrjZtk0G.js +0 -2
  282. package/dist/inspect-gt8bq-Tz.js +0 -2
  283. package/dist/manifestBuild-ifczArzr.js +0 -2
  284. package/dist/serveCommand-DfkisVWP.js +0 -1310
@@ -1,4 +1,4 @@
1
- # What's new in 0.26.0
1
+ # What's new in 0.29.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
@@ -9,240 +9,274 @@ BREAKING entries name a codemod; run `voltro update` to apply it.
9
9
 
10
10
  ### ⚠ BREAKING
11
11
 
12
- - **@voltro/protocol, @voltro/plugin-webhooks, @voltro/cli** — `defineEvent({ webhook: { retry } })` is removed. It never did anything.
12
+ - **@voltro/web** — **`useLoaderData()` throws where no `loader` is declared, and `useOptionalLoaderData()` is the way to read where one may be absent.**
13
13
 
14
- The field was typed, documented as "default retry policy for new subscriptions", and read by nothing `grep` for `spec.retry` across the repo returned no hits. Setting it produced no error, no warning and no effect: the value was dropped where an event descriptor is projected into an outgoing webhook descriptor, and a comment there explained why (the plugin's `RetryPolicy` is a richer shape than the two numbers the protocol carried, so forwarding it blind would install a policy nobody wrote). A test pinned that dropping as correct.
14
+ `useLoaderData()` was `useContext(LoaderDataContext) as LoaderData<T>` a cast over a context whose default was `undefined`. At a level with no `loader`, `const { project } = useLoaderData<Data>()` died at `Cannot destructure property 'project' of undefined`: a message naming the property rather than the mistake, and under `renderMode: 'ssr'` a throw that fails the entire server render instead of degrading. Reported by a consumer who spent a cycle on it.
15
15
 
16
- The reasoning was sound and the result was still wrong, because none of it reached the user: they wrote a typed option and got silence. This is the third instance of that exact shape in this feature`broadcast({ channel })` was declared, named in its own doc comment as the multi-deployment answer, and forwarded by nothing; an event's `guards` were accepted, serialised into the manifest, reported by doctor and counted in the devtools panel while nothing enforced them. Two were found by consumers. This one was found by walking the option surface and asking, per field, who reads it.
16
+ **What this is NOT: a `| undefined` return type.** That was the obvious fix and it is wrong. The router never renders a page that declares a `loader` without its data a settled loader commits its data and the displayed route together, a pending one renders the `Pending` skeleton (or keeps the previous page), and one that threw renders the error subtree; three separate branches. Widening the type would have taxed every correct call site to model a state the router already prevents.
17
17
 
18
- Retry belongs on the SUBSCRIPTION, where the full `RetryPolicy` shape is available and typed. If you set it on the event, delete it — nothing changes at runtime, because nothing was reading it.
18
+ So the return type is unchanged and the absence becomes loud instead:
19
19
 
20
- **The guard that exists for this class did not catch it, and that is the more important half.** `declaredOptionsEnforced.test.ts` checks a hand-maintained list of options; it can only re-verify the ones somebody remembered to add, and it covers no nested field at all. It was green throughout. Deriving that list from the type rather than curating it is filed as follow-up — the same lesson as `procedureWireReachability.test.ts`, which was satisfied at every site it knew about while the defect sat at a site it did not consider one.
21
- - **@voltro/workflow** — The `retry:` field on a workflow `step({...})` is now **ENFORCED**, not dashboard metadata. The framework compiles the declared policy to an Effect `Schedule` and retries `execute` accordingly — so `step({ retry: { maxAttempts: 5 } })` actually retries five times, no hand-written `Effect.retry` needed.
20
+ - a level with **no `loader`** throws, naming the cause and pointing at `useOptionalLoaderData()`; - **`useOptionalLoaderData()`** returns `undefined` there for the one legitimate case, a component genuinely mounted both under routes that declare a loader and routes that do not; - a loader that legitimately resolves to `undefined` does **not** throw. Declaring a loader and returning nothing is a choice; having no loader is not.
22
21
 
23
- It became a real, innovative policy while it was at it — the conditions you actually want, default-correct:
22
+ **An empty RESULT is not an absence.** A loader returning `{ items: [] }` returns exactly that through both hooks. `undefined` never means "the query found nothing" only "there is no loader at this level". The wire already made this distinction (`pageLoaderRan`, documented as "not derivable from loaderData"); the hooks now honour it too.
24
23
 
25
- - **Error classification** `retryableErrors: ['ProviderDown', 'RateLimited']` (retry only these typed-error `_tag`s; everything else fails fast) or `retryable: (error) => boolean`. Retry the transient, fail the permanent. - **A time BUDGET, not just a count** `maxElapsed: '5 minutes'` stops retrying once that much wall-clock has elapsed, even if attempts remain. A deadline. - **Jitter** `jitter` (ON by default) spreads retries so a fleet doesn't re-hit a recovering dependency in lockstep. - **Capped backoff** — `maxDelay` ceilings exponential growth; `strategy` (`exponential` / `fixed` / `linear`), `baseDelay`, `factor`, `step`. - **Provider-driven backoff** `respectRetryAfter` honors a `retryAfterMillis` / `retryAfter` hint on the error as a floor (a 429 `Retry-After`).
24
+ Internally the provider carries a `NO_LOADER_DATA` marker instead of `undefined`. It is still a provider, deliberately: dropping it for loader-less levels would let a page fall through to its layout's data and silently render a neighbour's value worse than the crash it replaces, because nothing would report it. Server and client set the marker from the same fact (`descriptor.loader` / `segment.loader`), so a component cannot render on one side and throw on the other.
26
25
 
27
- Retries run inside the one step and are transparent to the durable engine; the step's final outcome is recorded, and the serialisable knobs still feed the dashboard. `stepModule.retry(…, Schedule)` remains for full hand-written `Schedule` control.
26
+ Covered by `loaderDataAbsence.test.tsx` (both render paths, the shield, the empty-result and legitimately-undefined cases), red-verified by removing the marker.
28
27
 
29
- **BREAKING, and check the first half before the second.**
28
+ **`voltro update` carries you across this** codemod `0.29.0/01_loader-data-absence`.
30
29
 
31
- **A step that declared `retry:` and nothing else ran ONCE. It now runs up to `maxAttempts` times.** In 0.25.0 the field's own type said so — *"Pure metadata — does NOT change retry behavior on its own"* — so trusting it was correct. If `execute` is not idempotent (a charge, an email, an outbound POST), that is real duplicate work beginning on this upgrade, with nothing in your code changed to cause it. Per step: make the effect idempotent, or set `maxAttempts: 1`, or narrow with `retryableErrors: [...]` so only transient failures retry.
30
+ ### Added
32
31
 
33
- The second half is the one you can see in your own source: the old docs told you to ALSO wrap the step in `stepModule.retry` / `Effect.retry`, and a step that did both now retries TWICE. Keep the declarative `retry:` (it also drives the dashboard) and drop the redundant wrapper or, if your hand-written `Schedule` did something the policy can't express, keep it and drop `retry:` from that step.
32
+ - **@voltro/runtime, @voltro/cli** aggregate-derivation (sharpened): the mutation/action interceptor `meta` now carries the descriptor's DECLARED write-target table names (`meta.target`) for an audit/derivation consumer purely additive, omitted when no target is declared. And `voltro doctor` gains a junction-FK finding: it flags a link/junction table whose FK wiring is suspect (composite-PK member unwired, an unwired id beside a real `reference()` sibling, pure link-only table), reading real declared column types (not regex) with a tight non-FK-id exclusion so a lone foreign id on a normal entity stays silent. The FK-walk DSL + `voltro audit map` are deferred (single-consumer). apiSurface compatible additive exports + type-alias renumbering only. codemod: none.
33
+ - **@voltro/plugin-billing** — Stripe Checkout now collects a customer's VAT / tax ID (`tax_id_collection`), and reuses an existing customer's captured name + address so the tax ID attaches correctly. Additive; `automatic_tax` was already on.
34
+ - **@voltro/database, @voltro/protocol, @voltro/runtime** — New `rule()` primitive: `table(...).rule(name, predicate, { severity? })` declares a cross-table transactional invariant evaluated INSIDE the mutation transaction (predicate reads share the write's MVCC snapshot via the dialect-neutral query layer — correct on all four dialects, no per-dialect code). A violation rolls the transaction back and fails with the typed, wire-preserved `BusinessRuleViolation` (auto-merged into every mutation's error union at `mutationToRpc`, like `ScopeError`); `severity: 'warning'` logs + commits instead. codemod: none.
35
+ - **@voltro/cli** — Three CLI commands: `voltro typecheck` (runs `tsc --noEmit` using the APP's own TypeScript, resolved from the app's node_modules), `voltro info` (CLI/node/pm/dialect + every installed `@voltro/*` version, flags lockstep skew with a non-zero exit), and `voltro new <query|mutation|action|workflow|page> <name>` (scaffolds the correct file convention incl. the descriptor/executor split; refuses overwrite without `--force`). codemod: none.
36
+ - **@voltro/cli** — DNS-rebinding Host guard on the `voltro dev` inspect surface (the API dev server binds 0.0.0.0 and had no Host validation, unlike the web dev server's vite `allowedHosts`). Allows loopback names + any IP literal (an IP can't be DNS-rebound, so phone-on-LAN testing keeps working) + an operator `VOLTRO_INSPECT_ALLOWED_HOSTS` allowlist; any other Host domain → 403. Dev-only by design (prod is token-gated + served on a public hostname). codemod: none.
37
+ - **@voltro/datetime** — New `@voltro/datetime` package (Phase 1): UTC-storage + timezone-aware helpers on the TC39 Temporal standard, plus a request-scoped timezone-context seam (`@voltro/datetime/context`). The `.` entry is browser-safe (no `effect`, no `node:*`); `effect` is an optional peer for the context seam. `interval()`/`rrule()`/schema DSL types are deferred to later phases.
38
+ - **@voltro/runtime** — `defineExpectation()` — reactive data-quality contracts as standing reactions. An expectation over a table (`freshness`/`nullRate`/`rowCount`/`valueBounds`) is maintained incrementally from `store.onChange` CDC deltas by the same IVM engine that backs `defineAggregate({ incremental })` (O(1) per write, no re-query); it tips `holding`↔`violated` with the provenance (traceId/subject/procedure) of the write that caused it, observable via `ExpectationRegistry`. `freshness` also re-compares its IVM-maintained max against the moving clock (so "writes stopped" is detectable — a metric re-comparison, not a data poller). `*.expectation.ts` file discovery wires in the CLI. codemod: none.
34
39
 
35
- `codemod: 0.26.0/02_step-retry-enforced` (manual) prints both, and fires for any project declaring `retry:` on a step not only those with a manual wrapper.
40
+ (apiSurface: compatible the runtime golden churn this session is additions plus api-extractor renumbering its internal `Row_N` dedup alias; no public symbol was removed or resignatured, so no consumer breaks.)
41
+ - **@voltro/runtime** — `defineExperiment()` — online A/B / holdout experiments as live IVM aggregates. Per-variant metrics (count/sum/avg/conversionRate) are maintained incrementally from a table's CDC by grouping rows onto a synthetic variant column through one `AggregateMaintainer` (real-time lift/diff vs a baseline, no batch pipeline); assignment is a salted FNV-1a hash → `[0,1)` (client-reproducible, no `node:crypto`), holdout carved off the top so treatments don't perturb it. Observable via `ExperimentRegistry`/`useExperiment`. Correctness pinned by a brute-force oracle (incremental == full recompute) over insert/update/delete for all four metrics. `*.experiment.ts` file discovery wires in the CLI. codemod: none.
42
+ - **@voltro/cli** — `voltro doctor` gains two findings over declared events. **Delivery-semantics visibility** (informational): it now NAMES each declared event's delivery mode (`each` vs `latest`) — the mode decides what a MISSING message means (`each` counts a drop as a loss and tells the subscriber; `latest` supersedes and says nothing), and it was invisible after the fact everywhere except the devtools panel. **A scale WARN** for two declared-but-won't-scale shapes: a routing key with 3+ fields (every field fragments the subscriber set — distinct routes are the product of the fields' value spaces, so a payload discriminator smuggled into the key multiplies routes for nothing), and a `webhook:` block on a per-frame-looking event (`player.moved`, `cursor.moved`, `*.frameRendered` — every publish becomes N HTTP deliveries per second per target, and the plugin DEFERS the excess as pending rows rather than failing, so the symptom is a growing table). Both read the REAL declared descriptors — the routing-key field count comes from the same top-level schema-property reader the runtime validation uses, so it cannot disagree with the key the event routes on. Advisory, never blocking (same ladder as the orphan audit); surfaced in `--json` as `eventDelivery`. codemod: none.
43
+ - **@voltro/runtime, @voltro/cli** — Reactive-finops now EMITS cost events on real reactive work — the deferred second half of `attachFinops`. The Dispatcher gained a `recordCost?: (e: CostEvent) => void` sink (sibling to `recordDelivery`); each time a change re-runs an affected subscription and pushes it a delta, it records one `{ unit: 'recompute', amount: 1 }` event attributed to the subscription's tenant + traceId (both the row-set and computed-query delivery paths). Both boot paths (`voltro dev`, `voltro serve`) thread the finops runner's `record` into the dispatcher as `recordCost` — ONLY when `*.budget.ts` cost budgets were discovered, so an app with none allocates no `CostEvent` on the reactive hot path. Live attribution is observable via `CostRegistry`. codemod: none.
36
44
 
37
- ### Added
45
+ (apiSurface: compatible — one additive optional field on `DispatcherDependencies`; no public symbol removed or resignatured.)
46
+ - **@voltro/database, @voltro/runtime, @voltro/local-first** — local-first (deepened toward a working vertical): `crdtText()` is now a real database column type (`@voltro/database`). It stores an encoded CRDT state as `bytes` + a pure `crdtManaged` marker, so the declarative differ treats it as an ordinary nullable `bytes` column — no special DDL, and it round-trips through a plan on every dialect with zero churn (`crdtColumn.test.ts`). The authoritative server-side merge is wired into the runtime write path: the MutationStore folds an incoming encoded update into the stored state with `mergeCrdtStates` (`@voltro/local-first`) before writing, so two concurrent clients converge, and the reactive engine broadcasts the merged result (`crdtMerge.test.ts`, in-memory store, order-independent convergence). `@voltro/local-first` also gains a client persistence CONTRACT — `PersistenceAdapter` + `createInMemoryPersistence()` — and `loadPersistedSyncQueue`, which drains the offline sync queue into it so writes survive a reload. The browser-safe merge primitives stay separate from any `database` handle (the column type is server-side in `@voltro/database`; the merge core is the pure `@voltro/local-first` `.` entry).
38
47
 
39
- - **@voltro/plugin-audit, @voltro/plugin-versioning** **The audit trail can name its own actor, and it covers more than mutations.**
48
+ Still seamed (documented, not built): the durable persistence backing (WASM-SQLite / Turso), the bi-directional sync WIRE transport, the presence channel (Redis/NATS), and the higher-level `localFirst` table mixin + client codegen discovery. codemod: none (purely additive no user-authored code is affected; a `crdtText()` column is opt-in).
49
+ - **@voltro/local-first** — New opt-in `@voltro/local-first` package (Phase 4, first slice): CRDT + local-first primitives. `crdtText()` is a Yjs-backed CRDT text field behind our own `CrdtBackend` abstraction (the swap point for Loro later — nothing above the backend file imports `yjs`), with the deterministic merge primitive `mergeCrdtStates(a, b)` at its core (concurrent inserts converge order-independently, idempotent re-merge, empty-state identity). Ships the offline sync-queue as a pure reducer (`syncQueueReducer` — enqueue offline, FIFO drain on reconnect, requeue-on-fail with attempt counts), the connection-lifecycle state machine (`connectionReducer` + `deriveSyncStatus`), and `conflictPolicy()` / `lastWriteWins` for non-CRDT fields (deterministic, convergent tiebreak). React wrappers `useSyncQueue()` / `useConnectionStatus()` live under the `./react` subpath (`react` is an optional peer, kept off the pure `.` path). The `.` entry is browser-safe (no `node:*`, no `effect`). Client SQLite persistence (Turso/WASM), the bi-directional sync wire, the presence channel, and the `crdtText()` schema-DSL / `localFirst` mixin codegen wiring are declared as type-level seams (`./seams`) — deferred, not faked.
50
+ - **@voltro/local-first, @voltro/database, @voltro/runtime** — local-first (the vertical, integrated with existing framework infra): three of the four seams from the first slice are now BUILT against real, tested wiring, and the `localFirst` table mixin ships.
40
51
 
41
- *B1 the actor is a snapshot now, not a reference.* The argument that decides this lives inside ONE row: `_voltro_row_history.data` is a full-row snapshot, deliberately, so it survives what happens to its source while the same row's `changedBy` is a foreign key that does not. One record, two philosophies: the row's state preserved forever, its author only until someone exercises a right to be forgotten.
52
+ **Bi-directional sync wire (`@voltro/local-first`).** `createSyncClient({ transport })` maps the pure sync-queue reducer onto a `SyncTransport` (two functions an app binds to its EXISTING primitives `push` to the client's mutation caller writing the `crdtText()` column, `onRemoteState` to the reactive subscription streaming the row). A local edit merges optimistically + queues; reconnect drains to `push` with retry/attempt-bump; incoming merged state folds back via the CRDT. Tested against an in-memory dispatcher that mirrors the runtime's authoritative merge — offline edit drains on reconnect, a remote edit arrives and merges, two concurrent offline edits converge (`syncClient.test.ts`).
42
53
 
43
- That right is one we grant. `@voltro/plugin-governance`'s `governance.erase` (`delete | anonymize`) is ours and recommended, so a deployment can install auditPlugin + versioningPlugin + governancePlugin and have the third render the first two unreadable for precisely the subjects an investigation is about. Anonymisation is the worse half because it looks like it worked: the join SUCCEEDS and returns "Anonymised" for every entry that actor ever produced, retroactively rewriting history that was correct when written. A rename does the same, silently.
54
+ **Presence / awareness (`@voltro/local-first` + `./react`).** `usePresence(roomId, { cursor, name }, { channel })` returns `{ presence, others, setPresence }` over a `PresenceChannel` the SAME dumb string-payload pub/sub shape as the framework's `BroadcastProvider`, so a runtime binding forwards straight onto the app's broker (in-memory locally; Redis/NATS at scale, already shipped). Join/leave, announce-back discovery, cursor propagation, and TTL expiry live in the pure `createPresenceRoom`; `createInMemoryPresenceChannel` is the test/local transport. Tested pure (`room.test.ts`) and in a real DOM (`usePresence.test.tsx`): two peers see each other, updates propagate, a leaver drops, a silent peer expires.
44
55
 
45
- Both tables gain `actor json {id,type,displayName,email}`, resolved from the `actors` row at WRITE time the moment the identity is still true. `email` is read opportunistically, because the framework's own columns are `id`/`kind`/`displayName` and apps commonly extend it; insisting on a fixed shape would make the field useless where it is needed most. Resolution is best-effort and never fails the mutation it records, and absent stays absent a fabricated placeholder is the thing this column exists to prevent.
56
+ **Durable persistence (`@voltro/local-first`).** `createIndexedDbPersistence()` is a durable `PersistenceAdapter` over IndexedDBno WASM, no added dependency. The IDB implementation is injected, so it is tested against a fake backend that survives a reopenthe durability the in-memory adapter lacks (`indexedDb.test.ts`).
46
57
 
47
- `_voltro_audit_log` also gains `metadata json` the app writes: the noun a diff cannot contain. "Anna removed Bernd from the Frontend sub-team" is one row-delete plus a membership row, and no column-level detail reconstructs the sentence a compliance reader needs.
58
+ **`localFirst()` table mixin (`@voltro/database` + `@voltro/runtime`).** A marker mixin (adds no column) that opts a table into local-first sync + persistence; `isLocalFirst()` / `localFirstTables()` are pure discovery helpers, and the runtime SchemaRegistry reflects it as `hasLocalFirst(table)` (beside the existing `crdtColumns(table)`) — the discovery surface, with NO codegen change (a marker mixin rides `.with()` like any column type). The registry id is re-declared and pinned to the mixin by `localFirstMixinId.test.ts`, exactly like tenant/expires.
48
59
 
49
- *B2 actions and queries are audited too.* The interceptor was mutation-only, measured by the reporter against their own data: all ten rows carried mutation tags, so a successful login, a GDPR export and a third-party write from an action left no trace at all. For a compliance trail that is a LARGER hole than a missing name the question "who exported this" had no row to be missing one on. `interceptAction` and `interceptQuery` were available slots the plugin simply never filled.
60
+ Browser/server boundary preserved: the sync client, presence, and persistence are browser-safe (no `node:*`, no `@voltro/database`, no runtime); the authoritative CRDT merge stays server-side in `@voltro/runtime`. codemod: nonepurely additive, all opt-in.
50
61
 
51
- Actions record by default (they write). Queries are opt-in via `recordQueries`, because a read-heavy app writes one row per read and a trail that drowns in reads is worse than one missing them nobody searches it. Turn it on for the surfaces where the READ is the sensitive act, usually with `include`.
62
+ What GENUINELY remains a runtime seam (infra + a thin app binding, not un-built framework code): a `SyncTransport` bound to a specific running app's mutation/subscription, a `PresenceChannel` bound to a provisioned Redis/NATS broker at scale, and (optional) a wa-sqlite/Turso durable adapter for cross-tab SQL. All three sit behind interfaces the tested code already speaks.
63
+ - **@voltro/cli** — Native mobile SDK generators: `voltro build api --target swift` emits a Swift Package and `voltro build api --target kotlin` a Kotlin Multiplatform module, generated FROM the app's capability manifest (the same procedure descriptors + JSON Schemas the TypeScript client codegen reads — no source is re-parsed). Each package ships type-safe models (Codable structs / `@Serializable` data classes + enums), a one-shot HTTP client (query/mutation/action), a WebSocket subscription client (streams), an auth/tenant-context helper, and a push-registration stub. Faithful type mapping (string/number/boolean/array/nested-object/enum, optional → Swift `Optional` / Kotlin nullable). Flags: `--target`, `--out`, `--name`, `--kotlin-package`; default output `<appDir>/sdk/<target>`. The generated SOURCE is golden-string tested; cross-language COMPILE (swiftc / Gradle) and the native runtime (native modules, the APNs/FCM push sender, OTA build pipeline) are out of scope. codemod: none.
64
+ - **@voltro/cli, @voltro/web** — Page `export const preload` convention: a page declares `ReadonlyArray<string | { tag; input?(params) }>` and the SSR render (dev + start; inert for SSG) runs each subscription server-side and seeds it, so a `usePreloadedSubscription` on that page renders with data on first paint instead of re-fetching on mount. Read directly from the page module in the render loops (a purely server-side directive; the client never needs it). codemod: none.
65
+ - **@voltro/client, @voltro/web** — SSR-preloaded subscriptions: `usePreloadedSubscription(api, tag, input)` (`@voltro/client`) — `useSubscription` that reads its FIRST value from the SSR hydration payload instead of flashing an empty state and re-fetching on mount, then upgrades to the live WebSocket stream. The value is seeded server-side during a render (a loader, a layout loader) with `seedPreloadedSubscription(api, tag, input, value)`, keyed by the SAME `stableKey([tag, input])` the SubscriptionCache uses, and carried into the hydration payload alongside the store seeds (mirroring their request-scoped-bag + resolver inversion; the `node:async_hooks` scoping stays in `@voltro/web/ssr`). Because the value flows through `useSubscription`'s `initialSnapshot` render branch — read identically on the server and the client hydration render — there is no hydration mismatch. When no seed exists for the key (a client-side SPA navigation the server never rendered), it behaves exactly like `useSubscription`. codemod: none.
66
+ - **@voltro/react-native** — **New package `@voltro/react-native` — the credential-free mobile primitives.** The React client already runs in React Native (the runtime has no DOM dependency); this adds the mobile plumbing on top of it that needs no per-tenant Apple/Firebase credentials and no native runtime.
52
67
 
53
- codemod: none
54
- - **@voltro/runtime** — The credential bound now covers EVERY realtime primitive, not just events.
68
+ - **Device registration** — a `_voltro_devices` table (`@voltro/react-native/schema`: tenant + user scope, platform/token/locale/timezone, `(platform, token)` unique upsert target, per-user fan-out index) plus a `registerDevice(upsert, input)` client function. `resolveDeviceRegistration` normalises locale/timezone (input → env → ambient → `en`/`UTC` floor) into the row; `userId`/`tenantId` are the server's to stamp, never trusted from the client. - **`useBackgroundSync()`** — the interval / foreground-trigger state machine. The OS background-fetch registration stays the app's; the hook is a thin wrapper over a pure reducer (`backgroundSyncReducer` + `shouldSync`: single-flight, foreground-gated, interval-gated, forced triggers bypass only the interval). - **Offline-first defaults** (`offlineFirstDefaults`: local-first opt-out on mobile, sync on foreground + interval, status surfaced) and a standalone `useMobileConnectionStatus` (`connected | degraded | offline`) — deliberately not coupled to a transport or the in-flight local-first package. - **`defineDeepLink({ pattern, handler })`** descriptor + a pure matcher (`matchDeepLink('/orders/:id', '/orders/42')` → `{ id: '42' }`; segment-exact, scheme/host/query/trailing-slash normalised; params typed from the pattern literal).
69
+
70
+ The root export is RN-safe (no `node:*`, no `@voltro/database`; React is an optional peer reached only through the hooks). The `_voltro_devices` declaration is the server-side `@voltro/react-native/schema` subpath.
55
71
 
56
- An event stream got it first; live queries and `*.stream.ts` streams are the same kind of standing grant and did not have it. All three now end when the credential that authorized them expires, and the clients' existing reconnect re-opens them as a NEW requestfresh subject, guards re-run for real.
72
+ **Deferred as documented seams** (flagged in the package, not built): APNs/FCM **sender** adapters (need per-tenant Apple Developer / Firebase credentials); native module bindings (camera, biometrics, secure storage need a native runtime); Swift/Kotlin SDK generators (open product decision); universal-links / App-Links file automation; and the `*.deepLink.ts` codegen discovery wiring (one additive file, landed after the current release the descriptor shape is final, so until then links register via `matchFirstDeepLink`).
73
+ - **@voltro/runtime** — `defineCostBudget()` + `attachFinops()` — reactive FinOps: per-tenant / per-subscription compute-cost attribution + budgets. A `CostAccountant` folds each `CostEvent` (`{ tenantId, subscriptionId?, unit, amount, … }`) in O(1) into a standing per-tenant attribution accumulator (`total` + `byUnit` + `bySubscription` — the chargeback/showback answer) and every budget that watches its unit. A budget is a POLICY holding EVERY tenant to the same ceiling independently (mirroring `requireAiBudget`); its per-`(budget,tenant)` windowed counter crosses `ok`→`warn`→`exceeded` with the provenance of the causing event, recovers on a tumbling-window rollover (event- AND clock-driven) or an explicit `reset(tenantId)`, and is observable via `CostRegistry` (same shape as `ExpectationRegistry`: snapshot / get / breaches / subscribe). The engine is store-free + unit-testable; the descriptor + registry Tag are browser-safe. `*.budget.ts` file discovery, the `attachFinops` call in both boot paths, and the dispatcher/query cost-event taps are the forthcoming CLI wiring. codemod: none.
74
+ - **@voltro/cache, @voltro/ai** — Reactive semantic cache: `SemanticCache` (`@voltro/cache/semantic`) — an embedding-keyed LLM cache with a cosine-similarity vector index over the existing `CacheStore`, dependency-set capture as tags (`rowDep`/`tableDep`), and eviction by source change (`onSourceChange`/`onTableChange`, insert evicts table-coarse only). `@voltro/ai/semanticCache` wraps it: `semanticGenerateText`/`semanticGenerateObject` embed→lookup→hit-returns-cached (zero tokens) / miss-generates-and-stores under the captured deps, best-effort (a cache outage degrades to always-generate). Firing eviction on live writes is a one-line CLI-facade sink (documented; no runtime change). codemod: none.
75
+ - **@voltro/database, @voltro/runtime, @voltro/cli** — Field-level read permissions: a new `.readableBy(...scopes)` column modifier (Part B of the column-wire-visibility seam). A column marked `.readableBy('billing:read')` is stripped from query + subscription wire OUTPUT for any subject that holds NONE of the listed scopes, and present for one holding ANY of them — checked against the subject's EFFECTIVE scope set (raw subject scopes ∪ rbac role-derived scopes), with the `admin:full` bypass seeing every such column. It is the graded middle of the wire-exposure axis between a plain column (visible to everyone) and `.serverOnly()` (hidden from every client); the two compose (`.serverOnly()` still wins — hidden from everyone including admins). Enforced at the SAME Dispatcher read chokepoint as `.serverOnly()` (initial snapshot + every reactive delta) and at the `publicApi` one-shot REST GET in both boot paths (`voltro dev`, `voltro serve`). Subject-independent — and therefore memo-sharing — for any table that declares no `.readableBy(...)` column. Server-internal reads (`ctx.store.query`) still see the value; the strip is a wire concern only. Declaration rejects `.readableBy()` with no scope (that is `.serverOnly()`) and a blank scope string. codemod: none.
57
76
 
58
- It is ONE function (`boundByCredential`) that all three call rather than the same three lines in three binders. A value derived independently at several sites is the shape this repo has been bitten by repeatedly: every site looks correct and they disagree the moment one is edited.
77
+ apiSurface note: the one changed golden line is `OneShotQueryRunnerDeps.queryRows`, which gained a second `context: ServeRequestContext` parameter so the one-shot runner can apply the subject-aware strip. It is a callback the CONSUMER supplies, so an existing `(descriptor) => …` still satisfies the wider `(descriptor, context) => …` type the change cannot turn compiling code into non-compiling code. Everything else is a pure addition (`readableBy`, `readableByColumns`, `ReadableByColumn`, `forbiddenColumnsForSubject`, `stripForbiddenForWire`).
78
+ - **@voltro/cli** — `voltro evolve` — schema-evolution copilot for changing EXISTING schema safely. Given a change (`rename-column`/`retype-column`/`split-column`/`drop-column`/`rename-table`) it reads the OBSERVED graph (`app.graph.observed.generated.json`) + the app manifest to enumerate the real blast radius (handlers that actually touch the table; declared-but-unexercised ones flagged UNKNOWN, never assumed safe), then proposes a reviewable plan: a codemod (rename-column gets a real transform that renames the `*.entity.ts` field AND chains `.renamedFrom('old')` so the differ plans a catalog RENAME not a lossy drop+create, and annotates the handler sites the blast radius found; reshaping kinds get a `manual` codemod with generated steps) + a branch-verified backfill plan (per-kind SQL tied to `planBranchProvision`, snapshotting the exact tables the affected handlers touch) + a `voltro check` verify step. Dry-run by default; `--write` applies via the existing `runCodemods` toolkit; `--json` for CI. codemod: none.
79
+ - **@voltro/env** — Live secret rotation: `refreshEnvValue(key, value, { previous, graceMs })` installs a re-resolved env value a running process serves immediately while holding the OLD value for a grace window (lazy prune-on-read, no timer); `rotateSecretLive(key, { graceMs })` (`@voltro/env/server`) re-resolves through the backend + does the cutover, and `getSecretWithOverlap(key)` returns `{ current, previous }` — the current/previous verifier pattern for app env. Bounds (documented): this updates what code reading a secret PER USE sees (outbound keys, webhook-signing, field-encryption); it does not reconnect a live DB pool built with the old credential. codemod: none.
80
+ - **@voltro/cache, @voltro/cli** — The reactive semantic cache (`SemanticCache`) is now wireable as a framework-managed opt-in. Set `cacheSemantic: true` in `app.config.ts` and both boot paths build a `SemanticCache` over the SAME `CacheStore` the query `Cache` uses (`CacheLayer.storeLayer` is now exported so one store instance is shared by both), provide it as a `yield*`-able handler service, AND wire row-granular eviction off the runtime's existing `store.onChange` — a live DB write to a source row drops every semantic entry that depended on it (`onSourceChange`). Gated end-to-end: an app that leaves `cacheSemantic` off builds no vector index, no service, and no eviction sink. codemod: none.
59
81
 
60
- Where the halves sit, because they are easy to conflate: the per-delivery guard re-check catches RESOURCE revocation (its resolver does a live lookup); the credential bound catches the ROLE case, whose scopes were captured when the subscription opened and never change. Neither covers the other.
82
+ (apiSurface: compatible `CacheLayer.storeLayer` is a new export; nothing removed or resignatured.)
83
+ - **@voltro/web, @voltro/cli, @voltro/ui** — Framework SEO + a11y primitives: `seoAlternates()` (reciprocal absolute canonical + hreflang alternates + x-default, browser-safe), `PageMeta.noIndex` (emits robots noindex on every render path + excludes the route from the sitemap), build-time `dist/sitemap.xml` + `dist/robots.txt` generation (per-locale alternates, `WebAppConfig.seo.siteUrl`, `VOLTRO_SEO_NOINDEX` staging override, never overwrites a user `public/` copy), a dev-server disallow-all robots. Accessible `<Field>` defaults filled: Schema-`description` hints wired via `aria-describedby`, the RadioWidget error now associated, required marker `aria-hidden` + `aria-required`.
84
+ - **@voltro/cli** — File-convention discovery + boot attach for the standing primitives: `*.expectation.ts` (defineExpectation), `*.budget.ts` (defineCostBudget), `*.experiment.ts` (defineExperiment) are now discovered like `*.aggregate.ts` and attached in BOTH `voltro dev` and `voltro serve` (parity), each providing its registry (ExpectationRegistry/CostRegistry/ExperimentRegistry) as a handler Layer + a `GET /_voltro/inspect/{expectations,budgets,experiments}` snapshot. This is what makes the three primitives user-reachable via file convention. (Cost-EVENT emission — the dispatcher recordCost tap — remains the deferred secondary half, so budgets are declarable+observable but attribution stays 0 until it lands.) codemod: none.
85
+ - **@voltro/ai, @voltro/cli** — `voltro eval` — replay real recorded agent/AI runs and gate the deploy on the result. `defineEval({ name, cases, assert?, judge? })` (`@voltro/ai`) declares golden cases from recorded runs; `voltro eval` discovers `*.eval.ts`, replays each case against the CURRENT model, judges with HARD assertions (`contains`/`matches`/`equals`/`nonEmpty`/`maxLatencyMs`) plus an optional LLM judge (`generateObject`-backed, schema-constrained verdict), and exits 1 on any regression — a deploy-gate signal, `--json` feedable into CI. Reuses run-recording (`runAndPersist`/`threads`), the data-branch identity machinery (`branchNamespaceName`, `--branch`), and mirrors `voltro check`'s gate shape. The runner (`runEval`/`scoreCase`/`evaluateAssertions`) is pure over an injected replay + judge, so it is fully unit-testable without a provider. `*.eval.ts` is read only by `voltro eval` — it is deliberately not a boot/browser file convention. codemod: none.
86
+ - **@voltro/workflow, @voltro/cli** — Workflow **resume-from-step** — rewind a terminally-`failed` run to an operator-chosen step and re-execute from there, past the point it actually died. The generalisation of `redrive` (which only re-runs the failed step): resume resets the target step **and every step after it** (succeeded ones included), so a step that completed cleanly but on stale/wrong external state re-runs too, while the steps *before* the target replay from the durable journal. For the dead-letter case where the failure point is not the right recovery point.
87
+
88
+ - Engine adapter `resumeRunFromStep` on `@voltro/workflow/cluster` (sibling to `redriveFailedRun`, sharing the one `@effect/cluster`-coupling core — a live-cluster contract test asserts the step before the target REPLAYS while the target + downstream RE-RUN). - `voltro workflows resume-from-step <runId> <stepName>` + the inspect action `POST /_voltro/inspect/workflows/runs/:id/resume-from-step` `{ step }`, wired into **both** `voltro dev` and `voltro serve`. Refuses a non-`failed`/discarded run and an unknown step; declines cleanly (no journal / still running / already succeeded).
61
89
 
62
90
  codemod: none
63
- - **@voltro/runtime** — An event stream now re-authorizes on EVERY delivery, as a live query already did. Events were the weaker of the two for the same kind of grant.
64
91
 
65
- `servePipeline` states the reasoning for the identical case and it applies verbatim: a subscription is a LONG-LIVED grant, and the scopes that justified it can be withdrawn while it is still open — a role revoked, a resource un-shared, a membership ended. Without a re-check the socket keeps delivering what the subject may no longer read. Live queries have re-authorized per delivery for some time; event streams were checked once, at subscribe, and never again.
92
+ ### Fixed
66
93
 
67
- **This corrects a conclusion drawn in this repo one change earlier.** That change argued a re-check was unbuildable at this seam because the subject is captured per request, so re-checking it always confirms. True of the scopes ON the subject and wrong as a general claim, because `checkGuardsEffect` runs the async RESOURCE-SCOPE resolver, which does a live lookup. For a resource-scoped guard (`{ scope: 'arena:read', from: 'arenaId' }`, the shape events use) the re-check catches revocation for real. The two mechanisms cover different halves: this catches resource revocation, and the credential bound added alongside it catches the role case by refusing to outlive the token.
94
+ - **@voltro/client****`useAction(...).run` forked against the boot-window stub instead of waiting for the api.**
68
95
 
69
- A denial ENDS the stream rather than dropping the delivery. A silently skipped delivery is indistinguishable from "nothing happened", which is the one outcome this primitive exists to eliminate; the client is told, and its reconnect gets the refusal as a typed error. An unguarded event pays nothing the closure short-circuits before any effect is built.
96
+ `useSubscription` survives that window by design it reads through the loading cache, reports no data, and delivers when the real client arrives. `run` had no such backstop: called from a mount effect it threw *"rpc / cache calls are not invokable on a not-yet-resolved api"*.
70
97
 
71
- Red-verified, and the first version of that verification FAILED to go red: the test asserted only that the stream failed, and `Effect.timeout` also fails, so a stream that never ended satisfied it. It asserts the failure VALUE now a scope denial, explicitly not a timeout.
98
+ A component that fetches once on mount therefore had a race it could not see. It usually lost on a cold load and won on an HMR reload, so the page "worked when you looked at it".
72
99
 
73
- codemod: none
74
- - **@voltro/plugin-notifications** — `archive` / `unarchive`, `markUnread` and `markAllRead` — the four procedures that were keeping an app off this plugin.
100
+ **And the message REPLACED the real one**, which is the expensive half: one app reported api resolution while its upstream was answering `403`, and the `403` was invisible because the call never left the browser. SSR sharpened it — seeding a subscription via `initialSnapshot` makes `isAuthenticated` true on the very first render, so guards that gated mount effects behind "we have a user" stopped gating anything.
75
101
 
76
- The reporter's comparison was fair and worth repeating: our surface is richer than theirs on the parts we have (quiet hours, channel preferences, delivery logs) and was missing the ones a user touches most. **Archive was not merely a missing procedure — the word appeared nowhere in this plugin's types.** `readAt` covers read; the delivery table's `status` is the delivery outcome (`sent | failed | skipped`). Neither is an archive, and archiving is the action that empties an inbox. An inbox nobody can clear is one they stop opening.
102
+ `run` now waits for the api, which is what a caller expects and what the sibling primitive already does. The wait is **bounded** (15s) and the timeout says what happened: an api that never resolves is a real condition a name matching no configured api, a supervisor that gave up and hanging forever would trade a confusing error for no error at all.
77
103
 
78
- `archivedAt` is therefore its own column and its own state: archiving does not mark an item read, and an archived-but-unread item still counts toward `unreadCount`. A UI that conflates them cannot show what a user did.
104
+ The callback also stays stable across the window now: it reads the handle through a ref at call time rather than being recreated the moment the api resolves.
79
105
 
80
- `markUnread` exists because an inbox without a way back is a one-way ratchet, and `markAllRead` because marking two hundred items one at a time is not a feature. It reports how many rows it changed — a caller showing "12 marked read" must not be told 200 because that is how many rows exist.
106
+ `isUnresolvedApi(useFrameworkApi(name))` still composes for a caller who wants the readiness bit itself.
81
107
 
82
- All four are subject-scoped like `markRead`: an inbox action must not reach across subjects because an id happens to be guessable.
108
+ **Measured in a real browser against a real api process**, not only unit-covered `node scripts/browser-action-boot-window.mjs` (chromium, `e2e-fixtures/web-action-boot` `memory-api`), with the pre-fix shape restored as a negative control:
83
109
 
84
- codemod: none
85
- - **@voltro/database, @voltro/runtime** — `pluginRef(table, { orphanPolicy })` — point at a plugin-owned row from an app table, with a declared rule.
110
+ | | on a natural cold load | with a 3s `authHeaders` resolver | |---|---|---| | before | `ERR: rpc / cache calls are not invokable on a not-yet-resolved api` after **17 ms** | same error after **3 ms** | | after | `OK: {"ok":true}` after **34 ms** | `OK: {"ok":true}` after **3039 ms** |
86
111
 
87
- ```ts
88
- favouriteOf: pluginRef(aiFlowsTable, { orphanPolicy: 'delete' })
89
- sharedFlow: pluginRef(aiFlowsTable, { orphanPolicy: 'null' }).nullable()
90
- ```
112
+ The window really is only ~15–30 ms wide on a warm machine, so the page takes a negative control in the same instant the call is issued — invoking `handle.client` directly, which still throws. Without it, "run succeeded" would be indistinguishable from "the api had already resolved". The bound is exercised too: a resolver that outlasts the budget settles at 15006 ms with the timeout message, rather than never.
91
113
 
92
- No foreign key is emitted, and that part was already right: the plugin owns its table and may rename it the `_voltro_` migration did exactly that across ten tables so a cross-boundary FK would turn every rename into a coordinated migration of every app pointing at it. `ai_flow_runs.flowRef` is a plain string for the same reason, and `plugin-storage` ships `assetRef({ fk: false })`.
114
+ One thing this does NOT claim, because an earlier draft did and was wrong: `run` waits for the api to RESOLVE, not to be REACHABLE. With the api process killed the supervisor still hands over a client in ~20 ms (an rpc client is built from a layer; nothing there needs a live socket), so the call goes out and fails with a genuine `Error in socket` — which is the point of the fix, a real transport error instead of a stub message that displaced it.
115
+ - **@voltro/cli** — **The atlassian credential codemod now tells you to grep for your own key, not just for `credentialsResolver`.**
93
116
 
94
- **What was lost with the FK is not the constraint but the ORPHANING RULE**, and a reporter's census shows the shape of it: 711 app→app references carrying an `orphanPolicy`, against 2 pointers at plugin rows. Not because pointing across the boundary is rare because there was no pattern, so each one becomes a hand-written subscriber that cleans up on delete. Bespoke referential integrity, re-implemented per app, and nothing notices when someone forgets one.
117
+ A team doing this migration found **four** call sites reading the PAT off the Subject and only one of them was the resolver: a session strategy stamping it into `metadata`, two delegation helpers building synthetic Subjects that carried it, and an avatar fetch. Fixing the resolver alone leaves the credential on the identity and the leak intact which is the entire point of the change.
95
118
 
96
- Four decisions, each answering an edge case they raised:
119
+ The note said as much in passing and was easy to read past. It now says it first, and names the reason: the resolver is where the credential is READ, not where it got onto the Subject. It also passes on what the reporter did afterwards — an invariant test that fails if anything puts a token-shaped key into a metadata bag again, mutation-tested by restoring the old line.
120
+ - **@voltro/workflow** — First-deploy cluster convergence: N runners started simultaneously against a fresh database (no `@effect/cluster` schema yet) no longer silently fail to converge. Root cause was `@effect/cluster`'s first-boot storage migration racing the pg catalog (its Migrator creates the tracking table without `IF NOT EXISTS`, and its `LOCK TABLE` guard only exists AFTER that table does). A new `clusterMigrationGateLayer` serializes the FIRST migration behind a cross-dialect advisory lock pinned to a single reserved connection (so acquire+release share a backend and auto-release on crash — the pooled `withMigrationLock` leaks here because storage build checks out several connections), building the storages sequentially. Warm boots skip it. New knob `VOLTRO_CLUSTER_MIGRATION_LOCK_TIMEOUT_MS` (default 60s). codemod: none.
121
+ - **@voltro/workflow** — **The cluster first-boot migration gate deadlocked on sqlite — an app on `store: 'sqlite'` with a workflow would never finish booting.**
97
122
 
98
- - **Tenant fail closed.** A referencing row whose tenant differs from the deleted row's, or which has none, is left alone. Deleting across a tenant boundary because a scope was missing is the one outcome worse than an orphan. - **Soft delete opt in per reference** (`onSoftDelete`). A soft delete is a state the target can undo, so cascading on it destroys rows a restore cannot bring back; and plugin tables are inconsistent here by design (`_voltro_ai_flows` has `deletedAt`, `_voltro_ai_flow_runs` does not), so a guess would be wrong for half of them. - **Rename the target is a table VALUE**, resolved through the handle the plugin exports, so a rename carries the rule with it. Referencing by string would reintroduce the coupling the missing FK exists to avoid. - **`'keep'` is a policy, not the absence of one.** Same behaviour as omitting it, arrived at deliberately and reviewable as such. The default stays `'keep'` — a default that deleted rows would be a footgun.
123
+ The gate serializes `@effect/cluster`'s first schema migration behind an advisory lock pinned to a *reserved* connection, because acquire and release must land on the same backend. On sqlite the lock is a no-op on both sides single-writer, single-process, no sibling to serialize against but the reservation around it was not: `sql.reserve` takes the ONE connection an in-process sqlite client has, and the locked work is the library's storage build, which then asks the pool for another and waits on a connection its own caller is holding.
99
124
 
100
- codemod: none
101
- - **@voltro/protocol, @voltro/runtime** — `defineStream` accepts `guards:`, and they are enforced.
125
+ It presents as a boot that never finishes, not as an error. Nothing logs.
102
126
 
103
- A stream was the ONE realtime primitive that could not express authorization at all. Queries, mutations and actions carry `guards:`; `StreamProcedureDescriptor` had no such field. Whatever protection a `*.stream.ts` had was hand-written inside its executor, where nothing could verify it existed not the boot audit, not `voltro doctor`, not a reviewer reading the descriptor. The absence was invisible in exactly the way that matters: a stream with no authorization and a stream whose authorization lives in its body look identical from outside.
127
+ Sqlite now runs the migration without reserving. Every other dialect is unchanged the reservation is load-bearing there, and removing it would leak a session lock onto an idle pooled connection that every late runner then blocks on.
104
128
 
105
- Checked at subscribe AND before every element, the same as a query's, for the same reason `servePipeline` already gives: a stream is a long-lived grant and the scopes that justified it can be withdrawn while it is still open. The guard INPUT is the call's decoded input, so a resource-scoped guard (`{ scope: 'feed:read', from: 'id' }`) sees which resource was asked for.
129
+ **Never released** (it landed after 0.28.0), but worth reading for how it was found. The webhook delivery suite is the only place we build the cluster engine against `:memory:` sqlite; five of its tests sat at their 30 s timeout while the *same* tests on postgres, mysql, mariadb and mssql passed, because those pools hand out a second connection. So the one configuration with no infrastructure the likeliest first thing a new user runs — was also the only one nothing else covered.
106
130
 
107
- A denial ENDS the stream rather than dropping the element. A skipped element is indistinguishable from "nothing to send", and the client must learn it lost access rather than infer it from silence. An unguarded stream pays nothing.
131
+ `clusterMigrationGate.test.ts` pins the connection count, not the outcome: a test asserting only "the work ran" passes on the broken code as long as its fake pool is willing to hand out a second connection, which is exactly the assumption the real sqlite client does not satisfy.
132
+ - **@voltro/cli** — **The credential-purge query in two 0.28.0 codemods was postgres-only, and its MySQL/MariaDB translation silently under-reported.**
108
133
 
109
- Deliberately NOT wired into the manifest, doctor or the devtools panel in this change. The event-`guards` defect was reporting surfaces showing protection that nothing enforced; enforcement without reporting is the safe direction of that same asymmetry it works and is merely not displayed yet.
134
+ Codemods `03_atlassian-credentials-context` and `04_audit-redacts-subject-metadata` both told you to check your existing rows with `subject::text ILIKE '%token%'`. `::text` and `ILIKE` do not run on MySQL/MariaDB, so the natural translation is a bare `LIKE` — which is case-**sensitive** against the `utf8mb4_bin` collation our own migrator emits for a `json()` column. `'%token%'` therefore does not match `jiraToken`, and a credential key is almost always camelCase.
110
135
 
111
- codemod: none
112
- - **@voltro/protocol, @voltro/runtime, @voltro/cli** — An event subscription can no longer outlive the credential that authorized it.
136
+ A team ran the translated query against 141 rows, got **0**, and nearly reported themselves clean. 117 of those rows held a working credential; they caught it only because the count looked implausible and they printed a sample row.
113
137
 
114
- Guards are checked once, at subscribe. That is not an oversight to patch: the subject comes from THAT request's layer, so re-checking it later inside the stream asks the same captured object and always gets the same answer. A "re-check on subject change" built at that seam would be a control path that always confirms — worse than no check, because it looks like one.
138
+ Both notes now use `LOWER(subject) LIKE '%token%'`, which is correct on every dialect we ship.
115
139
 
116
- The honest bound is a fact the token already carries. `ConnectionInfoValue` gains `credentialExpiresAt` (unix seconds, verified an unverified decode would let a client forge a far-future expiry and lift the very ceiling this imposes), and `bindEvent` ends the stream there. Absent means no bound, so the failure direction is the behaviour that already existed.
140
+ **Why this is worse than a syntax error, which is the part worth keeping:** a query that fails to run gets fixed. A query that runs and returns good news when the answer is wrong is read as an all-clear in the security-relevant half of a security-relevant codemod.
117
141
 
118
- **It is seamless, and that costs nothing to build.** `useEvent` already treats a clean end as a reconnect reason a server never legitimately finishes a stream a client still wants so it re-opens immediately. A reconnect is a NEW request: the subject is resolved afresh and the guards run again for real. Still entitled, it continues and the app sees nothing; no longer entitled, the reconnect is refused loudly instead of delivering forever on a dead credential. No application-side reconnect handling.
142
+ `codemodSqlPortability.test.ts` now scans every codemod note for postgres-only spellings (`::text`, `ILIKE`, `table_schema = 'public'`). It distinguishes SQL a user would copy from prose ABOUT sql by the backtick, because the first version fired on the very sentence warning against the construct and it carries a selftest, since a scan that silently stopped matching reads exactly like a clean tree.
143
+ - **@voltro/cli** — **Outgoing webhooks never delivered on the cluster engine — i.e. in every deployment.**
119
144
 
120
- `sessionExpiryFromHeaders` is a SHARED helper both boot paths call, and `SESSION_COOKIE_NAME` moved to `@voltro/protocol/session` so it has one definition rather than one per reader dev and serve deriving one value twice is how the two paths come to disagree silently.
145
+ `voltro.deliverWebhook` was provided per-emit: `execute(input).pipe(Effect.provide(deliverWebhookWorkflow.toLayer(…)))`, built fresh inside the emit callback. The in-memory engine tolerates that, because there the layer IS the registry. The **cluster** engine does not: a workflow must be registered as an entity type while the runtime is constructed, and an emit happens long afterwards. So every delivery died with
121
146
 
122
- This bounds EXPIRY, not revocation. A role revoked mid-session is not observed until the credential runs out, and the docs say so in both languages rather than implying more. Revocation belongs at the session seam — a revoke event that ends the connection is one place instead of one per primitive, and this reconnect machinery would then carry it for free.
147
+ ```
148
+ Entity type 'Workflow/voltro.deliverWebhook' not registered
149
+ ```
123
150
 
124
- codemod: none
125
- - **@voltro/plugin-webhooks** — Three additions that were the whole distance between a consumer and deleting their own webhook tables.
151
+ **after** the mutation had already returned `200`. Zero deliveries, zero rows in `_voltro_webhook_deliveries`, nothing in the calling service's logs. Reported by a consumer on MariaDB + cluster-sql for whom the feature had never once delivered in any environment.
126
152
 
127
- **`scope` an opaque app dimension on `_voltro_webhook_targets`.** Stored and returned verbatim, never interpreted; `listTargets(event, scope)` filters on equality against it. `.with(tenant())` is one level too coarse for real deployments: their endpoints are scoped to a TEAM and a tenant has many teams, so every read filters by it and every write guards on it.
153
+ The layer is now built at boot and registered in `allWorkflowLayers` alongside the app's own workflows, in both boot paths; the emit closure runs on that runtime. An app with outgoing webhooks and no workflows of its own now builds the workflow runtime too otherwise the fix becomes a different silent failure.
128
154
 
129
- The precedent is theirs, and it decided a migration: `_voltro_presence.meta` is json the framework stores and never interprets, and it is the ONLY reason their presence migration was lossless three denormalised columns went straight in. An earlier review of theirs called that plugin lossy and they withdrew it. The general form they derived is the right one: **a plugin that stores rows in an app's database on the app's behalf needs one place for the app's own dimension.**
155
+ **The axis is the part worth keeping.** Both boot paths carried the *identical* construction, so no dev/serve parity check could see it those compare the two paths to each other, and here they agreed. The difference was IN-MEMORY vs CLUSTER, and it looked like dev-vs-serve only because `voltro dev` defaults to the in-memory engine while a deployment uses the cluster one. **A difference between two configurations of ONE path is invisible to every guard that compares paths.**
130
156
 
131
- **`listDeliveries` / `getDelivery`.** There was no service method over `_voltro_webhook_deliveries`, so a management view could only query the table directly — which they declined, correctly: the 0.24.0 `agent_messages` rename taught them what app code coupled to a framework table name costs, and that one was survivable only because it was a rename. `listDeliveries` omits `payload` and `responseBody` so a list view does not pull response bodies for 200 rows; `getDelivery` adds them. Timestamps are normalised to ISO regardless of what the dialect returned, and an unparseable payload comes back verbatim rather than throwing — a management view must render a malformed row, not 500.
157
+ `deliverWebhookRegistration.test.ts` pins the boot registration across both paths (red-verified by removing it from one). It is the source half; the behavioural half needs a real SQL cluster engine and is not something a fake engine could stand in for.
158
+ - **@voltro/cli** — **`voltro serve` warned that framework-provided tables "are not a declared table".**
132
159
 
133
- **`updateTarget` and `testTarget`.** Editing a URL previously meant delete + re-subscribe, which rotates the secret (every receiver reconfigured) and orphans the delivery history. The patch writes only the keys present, so an absent key leaves the column alone while an explicit `null` clears it; `event` and `secret` stay unpatchable (a different event is a different subscription, and the secret has `rotateSecret`). `testTarget` sends ONE delivery, bypassing fan-out and the filter a filter excluding the probe would make a healthy endpoint look dead but NOT `active`, so a paused target queues exactly as an emit would and the test tells the truth about production.
160
+ The stale-`source` audit is called by both boot paths. `voltro dev` passed `allRegisteredTables()` the process registry, which includes framework- and plugin-provided tables. `voltro serve` passed `discovered.tables`, which is only what the APP declares. So a query naming `_voltro_agent_messages` (or the audit trail, or the notification inbox) was reported as naming a table that does not existabout a table that does.
134
161
 
135
- codemod: none
136
- - **@voltro/cli, @voltro/runtime, @voltro/workflow** — **Cross-replica workflow WAKE** — a triggered workflow now starts ~immediately across replicas, instead of waiting up to the storage-poll interval. When you trigger a workflow whose cluster shard is owned by ANOTHER replica, that replica used to pick the run up only on its next poll tick (up to 10s), because Voltro's single-runner topology has no runner-to-runner push. Now, on a trigger the framework publishes a tiny "wake" onto the SAME Redis/NATS broadcast bus a multi-replica deployment already runs for cross-replica reactivity; every replica subscribes and, on a wake, re-polls cluster storage right away — so the shard owner reads the new run now.
162
+ Same codebase, same version, two boots: dev silent, serve warning. It is the false positive fixed for dev in 0.27.0, still live on the serve path — now only where nobody is watching a terminal.
137
163
 
138
- - **Dialect-agnostic** it rides the broker, not the SQL dialect, so it works identically on postgres / mysql / mariadb / mssql (unlike a pg-only LISTEN/NOTIFY). No effect on sqlite (single-process, already immediate). - **Degrades cleanly** with no broadcast broker (single replica, or the in-process memory transport), there's nothing to wire and the poll interval (`VOLTRO_WORKFLOW_POLL_INTERVAL`) remains the bound. The wake is a latency optimisation, never a correctness dependency: a dropped wake just falls back to the poll. - Built by ONE shared builder wired into BOTH `voltro dev` and `voltro serve` (boot-path parity), fires on the fire-and-forget `start` / `child` triggers, and skips its own wake (the triggering replica already polled locally).
164
+ **Why no existing guard saw it.** It is not a ctx field and not a missing call, so neither the derived boot-path audit nor its ctx-key axis applies: both paths call the *same* function and hand it *different sets*. That is the same variant as the schedule-subject divergence each call site internally consistent, the difference visible only by comparing them. Reported by a consumer who noticed the two boots disagreeing on identical source.
139
165
 
140
- `codemod: none` additive; new opt-in behaviour that activates only when a cross-replica broker is present.
141
- - **@voltro/runtime, @voltro/workflow, @voltro/cli** — Workflow dead-letter management — a dead-letter VIEW + `discard`. Because the framework applies no retry of its own, a `failed` run is terminal: it is the dead-letter. `voltro workflows list --dead-letter` shows the queue of unhandled failures (`status = 'failed' AND discardedAt IS NULL`); `voltro workflows discard <id>` acknowledges one so it drops off that view. Discard is an ACK, not a re-classification — the run stays `status: 'failed'` (outcome + audit trail survive) and gains a `discardedAt` timestamp (mirrors how `cancelled` coexists with the status); `--status failed` still lists it, marked `discarded`. Discarding a non-failed run is refused; discarding is idempotent. New `discardedAt`/`discardedBy` columns on `_voltro_workflow_runs` (ride the declarative differ — no codemod), a `discard` inspect action + `--dead-letter` list filter, and `discardedAt` on the `WorkflowRunSummary` / `deadLettered` on `WorkflowRunListFilter`. Note: like the other workflow inspect ACTIONS (retry/cancel/…), discard is wired on the `voltro dev` inspect surface. `codemod: none` — additive schema + a new opt-in CLI/inspect surface; no user-authored code is affected.
142
- - **@voltro/workflow, @voltro/cli** — Workflow failover across replicas is now **tunable and proven**. When a replica running a durable workflow crashes, a surviving replica takes the run over and continues it from the last completed step (completed steps replay from the journal, not re-run) on any SQL store (postgres / mysql / mariadb / mssql). That already worked; what's new:
166
+ Both paths read the process registry now, pinned by a guard that fails if either reverts to the app-declared set.
167
+ - **@voltro/cli** — `.serverOnly()` columns are now stripped from a `publicApi` query's buffered REST GET response. The runtime dispatcher already strips every WS / `POST /rpc` snapshot (which is what the SSR web-router loaders and `usePreloadedSubscription` seeds fetch through, so the `__voltro_state__` hydration payload was already safe), but a query projected to a public REST endpoint has no subscription to drive it it read the store directly and shipped the raw row, including any `.serverOnly()` credential column (e.g. `keyHash`), to the caller. Both boot paths (`voltro dev`, `voltro serve`) now route the one-shot public read through the same `stripServerOnlyForWire` choke point. codemod: none.
168
+ - **@voltro/runtime** — `.serverOnly()` columns are now stripped from ALL query + subscription OUTPUT at the Dispatcher's read boundary (initial snapshot + every reactive delta), not just `crud.*` echoes + the boot audit so a hand-written query/subscription returning a raw row no longer leaks a server-only column to the wire. Server-internal reads (`ctx.store.query`) still see the column; the strip is wire-only and subject-independent, so it shares the read memo. codemod: none.
169
+ - **@voltro/cli, @voltro/i18n** — **`voltro dev` server-renders WITHOUT `<I18nProvider>`, so SSR could not be developed at all for a translated app.**
143
170
 
144
- - **Two operator knobs** for how fast a survivor reclaims a crashed replica's in-flight work — which is a lease-expiry floor (~35s default), NOT a polling one, so lowering it is the lever, and a push mechanism wouldn't help: `VOLTRO_WORKFLOW_FAILOVER_LEASE` (seconds; default 35) and `VOLTRO_WORKFLOW_FAILOVER_HEARTBEAT` (seconds; default 10, keep lease/3). Lower the lease for faster failover, at the cost of false-positive reclaims if a healthy replica stalls (GC / DB-latency) longer than the lease. Exposed as `failoverLeaseSeconds` / `failoverHeartbeatSeconds` on the workflow engine layer and read from env by `voltro serve`. - **A live multi-PROCESS failover test** (`@voltro/sql-postgres`) that boots two real cluster-runner processes against one postgres, SIGKILLs the one running a 3-step workflow mid-step, and asserts the survivor resumes it from the journal the completed step ran exactly ONCE across the crash. This exercises the hard-crash (lease-expiry) path a clean shutdown can't, and is the guarantee behind the docs. - Production-hardening docs (en + de) now cover the failover model, the `POD_IP` requirement, the at-least-once step boundary, and the tuning tradeoff.
171
+ There are three server renderers and each arranged the i18n wrapper for itself: `voltro build`'s prerender picked a wrap per locale, `voltro start` called the `i18n.resolve` baked into the generated `ssrEntry.ts`, and `voltro dev` which loads `@voltro/web/ssr` directly and therefore has no generated entry to callpassed **no `outerWrap` at all**. Any component calling `useT()` / `<T>` rendered fine under `voltro start` and threw on the server under `voltro dev`:
145
172
 
146
- `codemod: none` — additive config; nothing user-authored changes.
147
- - **@voltro/workflow, @voltro/cli** `VOLTRO_WORKFLOW_POLL_INTERVAL` (seconds → `messagePollSeconds` on the workflow engine layer) tunes NEW-message pickup latency across replicas. When you trigger a workflow whose shard is owned by the SAME replica, it starts immediately (a same-process push); when ANOTHER replica owns the shard, that replica picks it up on its next storage poll — up to 10s by default (Voltro's single-runner topology has no cross-runner push). Lower this for latency-sensitive multi-replica workloads, at the cost of more idle poll queries; it has no effect on a single replica. This is distinct from the failover knobs (a crash-reclaim lease, not new-message latency). The lower-idle-load alternative is a pg LISTEN/NOTIFY wake, not yet wired. `codemod: none` — additive.
148
- - **@voltro/workflow, @voltro/runtime, @voltro/cli, @voltro/devtools-ui, @voltro/voltro** — <!-- apiSurface: compatible — reasoned, not rubber-stamped. Three golden lines churn, all WIDENINGS (the direction the gate's rule is not about), and the actual consumers typecheck green against them: 1. `WorkflowRunEventType` gained `'run-redriven'` (in @voltro/workflow AND the @voltro/voltro re-export). It is a framework-EMITTED union — a reader gets a superset; every value that was one of the old members still is one. 2. `workflowEngineLayer`'s return went from `Layer<WorkflowEngine>` to `Layer<WorkflowEngine | Sharding | MessageStorage>` — it now EXPOSES the two cluster services it already built internally (so the re-drive adapter can reach the same instance). It is a framework-internal engine builder wired only by dev.ts / serveCommand (both cast loosely); every value-level use (`provideMerge`, `ManagedRuntime.make`) still compiles. @voltro/runtime + @voltro/cli, its real consumers, were typechecked after the change — green. Nothing was removed or narrowed. -->
173
+ ```
174
+ Error: [React Intl] Could not find required `intl` object.
175
+ <IntlProvider> needs to exist in the component ancestry.
176
+ ```
149
177
 
150
- `redrive` re-drive a terminally-`failed` workflow run from the step it died on, reusing its durable journal. The operator counterpart to `retry` (fresh execution, empty journal) and to `resume` (which only re-drives a *suspended* run): a plain `failed` run is a terminal `Complete(Failure)` in the cluster store that `resume` will not touch. Fix the downstream cause, then `voltro workflows redrive <runId>` (or `ctx.workflows.redrive(runId)`, or the inspect `redrive` action) and the engine re-delivers the run — every completed step **replays from the journal** (NOT re-executed) while the failed step(s) re-run. Ideal for a long multi-step pipeline where redoing steps 1…N‑1 is expensive or unsafe and you did NOT pre-declare `suspendOnFailure`.
178
+ Reported by a consumer whose 51 `renderMode: 'ssr'` pages were every one of them serving a spinner and two further defects sat behind this one, because nobody could get a page far enough to see them.
151
179
 
152
- Under the hood a single isolated adapter (`@voltro/workflow/cluster` `redriveFailedRun`) reaches into `@effect/cluster`'s `MessageStorage`/`Sharding` to clear the terminal `run` reply plus each failed step's journaled reply, then re-polls storage the same primitive the engine's own `resume` uses, minus its suspended-only guard. A live cluster **contract test** boots a real engine, fails a multi-step run, re-drives it, and asserts the completed step did NOT re-run, so an engine upgrade that moves those internals fails loudly instead of silently corrupting a re-drive.
180
+ `@voltro/i18n/server` gains **`makeSsrI18nResolver`** (cookie `voltro:lang` > `Accept-Language` > default the matching wrapper), and both the generated entry and the dev server now call it. The dev copy and the generated copy were going to be two hand-written versions of the same five lines, which is how they diverged in the first place. It stays React-free so the CLI takes no React dependency; dev loads the React half through Vite's SSR loader, as the prerender already did.
153
181
 
154
- Works in `voltro dev` AND `voltro serve` dead-letter recovery matters where incidents happen. Refuses a run that is not a not-yet-discarded failure (use `retry` for a fresh run, `resume` for a suspended one), and declines cleanly (`redriven: false` + a `reason`) when there is no durable journal (e.g. the memory store). Records a `run-redriven` lifecycle event. `codemod: none` a new opt-in action + SDK method; no user-authored code is affected.
155
- - **@voltro/workflow** — `suspendOnFailure` — resume a workflow from where it failed, reusing completed steps. Declare `suspendOnFailure: true` on a workflow and a failure of its top-level body no longer becomes a terminal `failed` run — it **suspends** with the durable journal intact, so `voltro workflows resume <id>` (or `ctx.workflows.resume`) re-drives it from the point of failure: every completed activity replays from the journal (NOT re-executed) and only the failed activity runs again. This is the durable-execution way to make a long multi-step workflow recoverable across a transient downstream outage without re-doing prior work — the opposite of `retry`, which starts a fresh execution with an empty journal. Maps to `@effect/workflow`'s `SuspendOnFailure` annotation. A suspended-on-failure run records `status='suspended'` WITH the failure reason (`errorTag`/`errorMessage` + a `suspend-on-failure` event, and it reaches the error reporter), so it is distinguishable from a plain sleep/signal suspension; it shows under `--status suspended`, NOT in the dead-letter view (it is recoverable, not dead). Default `false` — a failure stays terminal. `codemod: none` — a new opt-in workflow option; no user-authored code is affected.
182
+ Both dev render branches are coveredthe page, and `prepareSpaLayoutShell`, which builds its own `renderInput` and matters because a translated ROOT LAYOUT above a client-only page hits `useT()` on the server exactly as a page does.
156
183
 
157
- ### Fixed
184
+ **And the dev SSR failure path no longer hands the raw error to the logger.** A React SSR error carries the element/props graph; formatting it through `util.inspect` can exceed V8's ~512 MB string cap, at which point `RangeError: Invalid string length` from `inspect` *becomes* the reported error and the real message is gone. The consumer had to monkey-patch `console.error` from application code to recover a one-line i18n error. `boundedErrorText` reads `stack`/`message` only, caps the result, names the truncation, and includes `cause` / `AggregateError` children.
158
185
 
159
- - **@voltro/runtime, @voltro/cli, @voltro/plugin-webhooks** Three gaps named in the previous change set, closed.
186
+ Verified against a real `voltro dev` process rendering a fixture with two locales: the marker appears in the SERVER body, the `voltro:lang` cookie selects the German catalog (so the wrap is per-request, not a fixed default), and both branches were red-verified by removing their spread.
160
187
 
161
- **`onSoftDelete` could not fire.** A soft delete is not a `delete` event it is an UPDATE that sets `deletedAt` — and the rule matcher only looked at `op === 'delete'`, so the option existed and the event it needed never arrived. The matcher detects the null → non-null TRANSITION on `deletedAt` (the value alone would re-fire on every later write to a tombstoned row) and the boot wiring forwards updates as well as deletes.
188
+ codemod: noneno user-authored code changes shape; a page that was crashing now renders.
189
+ - **@voltro/cli** — **Every first visit to an SSR page hydration-mismatched, and `<html lang>` was a constant.**
162
190
 
163
- The tests were green throughout, because they passed `softDeleted: true` alongside `op: 'delete'` — a shape the change channel never produces. They proved the flag worked against something that does not exist.
191
+ Two defects on one surface, and the second is why the obvious fix for the first did not work.
164
192
 
165
- **`assertNoTagCollisions` ran only in `voltro dev`.** A plugin/app tag clash aborted boot in development and was checked nowhere in production, so a collision dev refuses could ship and whether it shadowed a route or crashed depended on what codegen happened to emit. It runs in `serve` now, honouring `overridesPlugin` identically from the same descriptors.
193
+ **1. The halves disagreed on the no-cookie case.** The generated client entry resolved the locale from the cookie only correctly refusing `navigator.languages`, which can diverge from what the server saw. But *dropping* the `Accept-Language` signal is not the same as *agreeing* with the server about it. With no `voltro:lang` cookie yet every first visit the server negotiated `Accept-Language` while the client fell through to `defaultLocale`. An English browser on a German-default app hydrated `de` over an `en` tree, so React discarded the whole server render: exactly what SSR was enabled to buy. It stopped the moment anything wrote the cookie, which is why one language switch made it un-reproducible for that developer.
166
194
 
167
- **`listDeliveries` filtered after the read.** `status` and `since` cannot go into the predicate, so taking exactly `limit` and then filtering silently returned too few — ask for 200 deliveries since Monday and you get however many of the newest 200 rows fall in that window, with no signal the answer was truncated from the wrong end. It over-fetches when a post-read filter is in play, then applies the limit.
195
+ The client now **adopts what the server resolved**, from `<html lang>`, before falling back to the cookie and the default. `navigator.languages` is still never read.
168
196
 
169
- That last test also passed against the old code at first: the fake store ignored `take` entirely, so nothing about paging was being tested. Modelling `take` made it red-verifiable, and it is 1 row instead of 5 without the fix.
197
+ **2. `<html lang>` never carried the resolved locale.** `voltro dev` read a `voltro:locale` cookie. Nothing writes that name — `resolveLocale`, the generated entry, `@voltro/ui-shadcn`'s ProfileMenu and the docs all use `voltro:lang` so the lookup always missed and the attribute was the literal `"en"` on every page of a German-default app. Measured by a consumer with three different `Accept-Language` values against `/login`: `<html lang="en">` all three times.
170
198
 
171
- codemod: none
172
- - **@voltro/cli, @voltro/runtime** — Two defects reported from a MariaDB deployment.
199
+ That is wrong on its own terms: `<html lang>` is what a screen reader pronounces in, what Chrome offers to translate *from*, and what hyphenation uses. It is now the locale THIS request resolved — the same value the `<I18nProvider>` renders with — falling back to `voltro:lang`, then the app's `defaultLocale`, never a hardcoded `'en'`.
173
200
 
174
- **`gc-snapshots` and `restore-snapshot` were postgres-only, silently.** `table_schema = 'public'` was hardcoded at four sites. On MySQL/MariaDB the schema IS the database name, so every one matched nothing and "matched nothing" prints the same line as "there is nothing": the reporter had a real `presence__dropped_20260803032340` while the tool said "dropped 0" and exited 0.
201
+ **It also cost the reporter a wrong fix**, which is the part worth keeping: they shipped "adopt `<html lang>` as the client fallback" with green tests, because the tests asserted their belief about what the attribute contained. A `curl` is what caught it. Both halves are now asserted against a running `voltro dev`, red-verified by restoring the old cookie name.
175
202
 
176
- It compounds because `VOLTRO_SOFT_DROP=1` is the right default for an unattended migrate job, so every drop becomes a snapshot and they accumulate forever when the reclaim tool cannot see them the safety net becomes litter.
203
+ The docs said the client "mirrors cookie and default for hydration safety" a sentence that reads as a guarantee and described the opposite of what happened. Corrected in both languages.
177
204
 
178
- Two sites were in `gc-snapshots`, which is what was reported. The other two are in **`restore-snapshot`**, which nobody had reached yet: that is the command you run AFTER something went wrong, and it would have answered "no snapshot found" for one that exists. Beneath the predicate sat a second postgres assumption the first one hid — `"double-quoted"` identifiers, which MySQL/MariaDB reject, so even a matching query could not have executed. Both are dialect-resolved now (`quoteIdent` was already imported and unused).
205
+ Still open, narrower: `voltro start`'s `<html lang>` prefers the resolved locale but falls back to `'en'` rather than `defaultLocale` when an app configures a locale whose catalog file is missing.
206
+ - **@voltro/runtime** — **An undeclared throw reached the client as a ~2 KB decode tree instead of its message.**
179
207
 
180
- **A stale `source:` is now reported at boot.** `source` is matched by NAME against change events, so one naming a table that no longer exists leaves the query not broken but permanently QUIET it serves its first snapshot and never updates, which is indistinguishable from "nothing has changed". The reporter hit it on the 0.24.0 agent rename: two queries kept the old string and the app booted clean with zero warnings. It is a string, so `tsc` cannot see it, and the codemod's promise that a missed rename "fails loudly with relation does not exist" is true of a SQL reference and false of this.
208
+ An executor threw a plain `TypeError`. The server logged it correctly. What the client got was the entire `ExitEncoded<…>` transformationevery member of the descriptor's `error:` union, the full type, and the actual cause on the *last* line. One consumer's account page rendered that verbatim where a reason belonged, and every app otherwise has to condense it heuristically to avoid putting a schema on screen.
181
209
 
182
- Resolved against the declared table set which the boot already holds, so it is free with a did-you-mean for the prefix-rename case that produced it. It WARNS rather than refusing: a table can legitimately live outside the declared schema, and a boot failure for those would be the worse trade. Computed in `loadDiscovered`, so dev / serve / doctor / check all see it, with a parity test that fails if it is wired into only one boot path.
210
+ **The channel is the part worth keeping, and only a deployed process settled it.** The first attempt guarded the DEFECT channel reasonable, and inert: an executor that throws is settled as a FAILURE by the async wrapper, so the encoded cause reads `_tag: "Fail"` and a defect-channel catch never fires. The existing `isInfraError` guard missed it too, because a plain `TypeError` has no `_tag`.
183
211
 
184
- Both red-verified against their own reverted fix.
212
+ So the rule is on the failure channel and is not a heuristic: **every error an app DECLARES carries a `_tag`** — that is the wire contract the client pattern-matches on so an `Error` without one is exactly the set the descriptor's `error:` union cannot contain. A tagged error passes through untouched.
185
213
 
186
- codemod: none
187
- - **@voltro/runtime** — The events docs promised an authorization guarantee the code does not provide.
214
+ An undeclared defect now collapses to the same small tagged `InternalError` the infra path already produced, carrying the message the server just logged. `message` only: no stack, no `cause` chain, no own fields — the same reasoning as `wireErrorFromCause`, where a nested object can hold a DSN or a token. Bounded at 500 chars with the truncation marked.
188
215
 
189
- "Guards are re-checked when the subject changes, not per delivery. Revoke a role and the stream ends." There is exactly ONE `checkGuardsEffect` call on the event path — in `bindEvent`, at subscribe — and no subject-change hook, no revocation path that touches a live subscription. A subject whose role is revoked keeps receiving, and this primitive reconnects forever by design, so "until the stream ends" can be a very long time.
216
+ **Measured against `voltro serve`**, an action doing `undefined.runWithEager()`, from the published fixture bundle:
190
217
 
191
- The documentation now says what happens: checked once, at subscribe, never again; if a permission change must take effect immediately, do not model the authorization boundary with an event subscription. en + de, agent-docs regenerated.
218
+ | | response | |---|---| | before | **543 bytes** of `ExitEncoded<…>` decode tree | | after | **185 bytes** `{"_tag":"InternalError","message":"Cannot read properties of undefined (reading 'runWithEager')","traceId":…}` |
192
219
 
193
- Correcting the sentence rather than implementing the re-check is deliberate, and the reasoning is the same one that made this worth finding: a security guarantee that is stated and not kept is worse than one that is absent, because readers build on the sentence. Re-checking on subject change is a real feature with real design questions (what ends the stream, how a subject change is even observed on a long-lived socket) and it should not be improvised inside a doc fix.
220
+ The server log line and the client message are now identical, which was the ask. The unit test was green through BOTH states, because it exercised the pure function and not the channel it hangs on.
194
221
 
195
- Same shape as the three defects already fixed in this pass — `webhook.retry`, `presencePlugin({ sweepIntervalMs })`, and the guards themselves, which were accepted, serialised into the manifest, reported by doctor and counted in the devtools panel while nothing enforced them. That one was about whether the check runs at all; this one is about how long its answer stays true.
222
+ **The asymmetry with `isInfraError` is deliberate.** A `SqlError` still collapses to the generic `'internal server error'`, because its message names internal `table.column` detail. An arbitrary app defect has no such known shape, and withholding its text too would leave the app exactly where it started with a reason it cannot show.
223
+ - **@voltro/plugin-webhooks** — **The scope lookups read one page of the target table and answered confidently from it.**
196
224
 
197
- codemod: none
198
- - **@voltro/runtime, @voltro/protocol, @voltro/cli** — **`ctx.events` is typed as what it actually is.** It was declared as the old string-emitter facade (`emit(name, data)`) long after that facade stopped being installed there, so the documented and taught call — `ctx.events.publish(descriptor, key, payload)` — was a `tsc` error while the runtime carried only `publish`. A consumer could not tell which of the two was lying and measured it with a cron probe:
225
+ `scope` is an app-defined JSON blob, so matching it cannot be a SQL predicate — a JSON comparison is dialect-divergent, and on MariaDB a `json()` column carries `utf8mb4_bin`, which has already produced a case-sensitive `LIKE` that reported a clean `0` over 141 dirty rows. Filtering in JS is the right call. Reading only the first 1000 rows to filter was not.
199
226
 
200
- EVENT_PROBE {"eventKeys":["publish"],"publishType":"function","emitType":"undefined"}
227
+ Past that many target rows, both callers returned a wrong answer rather than an error:
201
228
 
202
- Exactly the inverse of the declared type. Their workaround was a cast in the one primitive whose entire justification is typing.
229
+ - **`subscribe` minted a fresh secret for a LIVE endpoint.** Growing an endpoint inherits its secret precisely because the receiver verifies one signature for one URL. Not seeing the endpoint's rows meant inventing a new key, so half its rows then sign with a key the receiver does not hold — and the "these are not one endpoint" refusal never fires, because it only inspects what was fetched. - **`resolveTargets` threw `no target matches scope … the operation would have silently done nothing`** for a scope that does match. That message sits three lines under a comment about exactly this failure shape.
203
230
 
204
- What let it drift is the part worth recording: the builder installed the publisher with `as never`, so the compiler had the answer the whole time and was told not to give it — beside a comment in the same file stating that `emit` is gone. A context field is the one place this repo already treats such a cast as a defect in its own right; it is removed, so `tsc` is the guard now.
231
+ Both now page until the table is exhausted, ordered by `id` (unique `OFFSET` over a non-unique order can repeat or skip rows between pages, and mssql refuses `OFFSET` without an `ORDER BY`). Past 100k rows the scan THROWS: a scan that gives up quietly is the thing being fixed.
205
232
 
206
- **`overridesPlugin: true` on a query / mutation / action.** Correcting the premise first, because it matters for anyone reading the same report: sharing a NAMESPACE with a plugin already composes. `assertNoTagCollisions` compares FULL tags, so `notifications.list` beside the plugin's `notifications.inbox` has always been fine. Only an identical name collides, and that stays an error two handlers behind one tag is not something a caller can reason about.
233
+ `emit`'s fan-out pages too. Its cap was per-event (it has a real `event` predicate) and carried the comment *"sane bound 1000 targets per event is plenty"* but a bound whose overflow is a silent non-delivery is not a bound, it is a data-loss ceiling nobody is told about. Paging costs nothing in the normal case: one page, one round trip.
207
234
 
208
- What was missing is the deliberate replacement. The two escapes available before were to rename your procedure or to `alias` the whole plugin away, and both move the split from a domain boundary to "who built it" — for a frontend developer, the worst possible partition. The flag drops the plugin's route rather than merely permitting the pair (permitting it would leave two handlers bound, the state the check exists to prevent) and logs which routes it replaced.
235
+ **Why nothing caught it.** Every test harness in the package returns its whole row array from `query()` and ignores `take`/`skip` which is precisely what a paging bug looks like from the inside. `targetScan.test.ts` uses a harness that honours them, and that is the only reason its assertions mean anything.
209
236
 
210
- Explicit, never inferred: silently letting the app win would mean a plugin upgrade that adds a route could shadow an app procedure with no diff to read.
237
+ ### Internal (no consumer-facing effect)
211
238
 
212
- Three smaller ones from the same report: the `defineSchedule` timezone error now says that an ABSENT field is a `tsc` error and reaching the message means an EMPTY one (usually `process.env.TZ ?? ''`); the empty-relations warning names the `_relations.register.ts` entry that must go with the file; and `db apply` no longer says "nothing to apply" one line above "installing change triggers on 500 table(s)" — it says "no DDL to apply", which is what it meant.
239
+ - **@voltro/plugin-ai-flows** **`FlowStep` / `RunStep` are declared interfaces, so the api report stops churning.**
213
240
 
214
- codemod: none
215
- - **@voltro/plugin-presence** — `presencePlugin({ sweepIntervalMs })` is now read. It was declared, documented as "Sweep interval for stale rows. Default 60s.", and the sweep ran on `timeoutMs / 3` regardless — so setting it did nothing, and the stated default was wrong as well: with the 30s window the real interval was 10s, not 60s. The one number a reader could have checked the option against disagreed too.
241
+ `type FlowStep = typeof FlowStep.Type` is an alias to a mapped type, and TypeScript's declaration emit expands such an alias structurally rather than printing its name. Both types are reached from an exported table (`aiFlows.steps: json<ReadonlyArray<FlowStep>>()`), so a ~60-line expansion sat inline in `etc/plugin-ai-flows.api.md` — and its member order depends on which other packages were built in the same turbo run. Measured: a full-monorepo build and a single-package `--force` build emit `params` and `schema` (structurally identical, both `Schema.optional(Json)`) in different positions, so the pre-push drift gate rejected whichever order was committed. CI does not build the scope a dev machine does.
216
242
 
217
- It defaults to a third of `timeoutMs` (a vanished member is gone within roughly 1.3x the online window, which is the right relationship for almost every room) and an explicit value now wins.
243
+ An `interface` is a real declaration TypeScript prints by name. Both are pinned to their schemas by an `Equals` check that fails to compile on divergence, so the hand-written shape cannot drift from the runtime one.
218
244
 
219
- The interval was also derived at TWO sites the one the sweep ran on and the one reported to the inspect surface computed identically and independently. That is how a reported value and a real one drift apart with neither site looking wrong; it is derived once now.
245
+ **`apiSurface: compatible`, and the reason matters more than the label:** the 36 removed golden lines are the collapsed expansion, not a removed capability. The type is structurally identical the `Equals` pin proves exactness in both directions so no consumer expression changes meaning. What changed is how the report SPELLS the same type.
220
246
 
221
- Found by walking the plugin option surface and asking, per field, who reads it — the same pass that found `webhook.retry`. Fourth instance of this class in this feature. `declaredOptionsEnforced.test.ts` pins it, red-verified against the reverted fix.
247
+ Verified byte-identical across exactly the two build scopes that disagreed before.
248
+ - **@voltro/cli** — boot-validate (sharpened): a `pnpm boot-validate:sqlite` lane (driver-gated, degraded-boot: probes the sqlite driver chain, DEGRADED+exit-0 when absent, else a real embedded-sqlite durable-CRUD round-trip incl. reopen) with a `--self-test`; plus completing the internal `ApiAppConfig.store` union with `'sqlite'` (the resolver already supported it). The Tier-B service lanes (mysql/mssql/clickhouse/redis + a boot-validate compose) are the remainder. Ships with a new `api-backend-sqlite` template.
249
+ - **@voltro/plugin-webhooks, @voltro/workflow** — **The delivery workflow is now covered against a REAL SQL cluster engine, not only the in-memory one.**
222
250
 
223
- codemod: none
224
- - **@voltro/client** — `useEvent` crashed instead of waiting when its api had not resolved yet. Every other hook survives that window because it reads through `LoadingSubscriptionCache`, whose `subscribe` is a non-fetching no-op; `useEvent` forks its own fiber on the api handle's runtime, and the loading baseline's runtime is a stub with `runPromise` and nothing else — so a mount without a `<VoltroRuntimeProvider>` above it, or during the boot window before the client resolves, died with `runtimeRef.current.runFork is not a function`.
251
+ `deliverWorkflow.integration.test.ts` runs the delivery workflow against every dialect with the workflow ENGINE in `memory` — a documented, defensible trade (a cluster cold start made it slow and flaky under CI contention). It is also why a cluster-only defect shipped: a suite named "deliverWorkflow end-to-end per dialect" reads like coverage and was structurally blind to entity registration.
225
252
 
226
- It now stays `idle` until the api resolves, then subscribes. The gate is worth more than the crash it removes: this subscription retries a dropped connection forever on purpose, and the loading baseline's client is a proxy that throws on every call so a fork that had "worked" would have spun rather than failed.
253
+ `deliverWorkflowTwoRunners.integration.test.ts` stands up a real SQL-backed cluster engine on MariaDB and asserts the workflow resolves AND its body runs. `purgeClusterState` is exported from `@voltro/workflow/cluster-suite` so a suite outside the dialect packages can use it. It is ONE file on purpose: two suites purging the same `cluster_*` tables wipe each other's runners mid-run, which fails as something that looks nothing like shared state.
227
254
 
228
- The gate asks whether the runtime can fork rather than comparing the handle against the stub by identity, because a host that loads a bundled copy of `@voltro/client` alongside the resolved one — `@voltro/web`'s dist does — has its own stub object, and identity would answer "resolved" for a stub. Capability is true of every real runtime and false of every stub, in any number of copies.
255
+ **Two things measured on the way, both worth more than the test itself:**
229
256
 
230
- codemod: none
231
- - **@voltro/database, @voltro/runtime, @voltro/cli, @voltro/plugin-versioning, @voltro/plugin-audit** — Two features shipped one commit earlier were declared and inert. Both are now wired, and both are the exact defect class the change set they arrived in was about — declared, and nothing reads it.
257
+ - The old per-emit shape is what the shared cluster suite itself uses (`Effect.provide(handler.pipe(Layer.provideMerge(engine)))`) and it works *there* — because there is exactly one runner. In a deployment the boot runner owns the shard, and an ad-hoc participant registering the entity does not change where the message routes. That is the mechanism behind `Entity type 'Workflow/voltro.deliverWebhook' not registered`, and it is why no single-runner test could have caught it. - **A cluster test that hangs is usually not the cluster.** A stub handler returning `void` against a `success` schema of `{ finalStatus, attempts }` cannot be encoded, so the message is redelivered forever and `execute` never resolves — a 120s timeout with the row still in `cluster_messages`. Accumulated cluster state was blamed first, the purge added, and it still hung; counting the rows settled it. Look at the handler's return type before the cluster.
232
258
 
233
- **`pluginRef` was a library, not a feature.** `applyPluginRefRules` and `pluginRefSpecOf` had no caller anywhere. An app could declare `orphanPolicy: 'delete'` and the rule would never run: the column worked, the engine was correct, and nothing connected them. It is collected at boot from the registered tables and applied on the post-commit change channel, out of band so it can never back-pressure the change stream.
259
+ **And the two-runner case IS covered now** `deliverWorkflowTwoRunners.integration.test.ts` stands up a boot runner and a dispatcher against one live MariaDB and asserts BOTH directions:
234
260
 
235
- `collectPluginRefRules` also implements the edge case that was only a comment before: a `pluginRef` naming a table no installed plugin registers **refuses at boot**, naming both sides. A declared rule against an absent plugin would sit there looking enforced.
261
+ | boot runner A | dispatch | result | |---|---|---| | built WITHOUT the workflow's layer | B provides it at dispatch (the shipped shape) | **`not registered`**, body never ran | | built WITH it (what both boot paths do now) | same B | run completes, body executed |
236
262
 
237
- **`_voltro_row_history.actor` was always null.** The column existed and the row builder read `event.actor` which nothing ever set. The versioning plugin now resolves the snapshot from the `actors` row it already has store access to.
263
+ The negative control is the point: a test that can only pass cannot tell a registered entity from an unregistered one, and every single-runner test in this repo passes on the broken code. The defect case costs ~60s the ENGINE retries an unroutable message before the failure surfaces against ~3s for the fix. That is the price of having a reproduction at all; do not lower the timeout to tidy it.
264
+ - **@voltro/cli** — dev.ts refactor (dev-ts-decomposition Step 1+3): the SSE subscription-snapshot push now reuses the shared `buildSubscriptionsInspect` builder instead of an inlined byte-identical copy; the `_voltro_workflow_runs` refetch-and-emit shared by redrive + resume-from-step is one helper; and a live-span-leak GATE test asserts the inspect door gate sits above every ungated live-data branch. Pure refactor, zero behaviour change.
265
+ - **@voltro/cli** — **The message-API check called a real chainable member non-existent.**
238
266
 
239
- `resolveActorSnapshot` moved to `@voltro/database` for that: two plugins need it, it is the only package both depend on, and `actors` is a core table declared there. Putting it in the runtime was the first attempt and wrong — plugin-audit deliberately does not depend on the runtime.
267
+ `.index()` is a genuine member of the table builder with two overloads. api-extractor prints an overloaded member as a call-signature *object*:
240
268
 
241
- Both are guarded by WIRING tests, not only unit tests of the engines: in both cases the engine was correct and entirely inert, which no unit test could see. Red-verified by removing the wiring.
269
+ ```
270
+ index: {
271
+ <const F extends readonly [...]>(fields: F, options?: …): Table<…>;
272
+ <const IxName extends string, …>(name: IxName, …): Table<…>;
273
+ }
274
+ ```
242
275
 
243
- codemod: none
244
- - **@voltro/workflow** — **Multi-replica workflow runners now get a DISTINCT cluster identity** — a real sharding + failover correctness fix. `workflowEngineLayer` set only the cluster runner's *listen* address (from `POD_IP`), never its *advertised* address, and the advertised address IS the identity `@effect/cluster` keys `cluster_runners` and every owned shard on. So every replica fell back to the library default (`localhost:34431`) and they all registered as the SAME runner: one identity owning all 300 shards, no distribution, and failover that "worked" only because the colliding processes happened to poll the same rows.
276
+ which matches neither of the check's line-shaped rules (`foo(` / `foo: (…) =>`). So a correct comment naming it was reported as naming something that does not exist, and the static check went red on `main`.
277
+
278
+ **A false alarm is not the harmless direction here.** This check exists to be believed — its own failure text says "fix the message, or build the thing it promises". One that cries wolf gets its finding argued with instead of read.
245
279
 
246
- Now both the advertised (`runnerAddress`) and listen addresses are set from the `POD_IP`-derived identity, so two pods with distinct `POD_IP`s are two distinct runners shards distribute across them (verified: 3 runners 100 shards each, was 1 300) and a crashed replica's shards are genuinely handed off to a survivor. Surfaced by a new multi-process chaos test that needs three real, distinct runners to hand a run off twice.
280
+ The lookahead is the part worth recording: matching the generic precisely does **not** work, because `<const F extends Array<…>>` nests `>`, so a `<[^>]*>` character class stops inside it. The first version of the branch therefore matched nothing and looked like a fix. A call signature simply *starts* with `<` or `(` once trimmed; a data member starts with an identifier or `readonly` — which is what keeps `index?: { readonly where: string }` out, and with it the data-property bug the original rules exist to reject.
247
281
 
248
- Requires `POD_IP` (or `VOLTRO_WORKFLOW_RUNNER_HOST`) injected per pod — the same requirement the boot already warns about; it now actually determines identity, not just the (inert, under SingleRunner) listen address. `codemod: none` no user-authored code changes; `cluster_runners` is ephemeral and re-registers on boot, so stale old-identity rows age out on their own.
282
+ Both directions are now selftest cases, since a rule that quietly stops matching prints exactly like a clean tree.