@voltro/cli 0.54.0 → 0.56.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 (173) hide show
  1. package/CHANGELOG.md +639 -2
  2. package/bin/voltro.mjs +24 -0
  3. package/dist/{apiBuild-DTWp0S_q.js → apiBuild-Bdaetr37.js} +119 -88
  4. package/dist/apiBuild-Vw1figjO.js +2 -0
  5. package/dist/bin.js +1 -1
  6. package/dist/{build-D4ygSbnV.js → build-CI36wL4R.js} +339 -314
  7. package/dist/buildReport-52gHKgfO.js +64 -0
  8. package/dist/{checkCommand-Dg1G7Gwd.js → checkCommand-CAwFXrxA.js} +6 -6
  9. package/dist/{checkCommand-L7DTlpIF.js → checkCommand-D0QV_zM_.js} +1 -1
  10. package/dist/{clusterCmd-DrVFCzSj.js → clusterCmd-CdLB1GkT.js} +1 -1
  11. package/dist/{codegen-DSLM8Su9.js → codegen-Bth5lUTU.js} +76 -64
  12. package/dist/codegen-DbH7NbCR.js +2 -0
  13. package/dist/{codegenCommand-CG_Vx4lc.js → codegenCommand-CidbQzbv.js} +15 -14
  14. package/dist/{codemodRunner-Cd4xkC6u.js → codemodRunner-BlQPfjzA.js} +277 -0
  15. package/dist/{commands-6Kzi92Np.js → commands-CWjfThXv.js} +35 -35
  16. package/dist/{dashboardCommand-Cq1PWvI1.js → dashboardCommand-BekcY5Ls.js} +3 -3
  17. package/dist/{dataCommand-DYzW8vkv.js → dataCommand-2pccgbIy.js} +267 -195
  18. package/dist/{dbCommand-B4NWZtGL.js → dbCommand-DpK_vQET.js} +457 -441
  19. package/dist/dbCommand-DrycGWWt.js +2 -0
  20. package/dist/{dev-cKUiZZsB.js → dev-B9Gz0k85.js} +1 -1
  21. package/dist/{dev-CmuvUKRq.js → dev-Dw263KPu.js} +2598 -2452
  22. package/dist/doctorCommand-BMWs6aVm.js +2 -0
  23. package/dist/{doctorCommand-DCiFVMtZ.js → doctorCommand-aR_bFmIi.js} +353 -251
  24. package/dist/{dormancyCommand-w1TrmgYP.js → dormancyCommand-eXTQMbHU.js} +1 -1
  25. package/dist/{embeddingsCommand-CMgPyRTr.js → embeddingsCommand-CTmiQvwa.js} +1 -1
  26. package/dist/{envCommand-Cyynmcfa.js → envCommand-BDUgV7EM.js} +12 -12
  27. package/dist/{evolveCommand-BwvQ8dVH.js → evolveCommand-YV8qW1LU.js} +2 -2
  28. package/dist/frameworkTableAssembly-CGNC0qr7.js +2 -0
  29. package/dist/{frameworkTableAssembly-D7LJuALW.js → frameworkTableAssembly-DNOFXfEQ.js} +102 -100
  30. package/dist/index.js +1 -1
  31. package/dist/{infoCommand-DlYlUPqs.js → infoCommand-BjVXpMlP.js} +1 -1
  32. package/dist/inspect-CNYvNXPU.js +1484 -0
  33. package/dist/inspect-S2rWy1Ys.js +2 -0
  34. package/dist/{inspectCmd-niF97fAq.js → inspectCmd-CP-G0sVK.js} +1 -1
  35. package/dist/{inspectFetch-EMuhTG_9.js → inspectFetch-BU1NyzxV.js} +36 -24
  36. package/dist/{inspectMetrics-CGF94puw.js → inspectMetrics-BY0Sjb2F.js} +19 -19
  37. package/dist/interruptedReplace-CwnkBb2X.js +41 -0
  38. package/dist/interruptedReplace-qzmFI020.js +2 -0
  39. package/dist/{logsCmd-B6oNsfaZ.js → logsCmd-BU8uCdys.js} +1 -1
  40. package/dist/{manifestBuild-Cqgsx2bM.js → manifestBuild-CEkjfpwc.js} +1 -1
  41. package/dist/manifestBuild-DIa_s6u0.js +2 -0
  42. package/dist/{migrate-BK_Bbx-_.js → migrate-SICulyz1.js} +2 -2
  43. package/dist/precompressAssets-YhTi1aWp.js +40 -0
  44. package/dist/{probeCommand-_C0YU207.js → probeCommand-6HxEkNDG.js} +2 -2
  45. package/dist/{runtimeTrace-CGWx1Q6l.js → runtimeTrace-DgYMc09E.js} +1 -1
  46. package/dist/{scheduleCmd-DQRu6BZC.js → scheduleCmd-DYBUfo_T.js} +1 -1
  47. package/dist/{sdkgen-CDGHQUFj.js → sdkgen-PY-umd6O.js} +1 -1
  48. package/dist/{seedRunner-Dgsiwk_e.js → seedRunner-DISBKow-.js} +16 -16
  49. package/dist/serveCommand-BITS8Hpj.js +2 -0
  50. package/dist/{serveCommand-Bje09q1v.js → serveCommand-DIJ3ma76.js} +951 -909
  51. package/dist/serveEntry.js +1 -1
  52. package/dist/{start-Clz-1BHB.js → start-B9NGB8gn.js} +627 -588
  53. package/dist/{start-B0bnJgxI.js → start-BFQQkL1i.js} +1 -1
  54. package/dist/startEntry.js +1 -1
  55. package/dist/staticCachePolicy-CIyj6DbS.js +15 -0
  56. package/dist/{test-D_kW4KMj.js → test-jipIQ5Mx.js} +1 -1
  57. package/dist/{tracesCmd-DgtgOUdi.js → tracesCmd-BWYDqMy6.js} +1 -1
  58. package/dist/{updateCommand-CRJlAOaM.js → updateCommand-C9n_Z_oG.js} +8 -2
  59. package/dist/updateCommand-DsXEAHbd.js +2 -0
  60. package/dist/webDev-C2dRz9s5.js +2 -0
  61. package/dist/{webDev-DSI9SOhs.js → webDev-C53hJdcL.js} +1265 -1288
  62. package/dist/{webhooksCommand-BvzXNHji.js → webhooksCommand-uuPu8qQX.js} +1 -1
  63. package/dist/{workflowsCmd-BGF-mRZ5.js → workflowsCmd-g-DNpaUc.js} +1 -1
  64. package/package.json +67 -19
  65. package/templates/AGENTS.core.md +20 -1
  66. package/templates/AGENTS.md +21 -2
  67. package/templates/agent-docs/_index.md +1 -1
  68. package/templates/agent-docs/_manifest.json +1 -1
  69. package/templates/agent-docs/authentication.md +49 -6
  70. package/templates/agent-docs/cli.md +216 -11
  71. package/templates/agent-docs/data.md +209 -18
  72. package/templates/agent-docs/database/scaling.md +40 -1
  73. package/templates/agent-docs/deployment.md +53 -1
  74. package/templates/agent-docs/internationalization.md +32 -0
  75. package/templates/agent-docs/local-first-mobile.md +9 -2
  76. package/templates/agent-docs/observability.md +227 -0
  77. package/templates/agent-docs/plugins/billing.md +15 -0
  78. package/templates/agent-docs/plugins/broadcast.md +2 -1
  79. package/templates/agent-docs/plugins/ratelimit.md +6 -1
  80. package/templates/agent-docs/plugins/row-history.md +11 -0
  81. package/templates/agent-docs/plugins.md +65 -8
  82. package/templates/agent-docs/reference.md +5 -3
  83. package/templates/agent-docs/routing.md +18 -0
  84. package/templates/agent-docs/schema-driven-ui.md +125 -0
  85. package/templates/agent-docs/templates/appshells.md +3 -3
  86. package/templates/agent-docs/whats-new.md +408 -106
  87. package/templates/apps/api-ai/package.json +6 -6
  88. package/templates/apps/api-auth/package.json +8 -8
  89. package/templates/apps/api-backend/package.json +7 -7
  90. package/templates/apps/api-backend-deactivation/package.json +7 -7
  91. package/templates/apps/api-backend-mail/package.json +8 -8
  92. package/templates/apps/api-backend-mariadb/package.json +9 -9
  93. package/templates/apps/api-backend-sqlite/package.json +8 -8
  94. package/templates/apps/api-backend-storage/package.json +8 -8
  95. package/templates/apps/api-cms/package.json +9 -9
  96. package/templates/apps/api-collab/package.json +8 -8
  97. package/templates/apps/api-data-advanced/package.json +8 -8
  98. package/templates/apps/api-durable/package.json +8 -8
  99. package/templates/apps/api-feature-flags/package.json +9 -9
  100. package/templates/apps/api-governance/package.json +8 -8
  101. package/templates/apps/api-kv/package.json +8 -8
  102. package/templates/apps/api-moderation/package.json +8 -8
  103. package/templates/apps/api-observability/package.json +8 -8
  104. package/templates/apps/api-ratelimit/package.json +8 -8
  105. package/templates/apps/api-rbac/package.json +8 -8
  106. package/templates/apps/api-rest/package.json +7 -7
  107. package/templates/apps/api-row-history/package.json +8 -8
  108. package/templates/apps/api-saas/package.json +11 -11
  109. package/templates/apps/api-saas-starter/package.json +10 -10
  110. package/templates/apps/api-search/package.json +8 -8
  111. package/templates/apps/api-status/package.json +8 -8
  112. package/templates/apps/api-webhooks/package.json +9 -9
  113. package/templates/apps/changelog/package.json +7 -7
  114. package/templates/apps/changelog/src/pages/[locale]/page.tsx +7 -1
  115. package/templates/apps/edge-functions/package.json +2 -2
  116. package/templates/apps/frontend-admin/package.json +7 -8
  117. package/templates/apps/frontend-admin/src/pages/(marketing)/layout.tsx +1 -2
  118. package/templates/apps/frontend-admin/src/pages/admin/layout.tsx +1 -2
  119. package/templates/apps/frontend-app/package.json +8 -9
  120. package/templates/apps/frontend-app/src/pages/layout.tsx +1 -2
  121. package/templates/apps/frontend-auth/package.json +7 -8
  122. package/templates/apps/frontend-auth/src/components/AuthShell.tsx +1 -2
  123. package/templates/apps/frontend-blank/package.json +6 -7
  124. package/templates/apps/frontend-blank/src/pages/layout.tsx +1 -2
  125. package/templates/apps/frontend-cms/package.json +8 -9
  126. package/templates/apps/frontend-cms/src/pages/(app)/layout.tsx +1 -2
  127. package/templates/apps/frontend-collab/README.md +7 -2
  128. package/templates/apps/frontend-collab/package.json +9 -10
  129. package/templates/apps/frontend-collab/src/pages/layout.tsx +1 -2
  130. package/templates/apps/frontend-collab/src/pages/page.test.tsx +8 -4
  131. package/templates/apps/frontend-collab/src/pages/page.tsx +10 -5
  132. package/templates/apps/frontend-contact/package.json +7 -7
  133. package/templates/apps/frontend-contact/src/components/ContactForm.island.tsx +1 -1
  134. package/templates/apps/frontend-dashboard/package.json +6 -7
  135. package/templates/apps/frontend-dashboard/src/pages/(marketing)/layout.tsx +1 -2
  136. package/templates/apps/frontend-dashboard/src/pages/dashboard/layout.tsx +1 -2
  137. package/templates/apps/frontend-docs/package.json +8 -8
  138. package/templates/apps/frontend-i18n/package.json +6 -6
  139. package/templates/apps/frontend-landing/package.json +7 -7
  140. package/templates/apps/frontend-portal/package.json +7 -8
  141. package/templates/apps/frontend-portal/src/pages/(portal)/layout.tsx +1 -2
  142. package/templates/apps/frontend-saas/package.json +7 -8
  143. package/templates/apps/frontend-saas/src/pages/(marketing)/layout.tsx +1 -2
  144. package/templates/apps/frontend-saas/src/pages/dashboard/layout.tsx +1 -2
  145. package/templates/apps/frontend-spa/package.json +6 -7
  146. package/templates/apps/frontend-spa/src/pages/layout.tsx +1 -2
  147. package/templates/apps/frontend-ssr/package.json +6 -7
  148. package/templates/apps/frontend-ssr/src/pages/layout.tsx +1 -2
  149. package/templates/apps/frontend-ssr-api/package.json +7 -8
  150. package/templates/apps/frontend-ssr-api/src/pages/layout.tsx +1 -2
  151. package/templates/apps/frontend-static-blog/package.json +8 -8
  152. package/templates/apps/frontend-static-blog/src/components/ReadingProgress.island.tsx +1 -1
  153. package/templates/apps/frontend-static-blog/src/pages/[locale]/page.tsx +7 -1
  154. package/templates/apps/frontend-status/package.json +7 -8
  155. package/templates/apps/frontend-status/src/pages/layout.tsx +1 -2
  156. package/templates/apps/mobile-app/package.json +4 -4
  157. package/templates/baselines/compose/docker/api.Dockerfile +61 -5
  158. package/templates/baselines/compose/docker/web.Dockerfile +55 -10
  159. package/templates/baselines/compose-mariadb/docker/api.Dockerfile +61 -5
  160. package/templates/baselines/compose-mariadb/docker/web.Dockerfile +55 -10
  161. package/dist/apiBuild-CeUN55uk.js +0 -2
  162. package/dist/codegen-DjgxEOnD.js +0 -2
  163. package/dist/dbCommand-CSFWs9ev.js +0 -2
  164. package/dist/doctorCommand-J3qu4E0Y.js +0 -2
  165. package/dist/frameworkTableAssembly-IPD1pUnZ.js +0 -2
  166. package/dist/inspect-Bd8-9wsi.js +0 -1193
  167. package/dist/inspect-CuoDInfZ.js +0 -2
  168. package/dist/interruptedReplace-C3O3M1MM.js +0 -28
  169. package/dist/interruptedReplace-CvmiAM9K.js +0 -2
  170. package/dist/manifestBuild-C4-J1-m_.js +0 -2
  171. package/dist/serveCommand-BiPe8BJm.js +0 -2
  172. package/dist/updateCommand-CIoVDKnj.js +0 -2
  173. package/dist/webDev-DlvZO30c.js +0 -2
@@ -222,7 +222,7 @@ createVerifier({ secret: [process.env.WEBHOOK_SECRET, process.env.WEBHOOK_SECRET
222
222
  ...t === void 0 ? {} : { payload: t }
223
223
  };
224
224
  }, S = u({ scope: "voltro:webhooks" }), C = ["--out", "--name"], w = async (e) => {
225
- let { walk: t, loadDiscovered: n } = await import("./dev-cKUiZZsB.js"), { outgoingFromEvents: r } = await import("./webhookDiscovery-il9ti-HE.js");
225
+ let { walk: t, loadDiscovered: n } = await import("./dev-B9Gz0k85.js"), { outgoingFromEvents: r } = await import("./webhookDiscovery-il9ti-HE.js");
226
226
  return r((await n(await t(e))).events.map((e) => ({
227
227
  file: e.file,
228
228
  descriptor: e.descriptor
@@ -1,5 +1,5 @@
1
1
  import { n as e } from "./cliOutput-D1tSBoRM.js";
2
- import { n as t, o as n, r, t as i } from "./inspectFetch-EMuhTG_9.js";
2
+ import { n as t, o as n, r, t as i } from "./inspectFetch-BU1NyzxV.js";
3
3
  import { ansi as a } from "@voltro/logger";
4
4
  //#region src/workflowsCmd.ts
5
5
  var o = [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@voltro/cli",
3
- "version": "0.54.0",
3
+ "version": "0.56.0",
4
4
  "description": "The `voltro` CLI — dev server, codegen, migrations, project scaffolding, agent-docs seeding, and production serve.",
5
5
  "keywords": [
6
6
  "voltro",
@@ -754,6 +754,54 @@
754
754
  "title": "WidgetKind gained 'array' and 'reference' — total widget registries need two new entries",
755
755
  "kind": "manual"
756
756
  },
757
+ {
758
+ "version": "0.55.0",
759
+ "id": "0.55.0/01_target-relations-declare-columns",
760
+ "title": "A target's `relations:` names the junction's two columns now, not just the table",
761
+ "kind": "manual"
762
+ },
763
+ {
764
+ "version": "0.55.0",
765
+ "id": "0.55.0/02_widget-kind-gained-rich-text",
766
+ "title": "WidgetKind gained 'rich-text' — total widget registries need one new entry",
767
+ "kind": "manual"
768
+ },
769
+ {
770
+ "version": "0.56.0",
771
+ "id": "0.56.0/01_inspect_answers_are_observations",
772
+ "title": "inspect answers are `Observation`s — the payload is `.data`",
773
+ "kind": "manual"
774
+ },
775
+ {
776
+ "version": "0.56.0",
777
+ "id": "0.56.0/02_broadcast_gap_is_one_object",
778
+ "title": "attachBroadcastBus's onGap now receives a single BroadcastGap object",
779
+ "kind": "manual"
780
+ },
781
+ {
782
+ "version": "0.56.0",
783
+ "id": "0.56.0/03_outbox_drain_needs_an_identity",
784
+ "title": "drainOutbox now requires claimedBy — the fleet-wide exactly-once gate",
785
+ "kind": "manual"
786
+ },
787
+ {
788
+ "version": "0.56.0",
789
+ "id": "0.56.0/04_reaction_dedupe_is_one_claim",
790
+ "title": "runReaction's dedupe is a single atomic claim",
791
+ "kind": "manual"
792
+ },
793
+ {
794
+ "version": "0.56.0",
795
+ "id": "0.56.0/05_plugin_bind_context_claims_a_change",
796
+ "title": "PluginBindContext requires claimChange; change identity moved to @voltro/database",
797
+ "kind": "manual"
798
+ },
799
+ {
800
+ "version": "0.56.0",
801
+ "id": "0.56.0/06_serve_is_api_only",
802
+ "title": "`voltro serve` on a WEB app now refuses — use `voltro start`",
803
+ "kind": "manual"
804
+ },
757
805
  {
758
806
  "version": "0.6.0",
759
807
  "id": "0.6.0/01_no-dev-session-secret",
@@ -836,24 +884,24 @@
836
884
  "@effect/platform-node": "^0.108.0",
837
885
  "@effect/sql": "^0.52.0",
838
886
  "@effect/workflow": "^0.19.0",
839
- "@voltro/ai": "0.54.0",
840
- "@voltro/cache": "0.54.0",
841
- "@voltro/client": "0.54.0",
842
- "@voltro/content": "0.54.0",
843
- "@voltro/data-transfer": "0.54.0",
844
- "@voltro/database": "0.54.0",
845
- "@voltro/env": "0.54.0",
846
- "@voltro/kv": "0.54.0",
847
- "@voltro/logger": "0.54.0",
848
- "@voltro/plugin-auth": "0.54.0",
849
- "@voltro/plugin-broadcast": "0.54.0",
850
- "@voltro/plugin-mail": "0.54.0",
851
- "@voltro/plugin-storage": "0.54.0",
852
- "@voltro/plugin-webhooks": "0.54.0",
853
- "@voltro/protocol": "0.54.0",
854
- "@voltro/runtime": "0.54.0",
855
- "@voltro/serverless": "0.54.0",
856
- "@voltro/workflow": "0.54.0",
887
+ "@voltro/ai": "0.56.0",
888
+ "@voltro/cache": "0.56.0",
889
+ "@voltro/client": "0.56.0",
890
+ "@voltro/content": "0.56.0",
891
+ "@voltro/data-transfer": "0.56.0",
892
+ "@voltro/database": "0.56.0",
893
+ "@voltro/env": "0.56.0",
894
+ "@voltro/kv": "0.56.0",
895
+ "@voltro/logger": "0.56.0",
896
+ "@voltro/plugin-auth": "0.56.0",
897
+ "@voltro/plugin-broadcast": "0.56.0",
898
+ "@voltro/plugin-mail": "0.56.0",
899
+ "@voltro/plugin-storage": "0.56.0",
900
+ "@voltro/plugin-webhooks": "0.56.0",
901
+ "@voltro/protocol": "0.56.0",
902
+ "@voltro/runtime": "0.56.0",
903
+ "@voltro/serverless": "0.56.0",
904
+ "@voltro/workflow": "0.56.0",
857
905
  "chokidar": "^5.0.0",
858
906
  "ioredis": "^5.11.1",
859
907
  "tinyglobby": "^0.2.17",
@@ -142,6 +142,24 @@ line below replaces something real apps write by hand hundreds of times.
142
142
  notify-helper at the tail of the mutation body instead makes the reactivity
143
143
  invisible — you can only find it by reading every executor. Both are
144
144
  best-effort, so genuinely critical delivery still belongs in a workflow.
145
+ **A subscriber runs on EVERY replica.** That is right for a handler that
146
+ refreshes an index or drops a process-local cache, and wrong for one whose
147
+ effect IS a write someone receives — two pods send two mails. When the
148
+ handler is an effect, add `once:` (a deterministic key per change) and
149
+ exactly one replica runs it:
150
+ ```ts
151
+ defineSubscriber({
152
+ table: 'absence_requests', on: ['insert'],
153
+ once: true,
154
+ handler: notifyApprovers,
155
+ })
156
+ ```
157
+ `once: true` derives the key from the change (content + its position among
158
+ content-identical repeats) — prefer it. A function is for when you want to be
159
+ COARSER than one-per-change; a key you write must tell two genuine changes
160
+ apart, and a row id does not for `update`. `once` is AT MOST once — a replica
161
+ that wins and dies takes the event with it. `defineReaction`'s `dedupeKey`
162
+ gives its act the same fleet-wide gate.
145
163
  4. **A roll-up / counter recomputed on every read?** → **`defineAggregate`**
146
164
  (`*.aggregate.ts`). Add `incremental:` for `count|sum|avg|min|max` group-bys
147
165
  and it is MAINTAINED on write rather than recomputed. `read({ where })`
@@ -217,6 +235,7 @@ line below replaces something real apps write by hand hundreds of times.
217
235
  | `const rows = …; if (!rows[0]) throw new NotFound()` | `.one()` |
218
236
  | 3+ sequential `store.query` to assemble related data | `relations()` + `.with()` |
219
237
  | a notify/webhook helper called at the end of a mutation | `defineSubscriber` / `defineReaction` |
238
+ | a subscriber that sends a mail / notification / webhook | the same, plus **`once:`** — without it, one per replica |
220
239
  | `WHERE ownerId = me` in every list handler | `setRowFilter` (covers the SUBSCRIPTION too) |
221
240
  | a counter recomputed by scanning rows on every read | `defineAggregate` (+ `incremental:`) |
222
241
  | `requireScope(...)` as the first line of every executor | `guards:` on the descriptor |
@@ -358,7 +377,7 @@ public REST routes, declared via `restRoutes` in `app.config.ts`).
358
377
  | `*.workflow.tsx` + `*.workflow.server.tsx` | durable multi-step work |
359
378
  | `*.trigger.tsx` | domain event → workflow |
360
379
  | `*.cron.tsx` | scheduled job (single file) |
361
- | `*.subscribe.ts` | best-effort post-commit reaction to a table |
380
+ | `*.subscribe.ts` | best-effort post-commit reaction to a table (every replica; `once:` for one) |
362
381
  | `*.reaction.tsx` | standing reactive agent |
363
382
  | `*.aggregate.ts` | scheduled materialised query |
364
383
  | `*.startup.tsx` | run-once boot hook holding a resource |
@@ -142,6 +142,24 @@ line below replaces something real apps write by hand hundreds of times.
142
142
  notify-helper at the tail of the mutation body instead makes the reactivity
143
143
  invisible — you can only find it by reading every executor. Both are
144
144
  best-effort, so genuinely critical delivery still belongs in a workflow.
145
+ **A subscriber runs on EVERY replica.** That is right for a handler that
146
+ refreshes an index or drops a process-local cache, and wrong for one whose
147
+ effect IS a write someone receives — two pods send two mails. When the
148
+ handler is an effect, add `once:` (a deterministic key per change) and
149
+ exactly one replica runs it:
150
+ ```ts
151
+ defineSubscriber({
152
+ table: 'absence_requests', on: ['insert'],
153
+ once: true,
154
+ handler: notifyApprovers,
155
+ })
156
+ ```
157
+ `once: true` derives the key from the change (content + its position among
158
+ content-identical repeats) — prefer it. A function is for when you want to be
159
+ COARSER than one-per-change; a key you write must tell two genuine changes
160
+ apart, and a row id does not for `update`. `once` is AT MOST once — a replica
161
+ that wins and dies takes the event with it. `defineReaction`'s `dedupeKey`
162
+ gives its act the same fleet-wide gate.
145
163
  4. **A roll-up / counter recomputed on every read?** → **`defineAggregate`**
146
164
  (`*.aggregate.ts`). Add `incremental:` for `count|sum|avg|min|max` group-bys
147
165
  and it is MAINTAINED on write rather than recomputed. `read({ where })`
@@ -217,6 +235,7 @@ line below replaces something real apps write by hand hundreds of times.
217
235
  | `const rows = …; if (!rows[0]) throw new NotFound()` | `.one()` |
218
236
  | 3+ sequential `store.query` to assemble related data | `relations()` + `.with()` |
219
237
  | a notify/webhook helper called at the end of a mutation | `defineSubscriber` / `defineReaction` |
238
+ | a subscriber that sends a mail / notification / webhook | the same, plus **`once:`** — without it, one per replica |
220
239
  | `WHERE ownerId = me` in every list handler | `setRowFilter` (covers the SUBSCRIPTION too) |
221
240
  | a counter recomputed by scanning rows on every read | `defineAggregate` (+ `incremental:`) |
222
241
  | `requireScope(...)` as the first line of every executor | `guards:` on the descriptor |
@@ -358,7 +377,7 @@ public REST routes, declared via `restRoutes` in `app.config.ts`).
358
377
  | `*.workflow.tsx` + `*.workflow.server.tsx` | durable multi-step work |
359
378
  | `*.trigger.tsx` | domain event → workflow |
360
379
  | `*.cron.tsx` | scheduled job (single file) |
361
- | `*.subscribe.ts` | best-effort post-commit reaction to a table |
380
+ | `*.subscribe.ts` | best-effort post-commit reaction to a table (every replica; `once:` for one) |
362
381
  | `*.reaction.tsx` | standing reactive agent |
363
382
  | `*.aggregate.ts` | scheduled materialised query |
364
383
  | `*.startup.tsx` | run-once boot hook holding a resource |
@@ -730,7 +749,7 @@ each plugin's own README.
730
749
 
731
750
  | Topic | Open | Summary |
732
751
  |---|---|---|
733
- | **What's new in 0.54.0** | `node_modules/@voltro/cli/templates/agent-docs/whats-new.md` | Everything that changed in this version. Read it before hand-rolling something the framework may now ship. |
752
+ | **What's new in 0.56.0** | `node_modules/@voltro/cli/templates/agent-docs/whats-new.md` | Everything that changed in this version. Read it before hand-rolling something the framework may now ship. |
734
753
  | AI | `node_modules/@voltro/cli/templates/agent-docs/ai.md` | How Voltro treats AI — agents, tools, streaming, RAG — all primitives over the same WebSocket as the rest of the framework. |
735
754
  | Authentication | `node_modules/@voltro/cli/templates/agent-docs/authentication.md` | How @voltro/plugin-auth wires password + session-cookie auth across api + web, plus the pluggable identity-strategy protocol. |
736
755
  | Caching | `node_modules/@voltro/cli/templates/agent-docs/caching.md` | Voltro's caching layer (@voltro/cache) — an always-on memory default, swappable Redis-compatible backends, a low-level wrap primitive, and automatic query-result invalidation. |
@@ -9,7 +9,7 @@ each plugin's own README.
9
9
 
10
10
  | Topic | Open | Summary |
11
11
  |---|---|---|
12
- | **What's new in 0.54.0** | `node_modules/@voltro/cli/templates/agent-docs/whats-new.md` | Everything that changed in this version. Read it before hand-rolling something the framework may now ship. |
12
+ | **What's new in 0.56.0** | `node_modules/@voltro/cli/templates/agent-docs/whats-new.md` | Everything that changed in this version. Read it before hand-rolling something the framework may now ship. |
13
13
  | AI | `node_modules/@voltro/cli/templates/agent-docs/ai.md` | How Voltro treats AI — agents, tools, streaming, RAG — all primitives over the same WebSocket as the rest of the framework. |
14
14
  | Authentication | `node_modules/@voltro/cli/templates/agent-docs/authentication.md` | How @voltro/plugin-auth wires password + session-cookie auth across api + web, plus the pluggable identity-strategy protocol. |
15
15
  | Caching | `node_modules/@voltro/cli/templates/agent-docs/caching.md` | Voltro's caching layer (@voltro/cache) — an always-on memory default, swappable Redis-compatible backends, a low-level wrap primitive, and automatic query-result invalidation. |
@@ -206,7 +206,7 @@
206
206
  "group": null,
207
207
  "description": "OpenTelemetry tracing in Voltro — the auto-emitted spans for every primitive, span attributes and nesting, the three enabling modes (console / OTLP / buffer), and adding your own spans with Effect.withSpan.",
208
208
  "path": "agent-docs/observability.md",
209
- "files": 4
209
+ "files": 5
210
210
  },
211
211
  {
212
212
  "id": "plugins",
@@ -2617,10 +2617,14 @@ Optional, one line, and it buys back a feature the filter otherwise switches
2617
2617
  off for the whole app:
2618
2618
 
2619
2619
  ```ts
2620
+ const OWNED = new Set(['documents', 'comments'])
2621
+
2620
2622
  setRowFilter({
2621
2623
  load,
2622
- predicate,
2623
- tables: ['bookmarks', 'recentSearches', 'todoSchedules', 'todoTags'],
2624
+ predicate: (ctx, table) => (OWNED.has(table) ? eq('ownerId', ctx.userId) : undefined),
2625
+ // Derived from the same set the predicate reads. Two hand-kept lists is the
2626
+ // shape in which a table lands in exactly one of them.
2627
+ tables: [...OWNED],
2624
2628
  })
2625
2629
  ```
2626
2630
 
@@ -2629,10 +2633,49 @@ is excluded for a subscription whose row set is re-resolved per delivery —
2629
2633
  replaying deltas could serve rows the subject has since lost. Without a
2630
2634
  declaration the framework cannot tell which tables your predicate may reach, so
2631
2635
  it excludes them all: one registration disables cheap reconnects for every
2632
- subscription in the process. A deployment measured a filter over 4 tables
2633
- costing it on all 173 of their query descriptors, 55 of whose source tables the
2634
- filter never touches. With the declaration, only subscriptions on the listed
2635
- tables are excluded.
2636
+ subscription in the process, including every one reading a table your predicate
2637
+ never returns anything for. With the declaration, only subscriptions on the
2638
+ listed tables are excluded.
2639
+
2640
+ **What it does not buy, and this bounds the whole feature.** A subscription only
2641
+ has a delta chain when its executor returns a **descriptor**. One that returns a
2642
+ mapped value or a page envelope —
2643
+
2644
+ ```ts
2645
+ export default async ({ database }) => {
2646
+ const rows = await database.notifications.where(...)
2647
+ return { notifications: rows.map(toDto), hasMore: rows.length === 20 }
2648
+ }
2649
+ ```
2650
+
2651
+ — re-runs an opaque handler and emits snapshots, with or without a filter. So
2652
+ the count `tables:` gives back is the count of descriptor-returning
2653
+ subscriptions, not the number of queries in your app. Declare it anyway (it
2654
+ costs nothing, and it applies the moment such a query returns the builder), but
2655
+ measure before expecting a change.
2656
+
2657
+ **How to see which of yours are which.** The two shapes are indistinguishable
2658
+ from the outside — a subscription that resumed and one that was never eligible
2659
+ both reconnect with rows on the screen. So the runtime records its own verdict
2660
+ at the moment it decides, per query label:
2661
+
2662
+ ```sh
2663
+ curl -s localhost:4000/_voltro/inspect/subscriptions | jq .resume
2664
+ ```
2665
+
2666
+ ```json
2667
+ [
2668
+ { "label": "documents.list", "resumable": 12, "excluded": {} },
2669
+ { "label": "notifications.list", "resumable": 0, "excluded": { "computed": 8 } },
2670
+ { "label": "comments.list", "resumable": 0, "excluded": { "row-filter": 3 } }
2671
+ ]
2672
+ ```
2673
+
2674
+ A label appears once something has subscribed to it, so click through the app
2675
+ first. `computed` means no declaration can ever help that query; `row-filter`
2676
+ means the filter narrows its source and the exclusion is the point;
2677
+ `eager-load` means dropping the `.with(...)` would flip it. `voltro dev` also
2678
+ logs each verdict once per label under the `voltro:resume` scope.
2636
2679
 
2637
2680
  **Why a declaration and not a probe.** Resolving the scope at subscribe time
2638
2681
  and treating "returns `undefined` for this table" as safe is cheaper and
@@ -1441,6 +1441,86 @@ Type mapping is faithful: `string → String`, `integer → Int`, `number → Do
1441
1441
 
1442
1442
  **Scope — this is the SDK code generator, not a native runtime.** Deliberately out of scope (they need a native runtime or managed infra, not generated client code): native module bindings (camera, biometrics), the APNs/FCM push **sender** (per-tenant Apple/Firebase credentials, provisioned server-side), and the managed OTA / EAS build pipeline. The generated source is verified at the generator level (golden-string tests over the emitted Swift + Kotlin). Compiling it with `swiftc` / Gradle is the remaining step in your own mobile CI — the framework harness has no Swift/Kotlin toolchain.
1443
1443
 
1444
+ ## Serving: asset prefix, pre-compression, sourcemaps, keep-alive
1445
+
1446
+ Four knobs that decide what leaves the container. All are `voltro start`
1447
+ concerns; none change what your code does.
1448
+
1449
+ ```ts
1450
+ // apps/web/app.config.ts
1451
+ web: {
1452
+ // Serve `assets/` from a CDN. Becomes vite's `base`, so every emitted URL —
1453
+ // entry, modulepreloads, CSS, images, fonts — is written with the prefix at
1454
+ // BUILD time. Pre-rendered HTML still comes from the app; upload
1455
+ // `dist/assets/` to the prefix on deploy. Filenames are content-hashed, so a
1456
+ // previous deploy's assets stay valid for a visitor mid-navigation.
1457
+ assetPrefix: 'https://cdn.example.com/_assets',
1458
+
1459
+ // Emit `.map` files WITHOUT a `//# sourceMappingURL` comment, so nothing in
1460
+ // the shipped JS points at them. For `plugin-sentry`: upload them in your
1461
+ // deploy step and DELETE them before the image is built. `voltro start`
1462
+ // refuses to serve a `.map` regardless, so a forgotten delete is not a leak.
1463
+ sourcemaps: 'hidden',
1464
+ },
1465
+ http: {
1466
+ // Node hangs up an idle keep-alive connection after 5s; every proxy in front
1467
+ // holds one longer, and the request that lands in that window comes back as
1468
+ // a 502. Default 72000 clears nginx-60/ALB-60; raise it above YOUR proxy's
1469
+ // idle timeout. `headersTimeoutMs` must exceed it and is derived if omitted.
1470
+ keepAliveTimeoutMs: 72_000,
1471
+ },
1472
+ ```
1473
+
1474
+ **Pre-compression is automatic.** `voltro build` writes `.br` (quality 11) and
1475
+ `.gz` beside every content-hashed asset over 1 KB, and `voltro start` serves the
1476
+ variant when the client accepts it. Measured on one 321 KB chunk, three requests:
1477
+
1478
+ | | time | bytes |
1479
+ |---|---:|---:|
1480
+ | compressed per request (before) | 5.6 / 5.0 / 4.6 ms | 100 665 |
1481
+ | pre-compressed (now) | 1.6 / 1.7 ms | **86 083** |
1482
+
1483
+ Faster *and* smaller: a build can afford brotli q11 where a per-request path
1484
+ cannot. Both encodings of one asset carry the same `ETag` — it is computed over
1485
+ the uncompressed file, so a shared cache sees one representation.
1486
+
1487
+ **A cross-origin api gets a `preconnect`.** When an api's `wsUrl` is on another
1488
+ host, the shell carries `<link rel="preconnect" href="…" crossorigin>` so DNS +
1489
+ TCP + TLS overlap the bundle download instead of following it. Same-origin apis
1490
+ are skipped — the browser already has that connection.
1491
+
1492
+ ## `voltro serve <appDir>` — API apps only
1493
+
1494
+ `voltro serve` is the production server for an **API** app. On a **web** app it
1495
+ refuses and points you at `voltro start`:
1496
+
1497
+ ```text
1498
+ `voltro serve` is the production server for an API app — this is a WEB app.
1499
+
1500
+ production voltro start .
1501
+ development voltro dev .
1502
+ ```
1503
+
1504
+ That is a refusal, not a missing feature. `serve` used to build a web app a
1505
+ second time and hand it to `vite preview`, and both halves were wrong:
1506
+
1507
+ - **In production it never ran.** The launcher's serve fast path requires
1508
+ `.framework/dist-api/serveBundle/serveEntry.js` — an artefact a web build does
1509
+ not produce — so a web app exited 1 pointing at a path that cannot exist,
1510
+ directly after a `voltro build` that had just succeeded.
1511
+ - **Below production it destroyed the build.** That second build had no
1512
+ `@tailwindcss/vite`, no image pipeline and no per-page islands entries. With
1513
+ Tailwind it aborted; without it, it succeeded — and since both builds write
1514
+ `.framework/dist`, which Vite empties, it deleted `dist/server`, the island
1515
+ shells and every pre-rendered page. `voltro start` could then not boot at all.
1516
+
1517
+ So the table is:
1518
+
1519
+ | app | development | production |
1520
+ |---|---|---|
1521
+ | web | `voltro dev` | `voltro start` |
1522
+ | api | `voltro dev` | `voltro serve` |
1523
+
1444
1524
  ## `voltro doctor` — preflight a production serve
1445
1525
 
1446
1526
  Production `voltro serve` for an **API** app boots ONLY from the precompiled serve
@@ -1775,6 +1855,7 @@ It covers both halves of the stack:
1775
1855
  | server | `requireScope(...)` at the top of an executor | `guards:` on the descriptor |
1776
1856
  | server | a `token` / `secret` / `password` column with no encryption | `.encrypted()` |
1777
1857
  | server | a notify / webhook helper called at a mutation's tail | `defineSubscriber` / `defineReaction` |
1858
+ | server | a `*.subscribe.ts` handler that writes or publishes, with no `once:` | `once: true` on `defineSubscriber` |
1778
1859
  | server | `hasMore` + `limit + 1` | `paginateById` |
1779
1860
  | server | `.getTime()` / `.toISOString()` mapping a row on the way out | `timestampMs` / `timestampMsOrNull` from `@voltro/database/wire` in the descriptor's `output` struct |
1780
1861
  | client | per-field `useState` + a submit flag | `useFormBinding` |
@@ -1801,6 +1882,28 @@ already uses `.one()`, stays silent.
1801
1882
 
1802
1883
  Two of them are worth spelling out, because their advice is not one-line:
1803
1884
 
1885
+ **The subscriber rule is the second half of the mutation-tail rule.** That one
1886
+ moves an effect OUT of a mutation and into a subscriber, which is right — and
1887
+ lands it on a channel every replica listens to. `store.onChange` is a broadcast:
1888
+ correct for a READER (a cache drop, an index refresh, a live query must run
1889
+ everywhere) and a multiplier for an EFFECT, because there is nothing to make
1890
+ idempotent — the effect IS the write, so each run produces another one. One
1891
+ `INSERT` behind two replicas therefore writes two notification rows, and four
1892
+ with a broadcast bus in front.
1893
+
1894
+ So the rule fires when a handler WRITES (`ctx.store.insert` / `update` /
1895
+ `upsert` / …), publishes (`ctx.publish`), or calls a `notify` / `sendWebhook` /
1896
+ `sendMail`-shaped helper, and the subscriber declares no `once:`. Any `once:`
1897
+ silences it — `true` or a key function — because the question is whether the
1898
+ decision was made, not which way. It reads the handler through the AST, so
1899
+ `handler: notifyApprovers` naming a function in the same file is judged exactly
1900
+ like an inline arrow; a handler IMPORTED from another module is not judged at
1901
+ all, since its body is not in the file being read.
1902
+
1903
+ It stays quiet on a reader on purpose. `once:` on a cache-warming subscriber
1904
+ would silence it on every replica but one, which is worse than the repetition it
1905
+ removes — only the handler's author knows which of the two they wrote.
1906
+
1804
1907
  **The credential-column rule skips names that aren't credentials.** A name ending
1805
1908
  in `Id` / `_id`, a name ending in `Hash` / `_hash`, and a name beginning with
1806
1909
  `vault` are all left alone:
@@ -2969,17 +3072,67 @@ the swap could not run: 2 row(s) in the bundle reference a row the bundle does n
2969
3072
  The target is UNCHANGED — the swap runs in one transaction and none of it committed.
2970
3073
  ```
2971
3074
 
2972
- Staging tables from a run that died mid-load are collected by the next
2973
- `replace` over the same tables. One over a DIFFERENT set leaves them, and
2974
- nothing else removes them:
3075
+ ### Staging tables a dead run left behind
3076
+
3077
+ A staged run RECORDS the scratch tables it creates, in the same
3078
+ `_voltro_replace_in_progress` table an interrupted destructive `replace` writes
3079
+ to — with one difference that matters: **a staging record never refuses a boot.**
3080
+ Nothing was destroyed, so there is nothing to refuse over. The boot reports
3081
+ instead:
3082
+
3083
+ ```
3084
+ staged data-import leftovers:
3085
+ - 3 staging table(s) from a `replace` over api, last active 74 minute(s) ago — DROPPED: the run
3086
+ is not resumable and has been silent long enough that nothing is loading into them.
3087
+ The target of a staged `replace` is untouched until one short swap at the end, so none of this is
3088
+ a reason to refuse the boot — it is a reason to know the disk is holding a copy of a bundle.
3089
+ ```
3090
+
3091
+ The run refreshes a heartbeat on that record every couple of seconds while rows
3092
+ land, which is what lets a boot tell the three cases apart:
3093
+
3094
+ | what the record says | what the boot does |
3095
+ |---|---|
3096
+ | silent past the threshold, started without `--no-atomic` | **drops** the tables it names |
3097
+ | still beating | leaves them — an import is loading into them right now, here or on another replica |
3098
+ | started `--no-atomic` | leaves them — its staging IS the resume point |
3099
+
3100
+ The threshold is `30` minutes by default. It is deliberately generous: the cost
3101
+ of collecting too early is that an in-flight import's swap fails with a missing
3102
+ table and you re-run it — the target is untouched either way — but the cost is
3103
+ still a re-run.
3104
+
3105
+ Declare a different one for a deployment whose imports routinely pause longer
3106
+ than that, waiting on an upstream export or a maintenance window:
3107
+
3108
+ ```ts
3109
+ // app.config.ts
3110
+ export default {
3111
+ dataTransfer: {
3112
+ stagingStaleMinutes: 90,
3113
+ },
3114
+ }
3115
+ ```
3116
+
3117
+ `VOLTRO_STAGING_STALE_MINUTES` overrides the declaration in turn — an operator
3118
+ acting on a running deployment outranks what the project declared. Note that the
3119
+ threshold decides only what a boot DROPS: leftover staging tables are named in
3120
+ the boot log either way.
3121
+
3122
+ What the boot does NOT collect, you can:
2975
3123
 
2976
3124
  ```bash
2977
3125
  voltro data clear-staging --yes
2978
3126
  ```
2979
3127
 
2980
- Deliberately a command and not a boot sweep: a booting process cannot tell a
2981
- leftover from a staging table another replica is loading into right now, and
2982
- deleting the second would destroy an import in flight.
3128
+ It now labels each table with what its own run says, so a resume point is
3129
+ distinguishable from a leftover before you drop it:
3130
+
3131
+ ```
3132
+ 2 staging table(s) from an earlier `--mode replace`:
3133
+ _voltro_staging_tasks — RESUMABLE: a `--no-atomic` re-run continues from it, last active 4 min ago
3134
+ _voltro_staging_notes — no run claims it (an orphan, or from before the marker)
3135
+ ```
2983
3136
 
2984
3137
  A **cycle** in the bundle's foreign keys is detected before the load, not after
2985
3138
  it. The swap inserts parents first, so two tables referencing each other cannot
@@ -3562,7 +3715,14 @@ A native run reports **blobs**, not rows: the vendor tool reports no row count w
3562
3715
 
3563
3716
  ### The provenance stamp — a restore that refuses the wrong DB
3564
3717
 
3565
- A native dump is opaque: it doesn't say which dialect made it, which schema shape it carries, or when. `backup` writes a sidecar `voltro-backup-stamp.json` next to the artifact recording exactly that — `dialect`, the live schema `fingerprint`, the `@voltro/cli` version, and the timestamp.
3718
+ A native dump is opaque: it doesn't say which dialect made it, which schema shape it carries, or when. `backup` writes a sidecar `voltro-backup-stamp.json` next to the artifact recording exactly that — `dialect`, the `@voltro/cli` version, the timestamp, and **two** schema fingerprints.
3719
+
3720
+ Two, because they are different facts and only one of them is a claim about the artifact:
3721
+
3722
+ - **`schemaFingerprint`** — the source database's whole live schema at backup time. This is what the skew warning below compares against a target.
3723
+ - **`dumpFingerprint`** — the schema the **artifact carries**: that same snapshot minus the tables the dump excludes. On postgres and the mysql family those are `_voltro_replace_in_progress` and `_voltro_data_transfers` (see above); on sqlite, turso and mssql nothing is excluded and the two values are equal.
3724
+
3725
+ The distinction is not bookkeeping. `voltro data backup` opens its own run row in `_voltro_data_transfers` *before* it dumps, so on any database the framework has run against, the artifact is two tables short of the live schema it was taken from. Anything comparing a restored schema against a stamped one has to compare against `dumpFingerprint` — the drill did not, and failed every healthy backup with *"the artifact is inconsistent."*
3566
3726
 
3567
3727
  `restore` reads the stamp **before touching the DB** and acts on two failures that are otherwise silent until they corrupt:
3568
3728
 
@@ -3578,13 +3738,39 @@ voltro data restore ./backups/2026-07-01 --drill --drill-url postgres://…/scra
3578
3738
  # or set DRILL_DB_URL and just: voltro data restore ./backups/2026-07-01 --drill
3579
3739
  ```
3580
3740
 
3581
- `--drill` restores the artifact into a **throwaway** database (from `--drill-url` / `DRILL_DB_URL`) and verifies it — **without ever touching the live DB**. It refuses a drill target that resolves to your live connection (a drill that `--clean`s production is the disaster it exists to rehearse against). After the restore it introspects the throwaway DB and compares its schema fingerprint to the backup's stamp:
3741
+ `--drill` restores the artifact into a **throwaway** database (from `--drill-url` / `DRILL_DB_URL`) and verifies it — **without ever touching the live DB**. It refuses a drill target that resolves to your live connection (a drill that `--clean`s production is the disaster it exists to rehearse against). After the restore it introspects the throwaway DB and probes its migration ledger:
3582
3742
 
3583
3743
  - **zero tables restored** → FAIL (the dump is empty or unreadable — this backup would not recover you),
3584
- - **fingerprint disagrees with the stamp** → FAIL (the restore didn't reproduce what was backed up),
3585
- - **tables + matching fingerprint** → PASS.
3744
+ - **schema fingerprint disagrees with the stamp's `dumpFingerprint`** → FAIL (the restore didn't reproduce what was backed up),
3745
+ - **`_voltro_migration_plans` restored EMPTY** → FAIL (see below),
3746
+ - **tables + matching fingerprint + a populated or absent ledger** → PASS.
3747
+
3748
+ A stamp too old to carry a `dumpFingerprint` gives a **PASS (partial)** that says the shape could not be cross-checked. It does not fall back to `schemaFingerprint`: that is the comparison that fails a healthy backup, and a check that is red on every real input gets switched off — taking its genuine failures with it.
3749
+
3750
+ It exits non-zero on any FAIL, so a scheduled CI job turns a silently-broken backup into a red build. Run it against your latest artifact on a cron — a backup you've never restored is a hypothesis, and this is how you keep it a fact.
3751
+
3752
+ #### The ledger check — the one thing a schema comparison cannot see
3753
+
3754
+ A fingerprint answers *"is the shape right?"*. A drill's real question is *"would my app come up against this?"*, and the gap between them is **content** — a framework table that restored with the right columns and the wrong rows.
3586
3755
 
3587
- It exits non-zero on any FAIL, so a scheduled CI job turns a silently-broken backup into a red build. Run it against your latest artifact on a cron a backup you've never restored is a hypothesis, and this is how you keep it a fact. (The verify is schema-level; a full app boot against the restored DB is a heavier check you can layer on top.)
3756
+ `voltro serve`'s boot gate reads the newest row of `_voltro_migration_plans` and refuses with `prod-mismatch` when there is none. So a ledger table that restores with exactly the right columns and **zero rows** is a database no source tree can boot, and its schema fingerprint is identical to a healthy one's. The drill fails that, and names it:
3757
+
3758
+ ```
3759
+ FAIL — restored 30 table(s) with the right shape, but `_voltro_migration_plans`
3760
+ came back EMPTY.
3761
+ `voltro serve` reads the newest row of that table as its boot gate and
3762
+ refuses with `prod-mismatch` when there is none.
3763
+ ```
3764
+
3765
+ A restored database with **no ledger table at all** is not a voltro-managed schema (a hand-made dump, someone else's database) — the drill says so and claims nothing about booting it, rather than failing it.
3766
+
3767
+ What the drill deliberately does **not** judge is a ledger whose fingerprint differs from what your code declares. It has no way to know which commit you will deploy next to this database, and `voltro db apply` clears that state anyway; failing a backup for it would make the drill red for a reason that is not about the backup.
3768
+
3769
+ #### Why there is no full app boot
3770
+
3771
+ Booting a real app against the restored database sounds like the stronger check, and it would be a weaker one. There is no app in the drill's path — it would have to boot a **fixture**, and a fixture booting says nothing about whether *your* app boots. It moves the drill from *"proves your backup"* to *"proves our fixture"* while reading as the bigger claim.
3772
+
3773
+ The part worth having does not need a process: the boot gate is a comparison, not a startup sequence, so the one boot-fatal condition that holds regardless of which code you deploy is reachable with a `SELECT`. That is the ledger check above.
3588
3774
 
3589
3775
  ### Point-in-time recovery (PITR) is your database's job, not the framework's
3590
3776
 
@@ -4465,6 +4651,25 @@ prints only because your tree matched it.
4465
4651
 
4466
4652
  Codemods that span multiple versions run in order (e.g. upgrading `0.2.0 → 0.4.0` runs the `0.3.0` and `0.4.0` codemods in sequence).
4467
4653
 
4654
+ ### When nothing changed, read WHICH nothing
4655
+
4656
+ Two states end an update with no diff, and they mean opposite things: the jump
4657
+ ships no codemods, or it ships some and every one of them decided your project
4658
+ is not affected. The summary names which:
4659
+
4660
+ ```text
4661
+ codemods: 2 ship for this jump; none matched your project.
4662
+ · 0.55.0/01_target-relations-declare-columns — its own check found nothing to change
4663
+ · 0.55.0/02_widget-kind-gained-rich-text — its own check found nothing to change
4664
+ ```
4665
+
4666
+ `none ship for this jump` is the first. The second lists the ids, because a
4667
+ codemod's own check is a predicate that can be wrong — and a gate that reads
4668
+ the wrong files answers "does not apply" for a project that is fully affected.
4669
+ If you recognise a subject in that list as something your app *does* use, that
4670
+ is a bug in the check rather than a fact about your code; the CHANGELOG entry
4671
+ for the version says what each one looks for.
4672
+
4468
4673
  ## The database is separate
4469
4674
 
4470
4675
  `voltro update` does **not** touch your database. Framework-owned `_voltro_*` tables (workflow runs, schedules, …) are reconciled by the declarative differ, not by codemods: when a release changes one of those tables, your next `voltro db apply` (or `voltro dev` boot, which auto-applies) picks up the change.