@voltro/cli 0.20.2 → 0.22.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 (75) hide show
  1. package/CHANGELOG.md +398 -0
  2. package/THIRD-PARTY-NOTICES.md +61 -61
  3. package/dist/{apiBuild-qZBEu59d.js → apiBuild-2zRxj2hJ.js} +93 -80
  4. package/dist/apiBuild-sqNmWmY5.js +2 -0
  5. package/dist/bin.js +41 -20
  6. package/dist/{commands-kYbfVoPO.js → commands-DGaeTn2f.js} +3055 -2640
  7. package/dist/dbCommand-CO3eSAZR.js +2 -0
  8. package/dist/{dbCommand-BPp9R0Wc.js → dbCommand-DVASmZj2.js} +305 -249
  9. package/dist/dev-BEYBSGJ7.js +3 -0
  10. package/dist/{dev-OHLsAq4K.js → dev-DeHVUxQx.js} +1732 -1686
  11. package/dist/index.js +1 -1
  12. package/dist/inspect-BA67TF6v.js +2 -0
  13. package/dist/inspect-_ldwsAwH.js +945 -0
  14. package/dist/{inspectMetrics-DvPNXmGA.js → inspectMetrics-4AVPn6IS.js} +584 -571
  15. package/dist/{manifestBuild-P9yuCY2d.js → manifestBuild-Bs1Uw22_.js} +1 -1
  16. package/dist/manifestBuild-i-fRHg_H.js +2 -0
  17. package/dist/serveCommand-DEVTtnLy.js +1161 -0
  18. package/dist/serveEntry.js +2 -2
  19. package/dist/{start-CXQ7WL1W.js → start-BjGUIIvj.js} +389 -389
  20. package/dist/startEntry.js +2 -2
  21. package/package.json +27 -27
  22. package/templates/AGENTS.md +1 -1
  23. package/templates/agent-docs/_index.md +1 -1
  24. package/templates/agent-docs/cli.md +181 -5
  25. package/templates/agent-docs/data.md +58 -0
  26. package/templates/agent-docs/database/migrations.md +175 -31
  27. package/templates/agent-docs/database/schema.md +7 -0
  28. package/templates/agent-docs/database/seedsdialects.md +3 -3
  29. package/templates/agent-docs/plugins.md +1 -1
  30. package/templates/agent-docs/schema-driven-ui.md +2 -2
  31. package/templates/agent-docs/templates/apibackends.md +1 -1
  32. package/templates/agent-docs/whats-new.md +236 -9
  33. package/templates/apps/api-ai/package.json +12 -12
  34. package/templates/apps/api-auth/package.json +13 -13
  35. package/templates/apps/api-backend/package.json +12 -12
  36. package/templates/apps/api-backend-deactivation/package.json +12 -12
  37. package/templates/apps/api-backend-mail/package.json +11 -11
  38. package/templates/apps/api-backend-mariadb/package.json +14 -14
  39. package/templates/apps/api-backend-storage/package.json +11 -11
  40. package/templates/apps/api-data-advanced/package.json +13 -13
  41. package/templates/apps/api-durable/package.json +13 -13
  42. package/templates/apps/api-feature-flags/package.json +14 -14
  43. package/templates/apps/api-governance/package.json +13 -13
  44. package/templates/apps/api-kv/package.json +13 -13
  45. package/templates/apps/api-moderation/package.json +13 -13
  46. package/templates/apps/api-observability/package.json +13 -13
  47. package/templates/apps/api-ratelimit/package.json +13 -13
  48. package/templates/apps/api-rbac/package.json +13 -13
  49. package/templates/apps/api-rest/package.json +12 -12
  50. package/templates/apps/api-saas/package.json +16 -16
  51. package/templates/apps/api-search/package.json +13 -13
  52. package/templates/apps/api-versioning/package.json +13 -13
  53. package/templates/apps/api-webhooks/package.json +14 -14
  54. package/templates/apps/changelog/package.json +8 -8
  55. package/templates/apps/edge-functions/package.json +5 -5
  56. package/templates/apps/frontend-admin/package.json +10 -10
  57. package/templates/apps/frontend-app/package.json +10 -10
  58. package/templates/apps/frontend-blank/package.json +9 -9
  59. package/templates/apps/frontend-contact/package.json +11 -11
  60. package/templates/apps/frontend-dashboard/package.json +9 -9
  61. package/templates/apps/frontend-docs/package.json +9 -9
  62. package/templates/apps/frontend-i18n/package.json +8 -8
  63. package/templates/apps/frontend-landing/package.json +9 -9
  64. package/templates/apps/frontend-spa/package.json +9 -9
  65. package/templates/apps/frontend-ssr/package.json +9 -9
  66. package/templates/apps/frontend-ssr-api/package.json +10 -10
  67. package/templates/apps/frontend-static-blog/package.json +8 -8
  68. package/dist/apiBuild-C9aHz4Yh.js +0 -2
  69. package/dist/dbCommand-DtUe-0dM.js +0 -2
  70. package/dist/dev-2AvdzDg2.js +0 -3
  71. package/dist/inspect-DcZ04OME.js +0 -2
  72. package/dist/inspect-Dwx0_tUj.js +0 -921
  73. package/dist/manifestBuild-D1MzJAiQ.js +0 -2
  74. package/dist/serveCommand-BKqTKWTX.js +0 -1136
  75. /package/templates/patches/{@effect__cluster@0.59.0.patch → @effect__cluster@0.60.0.patch} +0 -0
@@ -1,3 +1,3 @@
1
- import { et as e } from "./inspectMetrics-DvPNXmGA.js";
2
- import { t } from "./start-CXQ7WL1W.js";
1
+ import { X as e } from "./inspectMetrics-4AVPn6IS.js";
2
+ import { t } from "./start-BjGUIIvj.js";
3
3
  export { e as loadDotEnv, t as runStartCommand };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@voltro/cli",
3
- "version": "0.20.2",
3
+ "version": "0.22.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",
@@ -59,42 +59,42 @@
59
59
  "node": ">=24.0.0"
60
60
  },
61
61
  "dependencies": {
62
- "@effect/platform-node": "^0.107.0",
63
- "@effect/sql": "^0.51.1",
64
- "@effect/workflow": "^0.18.2",
65
- "@voltro/ai": "0.20.2",
66
- "@voltro/cache": "0.20.2",
67
- "@voltro/data-transfer": "0.20.2",
68
- "@voltro/database": "0.20.2",
69
- "@voltro/env": "0.20.2",
70
- "@voltro/kv": "0.20.2",
71
- "@voltro/logger": "0.20.2",
72
- "@voltro/plugin-auth": "0.20.2",
73
- "@voltro/plugin-broadcast": "0.20.2",
74
- "@voltro/plugin-mail": "0.20.2",
75
- "@voltro/plugin-storage": "0.20.2",
76
- "@voltro/plugin-webhooks": "0.20.2",
77
- "@voltro/protocol": "0.20.2",
78
- "@voltro/runtime": "0.20.2",
79
- "@voltro/serverless": "0.20.2",
80
- "@voltro/workflow": "0.20.2",
62
+ "@effect/platform-node": "^0.108.0",
63
+ "@effect/sql": "^0.52.0",
64
+ "@effect/workflow": "^0.19.0",
65
+ "@voltro/ai": "0.22.0",
66
+ "@voltro/cache": "0.22.0",
67
+ "@voltro/data-transfer": "0.22.0",
68
+ "@voltro/database": "0.22.0",
69
+ "@voltro/env": "0.22.0",
70
+ "@voltro/kv": "0.22.0",
71
+ "@voltro/logger": "0.22.0",
72
+ "@voltro/plugin-auth": "0.22.0",
73
+ "@voltro/plugin-broadcast": "0.22.0",
74
+ "@voltro/plugin-mail": "0.22.0",
75
+ "@voltro/plugin-storage": "0.22.0",
76
+ "@voltro/plugin-webhooks": "0.22.0",
77
+ "@voltro/protocol": "0.22.0",
78
+ "@voltro/runtime": "0.22.0",
79
+ "@voltro/serverless": "0.22.0",
80
+ "@voltro/workflow": "0.22.0",
81
81
  "chokidar": "^5.0.0",
82
82
  "ioredis": "^5.11.1",
83
83
  "tinyglobby": "^0.2.17",
84
84
  "ts-morph": "^28.0.0"
85
85
  },
86
86
  "optionalDependencies": {
87
- "@tailwindcss/vite": "^4.3.2",
87
+ "@tailwindcss/vite": "^4.3.3",
88
88
  "@vercel/nft": "^1.10.2",
89
- "@vitejs/plugin-react": "^6.0.3",
89
+ "@vitejs/plugin-react": "^6.0.4",
90
90
  "esbuild": "^0.28.0",
91
- "tsx": "^4.23.0",
92
- "vite": "^8.1.4"
91
+ "tsx": "^4.23.1",
92
+ "vite": "^8.1.5"
93
93
  },
94
94
  "peerDependencies": {
95
- "@effect/platform": "^0.96.2",
96
- "@effect/rpc": "^0.75.1",
97
- "effect": "^3.21.4"
95
+ "@effect/platform": "^0.97.0",
96
+ "@effect/rpc": "^0.76.0",
97
+ "effect": "^3.22.0"
98
98
  },
99
99
  "publishConfig": {
100
100
  "access": "public"
@@ -595,7 +595,7 @@ each plugin's own README.
595
595
 
596
596
  | Topic | Open | Summary |
597
597
  |---|---|---|
598
- | **What's new in 0.20.2** | `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. |
598
+ | **What's new in 0.22.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. |
599
599
  | 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. |
600
600
  | 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. |
601
601
  | 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.20.2** | `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.22.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. |
@@ -495,10 +495,11 @@ Three checks run at boot and print one line each when they have something to say
495
495
  - **`apiKeys: true` with no `apikeys:issue:*` scope declared.** The capability is
496
496
  on and reachable by nobody; every issue request fails its guard.
497
497
 
498
- `voltro doctor` adds a fourth, over your source: an executor that writes
499
- `ctx.request.subject.id` while naming no guard, which is how an anonymous caller
500
- reaches a NOT NULL column and gets a raw statement failure instead of a typed
501
- refusal.
498
+ `voltro doctor` adds a fourth, over your source: its **authz scan** asks of every
499
+ executor queries included whether it references an access check at all, and
500
+ lists the ones that reference none. It learns your own `require*` / `assert*`
501
+ guard names, so it does not report the call sites of guards you already wrote.
502
+ See [the authz scan](./build-and-start.md).
502
503
 
503
504
  ## `voltro dev <appDir>`
504
505
 
@@ -544,6 +545,45 @@ compile. Already-compiled (warm) pages are never throttled, so a hot app stays
544
545
  fully concurrent. Tune the cap with `VOLTRO_DEV_SSR_COMPILE_CONCURRENCY` (below) —
545
546
  drop it on a low-memory box, raise it on a big machine.
546
547
 
548
+ ### A failed server render fails the request
549
+
550
+ If the server render throws, `voltro dev` answers **500** with the error and its
551
+ stack, marks the response `x-voltro-rendered-by: ssr-dev-failed`, and logs it at
552
+ `error`. It does **not** fall back to a client-rendered shell.
553
+
554
+ That is deliberate, and it is the same outcome `voltro start` produces in
555
+ production. A fallback would hand you a page that renders in the browser and a
556
+ 500 in production from the identical code — and because an empty `<div
557
+ id="root">` is what a client-only app looks like, the usual conclusion is "the
558
+ framework does not server-render", not "my page threw". The failure is loud so
559
+ the cause is the thing you see.
560
+
561
+ The practical consequence: anything that only misbehaves under
562
+ `renderToPipeableStream` — a component that suspends with no `<Suspense>`
563
+ boundary above it, a loader that throws, a hydration-unsafe value — surfaces in
564
+ `voltro dev` at the moment you hit the route.
565
+
566
+ **Suspending is fine here, and does not need a boundary you add.** A component
567
+ that suspends during a streamed server render — a lazily-loaded translation
568
+ catalog, a `React.lazy` component, `react-i18next` with `useSuspense: true` —
569
+ renders normally: `renderToPipeableStream` treats the root as an implicit
570
+ boundary, so a suspend delays the shell flush instead of failing. Measured, not
571
+ assumed; a regression test pins it.
572
+
573
+ Do **not** add a blanket `<Suspense>` at the root to "fix" a suspend. It makes
574
+ things worse in a way that is hard to see: React downgrades an errored boundary
575
+ to client rendering, so a page that THROWS starts answering 200 with
576
+ `<template data-msg="Switched to client rendering">` instead of failing. You lose
577
+ the hard failure above and gain nothing — the suspend already worked. Mount
578
+ boundaries where you want a *fallback* (`loading.tsx` per route, `<Await>` for
579
+ deferred loader values), not to make suspending legal.
580
+
581
+ Two places where a suspend genuinely is not supported, both by React rather than
582
+ by choice: `renderToString`, which backs the static prerender
583
+ (`renderMode: 'static'`), emits the fallback instead of waiting — so a suspending
584
+ component in a prerendered page needs its own boundary or a resolved value; and
585
+ the client render after hydration, which follows React's own rules.
586
+
547
587
 
548
588
  Under `VOLTRO_LOG_LEVEL=debug` each cold compile logs its own duration, so a slow
549
589
  first paint can be attributed to a specific module:
@@ -737,6 +777,32 @@ The restart is a full re-exec — there is no in-process hot-reload of a
737
777
  handler body; editing a query's executor respawns the child (debounced
738
778
  80ms, so a burst of saves collapses into one restart).
739
779
 
780
+ ### How the old process is stopped
781
+
782
+ SIGTERM first. The child runs its teardown — plugin `onDeactivate`, the
783
+ CDC detach, the scheduler and workflow runtime, the connection pool, and
784
+ every `ctx.onShutdown(cb)` a `*.startup.ts` registered — and then exits.
785
+ That is normally tens of milliseconds and you never see it.
786
+
787
+ It gets **1.5 seconds**, then SIGKILL, and the escalation says so:
788
+
789
+ ```text
790
+ child ignored SIGTERM — escalated to SIGKILL pid=41207
791
+ ```
792
+
793
+ Read that as "something in this app's shutdown does not complete" — a
794
+ pool draining against a database that is already gone, a plugin
795
+ `onDeactivate` waiting on a dead socket. The restart still happens; it
796
+ just costs the full grace window every time, and whatever teardown had
797
+ not finished was cut off. Worth fixing at the source rather than living
798
+ with, because the same hang is a slow — then failed — shutdown in
799
+ production.
800
+
801
+ Neither timeout is optional: a stop that can wait forever is a dev
802
+ server that stops restarting entirely, with the old process still
803
+ holding the port and your browser's websocket still attached to code you
804
+ edited minutes ago.
805
+
740
806
  ## When the dev server stops
741
807
 
742
808
  A restart replaces the child; the supervisor keeps watching. When the dev
@@ -976,6 +1042,65 @@ Docker step) and prints the exact remedy: add a `voltro build .` step before
976
1042
  `voltro serve .`. Drop it into your image build right after `voltro build` to
977
1043
  guarantee the artefact is present before the image ships.
978
1044
 
1045
+ ### The authz scan
1046
+
1047
+ `voltro doctor` answers one mechanical question over every executor: **does it
1048
+ reference an access check at all?**
1049
+
1050
+ ```
1051
+ authz scan · 586 executor(s)
1052
+ ✗ no access check 21
1053
+ ⚠ inline ownership check, no named guard 24 (informational)
1054
+ ✓ guards: on the descriptor 0
1055
+ ✓ calls a guard from the vocabulary 320
1056
+ – accepted as recorded debt 221 (voltro-authz-allowlist.txt)
1057
+
1058
+ ✗ teams.deleteSubTeam — deletes teams with nothing constraining WHICH row
1059
+ api/teams/deleteSubTeam.mutation.server.ts
1060
+ ```
1061
+
1062
+ It scans **queries and streams too**, not only writes: an executor that takes an
1063
+ id and returns the row is the same hole as one that writes it. The exploitable
1064
+ shape is "acts on a row the client named, without comparing anything on that row
1065
+ to the caller", and a read has it.
1066
+
1067
+ **It learns your guard names.** An exported `require*` / `assert*` from your own
1068
+ source counts as a guard, so `requireTeamAccess()` is recognised without any
1069
+ configuration. Without that the scan would report every call site of your own
1070
+ guards, which is the failure mode that makes a check ignorable.
1071
+
1072
+ **An inline ownership check is informational.** `row.userId !== subject.id → new
1073
+ AccessDeniedError({})` is correct code — it is listed so you can see where the
1074
+ rule lives in a handler rather than on a descriptor, and it never fails the run.
1075
+
1076
+ Findings are ordered by blast radius: a `delete` outranks an `insert`, and a
1077
+ target table that is `tenant()`-scoped or referenced by other tables outranks one
1078
+ that is neither.
1079
+
1080
+ #### The ratchet — how to adopt this on an existing app
1081
+
1082
+ A first run on a large app reports hundreds of handlers, and nobody triages
1083
+ hundreds of findings. So record them once and fail only on what comes after:
1084
+
1085
+ ```bash
1086
+ voltro doctor --write-authz-allowlist # writes voltro-authz-allowlist.txt
1087
+ voltro doctor # exits 1 on anything NEW
1088
+ ```
1089
+
1090
+ The file is **debt, not approval** — every line is a handler nobody has confirmed
1091
+ is safe. It is keyed by rpc tag rather than path, so moving a file can neither
1092
+ re-open a hole nor hide one, and it is consulted **last**: an executor that gains
1093
+ a real guard is reported as guarded whether or not its line is still there. The
1094
+ list can only shrink unless someone adds to it deliberately.
1095
+
1096
+ #### Before you hand-roll another check
1097
+
1098
+ If your checks are imperative because a scope cannot express "may this subject
1099
+ act on THIS row", that is what `guards: [{ action, resourceType, resource }]` is
1100
+ for — and an app whose relationships live in its own tables (a `teamMembers` row,
1101
+ say) registers its own tuple source instead of copying data into a framework
1102
+ table. See [Authorization](../authentication/authorization.md).
1103
+
979
1104
  ### The predicate-column check
980
1105
 
981
1106
  `eq` / `isNull` / `inSet` are free functions, so the column name arrives as a bare
@@ -1470,6 +1595,30 @@ has never run is not "failing".
1470
1595
  Pair it with `voltro logs --level error`: a failing handler now logs at **error**
1471
1596
  level, so the two surfaces agree.
1472
1597
 
1598
+ ## `voltro schedule run <name>` — fire one job, now
1599
+
1600
+ ```sh
1601
+ voltro schedule run nightly-reconcile
1602
+ voltro schedule run nightly-reconcile --process billing --format json
1603
+ voltro schedule run nightly-reconcile --url https://api.example.com # a deployed app
1604
+ ```
1605
+
1606
+ For the normal case: a nightly job that corrects business data, and you want to
1607
+ run it once and watch. It reports the run id, and `voltro inspect schedules`
1608
+ shows the outcome.
1609
+
1610
+ A run id of `null` is not a failure and is reported as its own outcome: the run
1611
+ was **coordinated away** — another replica holds the lock, or the previous run is
1612
+ still going and this schedule's `onOverlap` is `'skip'`. Printing "ok" there
1613
+ would claim work that never started.
1614
+
1615
+ `--trigger external` records the run as externally triggered instead of manual,
1616
+ for schedules that are normally fired by an outside scheduler.
1617
+
1618
+ This works against `voltro serve` as well as `voltro dev`. It did not before —
1619
+ production mounted no inspect surface at all, which also meant the post-deploy
1620
+ gate below could only ever be run against a dev server.
1621
+
1473
1622
  ## Targeting a deployed app
1474
1623
 
1475
1624
  Every command in this family resolves its target from the local runtime registry — the apps running on *this* machine. Pass `--url` to point one at a **deployed** app instead:
@@ -1489,7 +1638,34 @@ voltro check --url https://api.example.com
1489
1638
 
1490
1639
  ## The inspect HTTP surface
1491
1640
 
1492
- Every `voltro dev` / `voltro start` instance exposes a read-only introspection surface under `/_voltro/inspect/*`. The [Voltro Dashboard](/docs/observability/dashboard) consumes it to render the route sitemap, RPC list, subscription panel, workflow runs, and metrics. You can also hit the endpoints directly with `curl` (the `voltro inspect` subcommands above are the thin wrapper over exactly these).
1641
+ Every `voltro dev` / `voltro start` instance exposes an introspection surface
1642
+ under `/_voltro/inspect/*`.
1643
+
1644
+ **It is not read-only.** The core endpoints are reads, but installed plugins
1645
+ mount their own — and some are POSTs that DO things: `plugin-governance` mounts
1646
+ `/erase` (an irreversible GDPR right-to-be-forgotten deletion) and `/export` (a
1647
+ full personal-data dump); `plugin-storage` mounts `/share` and `/revoke`.
1648
+
1649
+ **So a mutating method needs a second credential.** `VOLTRO_INSPECT_WRITE_TOKEN`,
1650
+ sent as the `x-voltro-inspect-write` header ON TOP of the bearer — an additional
1651
+ factor, not an alternative: the read token still has to be correct. GET / HEAD /
1652
+ OPTIONS are unaffected. Unset, those endpoints are refused.
1653
+
1654
+ ```sh
1655
+ curl -H "authorization: Bearer $VOLTRO_INSPECT_TOKEN" \
1656
+ -H "x-voltro-inspect-write: $VOLTRO_INSPECT_WRITE_TOKEN" \
1657
+ -X POST http://localhost:4000/_voltro/inspect/plugins/governance/erase
1658
+ ```
1659
+
1660
+ `voltro dev` mints it per project like the read token, and the dashboard proxy
1661
+ injects it for loopback targets, so the dev loop is unchanged. **Nothing mints it
1662
+ for `serve` / `start`** — in production a destructive endpoint should take a
1663
+ deliberate act to enable. Use a DIFFERENT value from the read token; reusing it
1664
+ gives the split no meaning.
1665
+
1666
+ A plugin mounting a non-GET inspect endpoint must also declare the
1667
+ `inspect:write` permission, and the boot audit refuses it otherwise. That governs
1668
+ what a PLUGIN may mount; the write credential governs who may call it. The [Voltro Dashboard](/docs/observability/dashboard) consumes it to render the route sitemap, RPC list, subscription panel, workflow runs, and metrics. You can also hit the endpoints directly with `curl` (the `voltro inspect` subcommands above are the thin wrapper over exactly these).
1493
1669
 
1494
1670
  ```bash
1495
1671
  PORT=4000 # from the app's app.config.ts
@@ -328,6 +328,32 @@ defineMutation({ name: 'notes.create', target: { table: 'notes', op: 'insert' },
328
328
 
329
329
  With that pairing, `useMutation('app', 'notes.create')` can stage an optimistic row in active `notes.list` caches without client-side cache plumbing.
330
330
 
331
+ ### A guard that reads a second table belongs in `source`
332
+
333
+ `source` is the reactive trigger set: the query re-runs when a listed table
334
+ changes, and only then. So a guard that loads a row from ANOTHER table to decide
335
+ access has made that table part of what the result depends on:
336
+
337
+ ```ts
338
+ export const teamBoard = defineQuery({
339
+ name: 'boards.forTeam',
340
+ input: Schema.Struct({ teamId: Schema.String }),
341
+ output: BoardRows,
342
+ // `boards` alone is wrong here — `requireTeamAccess` reads `teamMembers`.
343
+ source: ['boards', 'teamMembers'],
344
+ })
345
+ ```
346
+
347
+ Leave `teamMembers` out and the subscription does not re-run when membership
348
+ changes. **That is an authorization staleness, not a cosmetic one:** revoke
349
+ someone's membership and their open subscription keeps serving rows they may no
350
+ longer see, until something else happens to invalidate it.
351
+
352
+ Nothing warns about this at runtime — a query that silently stops reacting looks
353
+ exactly like one with nothing to report. Reported by a team whose own invariant
354
+ caught it after five computed queries under-declared their `source`; the fix was
355
+ array sources.
356
+
331
357
  ## `output` is the serializer — `timestampMs`
332
358
 
333
359
  A descriptor's `output` is not documentation of the shape. It **is** the
@@ -951,6 +977,38 @@ const message = matchError(err, {
951
977
  }, () => 'Something went wrong')
952
978
  ```
953
979
 
980
+ ## `internal: true` — off the wire entirely
981
+
982
+ Every discovered `*.mutation.ts` / `*.query.ts` / `*.action.ts` / `*.stream.ts` is
983
+ callable over the WebSocket by any authenticated browser session. `publicApi` and
984
+ `exposeAsTool` opt IN to wider surfaces; `internal: true` opts OUT of the default
985
+ one:
986
+
987
+ ```ts
988
+ export const createFromAction = defineMutation({
989
+ name: 'auditLog.createFromAction',
990
+ input: Schema.Struct({ actorId: Schema.String, eventType: Schema.String }),
991
+ output: Schema.Void,
992
+ internal: true,
993
+ })
994
+ ```
995
+
996
+ It is not emitted into `rpcGroup.generated.ts`, and neither `voltro dev` nor
997
+ `voltro serve` registers a route — the tag is unroutable over `/rpc` and the
998
+ WebSocket. Server code calls it by importing its executor directly.
999
+
1000
+ **A naming convention is not a boundary.** One app had grown 18 procedures named
1001
+ `*Internal`, meaning "only other server code calls this"; all 18 were in the
1002
+ client group, and one of them accepted `actorId` / `actorEmail` / `actorType`
1003
+ from the caller and wrote an audit row. No guard, zero callers, reachable by
1004
+ anyone logged in. If the only thing keeping a procedure off the wire is that
1005
+ nobody wrote a client call for it, it is on the wire — the same reasoning as
1006
+ `.serverOnly()` on a column, one level up.
1007
+
1008
+ **It is not a substitute for a guard.** An internal procedure still runs with
1009
+ whatever authority its caller has. This removes the wire surface, not the need to
1010
+ check who is asking; `voltro doctor`'s authz scan still covers it.
1011
+
954
1012
  ## When Not To Use A Mutation
955
1013
 
956
1014
  - **External I/O.** Use an action or workflow.