@voltro/cli 0.55.0 → 0.57.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 (175) hide show
  1. package/CHANGELOG.md +691 -0
  2. package/bin/voltro.mjs +24 -0
  3. package/dist/apiBuild-B83Cb2Rv.js +2 -0
  4. package/dist/{apiBuild-CMvLJM_K.js → apiBuild-DDr2aNFd.js} +119 -88
  5. package/dist/bin.js +1 -1
  6. package/dist/{build-S0QOzqPT.js → build-QKP6Bm0J.js} +308 -279
  7. package/dist/buildReport-52gHKgfO.js +64 -0
  8. package/dist/{checkCommand-DNkY5kwF.js → checkCommand-BISqx1OJ.js} +1 -1
  9. package/dist/{checkCommand-fbj9GDjN.js → checkCommand-DUtMWjcR.js} +6 -6
  10. package/dist/{clusterCmd-DrVFCzSj.js → clusterCmd-CdLB1GkT.js} +1 -1
  11. package/dist/{codegen-SIepQtUl.js → codegen-Bth5lUTU.js} +2 -1
  12. package/dist/codegen-DbH7NbCR.js +2 -0
  13. package/dist/{codegenCommand-3TDJezom.js → codegenCommand-DnVuDxwT.js} +11 -11
  14. package/dist/{codemodRunner-C2zxZUIw.js → codemodRunner-BlQPfjzA.js} +222 -0
  15. package/dist/{commands-BBYJ7Q3B.js → commands-CRbxgxv0.js} +35 -35
  16. package/dist/{dashboardCommand-D2kmyCLL.js → dashboardCommand-Bf_-Ne3P.js} +5 -5
  17. package/dist/{dataCommand-BEPPQiTl.js → dataCommand-BoBJJ-Gb.js} +3 -3
  18. package/dist/{dbCommand-DZTmOFT4.js → dbCommand-CMAIz-Bf.js} +457 -441
  19. package/dist/dbCommand-DHi_RuDl.js +2 -0
  20. package/dist/dev-Bl9HqtV7.js +3 -0
  21. package/dist/{dev-Ca_A_S9v.js → dev-DOEJXicj.js} +2623 -2485
  22. package/dist/{doctorCommand-CGZJK_4o.js → doctorCommand-BrWu67JZ.js} +524 -280
  23. package/dist/doctorCommand-DrQv9SL3.js +2 -0
  24. package/dist/{dormancyCommand-DY2rYpTa.js → dormancyCommand-xn2y-pJm.js} +1 -1
  25. package/dist/{embeddingsCommand-BoCqZsgp.js → embeddingsCommand-Cn5MbRDM.js} +1 -1
  26. package/dist/emptyResultHeadline-Csa5fZOF.js +18 -0
  27. package/dist/{envCommand-Bxy2fOjc.js → envCommand-UJmJIbs9.js} +8 -8
  28. package/dist/{evolveCommand-BsbZ-XDg.js → evolveCommand-Db30twUy.js} +2 -2
  29. package/dist/frameworkTableAssembly-B96WCNJA.js +2 -0
  30. package/dist/{frameworkTableAssembly-Do-cf6RJ.js → frameworkTableAssembly-vfkzuzEo.js} +131 -104
  31. package/dist/index.js +1 -1
  32. package/dist/{infoCommand-EmM3jPKD.js → infoCommand-BjVXpMlP.js} +1 -1
  33. package/dist/inspect-B0hL41s0.js +2 -0
  34. package/dist/inspect-DZnan87F.js +1500 -0
  35. package/dist/{inspectCmd-niF97fAq.js → inspectCmd-CP-G0sVK.js} +1 -1
  36. package/dist/{inspectFetch-EMuhTG_9.js → inspectFetch-BU1NyzxV.js} +36 -24
  37. package/dist/{inspectMetrics-CGF94puw.js → inspectMetrics-BY0Sjb2F.js} +19 -19
  38. package/dist/{logsCmd-B6oNsfaZ.js → logsCmd-C5y9HyrG.js} +59 -54
  39. package/dist/{manifestBuild-DjX5MoXy.js → manifestBuild-DnbFKF6w.js} +1 -1
  40. package/dist/manifestBuild-UXrnUcXP.js +2 -0
  41. package/dist/{migrate-CGFZS-1a.js → migrate-DtC3lu7H.js} +4 -4
  42. package/dist/precompressAssets-YhTi1aWp.js +40 -0
  43. package/dist/{probeCommand-Bs3iVBSL.js → probeCommand-C5fuN6Z2.js} +2 -2
  44. package/dist/{runtimeTrace-C1BTpHGQ.js → runtimeTrace-BQL_lfz6.js} +1 -1
  45. package/dist/{scheduleCmd-DQRu6BZC.js → scheduleCmd-DYBUfo_T.js} +1 -1
  46. package/dist/{sdkgen-CXMwLg9n.js → sdkgen-CMUPrDjH.js} +1 -1
  47. package/dist/{seedRunner-Dgsiwk_e.js → seedRunner-DISBKow-.js} +16 -16
  48. package/dist/serveCommand-B_w-Mpb_.js +2544 -0
  49. package/dist/serveCommand-Dtb48ffg.js +2 -0
  50. package/dist/serveEntry.js +1 -1
  51. package/dist/{start-EOV7s1NZ.js → start-YaUehtDV.js} +580 -558
  52. package/dist/{start-DH7cat4-.js → start-s25GAIgn.js} +1 -1
  53. package/dist/startEntry.js +1 -1
  54. package/dist/staticCachePolicy-CIyj6DbS.js +15 -0
  55. package/dist/{test-DO27-x2P.js → test-jipIQ5Mx.js} +1 -1
  56. package/dist/{tracesCmd-DgtgOUdi.js → tracesCmd-C1qKa94g.js} +69 -64
  57. package/dist/{updateCommand-C_jN1w18.js → updateCommand-C9n_Z_oG.js} +8 -2
  58. package/dist/updateCommand-DsXEAHbd.js +2 -0
  59. package/dist/webDev-B6ZMX42w.js +2 -0
  60. package/dist/{webDev-B7vNj4Bq.js → webDev-BgdkyjP6.js} +1379 -1303
  61. package/dist/{webhooksCommand-B1LVcyO3.js → webhooksCommand-CYXTNvXq.js} +2 -2
  62. package/dist/{workflowsCmd-BGF-mRZ5.js → workflowsCmd-g-DNpaUc.js} +1 -1
  63. package/package.json +55 -19
  64. package/templates/AGENTS.core.md +20 -1
  65. package/templates/AGENTS.md +21 -2
  66. package/templates/agent-docs/_index.md +1 -1
  67. package/templates/agent-docs/_manifest.json +2 -2
  68. package/templates/agent-docs/ai.md +1 -1
  69. package/templates/agent-docs/authentication.md +34 -19
  70. package/templates/agent-docs/cli.md +170 -0
  71. package/templates/agent-docs/configuration.md +29 -4
  72. package/templates/agent-docs/data.md +145 -5
  73. package/templates/agent-docs/database/scaling.md +50 -2
  74. package/templates/agent-docs/deployment.md +75 -1
  75. package/templates/agent-docs/internationalization.md +32 -0
  76. package/templates/agent-docs/local-first-mobile.md +9 -2
  77. package/templates/agent-docs/observability.md +227 -0
  78. package/templates/agent-docs/plugins/audit.md +21 -5
  79. package/templates/agent-docs/plugins/billing.md +17 -0
  80. package/templates/agent-docs/plugins/broadcast.md +2 -1
  81. package/templates/agent-docs/plugins/ratelimit.md +6 -1
  82. package/templates/agent-docs/plugins/row-history.md +11 -0
  83. package/templates/agent-docs/plugins.md +19 -0
  84. package/templates/agent-docs/reference.md +1 -0
  85. package/templates/agent-docs/scheduling.md +23 -0
  86. package/templates/agent-docs/schema-driven-ui.md +81 -0
  87. package/templates/agent-docs/templates/appshells.md +3 -3
  88. package/templates/agent-docs/whats-new.md +133 -77
  89. package/templates/apps/api-ai/package.json +6 -6
  90. package/templates/apps/api-auth/package.json +8 -8
  91. package/templates/apps/api-backend/package.json +7 -7
  92. package/templates/apps/api-backend-deactivation/package.json +7 -7
  93. package/templates/apps/api-backend-mail/package.json +8 -8
  94. package/templates/apps/api-backend-mariadb/package.json +9 -9
  95. package/templates/apps/api-backend-sqlite/package.json +8 -8
  96. package/templates/apps/api-backend-storage/package.json +8 -8
  97. package/templates/apps/api-cms/package.json +9 -9
  98. package/templates/apps/api-collab/package.json +8 -8
  99. package/templates/apps/api-data-advanced/package.json +8 -8
  100. package/templates/apps/api-durable/package.json +8 -8
  101. package/templates/apps/api-feature-flags/package.json +9 -9
  102. package/templates/apps/api-governance/package.json +8 -8
  103. package/templates/apps/api-kv/package.json +8 -8
  104. package/templates/apps/api-moderation/package.json +8 -8
  105. package/templates/apps/api-observability/package.json +8 -8
  106. package/templates/apps/api-ratelimit/package.json +8 -8
  107. package/templates/apps/api-rbac/package.json +8 -8
  108. package/templates/apps/api-rest/package.json +7 -7
  109. package/templates/apps/api-row-history/package.json +8 -8
  110. package/templates/apps/api-saas/package.json +11 -11
  111. package/templates/apps/api-saas-starter/package.json +10 -10
  112. package/templates/apps/api-search/package.json +8 -8
  113. package/templates/apps/api-status/package.json +8 -8
  114. package/templates/apps/api-webhooks/package.json +9 -9
  115. package/templates/apps/changelog/package.json +7 -7
  116. package/templates/apps/changelog/src/pages/[locale]/page.tsx +7 -1
  117. package/templates/apps/edge-functions/package.json +2 -2
  118. package/templates/apps/frontend-admin/package.json +7 -8
  119. package/templates/apps/frontend-admin/src/pages/(marketing)/layout.tsx +1 -2
  120. package/templates/apps/frontend-admin/src/pages/admin/layout.tsx +1 -2
  121. package/templates/apps/frontend-app/package.json +8 -9
  122. package/templates/apps/frontend-app/src/pages/layout.tsx +1 -2
  123. package/templates/apps/frontend-auth/package.json +7 -8
  124. package/templates/apps/frontend-auth/src/components/AuthShell.tsx +1 -2
  125. package/templates/apps/frontend-blank/package.json +6 -7
  126. package/templates/apps/frontend-blank/src/pages/layout.tsx +1 -2
  127. package/templates/apps/frontend-cms/package.json +8 -9
  128. package/templates/apps/frontend-cms/src/pages/(app)/layout.tsx +1 -2
  129. package/templates/apps/frontend-collab/README.md +7 -2
  130. package/templates/apps/frontend-collab/package.json +9 -10
  131. package/templates/apps/frontend-collab/src/pages/layout.tsx +1 -2
  132. package/templates/apps/frontend-collab/src/pages/page.test.tsx +8 -4
  133. package/templates/apps/frontend-collab/src/pages/page.tsx +10 -5
  134. package/templates/apps/frontend-contact/package.json +7 -7
  135. package/templates/apps/frontend-contact/src/components/ContactForm.island.tsx +1 -1
  136. package/templates/apps/frontend-dashboard/package.json +6 -7
  137. package/templates/apps/frontend-dashboard/src/pages/(marketing)/layout.tsx +1 -2
  138. package/templates/apps/frontend-dashboard/src/pages/dashboard/layout.tsx +1 -2
  139. package/templates/apps/frontend-docs/package.json +8 -8
  140. package/templates/apps/frontend-i18n/package.json +6 -6
  141. package/templates/apps/frontend-landing/package.json +7 -7
  142. package/templates/apps/frontend-portal/package.json +7 -8
  143. package/templates/apps/frontend-portal/src/pages/(portal)/layout.tsx +1 -2
  144. package/templates/apps/frontend-saas/package.json +7 -8
  145. package/templates/apps/frontend-saas/src/pages/(marketing)/layout.tsx +1 -2
  146. package/templates/apps/frontend-saas/src/pages/dashboard/layout.tsx +1 -2
  147. package/templates/apps/frontend-spa/package.json +6 -7
  148. package/templates/apps/frontend-spa/src/pages/layout.tsx +1 -2
  149. package/templates/apps/frontend-ssr/package.json +6 -7
  150. package/templates/apps/frontend-ssr/src/pages/layout.tsx +1 -2
  151. package/templates/apps/frontend-ssr-api/package.json +7 -8
  152. package/templates/apps/frontend-ssr-api/src/pages/layout.tsx +1 -2
  153. package/templates/apps/frontend-static-blog/package.json +8 -8
  154. package/templates/apps/frontend-static-blog/src/components/ReadingProgress.island.tsx +1 -1
  155. package/templates/apps/frontend-static-blog/src/pages/[locale]/page.tsx +7 -1
  156. package/templates/apps/frontend-status/package.json +7 -8
  157. package/templates/apps/frontend-status/src/pages/layout.tsx +1 -2
  158. package/templates/apps/mobile-app/package.json +4 -4
  159. package/templates/baselines/compose/docker/api.Dockerfile +61 -5
  160. package/templates/baselines/compose/docker/web.Dockerfile +55 -10
  161. package/templates/baselines/compose-mariadb/docker/api.Dockerfile +61 -5
  162. package/templates/baselines/compose-mariadb/docker/web.Dockerfile +55 -10
  163. package/dist/apiBuild-Cl0IDx8c.js +0 -2
  164. package/dist/codegen-CN6vMM4J.js +0 -2
  165. package/dist/dbCommand-BTyBGhIA.js +0 -2
  166. package/dist/dev-DfVZaoys.js +0 -3
  167. package/dist/doctorCommand-djmqEcDC.js +0 -2
  168. package/dist/frameworkTableAssembly-Df2Ymp2f.js +0 -2
  169. package/dist/inspect-DCqILJ1G.js +0 -1197
  170. package/dist/inspect-DGJwpOAb.js +0 -2
  171. package/dist/manifestBuild-CJ2zvPvT.js +0 -2
  172. package/dist/serveCommand-C7IrCD58.js +0 -2445
  173. package/dist/serveCommand-Cjt5S9hD.js +0 -2
  174. package/dist/updateCommand-nnFjDbl4.js +0 -2
  175. package/dist/webDev-1XpVnYkW.js +0 -2
@@ -416,3 +416,230 @@ curl "http://localhost:4000/_voltro/inspect/timeline/replay?table=todos&seq=42"
416
416
  - The inspect surface is a `voltro dev` / DevTools concern; out-of-band DB writes
417
417
  (not in the app's ChangeEvent stream) aren't recorded, and production should
418
418
  use OTLP for forensics.
419
+
420
+
421
+
422
+ ---
423
+
424
+ <!-- source: en/observability/inspect-scope.md -->
425
+ ## What an inspect answer is about
426
+
427
+ _Every /_voltro/inspect/* answer is an Observation — it says which scope it describes, which replica answered, and how complete it is. Reading the envelope, the four scopes, fleetSize, and ?scope=fleet._
428
+
429
+ Every 2xx `/_voltro/inspect/*` answer is an **Observation**: the payload plus
430
+ what a reader needs in order to act on it.
431
+
432
+ ```json
433
+ {
434
+ "data": { "…": "the payload" },
435
+ "scope": { "kind": "process" },
436
+ "origin": {
437
+ "replicaId": "api-7d9f-x2k",
438
+ "instanceId": "api-7d9f-x2k@1787893389882.k3f9aa",
439
+ "startedAt": 1787893389882,
440
+ "version": "0.56.0",
441
+ "bootPath": "serve"
442
+ },
443
+ "completeness": { "complete": false, "fleetSize": 3, "reason": "process-scoped: this is 1 of 3 replicas" },
444
+ "capturedAt": 1787893390411
445
+ }
446
+ ```
447
+
448
+ ## Why the payload alone was not enough
449
+
450
+ `/subscriptions` answers with the subscriptions of the **one process that
451
+ received the request**. `/schedules` answers with the whole fleet's, read from
452
+ the shared database. Both used to be plain JSON, so on a multi-replica
453
+ deployment the first is an unlabelled sample and reads exactly like the second.
454
+
455
+ On **one** replica the difference is invisible — and one replica is every
456
+ development environment, every e2e run and every template. So the environment
457
+ in which the two look identical is the one everybody builds and tests in, and
458
+ the difference only appears in production, where nobody can go and read the
459
+ source to settle it.
460
+
461
+ ## The four scopes
462
+
463
+ | `scope.kind` | What it means | Examples |
464
+ |---|---|---|
465
+ | `process` | True of `origin` and of nothing else. Another replica answers differently. | `/subscriptions`, `/metrics`, `/logs`, `/cache`, `/events`, `/traces`, `/cluster` |
466
+ | `shared-store` | Read from storage every replica shares — any of them would answer the same. | `/schedules`, `/workflows/*`, `/migrations`, `/database`, `/data/*` |
467
+ | `fleet` | Assembled from more than one process. `completeness` says who answered. | `/members`, `/subscriptions?scope=fleet` |
468
+ | `declaration` | From the source tree. Identical on every replica **of one version** — and different across a rolling deploy. | `/app`, `/routes`, `/manifest`, `/env`, `/rpc` |
469
+
470
+ `declaration` is its own kind rather than "fleet" on purpose: during a rolling
471
+ deploy your fleet genuinely runs two versions, and `origin.version` is how you
472
+ see which one answered.
473
+
474
+ ## `fleetSize` — the label that needs no aggregation
475
+
476
+ A `process`-scoped answer carries how many replicas exist:
477
+
478
+ ```json
479
+ "completeness": { "complete": false, "fleetSize": 3, "reason": "process-scoped: this is 1 of 3 replicas" }
480
+ ```
481
+
482
+ So a plain `curl` states that it is a fraction, and of what. Query a few times
483
+ and union by `origin.instanceId` — which you can now do, because the answer
484
+ says which process produced it.
485
+
486
+ **When the field is missing, it means "could not tell", not "one".** If the app
487
+ runs no membership registry, `fleetSize` is **absent** and `complete` is
488
+ `false` with a reason. Reporting `1` there would tell you that you are seeing
489
+ the whole fleet.
490
+
491
+ ## `?scope=fleet`
492
+
493
+ ```sh
494
+ curl -s -H "authorization: Bearer $VOLTRO_INSPECT_TOKEN" \
495
+ 'localhost:4000/_voltro/inspect/subscriptions?scope=fleet' | jq .
496
+ ```
497
+
498
+ Each replica publishes its counters into `_voltro_replica_observations` on a
499
+ timer, so **any** replica can answer by reading rather than by asking the
500
+ others. The answer is `fleet`-scoped and says what it is missing:
501
+
502
+ ```json
503
+ {
504
+ "data": { "resume": [
505
+ { "replicaId": "api-a", "version": "0.56.0", "ageMs": 4021, "payload": { "…": "counters" } },
506
+ { "replicaId": "api-b", "version": "0.56.0", "ageMs": 9114, "payload": { "…": "counters" } }
507
+ ] },
508
+ "scope": { "kind": "fleet", "assembledBy": "api-a", "assembledAt": 1787893390411 },
509
+ "completeness": {
510
+ "complete": false, "responded": 2, "expected": 3, "missing": ["api-c"],
511
+ "reason": "1 replica(s) have written nothing readable"
512
+ }
513
+ }
514
+ ```
515
+
516
+ Three properties are deliberate:
517
+
518
+ - **A replica that has written nothing is `missing`, not absent.** Dropping it
519
+ would make a partial answer look complete — the same unlabelled sample, one
520
+ level up and more expensive, because now you believe you asked everybody.
521
+ - **A stale row is reported with its `ageMs`, not filtered out.** Removing it
522
+ hides that the answer is partial; keeping it unmarked presents fiction as
523
+ current.
524
+ - **Mixed versions are named** in `completeness.versions` when the responders
525
+ disagree. A rolling deploy spans two shapes, and averaging them silently is
526
+ wrong in a way nothing downstream can detect.
527
+
528
+ **If there is no shared store, the request is refused with `501`** and a reason
529
+ — never answered with this replica's own numbers. Handing back a sample to
530
+ someone who asked for the fleet in writing is exactly the failure the envelope
531
+ exists to prevent.
532
+
533
+ ## `?replica=<id>` — asking one named replica
534
+
535
+ ```sh
536
+ # this replica's own answer (the default)
537
+ curl -s … /_voltro/inspect/subscriptions
538
+
539
+ # every replica's counters, assembled from the shared store
540
+ curl -s … '/_voltro/inspect/subscriptions?scope=fleet'
541
+
542
+ # ONE named replica, asked through the one you can reach
543
+ curl -s … '/_voltro/inspect/subscriptions?replica=api-7d9f-x2k'
544
+ ```
545
+
546
+ The answer comes back with **that** replica's `origin`, process-scoped: the
547
+ proxy does not launder whose answer it is.
548
+
549
+ The address is looked up in `_voltro_replica_observations` — **the caller names
550
+ an ID, never a URL**, and an id we do not know produces a `404` with no request
551
+ leaving the process. That is what keeps this from being an SSRF primitive.
552
+ "unknown replica" and "that replica published no reachable address" give the
553
+ same message on purpose: telling them apart would tell a caller which ids
554
+ exist.
555
+
556
+ A replica publishes an address only when it is genuinely reachable by a peer.
557
+ An unset `POD_IP` falls back to `127.0.0.1`, which is a shrug rather than a
558
+ statement, so it is recorded as **not** reachable and no peer will try it. Set
559
+ `VOLTRO_INSPECT_ADVERTISE_HOST` to declare one — including `127.0.0.1`, when
560
+ the peers really are on this machine.
561
+
562
+ A proxied request carries a hop header and is always answered locally, so
563
+ `?replica=` cannot cycle. A peer that does not answer inside a short deadline
564
+ becomes a `504` naming it, because a diagnostic that hangs is worse than one
565
+ that says no.
566
+
567
+ ## Writes are never fleet-addressable
568
+
569
+ `?scope=fleet` and `?replica=` exist only for reads. A mutating endpoint
570
+ (`/invoke`, `/seeds/run`, `/data/import`, `/migrations/rollback`,
571
+ `/agent/call`) rejects them: fanning a destructive operation out across a fleet
572
+ is not something an accidental query parameter should be able to ask for.
573
+
574
+ ## Reading it from your own tooling
575
+
576
+ ```sh
577
+ # the payload
578
+ curl -s … /_voltro/inspect/subscriptions | jq .data.resume
579
+
580
+ # is this the whole picture?
581
+ curl -s … /_voltro/inspect/subscriptions | jq '.completeness | {complete, fleetSize}'
582
+
583
+ # which pod answered?
584
+ curl -s … /_voltro/inspect/subscriptions | jq -r .origin.replicaId
585
+ ```
586
+
587
+ `capturedAt` is the **origin's** clock. Do not compare it with another
588
+ replica's — replica clocks disagree, which is why the fleet view reports an
589
+ `ageMs` computed against one reader's instant rather than a timestamp you are
590
+ invited to subtract.
591
+
592
+ ## The live stream
593
+
594
+ `/_voltro/inspect/stream` (SSE) carries `origin` on **every event**, not on a
595
+ handshake. A consumer that connects late — a reconnect, a second tab, a `curl`
596
+ piped into `jq` — never sees a handshake, and this is a live stream from
597
+ whichever replica the connection landed on. Without the per-event stamp, a tail
598
+ on a three-replica fleet shows a third of it continuously, with nothing on the
599
+ wire to say so.
600
+
601
+ `ts` is that replica's clock. Do not order two origins by it.
602
+
603
+ **It is mounted on both boot paths.** `voltro serve` used to answer `404` here
604
+ — the stream was wired for `voltro dev` and nowhere else — so every live view in
605
+ the dashboard worked in development and was dead in production. Both paths now
606
+ mount it through one builder.
607
+
608
+ **Authenticating it needs no special step from you, and one from the
609
+ dashboard.** `EventSource` accepts no headers, so a browser cannot attach a
610
+ bearer to an SSE request the way it does to every other inspect call. The
611
+ dashboard passes the app's token through a same-origin cookie scoped to its own
612
+ proxy path, cleared the moment the stream opens; the proxy moves it into an
613
+ `Authorization` header. Nothing about the token ever appears in a URL, and the
614
+ app receives a header like any other caller.
615
+
616
+ ## In the dashboards
617
+
618
+ Both dashboards unwrap `.data` in their fetch layer and keep the envelope. A
619
+ page whose data is process-local renders a notice saying which replica it is
620
+ showing and how many exist; a page whose answer is complete renders nothing,
621
+ because a banner over a complete answer teaches people to ignore banners.
622
+
623
+ ### The Fleet panel
624
+
625
+ Both dashboards ship a **Fleet** page — the self-hosted DevTools under
626
+ `/apps/<id>/fleet`, the hosted console under the app's *Fleet* tab. One page,
627
+ one shared component, two transports.
628
+
629
+ It renders **three** populations, and the two after the first are the point:
630
+
631
+ - the replicas that **answered**, each with its version, how long ago it
632
+ published, and whether a peer can reach it;
633
+ - the replicas that are **silent** — membership knows them and they have
634
+ published nothing readable. Shown as their own section rather than omitted,
635
+ because "not shown" and "not there" look identical and mean opposite things;
636
+ - the replicas whose answer is **stale**, with the age. Filtering them would
637
+ make a partial answer look complete; leaving them unmarked would present old
638
+ numbers as current.
639
+
640
+ A **mixed-version** note appears when the responders disagree — a rolling
641
+ deploy is in flight and the numbers span two shapes.
642
+
643
+ The completeness banner is phrased as a ratio (*"3 of 5 replicas answered"*)
644
+ rather than a count, because a count invites the reader to believe that is the
645
+ fleet.
@@ -356,7 +356,7 @@ What it discloses, and what it never does:
356
356
 
357
357
  | | |
358
358
  | --- | --- |
359
- | a string | its LENGTH — `string(113)`. Never a prefix, never a hash |
359
+ | a string | its LENGTH — `string(113)`. Never a prefix, never a hash. Below a floor, a bucket: `string(<16)` |
360
360
  | a number, a boolean, a date | its TYPE only. A number can BE the secret |
361
361
  | a declared field name | survives — `jiraToken` |
362
362
  | a key that is not an identifier | described, not reproduced — `<key:string(36)>` |
@@ -369,10 +369,26 @@ Two of those are worth a sentence each.
369
369
  it looks like a declared field: a plain short identifier. A legitimate key that
370
370
  is not one (`content-type`) loses its name and keeps its shape.
371
371
 
372
- **A string's length is a real disclosure, and a small one.** For a fixed-format
373
- credential it carries nothing — every token of a given kind is the same length.
374
- For a human-chosen password it is a weak hint. If that matters in your threat
375
- model, `'all'` is the default and stays available.
372
+ **A string's length is a real disclosure, and a small one above a floor.** For
373
+ a fixed-format credential it carries nothing — every token of a given kind is the
374
+ same length. For a human-chosen password it is a weak hint.
375
+
376
+ Below a floor it stops being small: a six-digit TOTP reported as `string(6)`, or
377
+ a four-digit PIN as `string(4)`, tells a reader with log access exactly what
378
+ shape to try. Nothing this mode is FOR lives under 16 characters — the case that
379
+ motivated it is 113 against an expected 44 — so a string shorter than that
380
+ reports `string(<16)`: shorter than the floor, not how much shorter.
381
+
382
+ ```ts
383
+ auditPlugin({ redactOutcome: 'shape', redactionLengthFloor: 0 })
384
+ ```
385
+
386
+ `0` turns the floor off, for a deployment whose audited payloads are ids and
387
+ tokens and every character of length is worth having; raise it for one holding
388
+ short human-entered secrets. An EMPTY string stays exact (`string(0)`) — "the
389
+ field arrived empty" is a real diagnosis and an empty string is not a secret. The
390
+ floor applies to described keys too, for the reason above them: a key that is not
391
+ a declared field is a key carrying data.
376
392
 
377
393
  `redactSubject` spells its variant `'metadata-shape'`, because its default
378
394
  (`'metadata'`) already names the field it acts on.
@@ -40,6 +40,8 @@ export default {
40
40
 
41
41
  `plans` is the single source of tier→limit truth — it lives in code, not the DB, so limits change by redeploy, not migration. Entitlement values are `number | 'unlimited'`. With no provider configured (and no `STRIPE_SECRET_KEY`), the plugin uses an in-memory **mock provider** — zero-config for dev and tests.
42
42
 
43
+ > **Under `NODE_ENV=production`, a mock chosen by ABSENCE warns.** `STRIPE_SECRET_KEY` is a deployment variable, and one that silently stops being set — a rotated secret, a typo in a values file, a CI variable nobody created — is routine. Without the line, an app that had been charging customers keeps answering every billing call successfully, reaches nobody, and leaves nothing to find afterwards. Set the key, or write `provider: 'mock'` so the mock is a decision on the page rather than an absence. An explicitly declared mock stays silent.
44
+
43
45
  What it provides:
44
46
 
45
47
  - The `_voltro_billing_*` tables (customers, subscriptions, invoices, usage, flush_claims, dunning_notices) via `extendSchema.tables`.
@@ -239,6 +241,21 @@ All three route through the existing `BillingService.reportUsage` (a local per-p
239
241
 
240
242
  **Idempotency + multi-instance.** The self-scheduled flush is per-period idempotent (`markReported` makes a re-flush a no-op) AND **cluster-coordinated by default**: each replica self-schedules, but an INSERT-wins claim on the flush window (`_voltro_billing_flush_claims`) means exactly one replica flushes a given window — so two replicas never double-push it, with no extra wiring. Set `flushIntervalMs: 0` to disable the timer entirely and drive `billing.flushUsage()` from your own `*.cron.tsx` instead.
241
243
 
244
+ **A `cdc` meter accrues once fleet-wide, not once per replica.** The change tap it
245
+ rides is delivered to EVERY replica — that is what makes a `changeScope: 'fleet'`
246
+ store (postgres LISTEN/NOTIFY, mysql binlog) cross-instance in the first place —
247
+ and accrual is an increment on a shared counter, so a tenant on two pods used to
248
+ be invoiced twice. Each change is now claimed in `_voltro_change_claims` before it
249
+ accrues, through the same INSERT-wins arbiter behind
250
+ [`defineSubscriber({ once })`](/docs/data/subscribers) and a reaction's
251
+ `dedupeKey`. Nothing to configure; the boot log warns loudly if a deployment ever
252
+ runs the tap ungated, because an over-counted meter is indistinguishable from a
253
+ correct one by looking at the number.
254
+
255
+ The claim key names the CHANGE, not the row: two genuine edits to one row share an
256
+ id, so an id-keyed meter on `op: 'update'` would count the first and drop every one
257
+ after it.
258
+
242
259
  **Boundaries (v1).** Metering captures writes the framework observes through `ctx.store` — the bulk helpers (`updateMany` / `deleteMany`) emit per-row ChangeEvents that ARE counted, but a single bulk SQL escape-hatch write isn't. The meter is best-effort post-commit telemetry, not a financial ledger of record.
243
260
 
244
261
  ## Plans, seats & the billed amount
@@ -51,7 +51,8 @@ The bus is **additive** to the inline emit path:
51
51
 
52
52
  - It publishes `{ origin, event }` on the `<namespace>:changes` channel.
53
53
  - It injects remote events into every other replica's store, skipping its own origin so there's no double-emit.
54
- - A broker outage degrades cross-replica fan-out only — local reactivity keeps working.
54
+ - A broker outage degrades cross-replica fan-out only — local reactivity keeps working, **including a broker that is down at boot**. The replica starts, serves, and joins the bus when the broker returns; the time it spent unsubscribed is then reported as a gap and every live query re-runs. A crash loop across the fleet is the wrong answer to a broker restart, which is exactly the moment every replica is dialling at once.
55
+ - A restarted peer is recognised as a **new process**, not the same one continuing. Each publish carries a per-process epoch, because a replica id survives a restart (`POD_NAME` on a StatefulSet, `VOLTRO_REPLICA_ID` by definition) and a serial does not.
55
56
 
56
57
  The plugin declares the `network:outbound:*` permission. The boot banner names the resolved tier (cross-instance via redis/nats, or off for the dialect when no broker is configured).
57
58
 
@@ -215,7 +215,12 @@ updates under concurrency), so it can't reuse a plain get/set cache. Three
215
215
  stores ship, all fail OPEN on backend errors (degrade to no-limit, never to 500s):
216
216
 
217
217
  - **Memory** *(default)* — single-process, zero-config. Perfect for dev and
218
- single-instance deploys; not shared across replicas.
218
+ single-instance deploys; not shared across replicas. **Which means every limit
219
+ is multiplied by the replica count**: `100/min` on five pods is 500/min, and a
220
+ limiter is usually the thing standing between an endpoint and abuse. The plugin
221
+ warns at boot when the environment says several replicas (`POD_NAME`,
222
+ `FLY_ALLOC_ID`, `K_REVISION`, … — or `REPLICA_COUNT`, which is a declaration in
223
+ both directions) and the store is still the process-local one.
219
224
  - **Postgres** — multi-node correct. Runs on the framework's **already-open
220
225
  `SqlClient`** — the same connection pool the app itself uses — so it reads no
221
226
  `DB_*`/`PG_*` env of its own and never opens a second pool. The plugin binds
@@ -138,6 +138,17 @@ Version numbers are 1-based in **both** timings, so switching `timing` does not
138
138
 
139
139
  **The refusal takes the row with it.** When the trail's insert fails, the write it covers is rolled back — including a write made OUTSIDE any transaction of your own. That has not always been true: the row's statement committed on its own and the trail ran as a second statement afterwards, so a failing trail left a committed row behind a write that reported failure. Anything that retried that write then met its own row and reported a duplicate key for a row nobody wrote twice. A table with recorders is written inside a transaction now, on every SQL dialect, so "the mutation fails with it" means what it says.
140
140
 
141
+ **Post-commit records once fleet-wide, not once per replica.** The change tap it
142
+ rides is delivered to EVERY replica — that is what makes a `changeScope: 'fleet'`
143
+ store (postgres LISTEN/NOTIFY, mysql binlog) cross-instance in the first place —
144
+ and it used to record on each of them. It did not surface as a conflict either:
145
+ versions are numbered `MAX(version) + 1`, so two replicas both computed version 1,
146
+ one won the primary key, and the loser's retry re-read MAX, got 2, and appended a
147
+ second entry. Three replicas produced versions `1, 2, 3` for one change — not
148
+ merely doubled, *mis-ordered*, and `selectAsOf` reads `version`. Each change is now
149
+ claimed fleet-wide before it is recorded, keyed on the change rather than the row.
150
+ Nothing to configure; `'in-transaction'` never had this.
151
+
141
152
  **Why the default is still `'post-commit'`.** In-transaction makes `_voltro_row_history` a hard dependency of every write path it covers: its availability becomes your write path's availability, and every covered write holds its locks longer. Post-commit loses at worst *one entry*; in-transaction can, at worst, stop writes to the covered tables entirely. For a compliance trail the second trade is the right one — for the undo / time-travel use this plugin also serves, it is not.
142
153
 
143
154
  ### Under CDC, and inside a transaction
@@ -1130,6 +1130,25 @@ framework's ALREADY-OPEN handles so it never rebuilds them:
1130
1130
  the SAME pool the app's store uses. Run raw SQL through it instead of
1131
1131
  standing up your OWN `ManagedRuntime` + pool from env. `undefined` on
1132
1132
  the in-memory store (no SQL engine) — guard with `if (ctx.sql)`.
1133
+ - **`ctx.claimChange(scope, key)`** — claim ONE change fleet-wide; `true` means
1134
+ this replica may run the effect. The counterpart of `scheduleCoordinated` for
1135
+ the OTHER thing that fires on every replica: a change tap. `onChangeEvent` is
1136
+ delivered to every replica — that is what makes a `changeScope: 'fleet'` store
1137
+ cross-instance — so a tap that COUNTS or SENDS multiplies by the replica count.
1138
+ `@voltro/plugin-billing` accrued a usage unit per metered row change and a
1139
+ tenant on two pods was invoiced twice.
1140
+
1141
+ `scope` namespaces the key (use your plugin's name); `key` must name the
1142
+ CHANGE. Derive it with `changeDigest` + `OccurrenceCounter` from
1143
+ `@voltro/database` rather than inventing one — a fleet change carries no LSN,
1144
+ no commit id and no `traceId`, so content plus its position among
1145
+ content-identical repeats is the only thing two replicas provably agree on. A
1146
+ row id is not enough: two genuine updates to one row share it.
1147
+
1148
+ AT MOST once — the claim is taken before the effect, so a replica that wins and
1149
+ dies takes the change with it, and a claim that cannot be written is taken by
1150
+ nobody. Fail-closed, like the rate slot and the budget guard.
1151
+
1133
1152
  - **`ctx.scheduleCoordinated(name, intervalMs, effect)`** — run a periodic
1134
1153
  task on ONLY ONE replica per tick, cluster-coordinated via the same
1135
1154
  claim-table exactly-once gate the cron scheduler uses. Replaces the
@@ -89,6 +89,7 @@ these before hand-rolling a form, a table, or a picker** — full guide in
89
89
  | [`usePreview`](/docs/ui/client-utilities/use-preview) | Mutation dry-run — run the real handler in a rolled-back transaction. |
90
90
  | [`useProvenance`](/docs/ui/client-utilities/use-provenance) | "Why is this value here?" — lineage lookup for a field. |
91
91
  | [`useOnRpcError`](/docs/ui/client-utilities/use-on-rpc-error) / `reportClientError` | Subscribe to the rpc error bus; report a client error to the server. |
92
+ | [`useSubscriptionHealth`](/docs/ui/client-utilities/use-subscription-health) | Which of an api's calls are currently REFUSED — a refused subscription never retries and reads as `loading`. |
92
93
  | [`useTracking`](/docs/ui/client-utilities/use-tracking) | Fire mount/unmount + interaction tracking events. |
93
94
  | [`useCapabilityManifest`](/docs/ui/client-utilities/use-capability-manifest) | The api's capability manifest (procedures + tables + schemas), fetched once. |
94
95
  | [`useRefreshSubscriptions`](/docs/ui/client-utilities/use-refresh-subscriptions) | Force-refresh live subscriptions (e.g. after an out-of-band change). |
@@ -539,6 +539,29 @@ defineSchedule({
539
539
 
540
540
  > The watchdog stops *waiting* and records the timeout; it cannot truly abort a Promise's in-flight side effects (JavaScript has no thread-kill). Make long handlers cooperative — check a deadline, or do the heavy lifting in a [workflow](/docs/workflows/overview) with its own step-level durability.
541
541
 
542
+ ## Across replicas — the heartbeat
543
+
544
+ `onOverlap: 'skip'` is a **cluster-wide** rule, not a per-process one: a firing is suppressed when an occurrence of the same schedule is running on *any* replica. That check reads `_voltro_schedule_runs`, so it needs a way to tell a run that is still working from one whose process died mid-run and left its row on `running` forever.
545
+
546
+ That is `heartbeatAt`: a run in flight bumps it every **30 seconds**, and a row silent for three beats is read as dead rather than as a live occurrence. Without it the only evidence a row carried was `firedAt`, and a window generous enough never to cut off a long run is also long enough to suppress a half-hourly cron for hours after one restart.
547
+
548
+ ```ts
549
+ // app.config.ts
550
+ export default defineAppConfig({
551
+ scheduling: {
552
+ scheduleHeartbeatMs: 30_000, // default; VOLTRO_SCHEDULE_HEARTBEAT_MS overrides it
553
+ },
554
+ })
555
+ ```
556
+
557
+ Raise it in a deployment with many long-running schedules where the writes matter more than the detection latency; a run shorter than one interval writes no beat at all and costs nothing either way. It is **not a lease** — a stale beat only stops a corpse from holding the schedule shut; nothing takes ownership of the run.
558
+
559
+ The skip is logged with what it measured, so the two cases are legible from the log alone:
560
+
561
+ ```
562
+ schedule: overlap skip name=reindex scope=cluster heldBy=pod-7f4c lastSeenMs=1840
563
+ ```
564
+
542
565
  ## Backfill — `backfill`
543
566
 
544
567
  When the process was down across one or more firing instants, what should happen on boot? Computed from the last `_voltro_schedule_runs` row for the schedule.
@@ -370,6 +370,36 @@ widget })` rides an annotation, `description` becomes help text, and
370
370
  underneath is an implementation detail — no engine type appears in the public
371
371
  API, and production builds stub its devtools channel automatically.
372
372
 
373
+ ### When the FORM decides which mutation it is
374
+
375
+ Some forms only learn their target from what the user does: a calendar entry
376
+ becomes a recurring **series** the moment "repeats" is ticked, and the series
377
+ mutation takes eleven more fields than the single one.
378
+
379
+ Pass a function of the current values instead of a tag:
380
+
381
+ ```tsx
382
+ const form = useFormBinding<CalendarInput>(
383
+ 'app',
384
+ (v) => (v.repeats ? 'calendarRecurringEvent.create' : 'calendarEntries.create'),
385
+ { defaults: { title: '', repeats: false } },
386
+ )
387
+ ```
388
+
389
+ The schema in force follows the tag, so `form.fields` grows and shrinks with it
390
+ and validation always matches what will actually be submitted. **The values do
391
+ not reset** — the engine is constructed once and never rebuilt, so everything
392
+ typed before the switch survives it. That is the whole reason this exists:
393
+ deriving the tag outside the binding is not available (the values belong to the
394
+ binding and do not exist before it), and re-mounting with a different tag throws
395
+ the user's input away.
396
+
397
+ Two details worth knowing. On the first render there are no values yet, so the
398
+ function is called with your `defaults`. And the accessibility ids are pinned to
399
+ the first tag and stay there — they are DOM ids, and letting them move on the
400
+ keystroke that flips the branch would remount every field, taking the focus and
401
+ the caret with it.
402
+
373
403
  ### Reference fields, uploads, and testing the form
374
404
 
375
405
  **Reference fields.** Mark a schema field as a table reference and it renders
@@ -1597,6 +1627,57 @@ identity* changed underneath them.
1597
1627
 
1598
1628
 
1599
1629
 
1630
+ ---
1631
+
1632
+ <!-- source: en/ui/client-utilities/use-subscription-health.md -->
1633
+ ## useSubscriptionHealth
1634
+
1635
+ _Which of an api's calls are currently refused — the state a screen cannot derive from its own hook._
1636
+
1637
+ A refused subscription is **terminal for its transport**. It does not retry, so
1638
+ there is no second error event to react to — and `SubscriptionFailed` presents
1639
+ `data: undefined`, which is the value most reading layers derive `loading` from.
1640
+ A component that branches on `data === undefined` therefore shows a spinner from
1641
+ then on, indefinitely. The state union models this correctly (`failed: true`,
1642
+ `loading: false`); what loses it is the wrapper hook in between, and a wrapper
1643
+ that passes `{ data, loading }` through is the natural shape to write.
1644
+
1645
+ `useSubscriptionHealth` answers the question no single hook can: **is anything on
1646
+ this api currently refused, and what.**
1647
+
1648
+ ```tsx
1649
+ import { useSubscriptionHealth } from '@voltro/client'
1650
+
1651
+ const SessionBar = () => {
1652
+ const { healthy, failed } = useSubscriptionHealth('app')
1653
+ if (healthy) return null
1654
+ return <Banner>Not loading: {failed.map((f) => f.tag).join(', ')}</Banner>
1655
+ }
1656
+ ```
1657
+
1658
+ A tag enters `failed` when a call for it fails and leaves when a call for the
1659
+ **same tag** succeeds. It is keyed by tag rather than counted, because the
1660
+ question a screen asks is "is this working now" and two failures of one call are
1661
+ one broken thing. `healthy` is the positive form of the same fact, so a component
1662
+ reads `if (!healthy)` instead of `if (failed.length > 0)`.
1663
+
1664
+ Each entry is `{ tag, source, error, at }` — `at` is the timestamp of the
1665
+ failure, so a bar can say *since 12:04* rather than only *broken*.
1666
+
1667
+ ## What it does not do
1668
+
1669
+ It is not a retry mechanism and not an error boundary. Recovery is the
1670
+ transport's job: an `Unauthenticated` failure makes the framework re-resolve the
1671
+ credential and rebuild the connection, and a rebuilt client re-subscribes on its
1672
+ own — a failed entry is never carried across the swap, so it is re-attempted
1673
+ rather than restored as broken. Nothing in your app wires that.
1674
+
1675
+ The register is scoped to one api's runtime and is reset when that runtime is
1676
+ rebuilt, deliberately: carrying a failure across a transport swap would report a
1677
+ call as broken that has not been tried since.
1678
+
1679
+
1680
+
1600
1681
  ---
1601
1682
 
1602
1683
  <!-- source: en/ui/client-utilities/use-tracking.md -->
@@ -81,7 +81,7 @@ A contact form, a newsletter signup, a theme toggle — anything that needs JS
81
81
 
82
82
  ```tsx
83
83
  // src/components/SignupForm.island.tsx
84
- import { island } from '@voltro/web'
84
+ import { island } from '@voltro/web/islands'
85
85
  const SignupForm = () => { /* … */ }
86
86
  export default island(SignupForm, { name: 'SignupForm', hydrate: 'visible' })
87
87
  ```
@@ -971,7 +971,7 @@ The post list ships zero JavaScript, but the post page wants a thin reading-prog
971
971
  // src/components/ReadingProgress.island.tsx
972
972
  import type { ReactNode } from 'react'
973
973
  import { useEffect, useState } from 'react'
974
- import { island } from '@voltro/web'
974
+ import { island } from '@voltro/web/islands'
975
975
 
976
976
  function ReadingProgress(): ReactNode {
977
977
  const [pct, setPct] = useState(0)
@@ -1180,7 +1180,7 @@ The form is an island with `hydrate: 'visible'` — it's below the fold, so hydr
1180
1180
  // src/components/ContactForm.island.tsx
1181
1181
  import type { FormEvent, ReactNode } from 'react'
1182
1182
  import { useState } from 'react'
1183
- import { island } from '@voltro/web'
1183
+ import { island } from '@voltro/web/islands'
1184
1184
  import { CONTACT_ENDPOINT } from '../config'
1185
1185
 
1186
1186
  type Status =