@voltro/cli 0.5.0 → 0.6.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 (82) hide show
  1. package/CHANGELOG.md +48 -1
  2. package/bin/voltro.mjs +54 -2
  3. package/dist/apiBuild-Bk4JBt-i.js +2 -0
  4. package/dist/{apiBuild-CZugTK46.js → apiBuild-BxY44VGj.js} +20 -20
  5. package/dist/bin.js +2 -2
  6. package/dist/{commands-CJheDVOr.js → commands-My_YlUJV.js} +2656 -2322
  7. package/dist/{dev-B71ICpXJ.js → dev-Bjq-nIvH.js} +2540 -2250
  8. package/dist/dev-D6b74iXI.js +2 -0
  9. package/dist/devActivity-1WtIVyHc.js +153 -0
  10. package/dist/devActivity.d.ts +46 -0
  11. package/dist/devActivity.js +2 -0
  12. package/dist/index.js +1 -1
  13. package/dist/serveCommand-C-XQ89NJ.js +1110 -0
  14. package/dist/serveEntry.js +2 -2
  15. package/package.json +22 -17
  16. package/templates/AGENTS.core.md +36 -1
  17. package/templates/AGENTS.md +36 -1
  18. package/templates/agent-docs/_manifest.json +3 -3
  19. package/templates/agent-docs/ai.md +35 -3
  20. package/templates/agent-docs/authentication.md +169 -0
  21. package/templates/agent-docs/cli.md +67 -1
  22. package/templates/agent-docs/configuration.md +38 -0
  23. package/templates/agent-docs/data.md +572 -40
  24. package/templates/agent-docs/database/querying.md +151 -3
  25. package/templates/agent-docs/database/schema.md +1 -0
  26. package/templates/agent-docs/deployment.md +171 -0
  27. package/templates/agent-docs/internationalization.md +2 -0
  28. package/templates/agent-docs/plugins.md +4 -4
  29. package/templates/agent-docs/reference.md +56 -2
  30. package/templates/agent-docs/templates/apibackends.md +13 -2
  31. package/templates/agent-docs/testing.md +157 -3
  32. package/templates/apps/api-ai/package.json +7 -7
  33. package/templates/apps/api-auth/app.config.ts +20 -13
  34. package/templates/apps/api-auth/package.json +8 -8
  35. package/templates/apps/api-backend/package.json +7 -7
  36. package/templates/apps/api-backend-deactivation/package.json +7 -7
  37. package/templates/apps/api-backend-mail/package.json +8 -8
  38. package/templates/apps/api-backend-mariadb/.env.example +10 -4
  39. package/templates/apps/api-backend-mariadb/package.json +9 -9
  40. package/templates/apps/api-backend-storage/package.json +8 -8
  41. package/templates/apps/api-data-advanced/app.config.ts +21 -8
  42. package/templates/apps/api-data-advanced/package.json +8 -8
  43. package/templates/apps/api-durable/package.json +8 -8
  44. package/templates/apps/api-feature-flags/package.json +9 -9
  45. package/templates/apps/api-governance/app.config.ts +15 -2
  46. package/templates/apps/api-governance/package.json +8 -8
  47. package/templates/apps/api-kv/package.json +8 -8
  48. package/templates/apps/api-moderation/package.json +8 -8
  49. package/templates/apps/api-observability/package.json +8 -8
  50. package/templates/apps/api-ratelimit/package.json +8 -8
  51. package/templates/apps/api-rbac/package.json +8 -8
  52. package/templates/apps/api-rest/package.json +7 -7
  53. package/templates/apps/api-saas/package.json +11 -11
  54. package/templates/apps/api-search/package.json +8 -8
  55. package/templates/apps/api-versioning/package.json +8 -8
  56. package/templates/apps/api-webhooks/app.config.ts +20 -1
  57. package/templates/apps/api-webhooks/package.json +8 -8
  58. package/templates/apps/changelog/package.json +6 -6
  59. package/templates/apps/edge-functions/package.json +2 -2
  60. package/templates/apps/frontend-admin/package.json +8 -8
  61. package/templates/apps/frontend-app/package.json +8 -8
  62. package/templates/apps/frontend-blank/package.json +7 -7
  63. package/templates/apps/frontend-contact/package.json +7 -7
  64. package/templates/apps/frontend-dashboard/package.json +7 -7
  65. package/templates/apps/frontend-docs/package.json +7 -7
  66. package/templates/apps/frontend-i18n/package.json +6 -6
  67. package/templates/apps/frontend-landing/package.json +7 -7
  68. package/templates/apps/frontend-spa/package.json +7 -7
  69. package/templates/apps/frontend-ssr/package.json +7 -7
  70. package/templates/apps/frontend-ssr-api/package.json +8 -8
  71. package/templates/apps/frontend-static-blog/package.json +6 -6
  72. package/templates/baselines/bare/.env.example +10 -3
  73. package/templates/baselines/compose/.env.example +10 -2
  74. package/templates/baselines/compose-mariadb/.env.example +10 -2
  75. package/templates/baselines/helm/.env.example +10 -3
  76. package/dist/apiBuild-D-iBYKa3.js +0 -2
  77. package/dist/dev-DNUetyG2.js +0 -2
  78. package/dist/serveCommand-C0WFA396.js +0 -1084
  79. package/templates/apps/api-auth/.env +0 -17
  80. package/templates/apps/api-data-advanced/.env +0 -17
  81. package/templates/apps/api-governance/.env +0 -4
  82. package/templates/apps/api-webhooks/.env +0 -6
@@ -1,4 +1,4 @@
1
- import { Nt as e, at as t } from "./dev-B71ICpXJ.js";
1
+ import { Lt as e, lt as t } from "./dev-Bjq-nIvH.js";
2
2
  import { a as n } from "./startupRunner-DhlX9nqd.js";
3
- import { t as r } from "./serveCommand-C0WFA396.js";
3
+ import { t as r } from "./serveCommand-C-XQ89NJ.js";
4
4
  export { e as loadDotEnv, n as registerAppModules, t as registerDriver, r as runServe };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@voltro/cli",
3
- "version": "0.5.0",
3
+ "version": "0.6.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",
@@ -31,6 +31,11 @@
31
31
  "import": "./dist/inspectState.js",
32
32
  "default": "./dist/inspectState.js"
33
33
  },
34
+ "./devActivity": {
35
+ "types": "./dist/devActivity.d.ts",
36
+ "import": "./dist/devActivity.js",
37
+ "default": "./dist/devActivity.js"
38
+ },
34
39
  "./startup": {
35
40
  "types": "./dist/startup.d.ts",
36
41
  "import": "./dist/startup.js",
@@ -52,22 +57,22 @@
52
57
  "@effect/platform-node": "^0.107.0",
53
58
  "@effect/sql": "^0.51.1",
54
59
  "@effect/workflow": "^0.18.2",
55
- "@voltro/ai": "0.5.0",
56
- "@voltro/cache": "0.5.0",
57
- "@voltro/data-transfer": "0.5.0",
58
- "@voltro/database": "0.5.0",
59
- "@voltro/env": "0.5.0",
60
- "@voltro/kv": "0.5.0",
61
- "@voltro/logger": "0.5.0",
62
- "@voltro/plugin-auth": "0.5.0",
63
- "@voltro/plugin-broadcast": "0.5.0",
64
- "@voltro/plugin-mail": "0.5.0",
65
- "@voltro/plugin-storage": "0.5.0",
66
- "@voltro/plugin-webhooks": "0.5.0",
67
- "@voltro/protocol": "0.5.0",
68
- "@voltro/runtime": "0.5.0",
69
- "@voltro/serverless": "0.5.0",
70
- "@voltro/workflow": "0.5.0",
60
+ "@voltro/ai": "0.6.0",
61
+ "@voltro/cache": "0.6.0",
62
+ "@voltro/data-transfer": "0.6.0",
63
+ "@voltro/database": "0.6.0",
64
+ "@voltro/env": "0.6.0",
65
+ "@voltro/kv": "0.6.0",
66
+ "@voltro/logger": "0.6.0",
67
+ "@voltro/plugin-auth": "0.6.0",
68
+ "@voltro/plugin-broadcast": "0.6.0",
69
+ "@voltro/plugin-mail": "0.6.0",
70
+ "@voltro/plugin-storage": "0.6.0",
71
+ "@voltro/plugin-webhooks": "0.6.0",
72
+ "@voltro/protocol": "0.6.0",
73
+ "@voltro/runtime": "0.6.0",
74
+ "@voltro/serverless": "0.6.0",
75
+ "@voltro/workflow": "0.6.0",
71
76
  "chokidar": "^5.0.0",
72
77
  "ioredis": "^5.11.1",
73
78
  "ts-morph": "^28.0.0"
@@ -126,6 +126,14 @@ line below replaces something real apps write by hand hundreds of times.
126
126
  delivery happens after commit, with backoff and a dead-letter. Do NOT call
127
127
  the remote from the mutation (not transactional), and do not hand-build a
128
128
  deliveries table + drain cron — that IS this primitive.
129
+ 11. **Calling a third party ON BEHALF OF A USER** (their Jira token, their Slack
130
+ grant)? → **`defineConnection({ id, kind: 'oauth2' | 'pat' })`** in a
131
+ `*.connection.ts`, then **`ctx.connections.get(id)`**. One declaration yields
132
+ the encrypted per-user token store, the OAuth authorize/callback pair,
133
+ refresh-before-use, and the `useConnection` + `<ConnectAccount>` connect UI.
134
+ The credential is bound to the REQUEST's subject — there is no parameter for
135
+ another subject. Do NOT hand-build a per-user token table, an OAuth action
136
+ pair, or a "credentials resolver" over your own schema.
129
137
 
130
138
  | You're about to write | Reach for instead |
131
139
  |---|---|
@@ -139,11 +147,13 @@ line below replaces something real apps write by hand hundreds of times.
139
147
  | hand-rolled limit+1 / `hasMore` cursor paging | `paginateBy` / `paginateById` |
140
148
  | a loop issuing one `store.query` per node of a walk | `ctx.load` / `ctx.loadMany` |
141
149
  | a deliveries table + drain cron + retry worker | `ctx.outbox.enqueue` + `defineOutboxHandler` |
150
+ | a per-user `integrationTokens` table + OAuth actions + a refresh check | `defineConnection` + `ctx.connections.get(id)` |
142
151
  | `row['name'] as string` on every field you read | nothing — `ctx.store.query(database.t….descriptor)` already returns the table's row type. A cast here means the descriptor was hand-built, or you are casting out of habit |
143
152
 
144
153
  Deep dive: **`data.md`** (`.one()`, subscribers, reactions, aggregates),
145
154
  **`database/querying.md`** (relations + `.with`), **`authentication.md`**
146
- (`guards:`, resource policies), **`plugins/governance.md`** (`.encrypted()`).
155
+ (`guards:`, resource policies), **`plugins/governance.md`** (`.encrypted()`),
156
+ **`data.md`** (`defineConnection`, the credentials vault).
147
157
 
148
158
  ### Pick the CLIENT primitive (decision rubric)
149
159
 
@@ -264,6 +274,31 @@ export default { type: 'web' as const, name: 'myWeb',
264
274
  Env, secrets, auth strategies, layers, plugins config → **`configuration.md`**.
265
275
  `store: 'memory'` loses data on restart — default to `postgres` for real work.
266
276
 
277
+ **NEVER hardcode a secret, not even as a fallback.** Not
278
+ `process.env.X ?? 'dev-only-change-me'`, not a value committed to `.env`. A
279
+ hardcoded fallback means every environment that forgot to configure the
280
+ variable shares one key that is readable by anyone with the repo — and it looks
281
+ completely healthy until someone forges a session. Declare it instead:
282
+
283
+ ```ts
284
+ env: defineEnv({
285
+ VOLTRO_SESSION_SECRET: envVar.secret({ generate: 'base64url' }), // ours → minted
286
+ STRIPE_SECRET_KEY: envVar.secret({ minLength: 20 }), // theirs → must be set
287
+ })
288
+ ```
289
+
290
+ `generate` makes `voltro dev` mint a unique per-project value into a gitignored
291
+ `.env.local` on first boot, so local development needs no placeholder at all.
292
+ Omit it for third-party credentials — an invented API key authenticates nobody,
293
+ and failing the boot gate is the useful outcome. Deployment values come from
294
+ `voltro secret generate <purpose>`; `voltro serve` refuses to start without them.
295
+
296
+ Also: don't read `process.env` for a secret at config time to pass it to a
297
+ plugin. `app.config.ts` is evaluated BEFORE the env gate and before minting, so
298
+ that read is what pushes you into writing a fallback. `authRoutesPlugin` and
299
+ `voltroPasswordStrategy` resolve `VOLTRO_SESSION_SECRET` themselves — omit
300
+ `secret:` entirely.
301
+
267
302
  ## The browser/server boundary (load-bearing — verify when you touch it)
268
303
 
269
304
  Codegen pulls every **descriptor** (`*.query.ts` / `*.mutation.ts` /
@@ -126,6 +126,14 @@ line below replaces something real apps write by hand hundreds of times.
126
126
  delivery happens after commit, with backoff and a dead-letter. Do NOT call
127
127
  the remote from the mutation (not transactional), and do not hand-build a
128
128
  deliveries table + drain cron — that IS this primitive.
129
+ 11. **Calling a third party ON BEHALF OF A USER** (their Jira token, their Slack
130
+ grant)? → **`defineConnection({ id, kind: 'oauth2' | 'pat' })`** in a
131
+ `*.connection.ts`, then **`ctx.connections.get(id)`**. One declaration yields
132
+ the encrypted per-user token store, the OAuth authorize/callback pair,
133
+ refresh-before-use, and the `useConnection` + `<ConnectAccount>` connect UI.
134
+ The credential is bound to the REQUEST's subject — there is no parameter for
135
+ another subject. Do NOT hand-build a per-user token table, an OAuth action
136
+ pair, or a "credentials resolver" over your own schema.
129
137
 
130
138
  | You're about to write | Reach for instead |
131
139
  |---|---|
@@ -139,11 +147,13 @@ line below replaces something real apps write by hand hundreds of times.
139
147
  | hand-rolled limit+1 / `hasMore` cursor paging | `paginateBy` / `paginateById` |
140
148
  | a loop issuing one `store.query` per node of a walk | `ctx.load` / `ctx.loadMany` |
141
149
  | a deliveries table + drain cron + retry worker | `ctx.outbox.enqueue` + `defineOutboxHandler` |
150
+ | a per-user `integrationTokens` table + OAuth actions + a refresh check | `defineConnection` + `ctx.connections.get(id)` |
142
151
  | `row['name'] as string` on every field you read | nothing — `ctx.store.query(database.t….descriptor)` already returns the table's row type. A cast here means the descriptor was hand-built, or you are casting out of habit |
143
152
 
144
153
  Deep dive: **`data.md`** (`.one()`, subscribers, reactions, aggregates),
145
154
  **`database/querying.md`** (relations + `.with`), **`authentication.md`**
146
- (`guards:`, resource policies), **`plugins/governance.md`** (`.encrypted()`).
155
+ (`guards:`, resource policies), **`plugins/governance.md`** (`.encrypted()`),
156
+ **`data.md`** (`defineConnection`, the credentials vault).
147
157
 
148
158
  ### Pick the CLIENT primitive (decision rubric)
149
159
 
@@ -264,6 +274,31 @@ export default { type: 'web' as const, name: 'myWeb',
264
274
  Env, secrets, auth strategies, layers, plugins config → **`configuration.md`**.
265
275
  `store: 'memory'` loses data on restart — default to `postgres` for real work.
266
276
 
277
+ **NEVER hardcode a secret, not even as a fallback.** Not
278
+ `process.env.X ?? 'dev-only-change-me'`, not a value committed to `.env`. A
279
+ hardcoded fallback means every environment that forgot to configure the
280
+ variable shares one key that is readable by anyone with the repo — and it looks
281
+ completely healthy until someone forges a session. Declare it instead:
282
+
283
+ ```ts
284
+ env: defineEnv({
285
+ VOLTRO_SESSION_SECRET: envVar.secret({ generate: 'base64url' }), // ours → minted
286
+ STRIPE_SECRET_KEY: envVar.secret({ minLength: 20 }), // theirs → must be set
287
+ })
288
+ ```
289
+
290
+ `generate` makes `voltro dev` mint a unique per-project value into a gitignored
291
+ `.env.local` on first boot, so local development needs no placeholder at all.
292
+ Omit it for third-party credentials — an invented API key authenticates nobody,
293
+ and failing the boot gate is the useful outcome. Deployment values come from
294
+ `voltro secret generate <purpose>`; `voltro serve` refuses to start without them.
295
+
296
+ Also: don't read `process.env` for a secret at config time to pass it to a
297
+ plugin. `app.config.ts` is evaluated BEFORE the env gate and before minting, so
298
+ that read is what pushes you into writing a fallback. `authRoutesPlugin` and
299
+ `voltroPasswordStrategy` resolve `VOLTRO_SESSION_SECRET` themselves — omit
300
+ `secret:` entirely.
301
+
267
302
  ## The browser/server boundary (load-bearing — verify when you touch it)
268
303
 
269
304
  Codegen pulls every **descriptor** (`*.query.ts` / `*.mutation.ts` /
@@ -17,7 +17,7 @@
17
17
  "group": null,
18
18
  "description": "How @voltro/plugin-auth wires password + session-cookie auth across api + web, plus the pluggable identity-strategy protocol.",
19
19
  "path": "agent-docs/authentication.md",
20
- "files": 11
20
+ "files": 12
21
21
  },
22
22
  {
23
23
  "id": "caching",
@@ -53,7 +53,7 @@
53
53
  "group": null,
54
54
  "description": "How Voltro's reactive data layer works — queries, mutations, actions, streams, all over one WebSocket with typed errors and tracked dependencies.",
55
55
  "path": "agent-docs/data.md",
56
- "files": 14
56
+ "files": 15
57
57
  },
58
58
  {
59
59
  "id": "database/advancedqueries",
@@ -161,7 +161,7 @@
161
161
  "group": null,
162
162
  "description": "Voltro Cloud (coming soon) — the managed runtime for your Voltro project. Today the Free control plane registers + observes your self-hosted apps.",
163
163
  "path": "agent-docs/deployment.md",
164
- "files": 6
164
+ "files": 7
165
165
  },
166
166
  {
167
167
  "id": "internationalization",
@@ -438,7 +438,18 @@ So one agent can run GPT-5.5 on a dedicated key while another inherits the env d
438
438
  **Model picker / routing** — the input selects among server-owned configs (UI picker, cost/size routing, plan tiers):
439
439
 
440
440
  ```tsx
441
- // descriptor: input: Schema.Struct({ prompt: Schema.String, tier: Schema.Literal('fast', 'smart') })
441
+ // support.agent.tsx — DESCRIPTOR: the picker is constrained to server-known tiers
442
+ import { defineAgent } from '@voltro/ai/agent'
443
+ import { Schema } from 'effect'
444
+
445
+ export const support = defineAgent({
446
+ name: 'support',
447
+ input: Schema.Struct({ prompt: Schema.String, tier: Schema.Literal('fast', 'smart') }),
448
+ })
449
+
450
+ // support.agent.server.tsx — EXECUTOR: map the tier onto server-owned configs
451
+ import { defineAgentExecutor } from '@voltro/ai'
452
+
442
453
  export default defineAgentExecutor(support, {
443
454
  model: (input) => input.tier === 'smart'
444
455
  ? { name: 'openai', model: 'gpt-5.5' }
@@ -449,7 +460,18 @@ export default defineAgentExecutor(support, {
449
460
  **BYOK (bring your own key)** — the caller supplies their OWN key; you fold it into the returned config:
450
461
 
451
462
  ```tsx
452
- // descriptor: input: Schema.Struct({ prompt: Schema.String, apiKey: Schema.String })
463
+ // support.agent.tsx — DESCRIPTOR: the caller's own key is part of the input
464
+ import { defineAgent } from '@voltro/ai/agent'
465
+ import { Schema } from 'effect'
466
+
467
+ export const support = defineAgent({
468
+ name: 'support',
469
+ input: Schema.Struct({ prompt: Schema.String, apiKey: Schema.String }),
470
+ })
471
+
472
+ // support.agent.server.tsx — EXECUTOR: fold the caller's key into the config
473
+ import { defineAgentExecutor } from '@voltro/ai'
474
+
453
475
  export default defineAgentExecutor(support, {
454
476
  model: (input) => ({ name: 'openai', model: 'gpt-5.5', apiKey: input.apiKey }),
455
477
  })
@@ -505,10 +527,20 @@ System prompts go in the agent definition's `system` field, NOT in the messages
505
527
  The `system` field lives on the **executor**. Template it with per-call values (locale, persona, plan-specific instructions) — the function receives the input typed from the descriptor's schema:
506
528
 
507
529
  ```tsx
530
+ // support.agent.tsx — DESCRIPTOR: every field `system` reads must be declared here
531
+ import { defineAgent } from '@voltro/ai/agent'
532
+ import { Schema } from 'effect'
533
+
534
+ export const support = defineAgent({
535
+ name: 'support',
536
+ input: Schema.Struct({ prompt: Schema.String, plan: Schema.optional(Schema.String) }),
537
+ })
538
+
508
539
  // support.agent.server.tsx — `plan` comes from the descriptor's input schema
540
+ import { defineAgentExecutor } from '@voltro/ai'
541
+
509
542
  export default defineAgentExecutor(support, {
510
543
  system: (input) => `You are speaking with a user on the ${input.plan ?? 'free'} plan.`,
511
- // …
512
544
  })
513
545
  ```
514
546
 
@@ -1771,6 +1771,12 @@ guards: [
1771
1771
  ]
1772
1772
  ```
1773
1773
 
1774
+ A guard answers **may you call this**. It cannot answer **which rows may you
1775
+ see** — a list has no single resource to name. For visibility that follows from a
1776
+ relationship ("tickets on teams I hold a role on"), declare a
1777
+ [row filter](/docs/authentication/row-level-security) instead; it AND-merges a
1778
+ subject-derived predicate into every read, so it narrows and never grants.
1779
+
1774
1780
  ### Activate it: register a tuple source
1775
1781
 
1776
1782
  A relationship guard needs to read the caller's relations. That comes from the
@@ -1858,3 +1864,166 @@ Full API — including the batch `useResourceCans` for per-row gating — in
1858
1864
  `rebacPolicyGraph()` returns every resource type, its actions, the relations each
1859
1865
  grants, and the implication edges — the policy graph a dashboard or an AI agent
1860
1866
  reads to reason about authority without grepping the code.
1867
+
1868
+
1869
+
1870
+ ---
1871
+
1872
+ <!-- source: en/authentication/row-level-security.md -->
1873
+ ## Row-level security
1874
+
1875
+ _setRowFilter — a subject-derived predicate AND-merged into every read, so relational visibility ("rows on teams I hold a role on") is declared once instead of hand-written into every list handler and every subscription._
1876
+
1877
+ Reads already scope themselves by tenant and by soft-delete, and a descriptor's
1878
+ [`guards:`](/docs/authentication/authorization#enforce-it-declaratively-guards)
1879
+ decide whether you may call a procedure **at all**. Neither of those says which
1880
+ **rows** you may see.
1881
+
1882
+ That gap matters as soon as visibility is *relational* — "tickets on teams I hold
1883
+ a role on". Without a row filter, that predicate has to be hand-written into
1884
+ every list handler and every subscription, and a filter you have to remember is a
1885
+ filter you only have to forget once.
1886
+
1887
+ `setRowFilter` declares it once. The framework AND-merges the resulting predicate
1888
+ into every read.
1889
+
1890
+ ## The two phases
1891
+
1892
+ ```ts no-check
1893
+ setRowFilter({
1894
+ load: (subject) => Effect<Ctx>, // ASYNC, once per request
1895
+ predicate: (ctx, table) => Predicate | undefined, // PURE + SYNC, per read
1896
+ })
1897
+ ```
1898
+
1899
+ - **`load`** resolves everything the predicates need — the memberships, the role
1900
+ rows, the project ids — **once per request**. It may read the store. This is
1901
+ the expensive half.
1902
+ - **`predicate`** derives the filter for one table from what `load` already
1903
+ fetched. It runs on **every read**, so it must be pure and synchronous. Return
1904
+ `undefined` for a table this filter does not constrain — which is most tables.
1905
+
1906
+ ### Why two halves rather than one function
1907
+
1908
+ A single async `(subject, table) => Promise<Predicate>` would be simpler to
1909
+ declare and much worse to run. Every read on the hot path would await, and the
1910
+ obvious implementation would re-query the membership tables **once per query** —
1911
+ so a handler that reads five tables pays five membership lookups.
1912
+
1913
+ Splitting the phases makes the per-read cost a map lookup and makes the
1914
+ per-request cost explicit and visible: one load, reused.
1915
+
1916
+ ## A worked example
1917
+
1918
+ The motivating shape — rows on teams the caller holds a role on:
1919
+
1920
+ ```ts no-check
1921
+ // apps/api/rls.startup.ts
1922
+ import { Effect } from 'effect'
1923
+ import { eq, inSet } from '@voltro/database'
1924
+ import { setRowFilter } from '@voltro/runtime'
1925
+ import { database } from './database/index'
1926
+
1927
+ setRowFilter({
1928
+ // ASYNC — once per request. Read your own tables here.
1929
+ load: (subject) =>
1930
+ Effect.promise(async () => {
1931
+ const rows = await database.teamMembers
1932
+ .where(eq('userId', subject.id ?? ''))
1933
+ .all()
1934
+ return rows.map((row) => row.teamId)
1935
+ }),
1936
+
1937
+ // PURE + SYNC — runs on every read.
1938
+ predicate: (teamIds: ReadonlyArray<string>, table: string) =>
1939
+ table === 'tickets' ? inSet('teamId', teamIds) : undefined,
1940
+ })
1941
+ ```
1942
+
1943
+ Register it at boot — `app.config.ts` or a `*.startup.ts`. It is process-global
1944
+ and last-write-wins. `Ctx` is whatever your `load` returns; the framework never
1945
+ inspects it.
1946
+
1947
+ With that registered, an ordinary list query needs no filter of its own:
1948
+
1949
+ ```ts no-check
1950
+ // apps/api/queries/tickets.list.query.server.ts
1951
+ export default () => database.tickets.orderBy('createdAt', 'desc')
1952
+ ```
1953
+
1954
+ A caller with no memberships gets zero rows. Nothing in the handler says so.
1955
+
1956
+ ## It can only narrow, never grant
1957
+
1958
+ The predicate is **AND-merged** onto whatever the handler already asked for — it
1959
+ never replaces it. A row filter cannot widen a query, so it can never become an
1960
+ accidental grant:
1961
+
1962
+ ```ts no-check
1963
+ // the handler asks for one ticket; the filter still applies
1964
+ database.tickets.where(eq('id', 'ticket-42'))
1965
+ // → id = 'ticket-42' AND teamId IN (…the caller's teams)
1966
+ ```
1967
+
1968
+ ## Both read paths are filtered
1969
+
1970
+ The filter applies to descriptor reads **and** to the fluent builder. A filter
1971
+ present on only one read path is not a filter, it is a detour:
1972
+
1973
+ ```ts no-check
1974
+ await ctx.store.query(tickets.descriptor) // filtered
1975
+ await ctx.store.select('tickets').all() // filtered
1976
+ ```
1977
+
1978
+ ## A failing `load` denies
1979
+
1980
+ If `load` fails, every constrained read is **refused** rather than silently
1981
+ running unfiltered. A row filter that degrades to "no filter" under load failure
1982
+ is worse than no filter at all, because the system keeps serving and nothing
1983
+ looks wrong.
1984
+
1985
+ The refusal is expressed as a predicate that matches nothing, not as an error —
1986
+ so a failure surfaces as an **empty result** rather than a 500 on every page. The
1987
+ underlying cause is reported separately, so it cannot pass unnoticed.
1988
+
1989
+ ## What does *not* bypass it
1990
+
1991
+ | | Bypasses the row filter? |
1992
+ |---|---|
1993
+ | `.unscoped()` / `crossTenant` | **No** |
1994
+ | a `system` subject | Yes |
1995
+
1996
+ `.unscoped()` and `crossTenant` exist for legitimate cross-tenant admin reads.
1997
+ They opt out of **tenant isolation**, not out of **authorization** — letting them
1998
+ also drop row visibility would turn an isolation opt-out into an authorization
1999
+ one, which is exactly the silent widening this feature exists to prevent.
2000
+
2001
+ Only a `system` subject bypasses, because a system subject is the framework
2002
+ acting as itself — janitor sweeps, migrations, the scheduler — rather than on
2003
+ behalf of a user. That bypass is deliberately the narrow, explicit one.
2004
+
2005
+ Apps that register no filter pay nothing.
2006
+
2007
+ ## Subscriptions re-resolve it
2008
+
2009
+ A subscription is the one read path that stays open for hours, so it is the one
2010
+ where a stale filter would matter most. Before every delivery the runtime
2011
+ re-derives the read from the **unfiltered base descriptor** and re-applies the
2012
+ freshly resolved filter.
2013
+
2014
+ A membership that ends mid-subscription therefore stops serving rows — the
2015
+ caller's open ticket list drops the rows they can no longer see, without a
2016
+ refresh and without the subscription having to be torn down.
2017
+
2018
+ ## Row filters vs. guards
2019
+
2020
+ They answer different questions, and a complete policy usually wants both:
2021
+
2022
+ | | Question | Failure |
2023
+ |---|---|---|
2024
+ | [`guards:`](/docs/authentication/authorization#enforce-it-declaratively-guards) | May you call this procedure? | typed `ScopeError`, before the executor runs |
2025
+ | `setRowFilter` | Which rows may you see? | the rows are simply absent |
2026
+
2027
+ A guard is the right tool for "may this caller edit *this* ticket". A row filter
2028
+ is the right tool for "which tickets appear in the list at all" — a question a
2029
+ guard cannot answer, because there is no single resource to name.
@@ -765,12 +765,13 @@ It covers both halves of the stack:
765
765
  | Scope | It notices | Reach for |
766
766
  |---|---|---|
767
767
  | server | `if (!rows[0]) throw …` | `.one()` / `.first()` |
768
- | server | 3+ sequential `store.query` in one handler | `relations()` + `.with()` |
768
+ | server | 3+ sequential `store.query` in one handler | `relations()` + `.with()` — or `Effect.all` |
769
769
  | server | `Effect.promise(() => ctx.store.…)` | `yield* EffectStore` |
770
770
  | server | `requireScope(...)` at the top of an executor | `guards:` on the descriptor |
771
771
  | server | a `token` / `secret` / `password` column with no encryption | `.encrypted()` |
772
772
  | server | a notify / webhook helper called at a mutation's tail | `defineSubscriber` / `defineReaction` |
773
773
  | server | `hasMore` + `limit + 1` | `paginateById` |
774
+ | server | `.getTime()` / `.toISOString()` mapping a row on the way out | `rowSchema(table)` as the descriptor's `output` |
774
775
  | client | per-field `useState` + a submit flag | `useFormBinding` |
775
776
  | client | a table with local sort/filter state | `useDataTable` |
776
777
  | client | `FileReader` / `readAsDataURL` | `useUpload` |
@@ -783,6 +784,71 @@ The rules are deliberately conservative — a detector that cries wolf trains yo
783
784
  to ignore it. A column that already carries `.encrypted()`, or a handler that
784
785
  already uses `.one()`, stays silent.
785
786
 
787
+ Two of them are worth spelling out, because their advice is not one-line:
788
+
789
+ **The credential-column rule skips names that aren't credentials.** A name ending
790
+ in `Id` / `_id`, a name ending in `Hash` / `_hash`, and a name beginning with
791
+ `vault` are all left alone:
792
+
793
+ | Column | Why it's skipped |
794
+ |---|---|
795
+ | `jiraSecretId`, `token_id` | an IDENTIFIER of a secret held elsewhere, not the secret |
796
+ | `apiKeyHash`, `password_hash` | the hash IS the protection — encrypting it is nonsense, and it breaks the column as a unique lookup key |
797
+ | `vaultToken` | a HANDLE into a secret store, naming a secret held elsewhere |
798
+
799
+ The suffix tests use a camelCase / underscore boundary on purpose: a blind
800
+ `/id$/i` would also swallow `apiKeyValid`, while `tokenIdentifier` — which ends
801
+ in neither — must still fire.
802
+
803
+ **The sequential-reads rule names TWO levers, and the criterion for choosing.**
804
+ Both shapes chain later reads off earlier results, so no text-level heuristic can
805
+ split them — you make the call:
806
+
807
+ - The reads are a **parent → child walk on ONE key** → declare `relations()` in a
808
+ `*.relations.ts` and collapse them into `.with({ … })`: one JSON-aggregate
809
+ query, on every dialect.
810
+ - The reads **collect ids from SEVERAL sources** (JSON-array references, a
811
+ junction carrying extra columns, JS-side sorting) → keep the assembly and run
812
+ the independent LEADING reads under `Effect.all`. Same queries, same results,
813
+ only concurrent — zero parity risk.
814
+
815
+ The second case is the common one. Measured on a real 74-hit codebase, about two
816
+ handlers were clean full-parity `relations()` conversions and the other ~72 were
817
+ multi-source assemblies where `.with()` covers only part of the work or subtly
818
+ changes behaviour. Prescribing `relations()` for all of them would be wrong ~97%
819
+ of the time — and advice that is usually wrong trains you to ignore the finding.
820
+
821
+ The human view shows the first three file paths per finding and says how many it
822
+ withheld. Those paths are the actionable part — a count you cannot turn back
823
+ into a work list tells you the size of the problem, not how to fix it — and the
824
+ matching rule lives inside the CLI, so you cannot re-derive the list with your
825
+ own grep. `--json` prints the complete scan, nothing elided, with no preflight
826
+ output mixed in:
827
+
828
+ ```bash
829
+ voltro doctor . --json # the complete scan: every file path, machine-readable
830
+ ```
831
+
832
+ ```json
833
+ {
834
+ "root": "/app/api",
835
+ "scannedFiles": 214,
836
+ "scannedDirs": ["queries", "mutations", "database"],
837
+ "findings": [
838
+ {
839
+ "id": "row-not-found",
840
+ "scope": "server",
841
+ "smell": "hand-written not-found branch on rows[0]",
842
+ "use": ".one() — fails with the typed NoRowFound on zero rows AND on more than one",
843
+ "files": ["queries/team.get.ts", "queries/user.get.ts", "…"]
844
+ }
845
+ ]
846
+ }
847
+ ```
848
+
849
+ That is the form to hand an agent, or to pipe into a script that works the list
850
+ file by file.
851
+
786
852
  ## `voltro capabilities` — what the framework actually exports
787
853
 
788
854
  Asked "what does this framework export", a language model will produce a
@@ -91,6 +91,44 @@ build-time constant, still works.)
91
91
  | `envVar.boolean(opts)` | `boolean` | `true/1/yes/on` ↔ `false/0/no/off/""` |
92
92
  | `envVar.url(opts)` | `string` | validated with `URL` |
93
93
  | `envVar.enum([…] as const, opts)` | literal union | must be one of the values |
94
+ | `envVar.secret(opts)` | `string` | server-only, required, length floor |
95
+
96
+ ### `envVar.secret` — for values that must not be guessable
97
+
98
+ ```ts
99
+ env: defineEnv({
100
+ // Ours to invent → `voltro dev` mints one per project.
101
+ VOLTRO_SESSION_SECRET: envVar.secret({ generate: 'base64url' }),
102
+ // Someone else's to issue → must be fetched, never invented.
103
+ STRIPE_SECRET_KEY: envVar.secret({ minLength: 20 }),
104
+ })
105
+ ```
106
+
107
+ It differs from `envVar.string({ access: 'secret' })` in three ways, each
108
+ closing a specific failure:
109
+
110
+ - **`access` is forced to `'secret'`** — a value with a length floor is never
111
+ something you meant to bundle into a browser.
112
+ - **There is no `default`.** A default secret is not a secret: every
113
+ deployment that forgot to set the variable would share it.
114
+ - **A length floor** (default 32). Presence alone does not catch the real
115
+ failure mode — a variable that is *set* but reads `change-me` signs forgeable
116
+ cookies while looking completely healthy.
117
+
118
+ **`generate` opts a variable into per-project minting.** On first `voltro dev`,
119
+ any declared-but-unset mintable secret is written to a gitignored `.env.local`
120
+ and the boot continues. That is why no Voltro template ships a secret value: a
121
+ placeholder in a template is a signing key published to everyone who downloads
122
+ it, and it passes every check you could write.
123
+
124
+ Leave `generate` off for anything a third party issues. A WorkOS API key is
125
+ just as secret and just as required, but inventing one produces a value that
126
+ merely *looks* right and authenticates nobody — better that the boot gate fails
127
+ and a human fetches the real one.
128
+
129
+ Minting is **development only**. `voltro serve`, `build` and `start` never mint:
130
+ in production a missing secret is a boot failure, which is the whole point.
131
+ Generate deployment values with `voltro secret generate <purpose>`.
94
132
 
95
133
  Every builder takes `{ access, optional?, default?, description?, example? }`.
96
134
  Use `default` for a fallback (the value type stays present, `A`); use