@voltro/cli 0.5.0 → 0.7.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 (87) hide show
  1. package/CHANGELOG.md +68 -1
  2. package/bin/voltro.mjs +63 -2
  3. package/dist/apiBuild-CSM74oEZ.js +2 -0
  4. package/dist/{apiBuild-CZugTK46.js → apiBuild-deIjJKJm.js} +2 -1
  5. package/dist/bin.js +2 -2
  6. package/dist/{commands-CJheDVOr.js → commands-BHdc71DG.js} +2671 -2960
  7. package/dist/dev-BYfrYwrT.js +2 -0
  8. package/dist/dev-Bqyq62Ak.js +6492 -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/inspectMetrics-BLUBAWLb.js +3711 -0
  14. package/dist/serveCommand-Dn6--0Bv.js +1111 -0
  15. package/dist/serveEntry.js +3 -2
  16. package/dist/start-CWZW_4Et.js +941 -0
  17. package/dist/startEntry.d.ts +5 -0
  18. package/dist/startEntry.js +3 -0
  19. package/package.json +22 -17
  20. package/templates/AGENTS.core.md +36 -1
  21. package/templates/AGENTS.md +36 -1
  22. package/templates/agent-docs/_manifest.json +3 -3
  23. package/templates/agent-docs/ai.md +35 -3
  24. package/templates/agent-docs/authentication.md +264 -0
  25. package/templates/agent-docs/cli.md +126 -3
  26. package/templates/agent-docs/configuration.md +38 -0
  27. package/templates/agent-docs/data.md +616 -40
  28. package/templates/agent-docs/database/querying.md +151 -3
  29. package/templates/agent-docs/database/schema.md +1 -0
  30. package/templates/agent-docs/deployment.md +171 -0
  31. package/templates/agent-docs/internationalization.md +2 -0
  32. package/templates/agent-docs/plugins.md +4 -4
  33. package/templates/agent-docs/reference.md +56 -2
  34. package/templates/agent-docs/templates/apibackends.md +13 -2
  35. package/templates/agent-docs/testing.md +215 -3
  36. package/templates/apps/api-ai/package.json +7 -7
  37. package/templates/apps/api-auth/app.config.ts +20 -13
  38. package/templates/apps/api-auth/package.json +8 -8
  39. package/templates/apps/api-backend/package.json +7 -7
  40. package/templates/apps/api-backend-deactivation/package.json +7 -7
  41. package/templates/apps/api-backend-mail/package.json +8 -8
  42. package/templates/apps/api-backend-mariadb/.env.example +10 -4
  43. package/templates/apps/api-backend-mariadb/package.json +9 -9
  44. package/templates/apps/api-backend-storage/package.json +8 -8
  45. package/templates/apps/api-data-advanced/app.config.ts +21 -8
  46. package/templates/apps/api-data-advanced/package.json +8 -8
  47. package/templates/apps/api-durable/package.json +8 -8
  48. package/templates/apps/api-feature-flags/package.json +9 -9
  49. package/templates/apps/api-governance/app.config.ts +15 -2
  50. package/templates/apps/api-governance/package.json +8 -8
  51. package/templates/apps/api-kv/package.json +8 -8
  52. package/templates/apps/api-moderation/package.json +8 -8
  53. package/templates/apps/api-observability/package.json +8 -8
  54. package/templates/apps/api-ratelimit/package.json +8 -8
  55. package/templates/apps/api-rbac/package.json +8 -8
  56. package/templates/apps/api-rest/package.json +7 -7
  57. package/templates/apps/api-saas/package.json +11 -11
  58. package/templates/apps/api-search/package.json +8 -8
  59. package/templates/apps/api-versioning/package.json +8 -8
  60. package/templates/apps/api-webhooks/app.config.ts +20 -1
  61. package/templates/apps/api-webhooks/package.json +8 -8
  62. package/templates/apps/changelog/package.json +6 -6
  63. package/templates/apps/edge-functions/package.json +2 -2
  64. package/templates/apps/frontend-admin/package.json +8 -8
  65. package/templates/apps/frontend-app/package.json +8 -8
  66. package/templates/apps/frontend-blank/package.json +7 -7
  67. package/templates/apps/frontend-contact/package.json +7 -7
  68. package/templates/apps/frontend-dashboard/package.json +7 -7
  69. package/templates/apps/frontend-docs/package.json +7 -7
  70. package/templates/apps/frontend-i18n/package.json +6 -6
  71. package/templates/apps/frontend-landing/package.json +7 -7
  72. package/templates/apps/frontend-spa/package.json +7 -7
  73. package/templates/apps/frontend-ssr/package.json +7 -7
  74. package/templates/apps/frontend-ssr-api/package.json +8 -8
  75. package/templates/apps/frontend-static-blog/package.json +6 -6
  76. package/templates/baselines/bare/.env.example +10 -3
  77. package/templates/baselines/compose/.env.example +10 -2
  78. package/templates/baselines/compose-mariadb/.env.example +10 -2
  79. package/templates/baselines/helm/.env.example +10 -3
  80. package/dist/apiBuild-D-iBYKa3.js +0 -2
  81. package/dist/dev-B71ICpXJ.js +0 -9896
  82. package/dist/dev-DNUetyG2.js +0 -2
  83. package/dist/serveCommand-C0WFA396.js +0 -1084
  84. package/templates/apps/api-auth/.env +0 -17
  85. package/templates/apps/api-data-advanced/.env +0 -17
  86. package/templates/apps/api-governance/.env +0 -4
  87. package/templates/apps/api-webhooks/.env +0 -6
@@ -0,0 +1,5 @@
1
+ export declare const loadDotEnv: (argv: ReadonlyArray<string>) => void;
2
+
3
+ export declare const runStartCommand: (args: ReadonlyArray<string>) => Promise<number>;
4
+
5
+ export { }
@@ -0,0 +1,3 @@
1
+ import { k as e } from "./inspectMetrics-BLUBAWLb.js";
2
+ import { t } from "./start-CWZW_4Et.js";
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.5.0",
3
+ "version": "0.7.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.7.0",
61
+ "@voltro/cache": "0.7.0",
62
+ "@voltro/data-transfer": "0.7.0",
63
+ "@voltro/database": "0.7.0",
64
+ "@voltro/env": "0.7.0",
65
+ "@voltro/kv": "0.7.0",
66
+ "@voltro/logger": "0.7.0",
67
+ "@voltro/plugin-auth": "0.7.0",
68
+ "@voltro/plugin-broadcast": "0.7.0",
69
+ "@voltro/plugin-mail": "0.7.0",
70
+ "@voltro/plugin-storage": "0.7.0",
71
+ "@voltro/plugin-webhooks": "0.7.0",
72
+ "@voltro/protocol": "0.7.0",
73
+ "@voltro/runtime": "0.7.0",
74
+ "@voltro/serverless": "0.7.0",
75
+ "@voltro/workflow": "0.7.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
 
@@ -351,6 +351,29 @@ if (ctx.subject.type === 'anonymous') {
351
351
  }
352
352
  ```
353
353
 
354
+ ## App metadata — `subjectFromUser(user, { metadata })`
355
+
356
+ `metadata` is the free-form slot the framework itself never reads. It is where a provider credential captured at login belongs — a plugin's `credentialsResolver` reads it back per request (`@voltro/plugin-atlassian` looks for `subject.metadata.jiraToken`, say), so nothing has to be re-fetched or stored server-side per call.
357
+
358
+ Pass it when you build the Subject:
359
+
360
+ ```ts
361
+ import { subjectFromUser } from '@voltro/plugin-auth'
362
+
363
+ const subject = subjectFromUser(user, {
364
+ memberships, // → metadata.memberships
365
+ metadata: { jiraToken: atlassianPat }, // → metadata.jiraToken
366
+ })
367
+ ```
368
+
369
+ The two merge — neither clobbers the other. **When a `memberships` key appears in both,** the dedicated `memberships` option wins: it is the typed input, and it is the one projected into the `{ tenantId, role }` shape `subjectMemberships()` and the tenant switcher read. Without the option, a `memberships` key inside `metadata` passes through unchanged. A Subject built with neither option has no `metadata` key at all.
370
+
371
+ Keys set this way survive the login paths: the sign-in / sign-up / magic-link / passkey handlers merge `sessionId` onto the existing slot, and the password strategy merges `provider` — they add, they don't replace. The one exception is naming a key `sessionId` or `provider` yourself; those two are overwritten by design.
372
+
373
+ **It survives a tenant switch too.** A switch rebuilds the Subject from the user record, so the built-in `/switch-tenant` route passes the caller's current `subject.metadata` through to `handleSwitchTenant` — a credential parked here keeps working after the user changes tenant. Calling `handleSwitchTenant` yourself? Pass `metadata` or the credential is dropped, and the symptom is unpleasant to diagnose: the user stays signed in while every call to the provider starts failing. `memberships` is deliberately *not* carried — it is re-derived for the target tenant, and a carried copy would report a role the user does not hold there.
374
+
375
+ Two things not to put here. **Anything the caller could benefit from changing** — the slot rides the signed session cookie, so it is tamper-evident, but it is also stale by design: it reflects the moment of sign-in, not the current database. And **anything large** — it is re-serialised into every session cookie.
376
+
354
377
  ## Resolution
355
378
 
356
379
  `AuthMiddleware` resolves a `Subject` on every request by running the [strategy chain](/docs/authentication/strategies) — `composeAuthStrategies` evaluates each strategy in order, first `matched` wins, first `failed` short-circuits to anonymous. A typical chain resolves, in order:
@@ -1771,6 +1794,12 @@ guards: [
1771
1794
  ]
1772
1795
  ```
1773
1796
 
1797
+ A guard answers **may you call this**. It cannot answer **which rows may you
1798
+ see** — a list has no single resource to name. For visibility that follows from a
1799
+ relationship ("tickets on teams I hold a role on"), declare a
1800
+ [row filter](/docs/authentication/row-level-security) instead; it AND-merges a
1801
+ subject-derived predicate into every read, so it narrows and never grants.
1802
+
1774
1803
  ### Activate it: register a tuple source
1775
1804
 
1776
1805
  A relationship guard needs to read the caller's relations. That comes from the
@@ -1858,3 +1887,238 @@ Full API — including the batch `useResourceCans` for per-row gating — in
1858
1887
  `rebacPolicyGraph()` returns every resource type, its actions, the relations each
1859
1888
  grants, and the implication edges — the policy graph a dashboard or an AI agent
1860
1889
  reads to reason about authority without grepping the code.
1890
+
1891
+
1892
+
1893
+ ---
1894
+
1895
+ <!-- source: en/authentication/row-level-security.md -->
1896
+ ## Row-level security
1897
+
1898
+ _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._
1899
+
1900
+ Reads already scope themselves by tenant and by soft-delete, and a descriptor's
1901
+ [`guards:`](/docs/authentication/authorization#enforce-it-declaratively-guards)
1902
+ decide whether you may call a procedure **at all**. Neither of those says which
1903
+ **rows** you may see.
1904
+
1905
+ That gap matters as soon as visibility is *relational* — "tickets on teams I hold
1906
+ a role on". Without a row filter, that predicate has to be hand-written into
1907
+ every list handler and every subscription, and a filter you have to remember is a
1908
+ filter you only have to forget once.
1909
+
1910
+ `setRowFilter` declares it once. The framework AND-merges the resulting predicate
1911
+ into every read.
1912
+
1913
+ ## The two phases
1914
+
1915
+ ```ts no-check
1916
+ setRowFilter({
1917
+ load: (subject) => Effect<Ctx>, // ASYNC, once per request
1918
+ predicate: (ctx, table) => Predicate | undefined, // PURE + SYNC, per read
1919
+ })
1920
+ ```
1921
+
1922
+ - **`load`** resolves everything the predicates need — the memberships, the role
1923
+ rows, the project ids — **once per request**. It may read the store. This is
1924
+ the expensive half.
1925
+ - **`predicate`** derives the filter for one table from what `load` already
1926
+ fetched. It runs on **every read**, so it must be pure and synchronous. Return
1927
+ `undefined` for a table this filter does not constrain — which is most tables.
1928
+
1929
+ ### Why two halves rather than one function
1930
+
1931
+ A single async `(subject, table) => Promise<Predicate>` would be simpler to
1932
+ declare and much worse to run. Every read on the hot path would await, and the
1933
+ obvious implementation would re-query the membership tables **once per query** —
1934
+ so a handler that reads five tables pays five membership lookups.
1935
+
1936
+ Splitting the phases makes the per-read cost a map lookup and makes the
1937
+ per-request cost explicit and visible: one load, reused.
1938
+
1939
+ ## A worked example
1940
+
1941
+ The motivating shape — rows on teams the caller holds a role on:
1942
+
1943
+ ```ts no-check
1944
+ // apps/api/rls.startup.ts
1945
+ import { Effect } from 'effect'
1946
+ import { eq, inSet } from '@voltro/database'
1947
+ import { setRowFilter } from '@voltro/runtime'
1948
+ import { database } from './database/index'
1949
+
1950
+ setRowFilter({
1951
+ // ASYNC — once per request. Read your own tables here.
1952
+ load: (subject) =>
1953
+ Effect.promise(async () => {
1954
+ const rows = await database.teamMembers
1955
+ .where(eq('userId', subject.id ?? ''))
1956
+ .all()
1957
+ return rows.map((row) => row.teamId)
1958
+ }),
1959
+
1960
+ // PURE + SYNC — runs on every read.
1961
+ predicate: (teamIds: ReadonlyArray<string>, table: string) =>
1962
+ table === 'tickets' ? inSet('teamId', teamIds) : undefined,
1963
+ })
1964
+ ```
1965
+
1966
+ Register it at boot — `app.config.ts` or a `*.startup.ts`. It is process-global
1967
+ and last-write-wins. `Ctx` is whatever your `load` returns; the framework never
1968
+ inspects it.
1969
+
1970
+ With that registered, an ordinary list query needs no filter of its own:
1971
+
1972
+ ```ts no-check
1973
+ // apps/api/queries/tickets.list.query.server.ts
1974
+ export default () => database.tickets.orderBy('createdAt', 'desc')
1975
+ ```
1976
+
1977
+ A caller with no memberships gets zero rows. Nothing in the handler says so.
1978
+
1979
+ ## It can only narrow, never grant
1980
+
1981
+ The predicate is **AND-merged** onto whatever the handler already asked for — it
1982
+ never replaces it. A row filter cannot widen a query, so it can never become an
1983
+ accidental grant:
1984
+
1985
+ ```ts no-check
1986
+ // the handler asks for one ticket; the filter still applies
1987
+ database.tickets.where(eq('id', 'ticket-42'))
1988
+ // → id = 'ticket-42' AND teamId IN (…the caller's teams)
1989
+ ```
1990
+
1991
+ ## Both read paths are filtered
1992
+
1993
+ The filter applies to descriptor reads **and** to the fluent builder. A filter
1994
+ present on only one read path is not a filter, it is a detour:
1995
+
1996
+ ```ts no-check
1997
+ await ctx.store.query(tickets.descriptor) // filtered
1998
+ await ctx.store.select('tickets').all() // filtered
1999
+ ```
2000
+
2001
+ ## When `load` fails
2002
+
2003
+ `load` reads your store — for relational visibility it *must* — which makes it
2004
+ exactly the kind of call that blips. Two separate questions follow from a
2005
+ failure, and the answers are deliberately different.
2006
+
2007
+ ### First: is the failure even real? (`retry`)
2008
+
2009
+ A transient failure must never reach the decision below, because once it gets
2010
+ there it is indistinguishable from an authorization answer. So `load` runs under
2011
+ a bounded retry before anything is concluded from it:
2012
+
2013
+ ```ts no-check
2014
+ import { Schedule } from 'effect'
2015
+
2016
+ setRowFilter({
2017
+ load,
2018
+ predicate,
2019
+ retry: Schedule.recurs(5), // your own schedule
2020
+ // retry: false, // exactly one attempt
2021
+ })
2022
+ ```
2023
+
2024
+ The default is `DEFAULT_ROW_FILTER_RETRY` (exported from `@voltro/runtime`):
2025
+ **three attempts, backing off exponentially from 20ms** — about 60ms of added
2026
+ latency in the worst case. It is sized for a blip (a connection reaped from the
2027
+ pool, a failover flap), not for an outage. A `load` still failing after that is
2028
+ not having a bad moment, and stretching the schedule only turns a fast honest
2029
+ error into a slow one while holding the request open.
2030
+
2031
+ ### Then: what does a real failure mean? (`onLoadError`)
2032
+
2033
+ Not "you may see nothing". **We cannot tell what you may see.** Those are
2034
+ different facts and only one of them is a fact — so the default raises a typed
2035
+ error:
2036
+
2037
+ ```ts no-check
2038
+ setRowFilter({
2039
+ load,
2040
+ predicate,
2041
+ onLoadError: 'fail', // default — raises RowFilterUnavailable
2042
+ // onLoadError: 'deny', // degrade to zero rows instead
2043
+ })
2044
+ ```
2045
+
2046
+ - **`'fail'` (default)** — the request fails with the typed
2047
+ `RowFilterUnavailable`. Handle it in your UI as an error state, the same as any
2048
+ other failed request.
2049
+ - **`'deny'`** — refusal is expressed as a predicate matching nothing, so the
2050
+ read returns an empty result. Choose this only if you have looked at the screen
2051
+ and are content for it to render empty during an outage. Your `onError`
2052
+ reporter still fires, so the failure stays findable in logs even though the
2053
+ response is a 200.
2054
+
2055
+ The default changed *to* `'fail'`, and the reasoning is worth stating plainly
2056
+ because the old default looked defensible: an empty result for an infrastructure
2057
+ failure is byte-identical to legitimate emptiness. The user reads "you have no
2058
+ tickets". The operator reads a healthy 200. The outage is invisible to both —
2059
+ the most misleading outcome on offer. Every constrained page **is** broken when
2060
+ this happens, and saying so is the only outcome either party can act on.
2061
+
2062
+ ### There is no fail-open option
2063
+
2064
+ A frequent request, and a deliberate refusal: there is no policy that serves
2065
+ **unfiltered** rows when the filter is unavailable, falling back to whatever
2066
+ check the handler carries.
2067
+
2068
+ Failing open on an authorization filter leaks data precisely when the system is
2069
+ under stress and nobody is reading dashboards. And it is only safe if every
2070
+ handler still carries its own row-level check — which is the entire thing a row
2071
+ filter exists to remove. A codebase where fail-open is safe is a codebase that
2072
+ did not need `setRowFilter`.
2073
+
2074
+ Both policies above are fail-**closed**: neither can ever produce an unfiltered
2075
+ read.
2076
+
2077
+ ### Subscriptions
2078
+
2079
+ A resolution failure mid-stream **revokes** the subscription and emits a typed
2080
+ error frame, rather than delivering an empty snapshot — an empty snapshot on a
2081
+ live subscription reads to a client as "every row you could see was just
2082
+ deleted". Make sure your subscription error handling surfaces it.
2083
+
2084
+ ## What does *not* bypass it
2085
+
2086
+ | | Bypasses the row filter? |
2087
+ |---|---|
2088
+ | `.unscoped()` / `crossTenant` | **No** |
2089
+ | a `system` subject | Yes |
2090
+
2091
+ `.unscoped()` and `crossTenant` exist for legitimate cross-tenant admin reads.
2092
+ They opt out of **tenant isolation**, not out of **authorization** — letting them
2093
+ also drop row visibility would turn an isolation opt-out into an authorization
2094
+ one, which is exactly the silent widening this feature exists to prevent.
2095
+
2096
+ Only a `system` subject bypasses, because a system subject is the framework
2097
+ acting as itself — janitor sweeps, migrations, the scheduler — rather than on
2098
+ behalf of a user. That bypass is deliberately the narrow, explicit one.
2099
+
2100
+ Apps that register no filter pay nothing.
2101
+
2102
+ ## Subscriptions re-resolve it
2103
+
2104
+ A subscription is the one read path that stays open for hours, so it is the one
2105
+ where a stale filter would matter most. Before every delivery the runtime
2106
+ re-derives the read from the **unfiltered base descriptor** and re-applies the
2107
+ freshly resolved filter.
2108
+
2109
+ A membership that ends mid-subscription therefore stops serving rows — the
2110
+ caller's open ticket list drops the rows they can no longer see, without a
2111
+ refresh and without the subscription having to be torn down.
2112
+
2113
+ ## Row filters vs. guards
2114
+
2115
+ They answer different questions, and a complete policy usually wants both:
2116
+
2117
+ | | Question | Failure |
2118
+ |---|---|---|
2119
+ | [`guards:`](/docs/authentication/authorization#enforce-it-declaratively-guards) | May you call this procedure? | typed `ScopeError`, before the executor runs |
2120
+ | `setRowFilter` | Which rows may you see? | the rows are simply absent — unless the filter itself could not load, which is a typed `RowFilterUnavailable` |
2121
+
2122
+ A guard is the right tool for "may this caller edit *this* ticket". A row filter
2123
+ is the right tool for "which tickets appear in the list at all" — a question a
2124
+ guard cannot answer, because there is no single resource to name.