@voltro/cli 0.21.0 → 0.22.1

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 (69) hide show
  1. package/CHANGELOG.md +316 -0
  2. package/dist/apiBuild-COyPDf3R.js +2 -0
  3. package/dist/{apiBuild-BHOtsQkp.js → apiBuild-DiWxVz-M.js} +2 -2
  4. package/dist/bin.js +3 -3
  5. package/dist/{commands-BZoimvvS.js → commands-BxRaIOBG.js} +1548 -1246
  6. package/dist/dbCommand-CO3eSAZR.js +2 -0
  7. package/dist/{dbCommand-CDBbZ-Ac.js → dbCommand-DVASmZj2.js} +295 -253
  8. package/dist/{dev-DKb92NYX.js → dev-BRiPgbKw.js} +1738 -1702
  9. package/dist/{dev-CZQ73Yoh.js → dev-Dd3EZzj5.js} +1 -1
  10. package/dist/index.js +1 -1
  11. package/dist/inspect-BA67TF6v.js +2 -0
  12. package/dist/inspect-_ldwsAwH.js +945 -0
  13. package/dist/{inspectMetrics-DMjWBQif.js → inspectMetrics-9ZSuDeqD.js} +148 -115
  14. package/dist/{manifestBuild-P9yuCY2d.js → manifestBuild-Bs1Uw22_.js} +1 -1
  15. package/dist/manifestBuild-i-fRHg_H.js +2 -0
  16. package/dist/{serveCommand-qHJCs0cX.js → serveCommand-DXJOARkO.js} +305 -306
  17. package/dist/serveEntry.js +2 -2
  18. package/dist/{start-B1x6Z60h.js → start-CsIjcOi-.js} +3 -3
  19. package/dist/startEntry.js +2 -2
  20. package/package.json +17 -17
  21. package/templates/AGENTS.md +1 -1
  22. package/templates/agent-docs/_index.md +1 -1
  23. package/templates/agent-docs/cli.md +118 -5
  24. package/templates/agent-docs/data.md +58 -0
  25. package/templates/agent-docs/database/migrations.md +142 -20
  26. package/templates/agent-docs/plugins.md +1 -1
  27. package/templates/agent-docs/schema-driven-ui.md +2 -2
  28. package/templates/agent-docs/templates/apibackends.md +1 -1
  29. package/templates/agent-docs/whats-new.md +32 -114
  30. package/templates/apps/api-ai/package.json +7 -7
  31. package/templates/apps/api-auth/package.json +8 -8
  32. package/templates/apps/api-backend/package.json +7 -7
  33. package/templates/apps/api-backend-deactivation/package.json +7 -7
  34. package/templates/apps/api-backend-mail/package.json +8 -8
  35. package/templates/apps/api-backend-mariadb/package.json +9 -9
  36. package/templates/apps/api-backend-storage/package.json +8 -8
  37. package/templates/apps/api-data-advanced/package.json +8 -8
  38. package/templates/apps/api-durable/package.json +8 -8
  39. package/templates/apps/api-feature-flags/package.json +9 -9
  40. package/templates/apps/api-governance/package.json +8 -8
  41. package/templates/apps/api-kv/package.json +8 -8
  42. package/templates/apps/api-moderation/package.json +8 -8
  43. package/templates/apps/api-observability/package.json +8 -8
  44. package/templates/apps/api-ratelimit/package.json +8 -8
  45. package/templates/apps/api-rbac/package.json +8 -8
  46. package/templates/apps/api-rest/package.json +7 -7
  47. package/templates/apps/api-saas/package.json +11 -11
  48. package/templates/apps/api-search/package.json +8 -8
  49. package/templates/apps/api-versioning/package.json +8 -8
  50. package/templates/apps/api-webhooks/package.json +9 -9
  51. package/templates/apps/changelog/package.json +6 -6
  52. package/templates/apps/edge-functions/package.json +2 -2
  53. package/templates/apps/frontend-admin/package.json +8 -8
  54. package/templates/apps/frontend-app/package.json +8 -8
  55. package/templates/apps/frontend-blank/package.json +7 -7
  56. package/templates/apps/frontend-contact/package.json +7 -7
  57. package/templates/apps/frontend-dashboard/package.json +7 -7
  58. package/templates/apps/frontend-docs/package.json +7 -7
  59. package/templates/apps/frontend-i18n/package.json +6 -6
  60. package/templates/apps/frontend-landing/package.json +7 -7
  61. package/templates/apps/frontend-spa/package.json +7 -7
  62. package/templates/apps/frontend-ssr/package.json +7 -7
  63. package/templates/apps/frontend-ssr-api/package.json +8 -8
  64. package/templates/apps/frontend-static-blog/package.json +6 -6
  65. package/dist/apiBuild-K7M6Rt0U.js +0 -2
  66. package/dist/dbCommand-oADAj6ez.js +0 -2
  67. package/dist/inspect-DcZ04OME.js +0 -2
  68. package/dist/inspect-Dwx0_tUj.js +0 -921
  69. package/dist/manifestBuild-D1MzJAiQ.js +0 -2
@@ -1,5 +1,5 @@
1
- import { X as e } from "./inspectMetrics-DMjWBQif.js";
1
+ import { X as e } from "./inspectMetrics-9ZSuDeqD.js";
2
2
  import { c as t } from "./seedRunner-D6eu-u5U.js";
3
3
  import { r as n } from "./appModuleLoader-C9r9mxZt.js";
4
- import { t as r } from "./serveCommand-qHJCs0cX.js";
4
+ import { t as r } from "./serveCommand-DXJOARkO.js";
5
5
  export { e as loadDotEnv, n as registerAppModules, t as registerDriver, r as runServe };
@@ -1,5 +1,5 @@
1
- import { $ as e, C as t, E as n, G as r, H as i, J as a, P as o, Q as s, R as c, U as l, V as u, W as d, _ as f, a as p, at as m, b as ee, c as h, ct as g, dt as _, et as te, f as v, ft as y, g as ne, h as b, i as re, j as ie, m as x, nt as S, o as ae, p as C, pt as w, q as T, r as E, s as D, t as O, tt as k, ut as A, v as j, w as oe, y as M } from "./inspectMetrics-DMjWBQif.js";
2
- import { D as se, E as ce, T as le, a as N, p as ue, w as de } from "./inspect-Dwx0_tUj.js";
1
+ import { $ as e, C as t, E as n, G as r, H as i, J as a, P as o, Q as s, R as c, U as l, V as u, W as d, _ as f, a as p, at as m, b as ee, c as h, ct as g, dt as _, et as te, f as v, ft as y, g as ne, h as b, i as re, j as ie, m as x, nt as S, o as ae, p as C, pt as w, q as T, r as E, s as D, t as O, tt as k, ut as A, v as j, w as oe, y as M } from "./inspectMetrics-9ZSuDeqD.js";
2
+ import { D as se, E as ce, T as le, a as N, p as ue, w as de } from "./inspect-_ldwsAwH.js";
3
3
  import { t as fe } from "./bootTiming-BdyP9nYw.js";
4
4
  import { dirname as pe, extname as P, join as F, resolve as I } from "node:path";
5
5
  import { fileURLToPath as L, pathToFileURL as R } from "node:url";
@@ -940,7 +940,7 @@ CREATE INDEX IF NOT EXISTS ${e}_servable_until_idx ON ${e} (servable_until);
940
940
  }), t.end(JSON.stringify({ error: "inspect disabled (VOLTRO_INSPECT=off)" }));
941
941
  return;
942
942
  }
943
- let i = N(a);
943
+ let i = N(a, "GET");
944
944
  if (!i.ok) {
945
945
  t.writeHead(401, {
946
946
  "content-type": "application/json",
@@ -1,3 +1,3 @@
1
- import { X as e } from "./inspectMetrics-DMjWBQif.js";
2
- import { t } from "./start-B1x6Z60h.js";
1
+ import { X as e } from "./inspectMetrics-9ZSuDeqD.js";
2
+ import { t } from "./start-CsIjcOi-.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.21.0",
3
+ "version": "0.22.1",
4
4
  "description": "The `voltro` CLI — dev server, codegen, migrations, project scaffolding, agent-docs seeding, and production serve.",
5
5
  "keywords": [
6
6
  "voltro",
@@ -62,22 +62,22 @@
62
62
  "@effect/platform-node": "^0.108.0",
63
63
  "@effect/sql": "^0.52.0",
64
64
  "@effect/workflow": "^0.19.0",
65
- "@voltro/ai": "0.21.0",
66
- "@voltro/cache": "0.21.0",
67
- "@voltro/data-transfer": "0.21.0",
68
- "@voltro/database": "0.21.0",
69
- "@voltro/env": "0.21.0",
70
- "@voltro/kv": "0.21.0",
71
- "@voltro/logger": "0.21.0",
72
- "@voltro/plugin-auth": "0.21.0",
73
- "@voltro/plugin-broadcast": "0.21.0",
74
- "@voltro/plugin-mail": "0.21.0",
75
- "@voltro/plugin-storage": "0.21.0",
76
- "@voltro/plugin-webhooks": "0.21.0",
77
- "@voltro/protocol": "0.21.0",
78
- "@voltro/runtime": "0.21.0",
79
- "@voltro/serverless": "0.21.0",
80
- "@voltro/workflow": "0.21.0",
65
+ "@voltro/ai": "0.22.1",
66
+ "@voltro/cache": "0.22.1",
67
+ "@voltro/data-transfer": "0.22.1",
68
+ "@voltro/database": "0.22.1",
69
+ "@voltro/env": "0.22.1",
70
+ "@voltro/kv": "0.22.1",
71
+ "@voltro/logger": "0.22.1",
72
+ "@voltro/plugin-auth": "0.22.1",
73
+ "@voltro/plugin-broadcast": "0.22.1",
74
+ "@voltro/plugin-mail": "0.22.1",
75
+ "@voltro/plugin-storage": "0.22.1",
76
+ "@voltro/plugin-webhooks": "0.22.1",
77
+ "@voltro/protocol": "0.22.1",
78
+ "@voltro/runtime": "0.22.1",
79
+ "@voltro/serverless": "0.22.1",
80
+ "@voltro/workflow": "0.22.1",
81
81
  "chokidar": "^5.0.0",
82
82
  "ioredis": "^5.11.1",
83
83
  "tinyglobby": "^0.2.17",
@@ -595,7 +595,7 @@ each plugin's own README.
595
595
 
596
596
  | Topic | Open | Summary |
597
597
  |---|---|---|
598
- | **What's new in 0.21.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. |
598
+ | **What's new in 0.22.1** | `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.21.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.22.1** | `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
 
@@ -776,6 +777,32 @@ The restart is a full re-exec — there is no in-process hot-reload of a
776
777
  handler body; editing a query's executor respawns the child (debounced
777
778
  80ms, so a burst of saves collapses into one restart).
778
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
+
779
806
  ## When the dev server stops
780
807
 
781
808
  A restart replaces the child; the supervisor keeps watching. When the dev
@@ -1015,6 +1042,65 @@ Docker step) and prints the exact remedy: add a `voltro build .` step before
1015
1042
  `voltro serve .`. Drop it into your image build right after `voltro build` to
1016
1043
  guarantee the artefact is present before the image ships.
1017
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
+
1018
1104
  ### The predicate-column check
1019
1105
 
1020
1106
  `eq` / `isNull` / `inSet` are free functions, so the column name arrives as a bare
@@ -1552,7 +1638,34 @@ voltro check --url https://api.example.com
1552
1638
 
1553
1639
  ## The inspect HTTP surface
1554
1640
 
1555
- 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).
1556
1669
 
1557
1670
  ```bash
1558
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.
@@ -90,6 +90,7 @@ voltro db apply # execute (dev only — refuses on NODE_ENV=pr
90
90
  voltro db apply --note '...' # apply with a freeform note recorded in history
91
91
  voltro db plans [--limit 20] # history from _voltro_migration_plans, newest first
92
92
  voltro db drift # live-vs-baseline check — exit 0 match, 3 no baseline, 4 drift
93
+ voltro db drift --accept # record the CURRENT live schema as the baseline (refuses unless db plan is empty)
93
94
  voltro db squash --before <date> # consolidate history into one snapshot
94
95
  voltro db restore-snapshot <id> # restore VOLTRO_SOFT_DROP=1 columns from a plan
95
96
 
@@ -119,6 +120,21 @@ there is no `--json` or `--sql`, and `voltro db apply` takes only
119
120
  deploy step ([prod pipeline](./prod-pipeline.md)), not a pre-serialised
120
121
  plan file.
121
122
 
123
+ ## Framework tables ride the same differ
124
+
125
+ The `_voltro_*` tables the framework owns are planned, classified and applied by
126
+ exactly the same code as yours — on every dialect. A framework release that adds
127
+ a table, adds a column or reshapes one lands on the boot that follows your
128
+ upgrade, wherever your own schema changes land. There is no separate command and
129
+ no dialect-specific step.
130
+
131
+ One asymmetry is deliberate and worth knowing if you ever read a plan: a
132
+ framework-owned table that **nobody declares** — `cluster_*` from the workflow
133
+ engine, a plugin's table after you removed the plugin — is never planned for a
134
+ drop. "Nobody declared it, so do not drop it" and "we declare it, so keep it
135
+ current" are different rules; collapsing them is what once made framework tables
136
+ evolve on postgres and nowhere else.
137
+
122
138
  ## Where to go next
123
139
 
124
140
  | Topic | Page |
@@ -367,6 +383,92 @@ If the live DB doesn't have a column called `firstName`, the marker is a no-op:
367
383
 
368
384
  The marker isn't validated against the live DB at schema-build time — it would have to introspect during type-checking, which is expensive. The runtime check fires at plan time.
369
385
 
386
+ ## The indexes come with it
387
+
388
+ Renaming a column is a metadata-only operation. Its **indexes** used to not be: index names are derived (`<table>_<column>_idx`), and no dialect renames an index when the column under it is renamed — so the planner saw `users_firstName_idx` on one side and `users_givenName_idx` on the other, and planned `DROP INDEX` + `CREATE INDEX`. On a large table that is a full B-tree rebuild: minutes of IO, and without `CONCURRENTLY` a write lock, behind a rename that was supposed to be instant.
389
+
390
+ The planner now folds that into a `rename-index` operation, which is a catalog-only statement everywhere it is emitted:
391
+
392
+ ```
393
+ ✓ rename-column users.firstName → users.givenName # catalog-only
394
+ ✓ rename-index users_firstName_idx → users_givenName_idx # catalog-only, no rebuild
395
+ ```
396
+
397
+ You do not annotate anything for this — it follows from the column rename you already declared.
398
+
399
+ Four cases deliberately still plan as drop + create, because pairing an old index with a new one has no evidence to stand on in them:
400
+
401
+ - **sqlite** — it has no rename statement at all. The plan you read matches what runs.
402
+ - **UNIQUE indexes** — they are constraint objects, and the syntax to rename one diverges by dialect.
403
+ - **Expression / json-path indexes** — the database normalises their key text, so there is no shape to compare; only the name, which is the thing that changed.
404
+ - **Two same-shaped indexes renamed at once** — nothing says which became which. Rebuilding both is slower; renaming the wrong one is worse.
405
+
406
+ ## Renaming a TABLE
407
+
408
+ The same problem one level up, and with more at stake: a table rename and a
409
+ drop+create look identical to the differ — old table gone, new table present —
410
+ except that guessing wrong costs every row. So it needs a marker too, and it
411
+ reads like its column counterpart:
412
+
413
+ ```ts
414
+ // Before:
415
+ export const notes = table('notes', { id: id({ prefix: 'note' }), body: text() })
416
+
417
+ // After — without the marker:
418
+ export const notes = table('archive_notes', { id: id({ prefix: 'note' }), body: text() })
419
+ // → planner sees DROP TABLE notes + CREATE TABLE archive_notes
420
+ // → the DROP is `lossy` and blocked; nothing happens until you acknowledge it
421
+
422
+ // With the marker:
423
+ export const notes = table('archive_notes', { id: id({ prefix: 'note' }), body: text() })
424
+ .renamedFrom('notes')
425
+ // → one `rename-table` op, classified `safe`
426
+ // → `ALTER TABLE notes RENAME TO archive_notes` — catalog-only, the rows stay put
427
+ ```
428
+
429
+ Unlike an index rename, every dialect has this statement — sqlite included — so
430
+ there is no dialect on which this falls back to a rebuild.
431
+
432
+ **Its indexes come with it.** The same derivation that bites a column rename bites
433
+ harder here: `notes_pkey` and `notes_<col>_idx` are named after the table, and no
434
+ dialect renames them when the table is renamed. The planner emits a `rename-index`
435
+ for each so the catalog catches up:
436
+
437
+ ```
438
+ ✓ rename-table notes → archive_notes
439
+ ✓ rename-index notes_pkey → archive_notes_pkey
440
+ ```
441
+
442
+ Without that the plan would try to drop the primary-key index and re-add it as a
443
+ plain UNIQUE, which postgres refuses outright.
444
+
445
+ **Three cases where the planner will NOT fold the rename**, each because folding
446
+ it could destroy data rather than move it — and none of them is silent:
447
+
448
+ - **The old name is still declared by something.** If your schema still has a
449
+ `notes` table, it is yours and stays put; the new table is created empty. This
450
+ is a legitimate outcome (it is what lets a framework plugin reclaim a name
451
+ without taking yours), so the plan runs — and the `create-table` line says why
452
+ the marker was not applied.
453
+ - **The new name already exists in the database.** → **refuses to plan.** Both
454
+ tables exist and only you know which holds the real rows. The fix tells you to
455
+ move them and drop one, or drop the empty one so the rename can run. Until
456
+ then the old table is untouched.
457
+ - **Two tables both claim the same old name.** → **refuses to plan.** Nothing
458
+ says which should receive the rows; remove the marker from all but one.
459
+
460
+ The last two refuse rather than degrade, because the quiet outcome — an empty
461
+ plan reading "schema up to date" while the old table still holds every row — is
462
+ the one that loses data by inaction.
463
+
464
+ **Lifecycle.** Same as `.renamedFrom()` on a column: a marker whose old table is
465
+ not in the database is a silent no-op, so it stays in your source across a staged
466
+ rollout and comes out once every environment has applied it.
467
+
468
+ **One constraint worth knowing:** a table whose name starts with `_` cannot derive
469
+ a typeid prefix, so it needs an explicit `id({ prefix: '…' })`. You will hear about
470
+ it at declaration, not at runtime.
471
+
370
472
  ## Lifecycle — when to remove the marker
371
473
 
372
474
  Keep the marker until the rename has been applied in EVERY env you care about (dev, staging, prod). The framework tracks applied ops in `_voltro_migration_plans`:
@@ -2067,9 +2169,24 @@ db drift: live schema DIVERGED from last applied state
2067
2169
  Something changed the live schema after the last apply. This command can see
2068
2170
  THAT it changed, not what or who — the fingerprints are hashes, not a diff.
2069
2171
 
2070
- To reconcile, run `voltro db plan` to see what your code expects vs the live DB.
2172
+ Your DECLARED schema is already satisfied — `voltro db plan` reports 0 operations
2173
+ against this database. So the live schema is not wrong, only unrecorded: something
2174
+ applied a change without going through the planner (a hand-run ALTER, a DBA
2175
+ window, a restored dump), or it touched a table your code does not declare.
2176
+
2177
+ If that was deliberate and the schema is right, record it:
2178
+
2179
+ voltro db drift --accept
2180
+
2181
+ It updates the latest ledger row's baseline to the live schema and invents no
2182
+ history entry. Drift then measures from here.
2071
2183
  ```
2072
2184
 
2185
+ When `db plan` is NOT empty it says that instead, with the count — so the two
2186
+ cases are told apart by the command rather than left to you. It used to close by
2187
+ asserting that a zero-operation plan meant "a table your code does not declare",
2188
+ which is one of two possibilities and the less likely one.
2189
+
2073
2190
  ### Out-of-band auto-applier
2074
2191
 
2075
2192
  Multiple tools applying to the same DB (the framework + a separate Flyway / Liquibase process / hand-written deploy script). The other tool's changes don't go through `_voltro_migration_plans`.
@@ -2084,31 +2201,36 @@ The drift detector ran against a read-replica that's lagging. Wait for the repli
2084
2201
 
2085
2202
  ## Reconciliation paths
2086
2203
 
2087
- ### Path 1 — adopt the live state by declaring it in TS
2204
+ ### Path 1 — accept the live state
2088
2205
 
2089
- When the live DB IS what you want (the manual DDL is correct, only
2090
- bypassing the planner was sloppy), bring the declared schema up to the
2091
- live shape: edit the `*.entity.ts` files so they describe exactly what
2092
- the live DB now has. The next `voltro db plan` then diffs empty, and a
2093
- `voltro db apply` records a fresh `_voltro_migration_plans` row at the
2094
- new fingerprint — re-baselining history without any DDL.
2206
+ When the live DB IS what you want (the manual DDL is correct, only bypassing the
2207
+ planner was sloppy), first make sure your declaration says so: edit the
2208
+ `*.entity.ts` files until `voltro db plan` diffs empty. Then record the live
2209
+ schema as the baseline:
2095
2210
 
2096
2211
  ```sh
2097
- voltro db plan # confirm the diff is now empty (declared == live)
2098
- voltro db apply --note 'accepting manual DDL from 2026-06-15 — see ticket #789'
2212
+ voltro db plan # must report 0 operations declared == live
2213
+ voltro db drift --accept
2099
2214
  ```
2100
2215
 
2101
- `voltro db apply` with an empty plan writes no DDL; it just locks in
2102
- the current fingerprint with your note. The history shows it:
2216
+ `--accept` writes the current live fingerprint onto the newest ledger row.
2217
+ Drift measures from there, and the next `voltro db drift` exits 0.
2103
2218
 
2104
- ```
2105
- plan_mig_5k79 fp=a8f2c9d1 env=dev src=auto-diff 0 op(s) 12ms ... by=alice
2106
- [note: accepting manual DDL from 2026-06-15 see ticket #789]
2107
- ```
2219
+ **It refuses unless `db plan` is empty**, and that guard is the whole point.
2220
+ Accepting a schema with operations still outstanding would record "this is what
2221
+ we applied" over a state nobody applied, and every later drift check would
2222
+ measure against that fiction. If operations are pending, run `voltro db apply` —
2223
+ that applies them AND writes a real baseline of its own.
2224
+
2225
+ It backfills the newest row rather than inserting one, because no migration ran
2226
+ and a history entry claiming otherwise would be worse than the gap it fills.
2108
2227
 
2109
- There is no metadata-only `--reconcile` flag re-baselining always
2110
- goes through the declare-then-apply loop, so the TS schema stays the
2111
- single source of truth.
2228
+ **An empty `voltro db apply` does NOT re-baseline an existing baseline.** It
2229
+ writes no DDL and no history row, so there is nothing for a `--note` to attach
2230
+ to it will tell you the note was ignored rather than swallow it. (It does fill
2231
+ a baseline that is still NULL, which is a different case: a database that never
2232
+ had one.) `--accept` is the command whose job is to say "the live schema is
2233
+ right, the ledger just did not know".
2112
2234
 
2113
2235
  ### Path 2 — corrective plan against drift
2114
2236
 
@@ -2206,7 +2328,7 @@ The hardest part of drift response is figuring out **what** changed + **who** di
2206
2328
  ```
2207
2329
  Detect: voltro db drift
2208
2330
  Fix code: voltro db apply (apply corrective plan from current diff)
2209
- Adopt DB: edit the *.entity.ts to match live, then voltro db apply (empty plan re-baselines)
2331
+ Adopt DB: edit the *.entity.ts to match live until db plan is empty, then voltro db drift --accept
2210
2332
  Backup: if data was lost, restore from your DB backup system — the framework can't help
2211
2333
  ```
2212
2334
 
@@ -92,7 +92,7 @@ Status legend: ✓ shipped · ◐ partial · — planned.
92
92
  | `@voltro/plugin-openapi` | ✓ | OpenAPI 3.1 spec (`GET /openapi.json`) + Swagger-UI (`GET /docs`) generated from `defineRestRoute` descriptors AND (opt-in) rpc procedures (queries/mutations/actions/streams → `POST /rpc/<name>`) — input/output/error Schemas via `JSONSchema.make`. [→ details](/docs/plugins/openapi) |
93
93
  | `@voltro/plugin-versioning` | ✓ | Full row history + time-travel — value snapshot of every insert/update/delete on listed tables into `_voltro_row_history` (rides the ChangeEvent tap); `rowHistory` / `rowAsOf` queries + `restoreAsOf` / `diffVersions`; TTL + per-row cap retention. [→ details](/docs/plugins/versioning) |
94
94
  | `@voltro/plugin-presence` | ✓ | Ephemeral realtime presence — heartbeat roster per channel (`presence.heartbeat`/`list`/`leave` + `usePresence`), a `useTyping` typing indicator, swept `_voltro_presence` table, cross-instance. [→ details](/docs/plugins/presence) |
95
- | `@voltro/plugin-scim` | ✓ | SCIM 2.0 provisioning — Users + Groups REST at `/scim/v2` (bearer-gated) incl. group-membership PATCH/PUT + the RFC 7644 discovery trio (ServiceProviderConfig/Schemas/ResourceTypes), `userName`/`externalId`/`displayName eq` filters, pagination, unique `userName`, `active:false` deactivation; `scim_users`/`scim_groups`. [→ details](/docs/plugins/scim) |
95
+ | `@voltro/plugin-scim` | ✓ | SCIM 2.0 provisioning — Users + Groups REST at `/scim/v2` (bearer-gated) incl. group-membership PATCH/PUT + the RFC 7644 discovery trio (ServiceProviderConfig/Schemas/ResourceTypes), `userName`/`externalId`/`displayName eq` filters, pagination, unique `userName`, `active:false` deactivation; `_voltro_scim_users`/`_voltro_scim_groups`. [→ details](/docs/plugins/scim) |
96
96
  | `@voltro/plugin-sso-saml` | ✓ | Enterprise SAML 2.0 SSO — SP-initiated login + Single Logout (both directions) + ACS + SP metadata under `/saml`; IdP-metadata-URL auto cert rotation, encrypted assertions, clock-skew, SP request signing. Signature verify via `@node-saml/node-saml` (optional+lazy), mints a framework session. [→ details](/docs/plugins/sso-saml) |
97
97
 
98
98
  API keys are **first-class** (not a plugin): `apiKeys: true` in `app.config.ts` → Bearer-key auth + admin-gated `/v1/api-keys` management, hash-only storage. [→ details](/docs/configuration/api-keys)
@@ -143,7 +143,7 @@ create-vs-update: they're *different mutations with different input schemas*, so
143
143
  they're naturally different forms — no "CRUD mode" switch.
144
144
 
145
145
  ```tsx
146
- import { AutoForm } from '@voltro/web'
146
+ import { AutoForm } from '@voltro/ui'
147
147
 
148
148
  // fields from the mutation's input schema; client+server share the schema;
149
149
  // submits via the mutation with op-correct optimistic (insert prepends, etc.)
@@ -177,7 +177,7 @@ A combined create-or-edit screen is a three-line wrapper:
177
177
  object → a `custom` placeholder asking for a render-prop.
178
178
  - **Rung 1 — one custom widget** via a `<Field>` render-prop:
179
179
  ```tsx
180
- import { AutoForm, Field, AsyncSelect } from '@voltro/web'
180
+ import { AutoForm, Field, AsyncSelect } from '@voltro/ui'
181
181
 
182
182
  <AutoForm api="app" mutation="todos.create">
183
183
  {() => (
@@ -2056,7 +2056,7 @@ curl -s -X POST http://localhost:4000/_voltro/inspect/invoke \
2056
2056
  # → { "ok": false, "error": { "_tag": "EntitlementExceeded", "entitlement": "projects", "limit": 3, "used": 3 } }
2057
2057
  ```
2058
2058
 
2059
- Each successful create also logs `[notify:project] → …: Project created` (console channel) and persists a `notification_inbox` row.
2059
+ Each successful create also logs `[notify:project] → …: Project created` (console channel) and persists a `_voltro_notification_inbox` row.
2060
2060
 
2061
2061
  ## Enable durable analytics
2062
2062