@voltro/cli 0.56.0 → 0.58.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 (128) hide show
  1. package/CHANGELOG.md +395 -0
  2. package/dist/apiBuild-CDE4NXNi.js +2 -0
  3. package/dist/{apiBuild-Bdaetr37.js → apiBuild-CkpsCqIu.js} +2 -2
  4. package/dist/bin.js +1 -1
  5. package/dist/build-51qBWfYY.js +981 -0
  6. package/dist/{checkCommand-D0QV_zM_.js → checkCommand-C1EbNmwl.js} +1 -1
  7. package/dist/{checkCommand-CAwFXrxA.js → checkCommand-C30J2F18.js} +4 -4
  8. package/dist/{codegenCommand-CidbQzbv.js → codegenCommand-kS3Twuc9.js} +12 -12
  9. package/dist/{codemodRunner-BlQPfjzA.js → codemodRunner-BKjCwGiN.js} +1127 -895
  10. package/dist/{commands-CWjfThXv.js → commands-DowWkYtd.js} +25 -25
  11. package/dist/{dashboardCommand-BekcY5Ls.js → dashboardCommand-gG1y4PFG.js} +3 -3
  12. package/dist/{dataCommand-2pccgbIy.js → dataCommand-hNOGKz4K.js} +7 -7
  13. package/dist/{dbCommand-DpK_vQET.js → dbCommand-CMAIz-Bf.js} +3 -3
  14. package/dist/dbCommand-DHi_RuDl.js +2 -0
  15. package/dist/{dev-Dw263KPu.js → dev-Ce2gEziv.js} +2411 -2391
  16. package/dist/dev-Dff_pdik.js +3 -0
  17. package/dist/{doctorCommand-aR_bFmIi.js → doctorCommand-5ULMpL10.js} +652 -289
  18. package/dist/doctorCommand-B8H2MYCE.js +2 -0
  19. package/dist/{dormancyCommand-eXTQMbHU.js → dormancyCommand-DYgI0Kf-.js} +3 -3
  20. package/dist/{embeddingsCommand-CTmiQvwa.js → embeddingsCommand-yJivQSrI.js} +2 -2
  21. package/dist/emptyResultHeadline-Csa5fZOF.js +18 -0
  22. package/dist/{envCommand-BDUgV7EM.js → envCommand-ORJUf8r_.js} +2 -2
  23. package/dist/{evolveCommand-YV8qW1LU.js → evolveCommand-DTawtTxf.js} +2 -2
  24. package/dist/frameworkTableAssembly-B96WCNJA.js +2 -0
  25. package/dist/{frameworkTableAssembly-DNOFXfEQ.js → frameworkTableAssembly-vfkzuzEo.js} +104 -85
  26. package/dist/index.js +1 -1
  27. package/dist/{infoCommand-BjVXpMlP.js → infoCommand-BFksHunl.js} +1 -1
  28. package/dist/inspect-CZvsmp8t.js +2 -0
  29. package/dist/{inspect-CNYvNXPU.js → inspect-PcC5J-Dl.js} +74 -59
  30. package/dist/{logsCmd-BU8uCdys.js → logsCmd-C5y9HyrG.js} +58 -53
  31. package/dist/manifestBuild-AUh31bFU.js +2 -0
  32. package/dist/{manifestBuild-CEkjfpwc.js → manifestBuild-BwiS4iYm.js} +1 -1
  33. package/dist/{migrate-SICulyz1.js → migrate-DtC3lu7H.js} +4 -4
  34. package/dist/{probeCommand-6HxEkNDG.js → probeCommand-BUYzc0CT.js} +1 -1
  35. package/dist/{runtimeTrace-DgYMc09E.js → runtimeTrace-BUk5zMXY.js} +1 -1
  36. package/dist/{sdkgen-PY-umd6O.js → sdkgen-CAhmmbjx.js} +1 -1
  37. package/dist/serveCommand-BUXUtSF5.js +2 -0
  38. package/dist/serveCommand-DhtC7WBq.js +2545 -0
  39. package/dist/serveEntry.js +1 -1
  40. package/dist/{start-BFQQkL1i.js → start-C75w6ftA.js} +1 -1
  41. package/dist/{start-B9NGB8gn.js → start-DNtIlBcn.js} +527 -502
  42. package/dist/startEntry.js +1 -1
  43. package/dist/{tracesCmd-BWYDqMy6.js → tracesCmd-C1qKa94g.js} +68 -63
  44. package/dist/{updateCommand-C9n_Z_oG.js → updateCommand-CtuD5r1t.js} +1 -1
  45. package/dist/updateCommand-eVKVHj3N.js +2 -0
  46. package/dist/webDev-Bzb8ZFus.js +2 -0
  47. package/dist/{webDev-C53hJdcL.js → webDev-CBN9KkyQ.js} +949 -834
  48. package/dist/{webhooksCommand-uuPu8qQX.js → webhooksCommand-BPJQOGTe.js} +2 -2
  49. package/package.json +61 -19
  50. package/templates/AGENTS.md +1 -1
  51. package/templates/agent-docs/_index.md +1 -1
  52. package/templates/agent-docs/_manifest.json +2 -2
  53. package/templates/agent-docs/ai.md +175 -1
  54. package/templates/agent-docs/authentication.md +68 -19
  55. package/templates/agent-docs/cli.md +48 -0
  56. package/templates/agent-docs/configuration.md +29 -4
  57. package/templates/agent-docs/data.md +30 -0
  58. package/templates/agent-docs/database/scaling.md +10 -1
  59. package/templates/agent-docs/database/transactions.md +48 -0
  60. package/templates/agent-docs/deployment.md +22 -0
  61. package/templates/agent-docs/observability.md +13 -2
  62. package/templates/agent-docs/plugins/audit.md +21 -5
  63. package/templates/agent-docs/plugins/auth-supabase.md +21 -0
  64. package/templates/agent-docs/plugins/billing.md +2 -0
  65. package/templates/agent-docs/plugins/sentry.md +91 -8
  66. package/templates/agent-docs/reference.md +1 -0
  67. package/templates/agent-docs/scheduling.md +23 -0
  68. package/templates/agent-docs/schema-driven-ui.md +110 -0
  69. package/templates/agent-docs/whats-new.md +103 -399
  70. package/templates/apps/api-ai/package.json +6 -6
  71. package/templates/apps/api-auth/package.json +8 -8
  72. package/templates/apps/api-backend/package.json +7 -7
  73. package/templates/apps/api-backend-deactivation/package.json +7 -7
  74. package/templates/apps/api-backend-mail/package.json +8 -8
  75. package/templates/apps/api-backend-mariadb/package.json +9 -9
  76. package/templates/apps/api-backend-sqlite/package.json +8 -8
  77. package/templates/apps/api-backend-storage/package.json +8 -8
  78. package/templates/apps/api-cms/package.json +9 -9
  79. package/templates/apps/api-collab/package.json +8 -8
  80. package/templates/apps/api-data-advanced/package.json +8 -8
  81. package/templates/apps/api-durable/package.json +8 -8
  82. package/templates/apps/api-feature-flags/package.json +9 -9
  83. package/templates/apps/api-governance/package.json +8 -8
  84. package/templates/apps/api-kv/package.json +8 -8
  85. package/templates/apps/api-moderation/package.json +8 -8
  86. package/templates/apps/api-observability/package.json +8 -8
  87. package/templates/apps/api-ratelimit/package.json +8 -8
  88. package/templates/apps/api-rbac/package.json +8 -8
  89. package/templates/apps/api-rest/package.json +7 -7
  90. package/templates/apps/api-row-history/package.json +8 -8
  91. package/templates/apps/api-saas/package.json +11 -11
  92. package/templates/apps/api-saas-starter/package.json +10 -10
  93. package/templates/apps/api-search/package.json +8 -8
  94. package/templates/apps/api-status/package.json +8 -8
  95. package/templates/apps/api-webhooks/package.json +9 -9
  96. package/templates/apps/changelog/package.json +7 -7
  97. package/templates/apps/edge-functions/package.json +2 -2
  98. package/templates/apps/frontend-admin/package.json +7 -7
  99. package/templates/apps/frontend-app/package.json +8 -8
  100. package/templates/apps/frontend-auth/package.json +7 -7
  101. package/templates/apps/frontend-blank/package.json +6 -6
  102. package/templates/apps/frontend-cms/package.json +8 -8
  103. package/templates/apps/frontend-collab/package.json +9 -9
  104. package/templates/apps/frontend-contact/package.json +7 -7
  105. package/templates/apps/frontend-dashboard/package.json +6 -6
  106. package/templates/apps/frontend-docs/package.json +8 -8
  107. package/templates/apps/frontend-i18n/package.json +6 -6
  108. package/templates/apps/frontend-landing/package.json +7 -7
  109. package/templates/apps/frontend-portal/package.json +7 -7
  110. package/templates/apps/frontend-saas/package.json +7 -7
  111. package/templates/apps/frontend-spa/package.json +6 -6
  112. package/templates/apps/frontend-ssr/package.json +6 -6
  113. package/templates/apps/frontend-ssr-api/package.json +7 -7
  114. package/templates/apps/frontend-static-blog/package.json +8 -8
  115. package/templates/apps/frontend-status/package.json +7 -7
  116. package/templates/apps/mobile-app/package.json +4 -4
  117. package/dist/apiBuild-Vw1figjO.js +0 -2
  118. package/dist/build-CI36wL4R.js +0 -868
  119. package/dist/dbCommand-DrycGWWt.js +0 -2
  120. package/dist/dev-B9Gz0k85.js +0 -3
  121. package/dist/doctorCommand-BMWs6aVm.js +0 -2
  122. package/dist/frameworkTableAssembly-CGNC0qr7.js +0 -2
  123. package/dist/inspect-S2rWy1Ys.js +0 -2
  124. package/dist/manifestBuild-DIa_s6u0.js +0 -2
  125. package/dist/serveCommand-BITS8Hpj.js +0 -2
  126. package/dist/serveCommand-DIJ3ma76.js +0 -2485
  127. package/dist/updateCommand-DsXEAHbd.js +0 -2
  128. package/dist/webDev-C2dRz9s5.js +0 -2
@@ -222,8 +222,8 @@ 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-B9Gz0k85.js"), { outgoingFromEvents: r } = await import("./webhookDiscovery-il9ti-HE.js");
226
- return r((await n(await t(e))).events.map((e) => ({
225
+ let { walk: t, loadDiscovered: n } = await import("./dev-Dff_pdik.js"), { outgoingFromEvents: r } = await import("./webhookDiscovery-il9ti-HE.js");
226
+ return r((await n(await t(e), { root: e })).events.map((e) => ({
227
227
  file: e.file,
228
228
  descriptor: e.descriptor
229
229
  }))).map((e) => x(e.descriptor));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@voltro/cli",
3
- "version": "0.56.0",
3
+ "version": "0.58.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,48 @@
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
+ },
805
847
  {
806
848
  "version": "0.6.0",
807
849
  "id": "0.6.0/01_no-dev-session-secret",
@@ -884,24 +926,24 @@
884
926
  "@effect/platform-node": "^0.108.0",
885
927
  "@effect/sql": "^0.52.0",
886
928
  "@effect/workflow": "^0.19.0",
887
- "@voltro/ai": "0.56.0",
888
- "@voltro/cache": "0.56.0",
889
- "@voltro/client": "0.56.0",
890
- "@voltro/content": "0.56.0",
891
- "@voltro/data-transfer": "0.56.0",
892
- "@voltro/database": "0.56.0",
893
- "@voltro/env": "0.56.0",
894
- "@voltro/kv": "0.56.0",
895
- "@voltro/logger": "0.56.0",
896
- "@voltro/plugin-auth": "0.56.0",
897
- "@voltro/plugin-broadcast": "0.56.0",
898
- "@voltro/plugin-mail": "0.56.0",
899
- "@voltro/plugin-storage": "0.56.0",
900
- "@voltro/plugin-webhooks": "0.56.0",
901
- "@voltro/protocol": "0.56.0",
902
- "@voltro/runtime": "0.56.0",
903
- "@voltro/serverless": "0.56.0",
904
- "@voltro/workflow": "0.56.0",
929
+ "@voltro/ai": "0.58.0",
930
+ "@voltro/cache": "0.58.0",
931
+ "@voltro/client": "0.58.0",
932
+ "@voltro/content": "0.58.0",
933
+ "@voltro/data-transfer": "0.58.0",
934
+ "@voltro/database": "0.58.0",
935
+ "@voltro/env": "0.58.0",
936
+ "@voltro/kv": "0.58.0",
937
+ "@voltro/logger": "0.58.0",
938
+ "@voltro/plugin-auth": "0.58.0",
939
+ "@voltro/plugin-broadcast": "0.58.0",
940
+ "@voltro/plugin-mail": "0.58.0",
941
+ "@voltro/plugin-storage": "0.58.0",
942
+ "@voltro/plugin-webhooks": "0.58.0",
943
+ "@voltro/protocol": "0.58.0",
944
+ "@voltro/runtime": "0.58.0",
945
+ "@voltro/serverless": "0.58.0",
946
+ "@voltro/workflow": "0.58.0",
905
947
  "chokidar": "^5.0.0",
906
948
  "ioredis": "^5.11.1",
907
949
  "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.56.0** | `node_modules/@voltro/cli/templates/agent-docs/whats-new.md` | Everything that changed in this version. Read it before hand-rolling something the framework may now ship. |
752
+ | **What's new in 0.58.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.56.0** | `node_modules/@voltro/cli/templates/agent-docs/whats-new.md` | Everything that changed in this version. Read it before hand-rolling something the framework may now ship. |
12
+ | **What's new in 0.58.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",
@@ -260,7 +260,7 @@
260
260
  "group": null,
261
261
  "description": "Project the typed descriptor graph into UI — forms, tables, pickers, and reactive components, all bound to a descriptor with near-zero glue.",
262
262
  "path": "agent-docs/schema-driven-ui.md",
263
- "files": 23
263
+ "files": 24
264
264
  },
265
265
  {
266
266
  "id": "security",
@@ -172,6 +172,38 @@ 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
+ `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.
189
+
190
+ `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.
191
+
192
+ `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".
193
+
194
+ ### What the runtime can actually run — `executableModalities`
195
+
196
+ 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:
197
+
198
+ ```ts
199
+ import { executableModalities, canExecute } from '@voltro/ai'
200
+
201
+ executableModalities() // Set { language, embedding, image, video, speech, reranking, transcription }
202
+ canExecute(model) // false for `realtime` today
203
+ ```
204
+
205
+ 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.
206
+
175
207
  ## Per-config key + base URL
176
208
 
177
209
  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.
@@ -1326,7 +1358,7 @@ defaults to treating an `AgentEvent`-shaped `{ _tag: 'done' | 'error' }` as the
1326
1358
  end of the run. Override `isTerminal` when your element type signals completion
1327
1359
  some other way, or the hook will keep trying to resume a finished stream.
1328
1360
 
1329
- For multi-node deployments use `dataStoreResumableStreamStore(ctx.store)` — it persists to the framework's own database (`streamEventsTable` + `streamStateTable`, register them in your `database/index.ts`) and elects exactly ONE producer per `streamId` via an atomic claim, so only one node runs the model while every node's consumers tail the shared log. Sweep finished streams with `gcResumableStreams(store, { olderThan })`.
1361
+ For multi-node deployments use `dataStoreResumableStreamStore(ctx.store)` — it persists to the framework's own database (`_voltro_stream_events` + `_voltro_stream_state`, created for you: the table assembly adds both for any app that depends on `@voltro/ai`, so `voltro dev` / `voltro db apply` / `voltro migrate` all plan them) and elects exactly ONE producer per `streamId` via an atomic claim, so only one node runs the model while every node's consumers tail the shared log. Sweep finished streams with `gcResumableStreams(store, { olderThan })`.
1330
1362
 
1331
1363
  For the fastest path, `redisResumableStreamStore(redis, { ttlSeconds })` backs the log with a Redis LIST (`RPUSH`/`LRANGE`) plus a `SET … NX` producer claim — TTL evicts finished/abandoned streams without a sweep. `@voltro/ai` takes no Redis dependency; you inject a tiny `ResumableRedis` client (five methods: `setNx` / `rpush` / `lrange` / `set` / `exists`) adapting ioredis / node-redis. All three backends satisfy the same `ResumableStreamStore` interface, so they swap without touching the producer/consumer code.
1332
1364
 
@@ -1623,6 +1655,19 @@ const top5 = reranked.map((r) => r.document)
1623
1655
 
1624
1656
  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
1657
 
1658
+ ### Through the gateway
1659
+
1660
+ `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:
1661
+
1662
+ ```ts
1663
+ const reranked = yield* rerank({
1664
+ query, documents: candidates, getText: (d) => d.body, topN: 5,
1665
+ provider: { name: 'gateway', model: 'cohere/rerank-v4-pro' },
1666
+ })
1667
+ ```
1668
+
1669
+ 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.
1670
+
1626
1671
  ## Citing sources
1627
1672
 
1628
1673
  The model needs source info in its context to cite:
@@ -2468,3 +2513,132 @@ infrastructure. It does **not** execute the literal generated TypeScript against
2468
2513
  database — the **deployed** app does that, running the real artifacts on a real
2469
2514
  store. So the preview is a true feel for how the app behaves, not a bit-for-bit
2470
2515
  run of the code.
2516
+
2517
+
2518
+
2519
+ ---
2520
+
2521
+ <!-- source: en/ai/media.md -->
2522
+ ## Media modalities
2523
+
2524
+ _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._
2525
+
2526
+ 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).
2527
+
2528
+ | Modality | Primitive |
2529
+ |---|---|
2530
+ | `image` | `generateImage` |
2531
+ | `video` | `generateVideo` |
2532
+ | `speech` | `generateSpeech` |
2533
+ | `transcription` | `transcribe` |
2534
+
2535
+ ## `transcribe` — audio to text
2536
+
2537
+ ```ts
2538
+ import { transcribe } from '@voltro/ai'
2539
+
2540
+ const { text, segments } = yield* transcribe({
2541
+ source: { storageRef: recording.ref },
2542
+ resolveRef: (ref) => ctx.storage.read(ref),
2543
+ timestamps: 'segment',
2544
+ language: 'de',
2545
+ })
2546
+ ```
2547
+
2548
+ ### The source is a reference, not bytes
2549
+
2550
+ 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:
2551
+
2552
+ - **`{ 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.
2553
+ - **`{ url }`** — fetched by `transcribe`. Convenient, but the bytes do pass through the process: the SDK takes bytes or base64 and never a location.
2554
+ - **`{ bytes, mediaType }`** — already in hand. The one that costs memory by construction.
2555
+
2556
+ ### Timestamps and speakers
2557
+
2558
+ `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.
2559
+
2560
+ `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").
2561
+
2562
+ ## `generateSpeech` — text to audio
2563
+
2564
+ ```ts
2565
+ const { audio } = yield* generateSpeech({
2566
+ text: 'Willkommen.',
2567
+ provider: { name: 'gateway', model: 'openai/tts-1-hd' },
2568
+ voice: 'alloy',
2569
+ outputFormat: 'mp3',
2570
+ })
2571
+ ```
2572
+
2573
+ ### A provider the gateway does not carry
2574
+
2575
+ 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.
2576
+
2577
+ Register the adapter instead:
2578
+
2579
+ ```ts
2580
+ import { registerSpeechProvider, generateSpeech } from '@voltro/ai'
2581
+
2582
+ registerSpeechProvider('elevenlabs', {
2583
+ listVoices: () => …,
2584
+ synthesize: (request) => …,
2585
+ })
2586
+
2587
+ yield* generateSpeech({ text, provider: { name: 'elevenlabs', model: 'eleven_turbo_v3' }, voice })
2588
+ ```
2589
+
2590
+ 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.
2591
+
2592
+ 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. Built-in names (`mock`, `openai`, `gateway`, `anthropic`) cannot be shadowed: silently overriding one would make the same config mean different things depending on which module loaded first.
2593
+
2594
+ ## As a durable step — `transcribeStep`
2595
+
2596
+ A ninety-minute recording is not a request. `aiStep` and `aiObjectStep` have had
2597
+ `offload: true` since they shipped, and transcription — the modality that runs
2598
+ longest — is where it matters most:
2599
+
2600
+ ```ts
2601
+ const { text, language } = yield* transcribeStep({
2602
+ name: 'transcribe-recording',
2603
+ source: { storageRef: recording.ref },
2604
+ resolveRef: (ref) => ctx.storage.read(ref),
2605
+ offload: true,
2606
+ })
2607
+ ```
2608
+
2609
+ Offloaded, the run parks on a durable signal and frees its worker; the dispatcher
2610
+ performs the call and resumes it. The queue row carries the **source**, never the
2611
+ bytes — putting the audio in the row would reintroduce exactly the cost
2612
+ offloading exists to remove. A ref source therefore needs `resolveTranscriptionRef`
2613
+ wired on the dispatcher too, since that is the process doing the read.
2614
+
2615
+ The journal keeps `text`, `language` and `durationSec` and **not** `segments`: a
2616
+ journal is read back on every replay, and segments for a long recording would
2617
+ make each replay pay for the transcript again. The segments belong wherever the
2618
+ app puts the transcript.
2619
+
2620
+ ## On the client — `useTranscription`
2621
+
2622
+ The entrance is a file, so the two halves are one hook:
2623
+
2624
+ ```tsx
2625
+ const t = useTranscription('app', { action: 'media.transcribe' })
2626
+
2627
+ await t.transcribe(file)
2628
+ t.phase // 'idle' | 'uploading' | 'transcribing' | 'done' | 'error'
2629
+ t.progress // 0..1 while uploading, undefined once the model is running
2630
+ t.result // { text, language, durationSec, segments? }
2631
+ ```
2632
+
2633
+ `progress` is scoped to the phase on purpose. A single number across both reaches
2634
+ `1` when the **upload** finishes, while the model has not started — a bar that
2635
+ says finished is worse than no bar, so the transcribing phase reports `undefined`
2636
+ and an indeterminate spinner is the honest control.
2637
+
2638
+ The hook opens no transport: the upload is `useUpload`'s and the transcription is
2639
+ an action **you** declare, guard and point at a model. It owns the reduction,
2640
+ which is the part every app was rewriting.
2641
+
2642
+ ## What is not here yet
2643
+
2644
+ `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? |
@@ -2691,31 +2725,46 @@ comment, and this one is load-bearing: the resume grant is issued on its
2691
2725
  strength. Omit `tables:` entirely and nothing is verified and nothing resumes —
2692
2726
  the conservative default.
2693
2727
 
2694
- ## Eager loads are refused, not silently unfiltered
2728
+ ## Eager loads are narrowed too
2695
2729
 
2696
2730
  A relation pulled in with `.with(...)` is resolved **below** the seam that
2697
- applies the filter: the stores expand the eager tree themselves (the memory
2698
- store recurses through its own raw read; the SQL stores fold the relation into
2699
- one join). So a filtered table reached through a relation would come back
2700
- unfiltered.
2731
+ AND-merges the filter onto a read's base table: the stores expand the eager tree
2732
+ themselves (the memory store recurses through its own raw read; the SQL stores
2733
+ fold the relation into one join or JSON aggregate). So for a while a filtered
2734
+ table reached through a relation came back unfiltered, and then — once that was
2735
+ found — the read refused rather than serve those rows.
2701
2736
 
2702
- Rather than serve those rows, the read **refuses**, naming the table and both
2703
- ways out:
2737
+ Neither is the case now. The middleware writes your filter's predicate into each
2738
+ narrowed branch's `where`, which is where a caller could have put it themselves,
2739
+ so every resolver on every dialect applies it without knowing a row filter
2740
+ exists:
2704
2741
 
2742
+ ```ts
2743
+ // what you wrote
2744
+ notes.with({ readers: true })
2745
+
2746
+ // what the store resolves, when your filter narrows `readers`
2747
+ notes.with({ readers: { where: /* your filter's predicate for `readers` */ } })
2705
2748
  ```
2706
- row filter: 'readers' is reached through an eager load on 'notes', and an eager
2707
- relation is resolved BELOW the row filter — its rows would come back
2708
- unfiltered. Refusing the read rather than serving it.
2709
- → read 'readers' as its own query (it is filtered there), or
2710
- → drop 'readers' from this .with(...) if the relation does not need
2711
- row-level narrowing.
2712
- ```
2713
2749
 
2714
- Only relations reaching a table your filter actually narrows are affected
2715
- every other eager load is untouched. Applying the filter inside eager
2716
- compilation is the real fix and it is a per-dialect change; until then this is
2717
- a refusal rather than a leak, for the same reason the module refuses to
2718
- fail open.
2750
+ Your own `where` is kept and the filter goes **under** it, so a branch you
2751
+ narrowed stays narrower and nothing a caller writes can widen the filter.
2752
+ Nested `.with(...)` is narrowed at every level. A relation reaching a table your
2753
+ filter does not narrow is untouched.
2754
+
2755
+ **One case still refuses: a filtered `manyToMany` JUNCTION.** A branch `where`
2756
+ is a predicate on the relation's TARGET, and a filter narrowing the junction has
2757
+ nowhere to be expressed — so that read raises rather than joining junction rows
2758
+ the filter would have removed:
2759
+
2760
+ ```
2761
+ row filter: the junction table 'noteTags' behind relation 'tags' on 'notes' is
2762
+ row-filtered, and a many-to-many junction is read below the seam that could
2763
+ narrow it — its rows would be joined unfiltered. Refusing the read rather than
2764
+ serving it.
2765
+ → read 'noteTags' as its own query (it is filtered there), or
2766
+ → drop 'tags' from this .with(...).
2767
+ ```
2719
2768
 
2720
2769
  ## Row filters vs. guards
2721
2770
 
@@ -1856,6 +1856,8 @@ It covers both halves of the stack:
1856
1856
  | server | a `token` / `secret` / `password` column with no encryption | `.encrypted()` |
1857
1857
  | server | a notify / webhook helper called at a mutation's tail | `defineSubscriber` / `defineReaction` |
1858
1858
  | server | a `*.subscribe.ts` handler that writes or publishes, with no `once:` | `once: true` on `defineSubscriber` |
1859
+ | server | an executor builds a field its descriptor's `output` does not declare | add it to `output` — the struct IS the serializer |
1860
+ | server | a mutation writes a NULLABLE column through an input field that cannot be `null` | `Schema.optional(Schema.NullOr(...))` |
1859
1861
  | server | `hasMore` + `limit + 1` | `paginateById` |
1860
1862
  | server | `.getTime()` / `.toISOString()` mapping a row on the way out | `timestampMs` / `timestampMsOrNull` from `@voltro/database/wire` in the descriptor's `output` struct |
1861
1863
  | client | per-field `useState` + a submit flag | `useFormBinding` |
@@ -1900,6 +1902,25 @@ decision was made, not which way. It reads the handler through the AST, so
1900
1902
  like an inline arrow; a handler IMPORTED from another module is not judged at
1901
1903
  all, since its body is not in the file being read.
1902
1904
 
1905
+ **Two rules read a DECLARATION against a DECLARATION, and are therefore exact.**
1906
+ `executor-builds-an-undeclared-field` compares the object literal an executor
1907
+ returns with its descriptor's `output` struct: since the struct IS the
1908
+ serializer, a key it does not declare is stripped on the way out, so every
1909
+ reader downstream gets `undefined` and renders a blank with nothing logged
1910
+ anywhere. `nullable-column-a-mutation-cannot-clear` compares a mutation's target
1911
+ table's nullable columns with its input schema — a nullable column written
1912
+ through a field that cannot be `null` can be set once and never emptied.
1913
+
1914
+ Both REFUSE rather than guess, and the refusals are the interesting part. A
1915
+ spread on either side of the output comparison (`{ ...row, extra }`, or a
1916
+ `...Base` inside the declared struct) means the key set is not knowable from the
1917
+ source; reporting the visible half would name the field the author can already
1918
+ see and miss the ones they cannot. An output that is a named schema rather than
1919
+ a literal struct is unjudgeable, not empty — the second reading would make every
1920
+ field a finding. And a table declaration the scan cannot find produces no
1921
+ finding at all, because "this table has no nullable columns" and "I could not
1922
+ look" lead to opposite conclusions.
1923
+
1903
1924
  It stays quiet on a reader on purpose. `once:` on a cache-warming subscriber
1904
1925
  would silence it on every replica but one, which is worse than the repetition it
1905
1926
  removes — only the handler's author knows which of the two they wrote.
@@ -2368,6 +2389,33 @@ voltro check --url https://api.example.com
2368
2389
 
2369
2390
  `--token` (or `VOLTRO_INSPECT_TOKEN`) supplies the bearer; `VOLTRO_INSPECT_URL` sets a default target so you can drop the flag. Works for `inspect`, `logs`, `traces`, `workflows`, `cluster` and `check`.
2370
2391
 
2392
+ ### `logs` and `traces` need a ring on the target — off by default
2393
+
2394
+ A deployed app runs `voltro serve`, and `voltro serve` keeps **no** in-process log ring or span buffer unless you ask for one. Without it both commands answer:
2395
+
2396
+ ```
2397
+ voltro logs: NOTHING WAS SEARCHED — the target could not be read: HTTP 404
2398
+ endpoint not enabled on this instance — This instance keeps no in-process logs ring…
2399
+ ```
2400
+
2401
+ That headline is the point: *"no records matched the given filters"* would have sent you off widening `--tail` forever. Turn a ring on per app:
2402
+
2403
+ ```ts
2404
+ // app.config.ts
2405
+ export default defineAppConfig({
2406
+ inspect: {
2407
+ logs: true, // default size
2408
+ traces: { size: 2_000 }, // or name it
2409
+ },
2410
+ })
2411
+ ```
2412
+
2413
+ or per deployment, without a rebuild: `VOLTRO_INSPECT_LOGS=2000`, `VOLTRO_INSPECT_TRACES=on`, `=off` to override a config that declared one.
2414
+
2415
+ **Why off by default.** A ring is memory on every replica, forever, for data most deployments already collect from stdout through their platform's pipeline. That pipeline stays the main road; the ring answers the different question — *what has THIS instance been doing in the last few minutes* — and it answers it for a hosted app, where reading a pod's stdout is not an option.
2416
+
2417
+ **What a ring does not do.** It holds a bounded number of records on ONE replica. Against a fleet you are reading 1/N, and it is not retention: a restart empties it. `voltro dev` keeps both rings unconditionally — this switch is about what production does.
2418
+
2371
2419
  ## `voltro probe access` — is a declared guard actually enforced?
2372
2420
 
2373
2421
  `voltro check` reads an app's manifest and reports a procedure with **no access
@@ -202,10 +202,35 @@ is accepted but only reachable from server-side render code, and the boot
202
202
  ## Plugins declare their env
203
203
 
204
204
  A plugin that reads environment variables declares them via `declaredEnv` on its
205
- `VoltroPlugin` (metadata only — the plugin still reads its own values). This
206
- makes a plugin's env needs visible in the manifest, `.env.example`, and the
207
- dashboard. The first-party plugins already declare theirs, so the manifest is
208
- complete out of the box.
205
+ `VoltroPlugin`. The plugin still reads its own values; declaring them makes its
206
+ env needs visible in the manifest, `.env.example`, and the dashboard. The
207
+ first-party plugins already declare theirs, so the manifest is complete out of
208
+ the box.
209
+
210
+ **`required: true` is a boot CONTRACT, not documentation.** Every required entry
211
+ is resolved before any plugin activates, and one that does not resolve **aborts
212
+ the boot** with the plugin named:
213
+
214
+ ```
215
+ plugin environment validation failed (1 variable):
216
+ • STRIPE_SECRET_KEY — required by @voltro/plugin-billing, and nothing set it
217
+ Stripe API secret key.
218
+ → set the variable, or pass the value as a plugin option if the plugin takes one, or
219
+ declare it yourself in app.config.ts's `env` (an app declaration overrides a plugin's).
220
+ ```
221
+
222
+ That is the point of failing at the boot: a deployment variable which silently
223
+ stops being set — a rotated secret, a typo in a values file, a CI variable nobody
224
+ created — is routine, and the alternative is finding out from the first customer.
225
+
226
+ A `secret: true` variable resolves through the configured **secrets backend**,
227
+ not just `process.env`. A backend that cannot answer fails the boot too, reported
228
+ as *unreadable* rather than as an absent variable — those are different facts and
229
+ need different next steps.
230
+
231
+ Your own `defineEnv` declaration of the same variable **overrides the plugin's**:
232
+ declare it there to give it a default, or to mark it optional because you pass
233
+ the value as a plugin option instead.
209
234
 
210
235
  ## Anti-pattern
211
236
 
@@ -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`:
@@ -368,10 +368,19 @@ A changelog table that every replica polls (`SELECT … WHERE seq > :last`) woul
368
368
  ```
369
369
 
370
370
  ```
371
- [voltro:dev] reactivity: cross-instance fan-out is OFF for dialect 'mssql'. A write on one replica
371
+ [voltro:dev] reactivity: cross-instance fan-out is OFF for dialect 'cockroach'. A write on one replica
372
372
  will NOT reach clients on other replicas. Add @voltro/plugin-broadcast (Redis / NATS) to close the gap…
373
373
  ```
374
374
 
375
+ **The line reports what is RUNNING, not what the dialect could run.** It is derived from the store's resolved change scope, so a dialect that *can* carry changes natively but has capture switched off — `CDC=0`, or an app where every table is `.nonReactive()` — is reported as the gap it is, naming the cause rather than the dialect:
376
+
377
+ ```
378
+ [voltro:dev] reactivity: cross-instance fan-out is OFF. 'mariadb' can carry changes natively
379
+ (binlog CDC) but change capture is not running — CDC=0, or every table is .nonReactive()…
380
+ ```
381
+
382
+ Read that line, not the dialect, when you need to know whether the N² amplification below applies to you: without a native transport there is no second delivery to suppress.
383
+
375
384
  When BOTH a native path and the broadcast plugin are wired (e.g. postgres + broadcast), both stay active — and they carry **different things**. The native path carries table changes to every replica. The bus carries reactivity *channels* (`publishReactivity`), which are not database writes and so have no native transport at all.
376
385
 
377
386
  ```