@voltro/cli 0.57.0 → 0.59.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 (110) hide show
  1. package/CHANGELOG.md +256 -0
  2. package/dist/apiBuild-B9OvhITV.js +2 -0
  3. package/dist/{apiBuild-DDr2aNFd.js → apiBuild-BU5cd-Fn.js} +52 -50
  4. package/dist/bin.js +1 -1
  5. package/dist/build-uRMZrT8z.js +1066 -0
  6. package/dist/{checkCommand-BISqx1OJ.js → checkCommand-C1CQc8lD.js} +1 -1
  7. package/dist/{checkCommand-DUtMWjcR.js → checkCommand-DC7k1_hP.js} +3 -3
  8. package/dist/{codegenCommand-DnVuDxwT.js → codegenCommand-kS3Twuc9.js} +7 -7
  9. package/dist/{codemodRunner-BlQPfjzA.js → codemodRunner-kNnh1I7_.js} +1227 -911
  10. package/dist/{commands-CRbxgxv0.js → commands-cu34ENUT.js} +21 -21
  11. package/dist/{dashboardCommand-Bf_-Ne3P.js → dashboardCommand-gG1y4PFG.js} +5 -5
  12. package/dist/{dataCommand-BoBJJ-Gb.js → dataCommand-hNOGKz4K.js} +6 -6
  13. package/dist/{dev-DOEJXicj.js → dev-Ce2gEziv.js} +2422 -2419
  14. package/dist/dev-Dff_pdik.js +3 -0
  15. package/dist/{doctorCommand-BrWu67JZ.js → doctorCommand-CGjefPmk.js} +505 -284
  16. package/dist/doctorCommand-DXnNpKQT.js +2 -0
  17. package/dist/{dormancyCommand-xn2y-pJm.js → dormancyCommand-DYgI0Kf-.js} +3 -3
  18. package/dist/{embeddingsCommand-Cn5MbRDM.js → embeddingsCommand-yJivQSrI.js} +2 -2
  19. package/dist/{envCommand-UJmJIbs9.js → envCommand-ORJUf8r_.js} +8 -8
  20. package/dist/{evolveCommand-Db30twUy.js → evolveCommand-8CuFBlbQ.js} +2 -2
  21. package/dist/index.js +1 -1
  22. package/dist/{infoCommand-BjVXpMlP.js → infoCommand-D02A4WI2.js} +1 -1
  23. package/dist/inspect-CZvsmp8t.js +2 -0
  24. package/dist/{inspect-DZnan87F.js → inspect-PcC5J-Dl.js} +6 -7
  25. package/dist/manifestBuild-AUh31bFU.js +2 -0
  26. package/dist/{manifestBuild-DnbFKF6w.js → manifestBuild-BwiS4iYm.js} +1 -1
  27. package/dist/{probeCommand-C5fuN6Z2.js → probeCommand-BUYzc0CT.js} +1 -1
  28. package/dist/{runtimeTrace-BQL_lfz6.js → runtimeTrace-DDuKJgVi.js} +1 -1
  29. package/dist/{sdkgen-CMUPrDjH.js → sdkgen-vq1fF9D_.js} +1 -1
  30. package/dist/serveCommand-1R52mo6F.js +2 -0
  31. package/dist/{serveCommand-B_w-Mpb_.js → serveCommand-Dr4KXmTS.js} +726 -725
  32. package/dist/serveEntry.js +1 -1
  33. package/dist/{start-s25GAIgn.js → start-C75w6ftA.js} +1 -1
  34. package/dist/{start-YaUehtDV.js → start-DNtIlBcn.js} +641 -609
  35. package/dist/startEntry.js +1 -1
  36. package/dist/{updateCommand-C9n_Z_oG.js → updateCommand-Bw3mCUDT.js} +1 -1
  37. package/dist/updateCommand-inInBIAh.js +2 -0
  38. package/dist/webDev-Bzb8ZFus.js +2 -0
  39. package/dist/{webDev-BgdkyjP6.js → webDev-CBN9KkyQ.js} +154 -146
  40. package/dist/{webhooksCommand-CYXTNvXq.js → webhooksCommand-BPJQOGTe.js} +1 -1
  41. package/package.json +73 -19
  42. package/templates/AGENTS.md +1 -1
  43. package/templates/agent-docs/_index.md +1 -1
  44. package/templates/agent-docs/_manifest.json +1 -1
  45. package/templates/agent-docs/ai.md +223 -0
  46. package/templates/agent-docs/authentication.md +34 -0
  47. package/templates/agent-docs/cli.md +7 -6
  48. package/templates/agent-docs/data.md +30 -0
  49. package/templates/agent-docs/database/transactions.md +48 -0
  50. package/templates/agent-docs/observability.md +13 -2
  51. package/templates/agent-docs/plugins/auth-supabase.md +21 -0
  52. package/templates/agent-docs/plugins/sentry.md +131 -9
  53. package/templates/agent-docs/schema-driven-ui.md +29 -0
  54. package/templates/agent-docs/whats-new.md +26 -165
  55. package/templates/apps/api-ai/package.json +6 -6
  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 +9 -9
  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-row-history/package.json +8 -8
  76. package/templates/apps/api-saas/package.json +11 -11
  77. package/templates/apps/api-saas-starter/package.json +10 -10
  78. package/templates/apps/api-search/package.json +8 -8
  79. package/templates/apps/api-status/package.json +8 -8
  80. package/templates/apps/api-webhooks/package.json +9 -9
  81. package/templates/apps/changelog/package.json +7 -7
  82. package/templates/apps/edge-functions/package.json +2 -2
  83. package/templates/apps/frontend-admin/package.json +7 -7
  84. package/templates/apps/frontend-app/package.json +8 -8
  85. package/templates/apps/frontend-auth/package.json +7 -7
  86. package/templates/apps/frontend-blank/package.json +6 -6
  87. package/templates/apps/frontend-cms/package.json +8 -8
  88. package/templates/apps/frontend-collab/package.json +9 -9
  89. package/templates/apps/frontend-contact/package.json +7 -7
  90. package/templates/apps/frontend-dashboard/package.json +6 -6
  91. package/templates/apps/frontend-docs/package.json +8 -8
  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 +7 -7
  95. package/templates/apps/frontend-saas/package.json +7 -7
  96. package/templates/apps/frontend-spa/package.json +6 -6
  97. package/templates/apps/frontend-ssr/package.json +6 -6
  98. package/templates/apps/frontend-ssr-api/package.json +7 -7
  99. package/templates/apps/frontend-static-blog/package.json +8 -8
  100. package/templates/apps/frontend-status/package.json +7 -7
  101. package/templates/apps/mobile-app/package.json +4 -4
  102. package/dist/apiBuild-B83Cb2Rv.js +0 -2
  103. package/dist/build-QKP6Bm0J.js +0 -872
  104. package/dist/dev-Bl9HqtV7.js +0 -3
  105. package/dist/doctorCommand-DrQv9SL3.js +0 -2
  106. package/dist/inspect-B0hL41s0.js +0 -2
  107. package/dist/manifestBuild-UXrnUcXP.js +0 -2
  108. package/dist/serveCommand-Dtb48ffg.js +0 -2
  109. package/dist/updateCommand-DsXEAHbd.js +0 -2
  110. package/dist/webDev-B6ZMX42w.js +0 -2
@@ -222,7 +222,7 @@ createVerifier({ secret: [process.env.WEBHOOK_SECRET, process.env.WEBHOOK_SECRET
222
222
  ...t === void 0 ? {} : { payload: t }
223
223
  };
224
224
  }, S = u({ scope: "voltro:webhooks" }), C = ["--out", "--name"], w = async (e) => {
225
- let { walk: t, loadDiscovered: n } = await import("./dev-Bl9HqtV7.js"), { outgoingFromEvents: r } = await import("./webhookDiscovery-il9ti-HE.js");
225
+ let { walk: t, loadDiscovered: n } = await import("./dev-Dff_pdik.js"), { outgoingFromEvents: r } = await import("./webhookDiscovery-il9ti-HE.js");
226
226
  return r((await n(await t(e), { root: 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.57.0",
3
+ "version": "0.59.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",
@@ -802,6 +802,60 @@
802
802
  "title": "`voltro serve` on a WEB app now refuses — use `voltro start`",
803
803
  "kind": "manual"
804
804
  },
805
+ {
806
+ "version": "0.58.0",
807
+ "id": "0.58.0/01_system_writes_stamp_no_actor",
808
+ "title": "A `runAsSystem` write stamps no actor id instead of \"system\"",
809
+ "kind": "manual"
810
+ },
811
+ {
812
+ "version": "0.58.0",
813
+ "id": "0.58.0/02_workflow_caller_context_carries_a_scope",
814
+ "title": "ResolvedWorkflowCallerContext carries the run's resolved row-filter scope",
815
+ "kind": "manual"
816
+ },
817
+ {
818
+ "version": "0.58.0",
819
+ "id": "0.58.0/03_fleet_view_reports_departed_replicas",
820
+ "title": "A fleet view separates DEPARTED replicas from stale ones",
821
+ "kind": "manual"
822
+ },
823
+ {
824
+ "version": "0.58.0",
825
+ "id": "0.58.0/04_rpc_errors_reach_reporters",
826
+ "title": "rpc failures are published on the client error bus",
827
+ "kind": "manual"
828
+ },
829
+ {
830
+ "version": "0.58.0",
831
+ "id": "0.58.0/05_sentry_traces_on_by_default",
832
+ "title": "Sentry performance traces are on by default",
833
+ "kind": "manual"
834
+ },
835
+ {
836
+ "version": "0.58.0",
837
+ "id": "0.58.0/06_jwt_secret_may_be_a_function",
838
+ "title": "jwtSecret may be a function, so a build needs no production credential",
839
+ "kind": "manual"
840
+ },
841
+ {
842
+ "version": "0.58.0",
843
+ "id": "0.58.0/07_rerank_takes_a_provider_config",
844
+ "title": "rerank reaches the gateway through a ProviderConfig",
845
+ "kind": "manual"
846
+ },
847
+ {
848
+ "version": "0.59.0",
849
+ "id": "0.59.0/01_sourcemaps_moved_to_the_top_level",
850
+ "title": "`web.sourcemaps` moved to the top level of app.config.ts",
851
+ "kind": "transform"
852
+ },
853
+ {
854
+ "version": "0.59.0",
855
+ "id": "0.59.0/02_costsource_gained_unpriced",
856
+ "title": "`costSource` gained `unpriced`, and AI usage is counted in a unit",
857
+ "kind": "manual"
858
+ },
805
859
  {
806
860
  "version": "0.6.0",
807
861
  "id": "0.6.0/01_no-dev-session-secret",
@@ -884,24 +938,24 @@
884
938
  "@effect/platform-node": "^0.108.0",
885
939
  "@effect/sql": "^0.52.0",
886
940
  "@effect/workflow": "^0.19.0",
887
- "@voltro/ai": "0.57.0",
888
- "@voltro/cache": "0.57.0",
889
- "@voltro/client": "0.57.0",
890
- "@voltro/content": "0.57.0",
891
- "@voltro/data-transfer": "0.57.0",
892
- "@voltro/database": "0.57.0",
893
- "@voltro/env": "0.57.0",
894
- "@voltro/kv": "0.57.0",
895
- "@voltro/logger": "0.57.0",
896
- "@voltro/plugin-auth": "0.57.0",
897
- "@voltro/plugin-broadcast": "0.57.0",
898
- "@voltro/plugin-mail": "0.57.0",
899
- "@voltro/plugin-storage": "0.57.0",
900
- "@voltro/plugin-webhooks": "0.57.0",
901
- "@voltro/protocol": "0.57.0",
902
- "@voltro/runtime": "0.57.0",
903
- "@voltro/serverless": "0.57.0",
904
- "@voltro/workflow": "0.57.0",
941
+ "@voltro/ai": "0.59.0",
942
+ "@voltro/cache": "0.59.0",
943
+ "@voltro/client": "0.59.0",
944
+ "@voltro/content": "0.59.0",
945
+ "@voltro/data-transfer": "0.59.0",
946
+ "@voltro/database": "0.59.0",
947
+ "@voltro/env": "0.59.0",
948
+ "@voltro/kv": "0.59.0",
949
+ "@voltro/logger": "0.59.0",
950
+ "@voltro/plugin-auth": "0.59.0",
951
+ "@voltro/plugin-broadcast": "0.59.0",
952
+ "@voltro/plugin-mail": "0.59.0",
953
+ "@voltro/plugin-storage": "0.59.0",
954
+ "@voltro/plugin-webhooks": "0.59.0",
955
+ "@voltro/protocol": "0.59.0",
956
+ "@voltro/runtime": "0.59.0",
957
+ "@voltro/serverless": "0.59.0",
958
+ "@voltro/workflow": "0.59.0",
905
959
  "chokidar": "^5.0.0",
906
960
  "ioredis": "^5.11.1",
907
961
  "tinyglobby": "^0.2.17",
@@ -749,7 +749,7 @@ each plugin's own README.
749
749
 
750
750
  | Topic | Open | Summary |
751
751
  |---|---|---|
752
- | **What's new in 0.57.0** | `node_modules/@voltro/cli/templates/agent-docs/whats-new.md` | Everything that changed in this version. Read it before hand-rolling something the framework may now ship. |
752
+ | **What's new in 0.59.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. |
753
753
  | AI | `node_modules/@voltro/cli/templates/agent-docs/ai.md` | How Voltro treats AI — agents, tools, streaming, RAG — all primitives over the same WebSocket as the rest of the framework. |
754
754
  | Authentication | `node_modules/@voltro/cli/templates/agent-docs/authentication.md` | How @voltro/plugin-auth wires password + session-cookie auth across api + web, plus the pluggable identity-strategy protocol. |
755
755
  | Caching | `node_modules/@voltro/cli/templates/agent-docs/caching.md` | Voltro's caching layer (@voltro/cache) — an always-on memory default, swappable Redis-compatible backends, a low-level wrap primitive, and automatic query-result invalidation. |
@@ -9,7 +9,7 @@ each plugin's own README.
9
9
 
10
10
  | Topic | Open | Summary |
11
11
  |---|---|---|
12
- | **What's new in 0.57.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.59.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. |
@@ -8,7 +8,7 @@
8
8
  "group": null,
9
9
  "description": "How Voltro treats AI — agents, tools, streaming, RAG — all primitives over the same WebSocket as the rest of the framework.",
10
10
  "path": "agent-docs/ai.md",
11
- "files": 11
11
+ "files": 12
12
12
  },
13
13
  {
14
14
  "id": "authentication",
@@ -172,6 +172,52 @@ const models = await getAvailableModels({ modality: 'language' }) // filter op
172
172
 
173
173
  Pricing is normalised to **USD per 1,000,000 tokens**, the same shape as the cost toolkit's `ModelPrice` — so a catalog entry can feed the [cost ledger](/docs/ai/cost-tracking) directly (`estimateCostUsd(usage, { model, price })`). `getAvailableModels` is `async` (a plain Promise, not an Effect) and `gatewayProvider` is injectable for tests, so a picker query can call it without a live gateway in CI.
174
174
 
175
+ ### The full catalog — `readGatewayCatalog`
176
+
177
+ `getAvailableModels` asks the AI SDK's gateway provider, whose entry type carries six fields. The HTTP response carries **twenty-one**. `readGatewayCatalog()` reads the response:
178
+
179
+ ```ts
180
+ import { readGatewayCatalog, canExecute } from '@voltro/ai'
181
+
182
+ const models = await readGatewayCatalog({ modality: 'language' })
183
+ const usable = models.filter(canExecute).filter((m) => m.capabilities?.vision)
184
+ ```
185
+
186
+ Each entry adds `capabilities`, `modalities`, `contextWindow`, `maxOutputTokens`, `releasedAt`, `knowledgeCutoff` and `dataPolicy`.
187
+
188
+ Plus **`raw`** — the entry exactly as it came over the wire.
189
+
190
+ The normalised fields are the shared answer to the questions every app on the gateway asks. `raw` is for the ones only one app asks: a chips row built from the vendor's own `tags`, a pricing display that shows a structure we have not normalised, `zdr` and `no_training` kept apart instead of folded into `dataPolicy`.
191
+
192
+ ```ts
193
+ const models = await readGatewayCatalog({ modality: 'language' })
194
+ const chips = models[0]?.raw?.tags // vendor's own vocabulary
195
+ const audioRate = (models[0]?.raw?.pricing as { audio_per_second?: string })?.audio_per_second
196
+ ```
197
+
198
+ Without it, an app with its own display fetches `/v1/models` a second time and keeps a second parser of the same response — and then has to decide again, at every field the gateway adds, whether to wait for a framework release. That is the decision `raw` removes, which is why it is one field rather than three more normalised ones.
199
+
200
+ It is absent on the injected-provider path (the seam a test uses), for the same reason `capabilities` is: that path never saw a response, and an empty object would turn "no wire data" into the claim "the wire carried nothing".
201
+
202
+ `capabilities` is **unioned from three fields that each half-say it** — `tags`, `supported_parameters` and `modalities` — because they overlap and regularly disagree: a model tagged `reasoning` does not always carry the parameter, and the other way round. Taking either alone produces a false negative, and a false "cannot" removes a model from a picker with no way for anyone to find out why. That union is framework knowledge; without it every app writes the same forty lines, differently.
203
+
204
+ `dataPolicy` carries `zeroRetention` and `noTraining` (`'all' | 'some' | 'none'`). They decide whether a model may see customer text, and an unreadable value reads as `'none'` — the question is "may this", and an unparseable answer is not a yes.
205
+
206
+ `capabilities` and `modalities` are **optional**, and that is deliberate: they are populated on every HTTP read, and absent only when an entry came from the injected `gatewayProvider` a test uses. Defaulting them to all-false would turn "not known" into the claim "this model has no vision".
207
+
208
+ ### What the runtime can actually run — `executableModalities`
209
+
210
+ The catalog names eight modalities; a build can execute the ones it has primitives for. Offering a model it cannot run produces a control that writes into nothing, so ask rather than keep a list:
211
+
212
+ ```ts
213
+ import { executableModalities, canExecute } from '@voltro/ai'
214
+
215
+ executableModalities() // Set { language, embedding, image, video, speech, reranking, transcription }
216
+ canExecute(model) // false for `realtime` today
217
+ ```
218
+
219
+ A hand-maintained list of runnable kinds goes stale the moment a primitive ships, and it goes stale silently — the app keeps hiding a modality that now works. This set is tied to the exports that justify each entry, so shipping a primitive without adding it turns a test red.
220
+
175
221
  ## Per-config key + base URL
176
222
 
177
223
  Every `ProviderConfig` accepts an optional `apiKey` and `baseURL`. When EITHER is set, the framework builds a **dedicated provider instance** from it (`createOpenAI({ apiKey })` / `createAnthropic(...)` / `createGateway(...)`) instead of the env-default singleton. Omit both → it falls back to the standard env var. The mock provider ignores both.
@@ -1623,6 +1669,19 @@ const top5 = reranked.map((r) => r.document)
1623
1669
 
1624
1670
  Re-rankers are slower than vector search but much more accurate. Use the cheap vector search to narrow to ~20 candidates, then the rerank model to pick the top 5. Total latency: ~150-300ms vs. 50ms for vector-only. The default `provider: 'mock'` scores by lexical overlap — deterministic and key-free for tests; the `cohere` / `voyage` providers resolve their SDK lazily (install the provider package to use them).
1625
1671
 
1672
+ ### Through the gateway
1673
+
1674
+ `provider` also takes a `ProviderConfig`, the shape every other primitive here takes — so one `AI_GATEWAY_API_KEY` reaches every reranking model in the catalog instead of each vendor's own key:
1675
+
1676
+ ```ts
1677
+ const reranked = yield* rerank({
1678
+ query, documents: candidates, getText: (d) => d.body, topN: 5,
1679
+ provider: { name: 'gateway', model: 'cohere/rerank-v4-pro' },
1680
+ })
1681
+ ```
1682
+
1683
+ The string shorthands (`'cohere'`, `'voyage'`) stay for the direct-key route. They are deliberately not folded into `ProviderConfig`: that union is shared with `generateText` and friends, and adding `cohere` there would let you name a provider that has no language model — a config error the type currently catches.
1684
+
1626
1685
  ## Citing sources
1627
1686
 
1628
1687
  The model needs source info in its context to cite:
@@ -2468,3 +2527,167 @@ infrastructure. It does **not** execute the literal generated TypeScript against
2468
2527
  database — the **deployed** app does that, running the real artifacts on a real
2469
2528
  store. So the preview is a true feel for how the app behaves, not a bit-for-bit
2470
2529
  run of the code.
2530
+
2531
+
2532
+
2533
+ ---
2534
+
2535
+ <!-- source: en/ai/media.md -->
2536
+ ## Media modalities
2537
+
2538
+ _Speech, transcription, images and video — one provider config, one error type, one cost ledger. Plus the seam for a speech provider the gateway does not carry._
2539
+
2540
+ The gateway catalog names eight modalities. Four of them are media, and each has a primitive with the same shape as the rest of `@voltro/ai`: a `ProviderConfig`, an `AiError` failure channel, and usage that reaches the same [cost ledger](/docs/ai/cost-tracking).
2541
+
2542
+ | Modality | Primitive |
2543
+ |---|---|
2544
+ | `image` | `generateImage` |
2545
+ | `video` | `generateVideo` |
2546
+ | `speech` | `generateSpeech` |
2547
+ | `transcription` | `transcribe` |
2548
+
2549
+ ## `transcribe` — audio to text
2550
+
2551
+ ```ts
2552
+ import { transcribe } from '@voltro/ai'
2553
+
2554
+ const { text, segments } = yield* transcribe({
2555
+ source: { storageRef: recording.ref },
2556
+ resolveRef: (ref) => ctx.storage.read(ref),
2557
+ timestamps: 'segment',
2558
+ language: 'de',
2559
+ })
2560
+ ```
2561
+
2562
+ ### The source is a reference, not bytes
2563
+
2564
+ Audio is large and usually already somewhere. Pushing a ninety-minute recording through an rpc frame to hand it back to a provider is the wrong direction, so `source` takes three shapes and they are genuinely different:
2565
+
2566
+ - **`{ storageRef }`** — the case to reach for. The app resolves it, because the app's storage is what knows about tenancy, signing and lifetime. The resolver is **injected** (`resolveRef`) rather than imported, so `@voltro/ai` never learns about buckets.
2567
+ - **`{ url }`** — fetched by `transcribe`. Convenient, but the bytes do pass through the process: the SDK takes bytes or base64 and never a location.
2568
+ - **`{ bytes, mediaType }`** — already in hand. The one that costs memory by construction.
2569
+
2570
+ ### Timestamps and speakers
2571
+
2572
+ `timestamps: 'none' | 'segment' | 'word'` is a request, not a default — providers charge for the detail. `diarize: true` asks for speaker separation; a provider that cannot do it returns segments without `speaker` rather than failing.
2573
+
2574
+ `segments` is **absent** when the provider returned none, never `[]`. An empty array is a claim ("this audio has no speech"); absent is the truth ("the provider did not say").
2575
+
2576
+ ## `generateSpeech` — text to audio
2577
+
2578
+ ```ts
2579
+ const { audio } = yield* generateSpeech({
2580
+ text: 'Willkommen.',
2581
+ provider: { name: 'gateway', model: 'openai/tts-1-hd' },
2582
+ voice: 'alloy',
2583
+ outputFormat: 'mp3',
2584
+ })
2585
+ ```
2586
+
2587
+ ### A provider the gateway does not carry
2588
+
2589
+ The gateway serves nine speech models. A real voice catalog runs to hundreds behind vendors it does not carry — Cartesia, ElevenLabs, Google Cloud TTS — and an app with its own adapter for those had nowhere to plug it in, so it called its own synthesizer and skipped the primitive entirely, losing the shared error type and cost path with it.
2590
+
2591
+ Register the adapter instead:
2592
+
2593
+ ```ts
2594
+ import { registerSpeechProvider, generateSpeech } from '@voltro/ai'
2595
+
2596
+ registerSpeechProvider('elevenlabs', {
2597
+ listVoices: () => …,
2598
+ synthesize: (request) => …,
2599
+ })
2600
+
2601
+ yield* generateSpeech({ text, provider: { name: 'elevenlabs', model: 'eleven_turbo_v3' }, voice })
2602
+ ```
2603
+
2604
+ This ships **no** adapters, on purpose: bundling an ElevenLabs client would put the framework in the business of tracking somebody else's REST API, and the app already has the adapter. What the app deletes is its dispatch, not its adapters.
2605
+
2606
+ A registered provider receives every option the primitive already had — `voice`, `outputFormat`, `speed`, `language`, `instructions`, `providerOptions` — or the seam would be a narrower primitive wearing the same name.
2607
+
2608
+ #### Taking over a built-in name
2609
+
2610
+ `mock`, `openai`, `gateway` and `anthropic` resolve without a registration, and shadowing one by accident would make the same config mean different things depending on which module loaded first. So it takes a flag:
2611
+
2612
+ ```ts
2613
+ registerSpeechProvider('openai', myOpenAiTts, { replaceBuiltin: true })
2614
+ ```
2615
+
2616
+ The flag exists because a provider name is not always an alias you choose. `openai` is the value the gateway puts in its model ids, so it is what a stored catalog's rows carry and what a picker submits — an app told to pick another name cannot, without rewriting persisted rows and live sessions. Refusing outright did not protect that app; it made it keep its own dispatch for every provider, not just the colliding one.
2617
+
2618
+ What the refusal was protecting is kept: the intent is now in the call, so an override is a decision someone wrote down rather than an accident of load order. Registered providers are resolved **before** built-ins — otherwise the flag would let you register a name and never reach it.
2619
+
2620
+ ### Cost — speech is not billed in tokens
2621
+
2622
+ `generateSpeech` never records a ledger row itself; it returns `usage` and you pass it to `recordAiUsage`, the same as `generateText`. The difference is the unit. Every speech vendor meters characters of input or seconds of audio, so a `usage` shape that only knew tokens could not hold the call at all — speech spend was not un-priced, it was **absent**.
2623
+
2624
+ A registered provider returns a `MeteredUsage`:
2625
+
2626
+ ```ts
2627
+ synthesize: (request) => Effect.succeed({
2628
+ audio,
2629
+ usage: { unit: 'characters', input: request.text.length },
2630
+ })
2631
+ ```
2632
+
2633
+ ```ts
2634
+ yield* recordAiUsage(store, {
2635
+ provider: 'elevenlabs', model: 'eleven_turbo_v3', operation: 'generateSpeech',
2636
+ usage: { unit: 'characters', input: 4200 },
2637
+ price: { inputPer1M: 30, outputPer1M: 0 }, // your rate, in your unit
2638
+ })
2639
+ ```
2640
+
2641
+ The ledger row carries `unit` beside the two counts. **A non-token unit is never priced by the token table**: without a `price` or an `actualCostUsd` the row is written `costSource: 'unpriced'` with the real count and a zero cost, because a per-1M-token rate applied to a character count produces a number indistinguishable from a real one. Sum the count columns grouped by `unit`, never across it.
2642
+
2643
+ ## As a durable step — `transcribeStep`
2644
+
2645
+ A ninety-minute recording is not a request. `aiStep` and `aiObjectStep` have had
2646
+ `offload: true` since they shipped, and transcription — the modality that runs
2647
+ longest — is where it matters most:
2648
+
2649
+ ```ts
2650
+ const { text, language } = yield* transcribeStep({
2651
+ name: 'transcribe-recording',
2652
+ source: { storageRef: recording.ref },
2653
+ resolveRef: (ref) => ctx.storage.read(ref),
2654
+ offload: true,
2655
+ })
2656
+ ```
2657
+
2658
+ Offloaded, the run parks on a durable signal and frees its worker; the dispatcher
2659
+ performs the call and resumes it. The queue row carries the **source**, never the
2660
+ bytes — putting the audio in the row would reintroduce exactly the cost
2661
+ offloading exists to remove. A ref source therefore needs `resolveTranscriptionRef`
2662
+ wired on the dispatcher too, since that is the process doing the read.
2663
+
2664
+ The journal keeps `text`, `language` and `durationSec` and **not** `segments`: a
2665
+ journal is read back on every replay, and segments for a long recording would
2666
+ make each replay pay for the transcript again. The segments belong wherever the
2667
+ app puts the transcript.
2668
+
2669
+ ## On the client — `useTranscription`
2670
+
2671
+ The entrance is a file, so the two halves are one hook:
2672
+
2673
+ ```tsx
2674
+ const t = useTranscription('app', { action: 'media.transcribe' })
2675
+
2676
+ await t.transcribe(file)
2677
+ t.phase // 'idle' | 'uploading' | 'transcribing' | 'done' | 'error'
2678
+ t.progress // 0..1 while uploading, undefined once the model is running
2679
+ t.result // { text, language, durationSec, segments? }
2680
+ ```
2681
+
2682
+ `progress` is scoped to the phase on purpose. A single number across both reaches
2683
+ `1` when the **upload** finishes, while the model has not started — a bar that
2684
+ says finished is worse than no bar, so the transcribing phase reports `undefined`
2685
+ and an indeterminate spinner is the honest control.
2686
+
2687
+ The hook opens no transport: the upload is `useUpload`'s and the transcription is
2688
+ an action **you** declare, guard and point at a model. It owns the reduction,
2689
+ which is the part every app was rewriting.
2690
+
2691
+ ## What is not here yet
2692
+
2693
+ `realtime` — the catalog's six duplex speech-to-speech models — has no primitive. The reason is not the model surface, which the gateway exposes; it is that rpc has no client→server stream. Every streaming kind in Voltro (`subscription`, `stream`, `event`) is server→client, so relaying a duplex session over the existing authenticated connection needs a transport capability that does not exist yet. `canExecute({ modality: 'realtime' })` answers `false`, which is what keeps it out of a picker until it does.
@@ -2199,6 +2199,17 @@ A subscription is a long-lived grant. Its guards — scope and relationship alik
2199
2199
  — are re-evaluated before each delivery, so revoking a relation mid-session ends
2200
2200
  the stream with the typed error instead of continuing to push rows.
2201
2201
 
2202
+ The same holds for a `*.stream.ts`: its guards are checked when the stream is
2203
+ opened — **before the executor runs** — and again before each element. The
2204
+ subscribe-time check comes first for a reason that is not symmetry: a stream's
2205
+ executor is where the work happens, so a gate placed after it refuses the result
2206
+ of something that has already been done and paid for. A denial ends the stream
2207
+ with the typed error rather than dropping elements, because a skipped element is
2208
+ indistinguishable from "nothing to send".
2209
+
2210
+ Every primitive that accepts `guards:` enforces them: queries, mutations,
2211
+ actions, events and streams.
2212
+
2202
2213
  ## Every procedure decides — `guards:` or `openAccess:`
2203
2214
 
2204
2215
  A procedure that declares neither is **refused at boot**. `guards:` used to
@@ -2573,6 +2584,29 @@ wrapStoreWithMixinBehaviour(store, { subject, schemaRegistry, rowFilter: NO_ROW_
2573
2584
  do this; a system subject bypasses row filters by design, and it is now written
2574
2585
  down rather than inferred from an absence.
2575
2586
 
2587
+ You do not write it in your own schedules or subscribers. They run as a `system`
2588
+ subject and are exempt by that fact — not by appearing on a list. The distinction
2589
+ is worth stating, because the list was tried first: the resolution was taken
2590
+ through "all four request-context arms", and the arm that is not a request fell
2591
+ out of a list of request arms without anybody forgetting it. No entry added to a
2592
+ list closes that; the subject type is what every non-request path has in common.
2593
+
2594
+ A workflow splits along exactly that line, which is why it is the useful example.
2595
+ A BOOTSTRAP run — one with no recorded caller — is the system and bypasses. A run
2596
+ started BY A USER is not, and it reads through THAT user's filter, resolved when
2597
+ the step executes rather than restored from the row that started it. A workflow
2598
+ resumed three days later must not act on what its caller could see when they
2599
+ started it, for the same reason it must not act on the permissions they held
2600
+ then. If the scope cannot be resolved, the attempt fails rather than quietly
2601
+ reading fewer rows.
2602
+
2603
+ `ctx.storeForTenant(id)` is the one derived view worth naming separately. Called
2604
+ from a schedule it runs as a `serviceAccount` subject rather than the system one
2605
+ — deliberately, so a subject whose null tenant means *every* tenant cannot widen
2606
+ back out of the tenant it was just confined to. The row-filter bypass travels
2607
+ across that narrowing; the tenant confinement does not. A per-tenant sweep
2608
+ therefore reads exactly one tenant's rows, unfiltered.
2609
+
2576
2610
  ## What does *not* bypass it
2577
2611
 
2578
2612
  | | Bypasses the row filter? |
@@ -1455,13 +1455,14 @@ web: {
1455
1455
  // `dist/assets/` to the prefix on deploy. Filenames are content-hashed, so a
1456
1456
  // previous deploy's assets stay valid for a visitor mid-navigation.
1457
1457
  assetPrefix: 'https://cdn.example.com/_assets',
1458
-
1459
- // Emit `.map` files WITHOUT a `//# sourceMappingURL` comment, so nothing in
1460
- // the shipped JS points at them. For `plugin-sentry`: upload them in your
1461
- // deploy step and DELETE them before the image is built. `voltro start`
1462
- // refuses to serve a `.map` regardless, so a forgotten delete is not a leak.
1463
- sourcemaps: 'hidden',
1464
1458
  },
1459
+
1460
+ // Top level, NOT under `web:` — it covers every bundle the build minifies
1461
+ // (browser, SSR, start, and an api app's serve bundle). Emits `.map` files with
1462
+ // no `//# sourceMappingURL` comment, so nothing shipped points at them.
1463
+ // `plugin-sentry` can upload and sweep them for you; `voltro start` refuses to
1464
+ // serve a `.map` regardless, so a forgotten delete is not a leak.
1465
+ sourcemaps: 'hidden',
1465
1466
  http: {
1466
1467
  // Node hangs up an idle keep-alive connection after 5s; every proxy in front
1467
1468
  // holds one longer, and the request that lands in that window comes back as
@@ -5458,6 +5458,36 @@ error: Schema.Union(ConstraintViolation, MyDomainError),
5458
5458
 
5459
5459
  If you do not, it still reaches the client — collapsed to `InternalError` like any undeclared error, but carrying its own sentence (`ConstraintViolation: foreign key tasks_laneId_fkey on tasks: the referenced row does not exist`) rather than the opaque `Failed to execute statement` a raw `SqlError` produces.
5460
5460
 
5461
+ ### When the FRAMEWORK refuses the write
5462
+
5463
+ A few refusals are ours rather than the database's. The MySQL-family upsert has
5464
+ one: `ON DUPLICATE KEY UPDATE` fires on whichever unique key the incoming row
5465
+ violates, which may not be the one your `conflictColumns` named, so the store
5466
+ checks afterwards and rolls back when the statement reached a row those columns
5467
+ could not have selected. The database was perfectly happy — we are the ones
5468
+ saying no.
5469
+
5470
+ These arrive as `ConstraintViolation` too, on the same failure channel and with
5471
+ the same `kind`, so nothing downstream needs a second branch. They were plain
5472
+ throws until 0.58.0, which made them **defects**: the client received a `Defect`
5473
+ wrapping a stringified `InternalError`, with no tag to match on, for a condition
5474
+ an app can genuinely branch on.
5475
+
5476
+ They carry one extra field:
5477
+
5478
+ ```ts
5479
+ { _tag: 'ConstraintViolation',
5480
+ kind: 'unique', table: 'projectHours', operation: 'upsert',
5481
+ detail: 'MysqlStore.upsert: the row written to …' }
5482
+ ```
5483
+
5484
+ `detail` is a sentence the framework authored, and it replaces the generated one
5485
+ in `message`. It is populated only for these refusals — never from a driver
5486
+ message, and never with a row value. That is the same rule the generated sentence
5487
+ follows: on several dialects the driver's own text contains row data, so it is
5488
+ not forwarded. A sentence we wrote carries exactly what we put in it.
5489
+
5490
+
5461
5491
  ## Loader errors
5462
5492
 
5463
5493
  Loaders run in the web app — they receive `{ params, query, headers, signal }`, NOT a server `ctx` with `.store`. A loader reaches the backend through `query(...)` (the `POST /rpc` path, server-side only). It short-circuits with the branded control-flow signals `NotFoundError` / `RedirectError`:
@@ -263,6 +263,54 @@ await ctx.store.upsert('orgSlugs', {
263
263
  Requires a composite UNIQUE constraint on the table — declare it via
264
264
  `.unique([cols])` in the schema (see [Indexes](/docs/database/indexes#composite-unique-constraints)).
265
265
 
266
+ ### The row you get back is the row that was there
267
+
268
+ When an upsert on a non-`id` key MATCHES, it updates the existing row — and that
269
+ row keeps its own `id`. The `id` you passed is the one that would have been used
270
+ had it inserted, so a fresh id on every call is the normal shape:
271
+
272
+ ```ts
273
+ await ctx.store.upsert('projectHours', {
274
+ teamId: 't-1', year: 2026, hours: 111,
275
+ }, {
276
+ conflictColumns: ['teamId', 'year'],
277
+ update: ['hours'],
278
+ })
279
+ ```
280
+
281
+ The returned row carries the id of whichever row now holds those values. Compare
282
+ values, not ids, if you need to know whether you inserted or updated.
283
+
284
+ On MySQL and MariaDB this costs one extra guarantee, because the dialect cannot
285
+ express the question. Postgres names its target — `ON CONFLICT (teamId, year)`
286
+ fires on that index and nothing else — while `ON DUPLICATE KEY UPDATE` fires on
287
+ whichever unique key the incoming row violates, which may not be the one you
288
+ named. The store therefore checks afterwards that the row it reached carries your
289
+ conflict values, and refuses the write if it does not: that row would have
290
+ absorbed your values while your row was never written. The refusal names the
291
+ differing column; the values go to the server log rather than into a sentence
292
+ that may reach a user.
293
+
294
+ A conflict column the database GENERATES cannot be checked this way — its value
295
+ is never in the row you sent. The remaining columns are still compared; if a
296
+ conflict key consists ENTIRELY of generated columns there is nothing to compare,
297
+ and the upsert refuses rather than guessing.
298
+
299
+ ### Generated columns in a conflict key
300
+
301
+ A partial-unique index on MySQL/MariaDB is usually a STORED generated column plus
302
+ NULL-distinct semantics — the key constrains only the rows the expression marks.
303
+ An upsert can conflict on such a key, but only on the path where the DATABASE
304
+ evaluates the expression: the single-statement form, reached on MariaDB when the
305
+ row is a complete INSERT row and `update` is a column list.
306
+
307
+ The other path looks the row up by value first — it has to, because a function
308
+ `update` needs the existing row to compute its patch — and the value of a
309
+ generated column is not in the row you passed. There is no substitute for it, so
310
+ that combination refuses and names the column rather than guessing at one.
311
+ Postgres has no such split: `ON CONFLICT` names its index and the server
312
+ evaluates the column.
313
+
266
314
  ## `insertIgnore` — keep existing on conflict
267
315
 
268
316
  For idempotent write patterns where you want to ensure a row exists
@@ -513,7 +513,7 @@ others. The answer is `fleet`-scoped and says what it is missing:
513
513
  }
514
514
  ```
515
515
 
516
- Three properties are deliberate:
516
+ Four properties are deliberate:
517
517
 
518
518
  - **A replica that has written nothing is `missing`, not absent.** Dropping it
519
519
  would make a partial answer look complete — the same unlabelled sample, one
@@ -521,9 +521,20 @@ Three properties are deliberate:
521
521
  - **A stale row is reported with its `ageMs`, not filtered out.** Removing it
522
522
  hides that the answer is partial; keeping it unmarked presents fiction as
523
523
  current.
524
+ - **A row from a replica that is GONE is `departed`, not stale.** These are
525
+ different facts with different next steps: a stale row belongs to a replica
526
+ membership still lists, so it is running and has stopped refreshing — a fault,
527
+ and a reason for `complete: false`. A departed row belongs to a process that
528
+ exited; that is what a scale-down looks like, and it is not a reason for
529
+ anything. Conflating them made `complete` unreachable after any replica had
530
+ ever exited. They are still listed, with their age, because a reader who
531
+ cannot see them cannot tell a fleet that scaled down from a table being
532
+ written by something nobody is tracking.
524
533
  - **Mixed versions are named** in `completeness.versions` when the responders
525
534
  disagree. A rolling deploy spans two shapes, and averaging them silently is
526
- wrong in a way nothing downstream can detect.
535
+ wrong in a way nothing downstream can detect. Departed replicas are excluded
536
+ from the tally — a process that has been dead for hours reporting a "version
537
+ split" is a line somebody acts on.
527
538
 
528
539
  **If there is no shared store, the request is refused with `501`** and a reason
529
540
  — never answered with this replica's own numbers. Handing back a sample to
@@ -87,6 +87,27 @@ const resolve = composeAuthStrategies([
87
87
  | `defaultTenantId` | (none) | Fallback for single-tenant deployments. |
88
88
  | `scopesFromClaims` | (none) | Map claims (Supabase `role`, or an `app_metadata` permissions array) → `Subject.scopes`. |
89
89
 
90
+ ### `jwtSecret` may be a function — and should be, if you build an image
91
+
92
+ `voltro build` imports `app.config.ts`, so anything the config CONSTRUCTS runs at
93
+ build time. `jwtSecret: process.env.SUPABASE_JWT_SECRET` therefore makes an image
94
+ build need a production credential for a bundle that will not contain one, and a
95
+ placeholder is not an option — the framework ships no secret values, and an
96
+ `ARG SUPABASE_JWT_SECRET` in a Dockerfile is exactly that.
97
+
98
+ Pass a function instead:
99
+
100
+ ```ts
101
+ supabaseStrategy({ jwtSecret: () => process.env.SUPABASE_JWT_SECRET })
102
+ ```
103
+
104
+ The build never calls it. The BOOT does, once, through the strategy pass both
105
+ `voltro dev` and `voltro serve` share — so an unset variable still refuses to
106
+ start, with the strategy's id in the message. Deferring the read does not defer
107
+ the failure to the first request: an auth strategy that cannot verify a token
108
+ accepts nobody, and a process that started anyway serves a login that can never
109
+ succeed.
110
+
90
111
  Algorithms follow the mode — JWKS pins `['RS256', 'ES256']`, `jwtSecret` pins `['HS256']`; the two are mutually exclusive, so there is no alg-confusion downgrade surface (an RS256 setup can't be tricked into accepting an HS256-forged token). `none` is never accepted.
91
112
 
92
113
  ## Environment variables