@voltro/cli 0.50.0 → 0.51.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 (111) hide show
  1. package/CHANGELOG.md +160 -0
  2. package/dist/{apiBuild-CFYXo06U.js → apiBuild-CPDHXF72.js} +2 -2
  3. package/dist/apiBuild-GDKuGOMV.js +2 -0
  4. package/dist/appGraph-CFmFKHb2.js +2 -0
  5. package/dist/appGraph-yRuKjOba.js +111 -0
  6. package/dist/bin.js +1 -1
  7. package/dist/{build-CnSH-zru.js → build-DETLZAFt.js} +271 -240
  8. package/dist/{checkCommand-OL41BMIb.js → checkCommand-CWcnDArJ.js} +1 -1
  9. package/dist/{checkCommand-Do3SQbLx.js → checkCommand-DNuPiWMc.js} +56 -46
  10. package/dist/{codegenCommand-BJzaRz6q.js → codegenCommand-BNBHcNNj.js} +2 -2
  11. package/dist/{codemodRunner-De3k0pRd.js → codemodRunner-BDVixlSw.js} +278 -136
  12. package/dist/{commands-By23Ljny.js → commands-B1OiS9bX.js} +24 -23
  13. package/dist/{dashboardCommand-DN1rAABE.js → dashboardCommand-C-vvPY1B.js} +5 -5
  14. package/dist/{dataCommand-B93TUBIx.js → dataCommand-C1GxXW5q.js} +458 -384
  15. package/dist/dbCommand-C27HIsGE.js +2 -0
  16. package/dist/{dbCommand-Bf7Cy0mn.js → dbCommand-If4Y1xQ-.js} +1 -1
  17. package/dist/{dev-BWwE0QjK.js → dev-CK522MV5.js} +1 -1
  18. package/dist/{dev-1yu42ubu.js → dev-kdAg9Q7l.js} +1246 -1246
  19. package/dist/doctorCommand-BK4l18eG.js +2 -0
  20. package/dist/{doctorCommand-y-EbP8st.js → doctorCommand-nKmeW78u.js} +42 -26
  21. package/dist/{doctorSourceRelations-Dd48LPvO.js → doctorSourceRelations-c5mlN5HI.js} +66 -34
  22. package/dist/{dormancyCommand-Dc7KRNCG.js → dormancyCommand-CY3wa_SW.js} +1 -1
  23. package/dist/{embeddingsCommand-kPs132bB.js → embeddingsCommand-BDLIgje_.js} +1 -1
  24. package/dist/{envCommand-E12zSt51.js → envCommand-C6V_xVlT.js} +13 -13
  25. package/dist/{evolveCommand-B3nagPVc.js → evolveCommand-D3c4DSfN.js} +2 -2
  26. package/dist/{generateCommand-CcyvH2ve.js → generateCommand-DTNPGjxo.js} +1 -1
  27. package/dist/index.js +1 -1
  28. package/dist/{infoCommand-Cq0Yz98u.js → infoCommand-BnRFEF1o.js} +1 -1
  29. package/dist/{inspect-B7U7Cl_Z.js → inspect-CtL_xTbu.js} +1 -0
  30. package/dist/inspect-CuGDYES0.js +2 -0
  31. package/dist/manifestBuild-CPjhvM62.js +2 -0
  32. package/dist/{manifestBuild-BK42hu0k.js → manifestBuild-CuU1VrSm.js} +2 -2
  33. package/dist/{migrate-D3MK9BpK.js → migrate-DehuBakM.js} +1 -1
  34. package/dist/{runtimeTrace-BpLUcrfy.js → runtimeTrace-ZsBU7Tkx.js} +1 -1
  35. package/dist/{sdkgen-dl4-WKF6.js → sdkgen-O4XqWOjM.js} +1 -1
  36. package/dist/serveCommand-BRnPCxVd.js +2 -0
  37. package/dist/{serveCommand-8CTzQCY0.js → serveCommand-DdiYNBBu.js} +4 -4
  38. package/dist/serveEntry.js +1 -1
  39. package/dist/{start-7HcSIKEp.js → start-BLNmWkLa.js} +331 -327
  40. package/dist/{start-DvlYDyAr.js → start-Dzicuyw8.js} +1 -1
  41. package/dist/startEntry.js +1 -1
  42. package/dist/{updateCommand-i8JbrLAA.js → updateCommand-Bs322Q78.js} +1 -1
  43. package/dist/updateCommand-eXB35SEv.js +2 -0
  44. package/dist/{webDev-B4IEMB0n.js → webDev-B-ubQEMX.js} +283 -224
  45. package/dist/webDev-DposiF3j.js +2 -0
  46. package/dist/{webhooksCommand-B0bdyuQ4.js → webhooksCommand-FLYY9IXh.js} +1 -1
  47. package/package.json +41 -17
  48. package/templates/AGENTS.core.md +15 -5
  49. package/templates/AGENTS.md +16 -6
  50. package/templates/agent-docs/_index.md +1 -1
  51. package/templates/agent-docs/cli.md +92 -3
  52. package/templates/agent-docs/data.md +36 -0
  53. package/templates/agent-docs/internationalization.md +131 -13
  54. package/templates/agent-docs/whats-new.md +73 -27
  55. package/templates/apps/api-ai/package.json +7 -7
  56. package/templates/apps/api-auth/package.json +8 -8
  57. package/templates/apps/api-backend/package.json +7 -7
  58. package/templates/apps/api-backend-deactivation/package.json +7 -7
  59. package/templates/apps/api-backend-mail/package.json +8 -8
  60. package/templates/apps/api-backend-mariadb/package.json +9 -9
  61. package/templates/apps/api-backend-sqlite/package.json +8 -8
  62. package/templates/apps/api-backend-storage/package.json +8 -8
  63. package/templates/apps/api-cms/package.json +10 -10
  64. package/templates/apps/api-collab/package.json +8 -8
  65. package/templates/apps/api-data-advanced/package.json +8 -8
  66. package/templates/apps/api-durable/package.json +8 -8
  67. package/templates/apps/api-feature-flags/package.json +9 -9
  68. package/templates/apps/api-governance/package.json +8 -8
  69. package/templates/apps/api-kv/package.json +8 -8
  70. package/templates/apps/api-moderation/package.json +8 -8
  71. package/templates/apps/api-observability/package.json +8 -8
  72. package/templates/apps/api-ratelimit/package.json +8 -8
  73. package/templates/apps/api-rbac/package.json +8 -8
  74. package/templates/apps/api-rest/package.json +7 -7
  75. package/templates/apps/api-saas/package.json +11 -11
  76. package/templates/apps/api-saas-starter/package.json +10 -10
  77. package/templates/apps/api-search/package.json +8 -8
  78. package/templates/apps/api-status/package.json +8 -8
  79. package/templates/apps/api-versioning/package.json +8 -8
  80. package/templates/apps/api-webhooks/package.json +9 -9
  81. package/templates/apps/changelog/package.json +6 -6
  82. package/templates/apps/edge-functions/package.json +2 -2
  83. package/templates/apps/frontend-admin/package.json +8 -8
  84. package/templates/apps/frontend-app/package.json +9 -9
  85. package/templates/apps/frontend-auth/package.json +8 -8
  86. package/templates/apps/frontend-blank/package.json +7 -7
  87. package/templates/apps/frontend-cms/package.json +9 -9
  88. package/templates/apps/frontend-collab/package.json +10 -10
  89. package/templates/apps/frontend-contact/package.json +7 -7
  90. package/templates/apps/frontend-dashboard/package.json +7 -7
  91. package/templates/apps/frontend-docs/package.json +7 -7
  92. package/templates/apps/frontend-i18n/package.json +6 -6
  93. package/templates/apps/frontend-landing/package.json +7 -7
  94. package/templates/apps/frontend-portal/package.json +8 -8
  95. package/templates/apps/frontend-saas/package.json +8 -8
  96. package/templates/apps/frontend-spa/package.json +7 -7
  97. package/templates/apps/frontend-ssr/package.json +7 -7
  98. package/templates/apps/frontend-ssr-api/package.json +8 -8
  99. package/templates/apps/frontend-static-blog/package.json +6 -6
  100. package/templates/apps/frontend-status/package.json +8 -8
  101. package/templates/apps/mobile-app/package.json +4 -4
  102. package/dist/apiBuild-BWo8KI6n.js +0 -2
  103. package/dist/appGraph-KGDPTuTy.js +0 -110
  104. package/dist/appGraph-zuMGKVYX.js +0 -2
  105. package/dist/dbCommand-Bnl7MSGC.js +0 -2
  106. package/dist/doctorCommand-BmovAwb7.js +0 -2
  107. package/dist/inspect-DUze25t0.js +0 -2
  108. package/dist/manifestBuild-j0n109tt.js +0 -2
  109. package/dist/serveCommand-C_WI4TTG.js +0 -2
  110. package/dist/updateCommand-D91-TZ7E.js +0 -2
  111. package/dist/webDev-B79ji8M5.js +0 -2
@@ -0,0 +1,2 @@
1
+ import { _ as e, y as t } from "./webDev-B-ubQEMX.js";
2
+ export { e as tryRunWebServe, t as walkPagesTree };
@@ -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-BWwE0QjK.js"), { outgoingFromEvents: r } = await import("./webhookDiscovery-il9ti-HE.js");
225
+ let { walk: t, loadDiscovered: n } = await import("./dev-CK522MV5.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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@voltro/cli",
3
- "version": "0.50.0",
3
+ "version": "0.51.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",
@@ -694,6 +694,30 @@
694
694
  "title": "`InterruptedReplace.tables` is `number | null`",
695
695
  "kind": "manual"
696
696
  },
697
+ {
698
+ "version": "0.51.0",
699
+ "id": "0.51.0/01_computed-source-keeps-its-names",
700
+ "title": "A COMPUTED `source:` can keep its typed names — do not widen the whole registry",
701
+ "kind": "manual"
702
+ },
703
+ {
704
+ "version": "0.51.0",
705
+ "id": "0.51.0/02_restore-refuses-without-marker",
706
+ "title": "`voltro data restore` refuses when it cannot write the in-progress marker",
707
+ "kind": "manual"
708
+ },
709
+ {
710
+ "version": "0.51.0",
711
+ "id": "0.51.0/03_restore-assets-returns-counts",
712
+ "title": "`restoreAssetsFromCas` returns `{ count, objects }`, not a number",
713
+ "kind": "manual"
714
+ },
715
+ {
716
+ "version": "0.51.0",
717
+ "id": "0.51.0/04_i18n-timezone-prop",
718
+ "title": "`<I18nProvider>`: `timeZone` is its own prop, not an `intlConfig` member",
719
+ "kind": "transform"
720
+ },
697
721
  {
698
722
  "version": "0.6.0",
699
723
  "id": "0.6.0/01_no-dev-session-secret",
@@ -769,22 +793,22 @@
769
793
  "@effect/platform-node": "^0.108.0",
770
794
  "@effect/sql": "^0.52.0",
771
795
  "@effect/workflow": "^0.19.0",
772
- "@voltro/ai": "0.50.0",
773
- "@voltro/cache": "0.50.0",
774
- "@voltro/data-transfer": "0.50.0",
775
- "@voltro/database": "0.50.0",
776
- "@voltro/env": "0.50.0",
777
- "@voltro/kv": "0.50.0",
778
- "@voltro/logger": "0.50.0",
779
- "@voltro/plugin-auth": "0.50.0",
780
- "@voltro/plugin-broadcast": "0.50.0",
781
- "@voltro/plugin-mail": "0.50.0",
782
- "@voltro/plugin-storage": "0.50.0",
783
- "@voltro/plugin-webhooks": "0.50.0",
784
- "@voltro/protocol": "0.50.0",
785
- "@voltro/runtime": "0.50.0",
786
- "@voltro/serverless": "0.50.0",
787
- "@voltro/workflow": "0.50.0",
796
+ "@voltro/ai": "0.51.0",
797
+ "@voltro/cache": "0.51.0",
798
+ "@voltro/data-transfer": "0.51.0",
799
+ "@voltro/database": "0.51.0",
800
+ "@voltro/env": "0.51.0",
801
+ "@voltro/kv": "0.51.0",
802
+ "@voltro/logger": "0.51.0",
803
+ "@voltro/plugin-auth": "0.51.0",
804
+ "@voltro/plugin-broadcast": "0.51.0",
805
+ "@voltro/plugin-mail": "0.51.0",
806
+ "@voltro/plugin-storage": "0.51.0",
807
+ "@voltro/plugin-webhooks": "0.51.0",
808
+ "@voltro/protocol": "0.51.0",
809
+ "@voltro/runtime": "0.51.0",
810
+ "@voltro/serverless": "0.51.0",
811
+ "@voltro/workflow": "0.51.0",
788
812
  "chokidar": "^5.0.0",
789
813
  "ioredis": "^5.11.1",
790
814
  "tinyglobby": "^0.2.17",
@@ -404,11 +404,21 @@ Do NOT write `x.page.tsx`, `index.page.tsx` or `[id].page.tsx` — those were th
404
404
  **Every suffix above is a CONTRACT, not a label.** Something else depends on each
405
405
  promise, and `voltro doctor` enforces all of them:
406
406
 
407
- - `*.component.ui.tsx` may read (`useT`, `useCan`, `usePermissions`) but must
408
- never import a write hook (`useMutation`, `useAction`, `useUpload`, …). That is
409
- what lets a caller render it ten thousand times in a list, reuse it across
410
- features, and prerender it without reading its source. Lift the write into the
411
- owning `*.component.tsx` and pass a handler down.
407
+ - `*.component.ui.tsx` may read (`useT`, `useTFn`, `useCan`, `usePermissions`,
408
+ the `@voltro/i18n` formatters) but must never import a write hook
409
+ (`useMutation`, `useAction`, `useUpload`, …). That is what lets a caller render
410
+ it ten thousand times in a list, reuse it across features, and prerender it
411
+ without reading its source. Lift the write into the owning `*.component.tsx`
412
+ and pass a handler down.
413
+
414
+ **Reading is a recommendation, not a grudging allowance — call the hook, do
415
+ not take a prop.** Threading `t` / `formatDate` / `useCan` through props is
416
+ prop-drilling: it grows the prop surface with every formatter, has to be
417
+ restated in every intermediate signature and every test, and does NOT make the
418
+ component more reusable — it makes every call site worse. The one argument for
419
+ the prop, "it renders without a provider", is already paid for the moment any
420
+ test mounts one. Pick one way per component head: a `t` prop beside a
421
+ formatter hook is the worst of both.
412
422
  - `*.internal.ts` is the promise that refactoring inside that directory breaks
413
423
  nobody. An import from another subtree revokes it.
414
424
  - `*.types.ts` having no runtime export is what makes importing it free and makes
@@ -404,11 +404,21 @@ Do NOT write `x.page.tsx`, `index.page.tsx` or `[id].page.tsx` — those were th
404
404
  **Every suffix above is a CONTRACT, not a label.** Something else depends on each
405
405
  promise, and `voltro doctor` enforces all of them:
406
406
 
407
- - `*.component.ui.tsx` may read (`useT`, `useCan`, `usePermissions`) but must
408
- never import a write hook (`useMutation`, `useAction`, `useUpload`, …). That is
409
- what lets a caller render it ten thousand times in a list, reuse it across
410
- features, and prerender it without reading its source. Lift the write into the
411
- owning `*.component.tsx` and pass a handler down.
407
+ - `*.component.ui.tsx` may read (`useT`, `useTFn`, `useCan`, `usePermissions`,
408
+ the `@voltro/i18n` formatters) but must never import a write hook
409
+ (`useMutation`, `useAction`, `useUpload`, …). That is what lets a caller render
410
+ it ten thousand times in a list, reuse it across features, and prerender it
411
+ without reading its source. Lift the write into the owning `*.component.tsx`
412
+ and pass a handler down.
413
+
414
+ **Reading is a recommendation, not a grudging allowance — call the hook, do
415
+ not take a prop.** Threading `t` / `formatDate` / `useCan` through props is
416
+ prop-drilling: it grows the prop surface with every formatter, has to be
417
+ restated in every intermediate signature and every test, and does NOT make the
418
+ component more reusable — it makes every call site worse. The one argument for
419
+ the prop, "it renders without a provider", is already paid for the moment any
420
+ test mounts one. Pick one way per component head: a `t` prop beside a
421
+ formatter hook is the worst of both.
412
422
  - `*.internal.ts` is the promise that refactoring inside that directory breaks
413
423
  nobody. An import from another subtree revokes it.
414
424
  - `*.types.ts` having no runtime export is what makes importing it free and makes
@@ -707,7 +717,7 @@ each plugin's own README.
707
717
 
708
718
  | Topic | Open | Summary |
709
719
  |---|---|---|
710
- | **What's new in 0.50.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. |
720
+ | **What's new in 0.51.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. |
711
721
  | 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. |
712
722
  | 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. |
713
723
  | 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.50.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.51.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. |
@@ -290,6 +290,19 @@ carries its own guards, or it is a UI-affordance scope that `useCan` reads to
290
290
  hide a button and no server check backs on purpose. All three are fine. Not
291
291
  knowing which is not.
292
292
 
293
+ `rbac/unguarded-mutation` flags a mutation that declares neither a guard nor an
294
+ `openAccess:` reason. It skips **`internal: true`** procedures: those are in no
295
+ rpc group and on no route, so "any caller who can reach the rpc surface" names a
296
+ surface that does not exist — and neither remedy applies either, since a guard
297
+ would protect nothing and `openAccess` is refused outright on an internal
298
+ descriptor. This is the same `isWireReachable` predicate the boot access gate and
299
+ all three rpc-group assemblies use, so `check` and `voltro doctor` cannot answer
300
+ the question differently.
301
+
302
+ A manifest that does not carry the field at all — an api older than it — is read
303
+ as *reachable*, not as internal. The rule stays loud rather than going quiet on
304
+ the apps least able to notice.
305
+
293
306
  #### Declared vs OBSERVED — reconciled against reality
294
307
 
295
308
  A query's `source` and a mutation's `targets` are not documentation: the
@@ -323,6 +336,30 @@ Three things about that output are deliberate:
323
336
  about the runs that happened, not a proof about the ones that didn't, and
324
337
  `check`'s exit code gates CI. They are always warnings.
325
338
 
339
+ A fourth thing is not visible in that output and matters more than any of the
340
+ three: **the recorder only knows what ran.** A boot is not a run. An idle dev
341
+ instance that started, served nothing and stopped produces
342
+
343
+ ```
344
+ observed: 0/2755 procedures exercised (0%)
345
+ 2753 unexercised — no recording, so nothing is claimed about them
346
+ ```
347
+
348
+ which is the honest answer and also a useless one — the section says nothing
349
+ about 99.9% of the surface. If you want this as a CI gate, the recording pass has
350
+ to be a run that actually *calls* the procedures. `voltro e2e` is one: it spawns
351
+ `voltro dev` for the api, which turns recording on, and then drives the specs. A
352
+ harness of your own that boots the app in its own process needs
353
+ `VOLTRO_OBSERVE_GRAPH=1` set before anything is imported. Either way, booting the
354
+ app and then running `check` measures nothing, and the report will not pretend
355
+ otherwise.
356
+
357
+ It is still worth wiring up at low coverage, because the findings are per
358
+ procedure and do not need company. One deployment had exactly one procedure
359
+ observed, and that one produced a real defect: a mutation upserting
360
+ `push_subscriptions` with no declared target while two queries read that table as
361
+ their `source`, so a registered device never appeared in any running subscription.
362
+
326
363
  No file → the section is skipped silently. This is not derived by parsing your
327
364
  handlers: a static pass over code that reaches the store through shared helpers,
328
365
  behind conditionals, has a long tail of both false positives and false negatives
@@ -3337,10 +3374,17 @@ voltro data restore ./backups/2026-07-01 --assets # pg_restore / mariadb /
3337
3374
 
3338
3375
  These shell out to the vendor tools resolved from your `DB_DIALECT` + connection env. They produce a dialect-native artifact (`db.dump`, `db.sql`, `db.sqlite`, `db.bacpac`) that is lossless and point-in-time consistent for **same-dialect** restore — the right tool for disaster recovery. Secrets are passed via the tools' environment variables (`PGPASSWORD`, `MYSQL_PWD`), never on the command line, where the tool supports it. The named tool must be installed and on `PATH`.
3339
3376
 
3377
+ On **mariadb** the MariaDB-named binaries (`mariadb-dump`, `mariadb`) are preferred and Oracle's (`mysqldump`, `mysql`) are the fallback — with the reason carried into the failure, because the error that fallback produces (`Unknown table 'COLUMN_STATISTICS' in information_schema`, 1109) names a table nobody asked for. Two things worth knowing before you go and install the MariaDB client package:
3378
+
3379
+ - Do **not** reach for `--column-statistics=0`. That flag does not exist on `mariadb-dump`, so it patches the wrong client and breaks the right one.
3380
+ - A MariaDB **12.x** client requires TLS by default. Running it *by hand* against a server without TLS fails with `TLS/SSL error: SSL is required` (2026) and needs `--skip-ssl`. These commands are not affected — it is the first manual call after installing that trips.
3381
+
3340
3382
  ### `--assets` — the blobs are not in the dump
3341
3383
 
3342
3384
  A vendor dump contains rows. Your blobs are in object storage, and no `pg_dump` has ever seen them. So a rows-only backup restores a database whose rows reference objects that are not there — and the reference and the object are checked at different times, which is why that state is discovered by a user, months later, rather than by the restore.
3343
3385
 
3386
+ The backend it reads is the one your app **configured** — `storagePlugin({ provider: s3(…) })` if you installed it, the `STORAGE_*` env otherwise. (The commands used to resolve the env default unconditionally, so an app that configured its provider in code had its export, import and backup reading a different backend than the rest of it.)
3387
+
3344
3388
  `--assets` captures them alongside the dump, through the same content-addressed pipeline `voltro data export --assets` uses: each blob is streamed (never buffered whole), stored under `assets/<sha256>` so identical content is stored once, and listed in `assets/index.ndjson`. `restore --assets` streams them back and **re-hashes on the way**, so a corrupted artifact can never silently overwrite good bytes.
3345
3389
 
3346
3390
  ```
@@ -3353,28 +3397,73 @@ backups/2026-07-01/
3353
3397
 
3354
3398
  Three refusals, each for a belief that is otherwise acted on silently:
3355
3399
 
3356
- - **`backup --assets` with no storage provider configured → refused.** There is nothing to capture, and a flag that is accepted and ignored lets you build a rollback story on an artifact that does not contain what you asked for.
3400
+ - **`backup --assets` with no storage provider configured → refused.** There is nothing to capture, and a flag that is accepted and ignored lets you build a rollback story on an artifact that does not contain what you asked for. "Configured" means one of the two things a person actually did: installed `storagePlugin(...)`, or set `STORAGE_PROVIDER`. An in-memory provider nobody asked for is not a decision — and until recently it was what this check saw, which is why the refusal never fired and `--assets` wrote artifacts stamped as carrying blobs that held none.
3357
3401
  - **`restore --assets` on a rows-only backup → refused.** You believe the blobs are in there. Restoring the rows anyway produces exactly the dangling state this exists to prevent.
3358
3402
  - **restore *without* `--assets` on a backup that HAS them → warned, not refused.** Restoring rows without blobs is legitimate (a schema drill, a lower environment), and refusing it would push people at `--force`.
3359
3403
 
3404
+ **A reference the provider cannot resolve is reported, not fatal.** A row in `_voltro_storage_refs` can point at an object that was deleted, or that never arrived because an earlier import ran without `--assets`. That is a fact about your data, and no backup can put back bytes that are not there — so the capture records the key, steps over it, and the run says how many:
3405
+
3406
+ ```
3407
+ warn 177 of 178 blob reference(s) point at objects the storage provider does not have;
3408
+ they are NOT in this backup and no restore can bring them back.
3409
+ ```
3410
+
3411
+ Aborting on the first one made `--assets` unusable for exactly the deployment that needed it: 178 references, one resolvable, and the run stopped at the second — leaving an `assets/` directory with a single blob, **no `voltro-backup-stamp.json`** (the writer never got that far), and nothing anywhere saying 177 objects had been skipped. The stamp is now written on every path, including the one where the asset phase fails, because it describes the **dump** and the dump is already on disk and correct. Without it, `restore` greeted an artifact this tool had written minutes earlier with *"an older/handmade backup. Cannot verify dialect or schema version."*
3412
+
3413
+ Only a genuine *not found* is treated this way. A 403 from a rotated credential or a 5xx from a backend outage still fails the capture — calling those "the object is gone" would turn a recoverable outage into a backup that quietly contains nothing.
3414
+
3415
+ **Three numbers, because they answer three questions.** `_voltro_storage_refs` holds one row per *reference*, several of which legitimately name one *key*, and the content-addressed store keeps one body per distinct *object*:
3416
+
3417
+ ```
3418
+ 57 reference(s) → 16 key(s) → 16 object(s), 65476 byte(s) under assets/
3419
+ ```
3420
+
3421
+ The stamp carries all three (`references`, `count`, `objects`, with `totalBytes` and `objectBytes` beside them). It used to carry only the reference count under the name `count`, so a stamp read `57` over a directory holding 16 files — and anyone answering *"are all the blobs there?"* after a restore compared the two and found a 3.5× gap that was not one. A key named by several references is also fetched once now, rather than downloaded and hashed once per row.
3422
+
3360
3423
  Resume is per blob key, so re-running a `--assets` capture that was interrupted transfers only what is missing. The **dump itself has no resume** — a vendor artifact is one opaque file with no offset to restart from. If you need a resumable, chunkable, observable transfer, that is the logical path (`export` / `import`), and it is why the logical path exists.
3361
3424
 
3425
+ ### The stamp's skew warning compares the backup against the TARGET
3426
+
3427
+ `restore` reads `voltro-backup-stamp.json` before touching anything and warns when the backup's schema fingerprint differs from the target's. That warning used to say the difference was against "what this code declares", and it was not — the value it compares against is the target database's *live* schema, read by introspection at restore time. Bringing a target to the backup's shape makes the warning disappear while the declared fingerprint is a third value entirely, which is how the mislabel was caught. The comparison was always the useful one; only the sentence was wrong, and it sent readers looking for a code change where a database differed.
3428
+
3429
+ The same distinction shows up in `voltro db plan`, which prints `live … · declared …` rather than `from → to` for the same reason: **a hash of a live database never equals the hash of the declaration it came from.** Introspection cannot recover everything a declaration carries — generated expressions, `maxLength`, sensitivity markers — so the two are not comparable and are not meant to match. The plan's operation list is what says whether they agree; `0 operations` under two different fingerprints means they do.
3430
+
3362
3431
  ### A restore that is interrupted refuses the next boot
3363
3432
 
3364
3433
  `restore` writes one row into `_voltro_replace_in_progress` **before** the first destructive statement and removes it **after** the last write — the blobs included. Its presence at boot is a refusal naming the artifact that was going in.
3365
3434
 
3366
3435
  This is the counterpart to `--allow-live`, and it guards from the other side: `--allow-live` asks you not to restore over a running instance, and this says *this database is mid-restore, do not serve it*. A half-restored database looks exactly like a normal one from the inside — every query answers, nothing errors.
3367
3436
 
3368
- Whether the marker survives the restore depends on the dialect's restore shape, and that is stated rather than implied:
3437
+ **The restore artifact can erase the marker, and this table said otherwise.** It read "postgres, mysql, mariadb: drops only the objects the dump names — the marker survives". The reasoning is right and the premise was wrong: a native dump names the *whole* database, `_voltro_replace_in_progress` included, and a mysql-family restore writes `DROP TABLE IF EXISTS` in front of each table. The table sorts early, so the guard was removed near the *start* of the window it covers. Measured downstream: one row before the restore, zero after, twice.
3438
+
3439
+ Two changes, covering different dumps:
3440
+
3441
+ - **A backup taken by `voltro data backup` excludes the marker table** (`--exclude-table` / `--ignore-table`). It can no longer carry the thing that erases the guard on the way back in.
3442
+ - **`restore` writes the marker back after the tool exits**, on the failing path as well as the succeeding one. That covers dumps taken before this version and dumps made by hand. If it was removed and rewritten you get a warning saying so; if it could not be rewritten you get an error, because the guard is then off for that run and nothing will stop the next boot.
3443
+
3444
+ **A restore that cannot write the marker at all is refused.** Two things can prevent it — the bookkeeping store will not open (wrong credentials, an unreachable database, a missing env var, no `app.config.ts` from here), or the table is not there yet — and both mean the same thing to you: this restore would run with no guard. The refusal names which one it was:
3445
+
3446
+ ```
3447
+ ✗ refusing to restore: the in-progress marker cannot be written.
3448
+ reason: bookkeeping is unavailable: connect ECONNREFUSED 127.0.0.1:5432
3449
+ ```
3450
+
3451
+ This used to be a silent hole rather than a refusal, and worse than silent. The failure to open the store was caught and discarded, and the discarded value guarded *every* branch below it — including the refusal that would have reported the guard missing. So a restore ran on and, over a database with zero marker rows, printed *"the next boot will REFUSE, by design"*. The next boot did not refuse, and `voltro data clear-replace-marker` had nothing to clear. A restore is the operation you run against a target that is already unwell, so the precaution was falling away exactly when it was needed.
3452
+
3453
+ **`--no-marker`** restores without the guard, deliberately. It warns every time and names the reason the marker was unavailable. It exists because the accidental way did: if going unguarded is ever right, it should be something you typed.
3369
3454
 
3370
3455
  | dialect | shape | effect |
3371
3456
  |---|---|---|
3372
- | postgres, mysql, mariadb | drops only the objects the dump names | the marker survives, so a killed restore is caught at the next boot |
3457
+ | postgres, mysql, mariadb | the dump names the whole database, so the restore drops the marker table too | our backups exclude it; for any other dump the marker is written back after the tool exits |
3373
3458
  | sqlite, turso | whole-file replacement — made **atomic** (temp file + rename) | there is no half-restored state to catch; a killed restore leaves the live file untouched |
3374
3459
  | mssql | `sqlpackage /Action:Import` replaces the database | a *failed* import is the one case not covered here — verify with `--drill` |
3375
3460
 
3376
3461
  Clear a marker deliberately with `voltro data clear-replace-marker --yes` once you have decided the current state is correct.
3377
3462
 
3463
+ **`_voltro_data_transfers` is excluded for the same reason, one table over.** The restore opens its own run row there *before* the tool starts; a dump that carried the table dropped it mid-flight, and the update recording the outcome then wrote into a table that no longer held the row. The visible result was that a failed native restore did not appear in `voltro data transfers` at all — only the `backup` row the dump had brought over from the *source* database. The command that answers "did the restore finish" could not see the run asking.
3464
+
3465
+ Exactly those two tables are excluded, and the line is deliberate: a native restore into the same deployment *should* bring the migration ledger, the stored plans, the CDC offsets and the schedule claims — they describe the data being restored. These two describe the *restore*, and a record of an operation must not be overwritten by the operation it records.
3466
+
3378
3467
  ### Both directions are in the history
3379
3468
 
3380
3469
  `backup` and `restore` write a row to the same `_voltro_data_transfers` record `import` and `export` use, so `voltro data transfers` answers "did last night's backup finish" from the instance that ran it:
@@ -1700,6 +1700,32 @@ That matters because the failure it replaces is silent. A `source:` is matched b
1700
1700
 
1701
1701
  The file is generated, so **commit it** like the rpc group and let `voltro dev` rewrite it. Before the first run — and in a project that never generates it — `source:` is plain `string` again, which is exactly the previous behaviour; there is no configuration and nothing to opt into.
1702
1702
 
1703
+ The error carries a suggestion when the name is close to a real one — `Did you mean '"error_logs"'?` — so a rename usually resolves without leaving the editor.
1704
+
1705
+ #### A COMPUTED `source:` can keep its names
1706
+
1707
+ A generic reader — the table arrives in `input`, the caller picks it out of a registry — is computed, and yet every name it can produce is known. The obvious derivation is not assignable:
1708
+
1709
+ ```ts
1710
+ source: Object.keys(JUNCTION_REGISTRY), // string[] — ✗
1711
+ ```
1712
+
1713
+ The tempting exit is a cast, or annotating `ReactivitySourceValue` (the wide `string | ReactivityChannel` shape, exported from `@voltro/protocol`). Both compile at once and take that **entire** set of tables out of the check permanently. Keep the literal's keys instead:
1714
+
1715
+ ```ts
1716
+ const REGISTRY = { … } as const satisfies Readonly<Record<string, JunctionMeta>>
1717
+
1718
+ // consumers that index with a plain string still get the wide type
1719
+ export const JUNCTION_REGISTRY: Readonly<Record<string, JunctionMeta>> = REGISTRY
1720
+
1721
+ // … and the source list keeps its literal names
1722
+ export const JUNCTION_TABLE_NAMES = Object.keys(REGISTRY) as ReadonlyArray<keyof typeof REGISTRY>
1723
+ ```
1724
+
1725
+ The part that is easy to get wrong: an **annotation widens the keys back**, even when the literal carries `as const`. A registry written as `export const R: Readonly<Record<string, Meta>> = { … } as const` has `keyof typeof R === string`, and nothing about it looks wrong — the annotation is checked against the literal and then replaces its type. `satisfies` checks without replacing. That is the whole reason for the two-line split above.
1726
+
1727
+ Reach for `ReactivitySourceValue` when there genuinely is no key set to keep — a name read from a config file, or assembled at runtime. Not when recovering one takes two lines.
1728
+
1703
1729
  Two things it deliberately does not narrow. A **plugin's** route `source:` stays `string`: a plugin ships against many apps and cannot know any of their tables. And nothing that READS a descriptor's source at runtime narrows either — a reader that refused an unknown name would reject the stale name it exists to report.
1704
1730
 
1705
1731
  **It does not check the other direction — except for relations.** A `source:` that omits a table the query genuinely reads is silent: the name is right, the table exists, and nothing has an opinion. That is the failure that costs a user report — they type, the row lands, and the panel does not move.
@@ -1714,6 +1740,16 @@ Two things it deliberately does not narrow. A **plugin's** route `source:` stays
1714
1740
 
1715
1741
  An eager-loaded relation is composition by definition — its rows are IN the result — and its table comes from the relation registry, so the rule has no heuristic and no exception list. A **many-to-many** wants the junction table too, and says so separately: adding or removing a link writes only the junction row, so declaring the target alone leaves the list stale on exactly the operation a user performs to change it.
1716
1742
 
1743
+ Nested relations resolve in the **same** pass: a relation loaded under `.with({ subTasks: { with: { watchers: true } } })` is looked up on `task_sub_tasks`, not on the query's base, so both levels are reported at once rather than one per run.
1744
+
1745
+ The one thing it still cannot see is a base table it cannot find — neither declared in `source:` nor written as `database.<name>` / `.select('<name>')`. There is then no table to resolve the relations against, and the query would otherwise report clean while every relation on it is unchecked. So doctor says so:
1746
+
1747
+ ```
1748
+ ⚠ 1 query could not be fully checked — the base table is neither declared nor readable from the executor:
1749
+ reports.byKind: `blocked` resolved against no table
1750
+ declare the base table in `source:` and re-run — relations under it are unchecked until then.
1751
+ ```
1752
+
1717
1753
  What doctor deliberately does NOT do is derive every table an executor reads. That needs a judgement — does this read **compose** the result or merely **restrict** it? — and only composing reads belong in `source:`; a restricting one re-running on every unrelated write puts a hundred lists back on the wire. A scan has to infer that from syntax, and a rule that guesses on a correct codebase teaches you to ignore it.
1718
1754
 
1719
1755
  So: when a live view does not update, check the writing table is in the reading query's `source:` before anything else.
@@ -36,6 +36,13 @@ export default {
36
36
  // supported one. MUST be in `locales`. Pick your source-of-truth
37
37
  // language — typically English.
38
38
  defaultLocale: 'en' as const,
39
+ // The zone every date/time formatter renders in. Omit it and each
40
+ // runtime uses its own — the pod's on the server, the viewer's in the
41
+ // browser — which is a hydration mismatch on every SSR timestamp.
42
+ // An IANA name pins one zone for everyone; 'viewer' resolves it per
43
+ // request from the `voltro:tz` cookie.
44
+ timeZone: 'viewer' as const,
45
+ defaultTimeZone: 'UTC' as const,
39
46
  }
40
47
  ```
41
48
 
@@ -79,6 +86,18 @@ The client **adopts what the server resolved**, reading it from the `<html lang>
79
86
 
80
87
  `<html lang>` carries the same resolved locale — the value the `<I18nProvider>` renders with, on the same request. That matters on its own: it is what a screen reader pronounces in, what Chrome offers to translate *from*, and what hyphenation uses.
81
88
 
89
+ ### The same contract carries the render zone and the render clock
90
+
91
+ `lang` is one of three answers the server decides and publishes so the client does not form its own:
92
+
93
+ | attribute | what it carries |
94
+ |---|---|
95
+ | `lang` | the resolved locale |
96
+ | `data-voltro-tz` | the IANA zone every date/time formatter renders in — set `timeZone` in `app.config.ts` |
97
+ | `data-voltro-now` | the server's render instant, so `useRelativeTime` produces the same string in the hydration pass |
98
+
99
+ Locale was already agreed; the zone and the clock were each read from the ambient runtime, which meant a server-rendered timestamp was a hydration mismatch waiting for a wide enough offset or a slow enough connection. See [Plurals & formatting → Timezones under SSR](/docs/i18n/formatting#timezones-under-ssr--the-setting-that-is-not-a-preference) — that is the page to read before you migrate hand-rolled `toLocaleString()` calls onto the hooks.
100
+
82
101
  See [Catalogs](/docs/i18n/catalogs) for the type-safe catalog convention and the component hooks, [Plurals & formatting](/docs/i18n/formatting) for CLDR plural selection and the `Intl`-backed date / number / relative-time hooks, and [URL strategies](/docs/i18n/url-strategies) for cookie-only vs URL-prefix routing.
83
102
 
84
103
  ## The cookie names are exported
@@ -675,7 +694,19 @@ formatDate(order.createdAt, { dateStyle: 'medium' })
675
694
  formatDate(order.createdAt, { dateStyle: 'medium', timeStyle: 'short', timeZone: 'Europe/Berlin' })
676
695
  ```
677
696
 
678
- `(value, options?) => string`, where `value` is a `Date`, a timestamp number, or a date string, and `options` is `Intl.DateTimeFormatOptions`. **Omit `timeZone` and the viewer's own zone is used** — which is what a multi-timezone app wants. Pass one only when the value genuinely belongs to a fixed zone (a store's opening hours, a scheduled broadcast). Pinning a global zone across the whole app is the anti-pattern this replaces.
697
+ `(value, options?) => string`, where `value` is a `Date`, a timestamp number, or a date string, and `options` is `Intl.DateTimeFormatOptions`.
698
+
699
+ **The zone comes from the provider, which you configure once** — `timeZone` in the web `app.config.ts` (see [Timezones under SSR](#timezones-under-ssr-the-setting-that-is-not-a-preference)). Pass `timeZone` in the options only when the value genuinely belongs to a fixed zone regardless of who is looking (a store's opening hours, a scheduled broadcast); that overrides the provider for one call.
700
+
701
+ **With `timeZone` unset in `app.config.ts` there is no zone at all, and each runtime falls back to its own.** On a server-rendered page that is the pod's zone for the markup and the viewer's for the hydration render — a mismatch on every timestamp, and a different calendar day across midnight. `useTimeZone()` returns `undefined` in exactly that state, so you can assert on it.
702
+
703
+ ### `useTimeZone`
704
+
705
+ ```tsx
706
+ const timeZone = useTimeZone() // 'Europe/Berlin' — or undefined when none is pinned
707
+ ```
708
+
709
+ `undefined` is a real answer and worth branching on: it means nobody decided, so the server and the browser are each using their own zone. It is not the same as `'UTC'`.
679
710
 
680
711
  ### `useRelativeTime`
681
712
 
@@ -688,7 +719,9 @@ relativeTime(comment.postedAt, { numeric: 'always' }) // "1 day ago" instead of
688
719
  relativeTime(comment.postedAt, { now: renderedAt }) // measure against a fixed base
689
720
  ```
690
721
 
691
- `(value, options?) => string`. Options are `Intl.RelativeTimeFormatOptions` plus a `now` override (a `Date`, number, or string) for deterministic rendering and tests; the default base is `Date.now()`.
722
+ `(value, options?) => string`. Options are `Intl.RelativeTimeFormatOptions` plus a `now` override (a `Date`, number, or string) for deterministic rendering and tests.
723
+
724
+ **Hydration-safe by default.** Without an explicit `now`, the base is the server's render instant for the server render *and* the hydration pass that has to match it — published as `<html data-voltro-now>` — then the live clock once hydration commits. So "3 minutes ago" cannot become "4 minutes ago" between the HTML and the first client render just because the network was slow, which is what a plain `Date.now()` base does whenever a unit boundary falls in the gap.
692
725
 
693
726
  It picks the **largest unit that fits**, so a 90-minute delta reads "1 hour ago", not "90 minutes ago". Anything under a second renders through the `second` unit at 0 — "now" — which avoids the "0 seconds ago" flicker hand-rolled versions produce. `numeric: 'auto'` is the default, so English gets "yesterday" rather than "1 day ago".
694
727
 
@@ -721,7 +754,7 @@ For a component that needs several at once, without stacking five hook calls:
721
754
  import { useFormatters } from '@voltro/i18n'
722
755
 
723
756
  function ActivityRow({ entry }: { readonly entry: Entry }) {
724
- const { locale, formatDate, relativeTime, formatNumber, plural } = useFormatters()
757
+ const { locale, timeZone, formatDate, relativeTime, formatNumber, plural } = useFormatters()
725
758
 
726
759
  return (
727
760
  <li lang={locale}>
@@ -735,7 +768,77 @@ function ActivityRow({ entry }: { readonly entry: Entry }) {
735
768
  }
736
769
  ```
737
770
 
738
- It returns the active `locale` plus `formatDate`, `relativeTime`, `formatNumber` and `plural` — memoized together. Currency is not in the bundle because it needs its ISO code up front; call `useFormatCurrency(code)` alongside it when you need one.
771
+ It returns the active `locale` and `timeZone` plus `formatDate`, `relativeTime`, `formatNumber` and `plural` — memoized together. Currency is not in the bundle because it needs its ISO code up front; call `useFormatCurrency(code)` alongside it when you need one.
772
+
773
+ ## Timezones under SSR — the setting that is not a preference
774
+
775
+ A formatter is deterministic given the value, the locale, the zone and the clock. The provider supplies all four, and the two beyond locale are the ones that differ between the server and the browser:
776
+
777
+ | | Where it came from before | What that means under SSR |
778
+ |---|---|---|
779
+ | **locale** | the provider, both sides | agreed already — the server publishes `<html lang>` and the client reads the attribute rather than `navigator.languages` |
780
+ | **zone** | the runtime | the POD on the server (UTC on a container with no `TZ`), the VIEWER's machine in the browser |
781
+ | **clock** | `Date.now()` | two numbers, differing by the network latency |
782
+
783
+ So a server-rendered timestamp was a hydration mismatch (React error #418) waiting for a wide enough offset or a slow enough connection, and across midnight it was a different calendar **day**. The fix is the one the locale already used: **the server decides, publishes its answer, and the client reads the answer instead of forming its own.**
784
+
785
+ ### Configure it once
786
+
787
+ ```ts
788
+ // apps/<project>/web/app.config.ts
789
+ export default {
790
+ type: 'web' as const,
791
+ name: 'myApp',
792
+ port: 5191,
793
+ locales: ['de', 'en'] as const,
794
+ defaultLocale: 'de' as const,
795
+
796
+ timeZone: 'Europe/Berlin' as const, // one zone for every viewer
797
+ // …or:
798
+ // timeZone: 'viewer' as const, // resolve per request, per user
799
+ // defaultTimeZone: 'UTC' as const, // before the viewer's zone is known
800
+ }
801
+ ```
802
+
803
+ Whatever it resolves to is stamped on the document as `<html data-voltro-tz>`, and the generated client entry reads that attribute. Both sides then format against one value — which is the property that removes the mismatch, whether or not the value is the viewer's true zone. Being *wrong together* is repairable after mount; being *different* is not.
804
+
805
+ `timeZone` requires `locales`, because the zone rides the `<I18nProvider>` the framework generates from it.
806
+
807
+ ### `timeZone: 'viewer'` — how the server learns the zone
808
+
809
+ Through the `voltro:tz` cookie, which has two writers and wants both:
810
+
811
+ 1. **The framework's script**, injected into `<head>`, seeds it from `Intl.DateTimeFormat().resolvedOptions().timeZone` when the cookie is absent. From the second request onward the server renders in the browser's zone with no login and no app code. It never overwrites an existing value and never reloads the page.
812
+ 2. **Your app, at login** — overwriting it with the zone you hold for the signed-in user. That is the authoritative one: a profile field or an identity provider's `timeZone` claim beats the machine a user happens to be sitting at.
813
+
814
+ Write it from `middleware.ts`, which runs per request and can return cookies:
815
+
816
+ ```ts
817
+ // apps/<project>/web/middleware.ts
818
+ import { defineMiddleware } from '@voltro/web/middleware'
819
+ import { TIMEZONE_COOKIE, isSupportedTimeZone } from '@voltro/i18n'
820
+
821
+ export const userTimeZone = defineMiddleware({
822
+ run: async (req) => {
823
+ const zone = await zoneForSession(req.cookies) // your session → the user's own zone
824
+ if (!isSupportedTimeZone(zone) || req.cookies[TIMEZONE_COOKIE] === zone) return undefined
825
+ return {
826
+ setCookies: [
827
+ { name: TIMEZONE_COOKIE, value: zone, path: '/', maxAge: 31_536_000, sameSite: 'lax' as const },
828
+ ],
829
+ }
830
+ },
831
+ })
832
+ ```
833
+
834
+ A cookie set here is applied to the jar the SAME render reads, so the zone takes
835
+ effect on the response that sets it rather than the one after.
836
+
837
+ Validate before you write. An unusable zone is dropped on the way in (a stale cookie, a typo in the config, a runtime with a trimmed ICU) rather than forwarded — `Intl.DateTimeFormat` throws on an unknown zone, and one bad value would otherwise degrade every timestamp in the app to a raw `Date` string.
838
+
839
+ ### Prerendered pages
840
+
841
+ A `renderMode: 'static'` page is one artefact for every viewer, so `'viewer'` cannot mean the viewer there — it resolves to `defaultTimeZone`. The build publishes that value and its own build instant, so the markup and the first client render still agree; a relative time in a prerendered page corrects itself in one frame after mount rather than mismatching.
739
842
 
740
843
  ## Formatters vs. ICU in the catalog
741
844
 
@@ -750,7 +853,8 @@ For dates, numbers and relative time the hooks are the blessed path; reach for `
750
853
 
751
854
  - **Don't write `(s)`, `count === 1 ? 'x' : 'xs'`, or a `+ 's'` suffix.** It is wrong in most languages and cannot be fixed by a translator. Use `plural` / `usePlural` or ICU in the catalog.
752
855
  - **Don't hand-roll "X minutes ago".** `useRelativeTime` is one hook, is localized, and handles the sub-second and unit-selection cases that inline versions get wrong.
753
- - **Don't pin a global `timeZone` / locale for the whole app.** The formatters resolve the active locale from the provider; a pin makes every viewer read the app in one user's settings.
856
+ - **Don't pass `timeZone` at every call site to work around a missing config.** Set `timeZone` in `app.config.ts` once. A per-call convention is a rule every new call has to remember, and the ones that forget are invisible until a viewer in another zone reads a wrong date.
857
+ - **Don't call `Intl.DateTimeFormat().resolvedOptions().timeZone` in a component to "fix" SSR.** It is the viewer's true zone and therefore the wrong value: the server could not know it, so the server did not render with it, and using it on the client guarantees the mismatch. Let the server decide and publish — that is what `timeZone: 'viewer'` does.
754
858
  - **Don't pass a locale-formatted string to a machine consumer.** Formatted output is presentation — send ISO strings and raw numbers to APIs, `dateTime` attributes and sort keys.
755
859
  - **Don't format inside a `.map()` by constructing `Intl` objects yourself.** The hooks memoize per locale; a fresh `new Intl.NumberFormat(...)` per row is the slow path.
756
860
 
@@ -840,14 +944,28 @@ program.pipe(withTimezone('Europe/Berlin'))
840
944
  `currentTimezone` never fails — an absent context is the documented `UTC`
841
945
  default, so call sites don't handle a missing-service error.
842
946
 
843
- > **What ships today is the SEAM, not the wiring.** `@voltro/datetime/context`
844
- > exports `currentTimezone`, `withTimezone` and `resolvedTimezoneLayer`, and you
845
- > provide the layer yourself. The framework does **not** yet install it per
846
- > request, and there is **no `useTimezone()` hook** in the web layer both are
847
- > planned to land in `@voltro/runtime` + `@voltro/web`. Until then: resolve the
848
- > zone yourself (`resolveTimezone`) and provide `resolvedTimezoneLayer` around
849
- > the work that needs it, or pass an explicit `timeZone` argument. Without a
850
- > provided layer every call reads the `UTC` default.
947
+ > **Two zones, and only one of them is wired. Keep them apart.**
948
+ >
949
+ > The **render zone** what a date LOOKS like in the UI — is wired end to end.
950
+ > Set `timeZone` in the web `app.config.ts`, and the framework resolves it per
951
+ > request, publishes it on the document, and every `@voltro/i18n` formatter on
952
+ > both sides of the hydration boundary uses it. Read it with `useTimeZone()`
953
+ > from `@voltro/i18n`. See
954
+ > [Formatting Timezones under SSR](/docs/i18n/formatting#timezones-under-ssr--the-setting-that-is-not-a-preference).
955
+ >
956
+ > The **server-side compute zone** — what `startOfDay` or a workflow's "same
957
+ > time tomorrow" resolves against inside a handler — is still the SEAM only.
958
+ > `@voltro/datetime/context` exports `currentTimezone`, `withTimezone` and
959
+ > `resolvedTimezoneLayer`; the framework does **not** install the layer per
960
+ > request, so resolve the zone yourself (`resolveTimezone`) and provide
961
+ > `resolvedTimezoneLayer` around the work that needs it, or pass an explicit
962
+ > `timeZone` argument. Without a provided layer every call reads the `UTC`
963
+ > default.
964
+ >
965
+ > Rendering a date correctly does NOT give a route handler the user's zone, and
966
+ > a handler that has it does NOT change what the browser renders. They are
967
+ > separate values today and a `timeZone` in `app.config.ts` configures the
968
+ > first one only.
851
969
 
852
970
  ## Arithmetic — the DST split is in the names
853
971