or be listed in the new\n `IndexSpec.queryableFields`, else the call fails with `SearchFieldRejected`.\n\n`search.query` now carries a wire error union (`SearchIndexNotFound | SearchFieldRejected`),\nwhich existing callers decode as a rejected promise exactly as they already do for any\nother typed error. If your app depended on one of the closed behaviours, the fix is a\ndeclaration — declare the index, or add the key to `allowedEngineParams` — not an edit to\na call site.\n\nSearch also stopped losing an index update to a single engine failure: a `transient`\nfailure is retried with backoff, and anything that outlives its retries is written to a\n`_voltro_search_drift` ledger that a coordinated sweep repairs by re-reading the row.\n\n### `billing.plan()` no longer downgrades on the first decline\n\n`plan()` used to answer `'free'` the instant a subscription went past-due — a bounced card\ndowngraded the customer on the same second, with no grace at all, while the docs promised\nthe opposite. It now answers the paid plan for the whole grace window (168 h by default)\nand falls back only once the lockout is real, and only under `lockout: 'hard'`.\n\nIf you wrote your own `BillingProvider`, it gained a **required** `fetchSubscription`\nreturning the subscription's CURRENT status — it is what dunning refuses to lock a\ncustomer out without. `BillingEvent` gained `occurredAt` on every variant; `Subscription`\ngained `pastDueSince` and `statusEventAt`. See\n[`@voltro/plugin-billing`](/docs/plugins/billing).\n\n### A resumed workflow re-resolves its caller's authority\n\n`_voltro_workflow_start_contexts.subject` held the caller's whole `Subject`, scopes\nincluded. A cluster runner read it back — a different process, possibly days later — and\nran the workflow with the authority the caller had at *start* time.\n\n> **Identity is persisted. Authority is re-resolved at resume, or absent.**\n\nIdentity — type, id, tenantId, metadata — still survives, so a workflow started by tenant A\nstill acts on tenant A's rows in three days' time. Authority comes from your own\n`auth.resolveScopes` on every execution attempt, with `ctx.origin === 'workflow'`. **An app\nthat wires no resolver gets runs with no scopes** — the fail-closed direction, and exactly\nwhat a cookie-authenticated caller already gets on the request path.\n`{ kind: 'unavailable' }` fails the attempt loudly rather than running with less authority\nthan the caller has.\n\n`auth.resolveScopes`'s context grew a discriminator and lost a guarantee: `ctx.origin` is\n`'request' | 'workflow'`, and **`ctx.clientId` is now `number | undefined`** — a workflow\nexecution has no connection, and `ctx.headers` is `{}`. That type change is where a\nresolver reading `clientId` sees the compile error.\n\nNo migration to run. Rows written by the previous version still contain scopes and are\n**stripped on read**.\n\n### Soft re-auth must present a credential\n\n`bindConnectionSubject(clientId, subject)` stored a resolved `Subject`, and the auth\nmiddleware short-circuited on it for the life of the connection. Three things stopped\nhappening, none audible: the session-revocation check, `resolveScopes` and the scope\ncache, and the credential-expiry record that stops a subscription outliving its token. And\nit existed **only under `voltro dev`** — the framework's own switch-tenant rebind worked in\ndevelopment and was a silent no-op in production.\n\n`bindConnectionCredential(clientId, { cookies, headers })` patches the connection's headers\nand the middleware runs the same chain a fresh request runs. **A rebinder must now have a\ncredential to present** — pass the one you were already about to `Set-Cookie`\n(`issueSession(...)` returns `{ value, setCookie }`), or a Bearer header for a token app.\n\n`onBindConnectionSubject` and `connectionSubjectsSnapshot` are **gone with no replacement**,\nbecause nothing read them. `unbindConnectionSubject` is now `unbindConnection`.\n\n### Retention sweeps that will delete existing history\n\nSeveral tables that nothing ever deleted from are now bounded. **The first sweep runs\nshortly after boot and drains the backlog in batches.** If you need any of this history,\nraise the window with the environment variable *before* you deploy — an app's own\n`registerRetention` also outranks a framework default, and app registrations now win over\nplugin and framework ones instead of losing silently.\n\n| Table | Default | Override |\n|---|---|---|\n| `_voltro_ai_flow_runs` (terminal runs only) | 90 days | `VOLTRO_AI_FLOW_RUNS_TTL_HOURS` |\n| `_voltro_undo_log` | 30 days | `VOLTRO_UNDO_LOG_TTL_HOURS` |\n| `_voltro_ai_inferences` (completed only) | 30 days | `VOLTRO_AI_INFERENCES_TTL_HOURS` |\n| `_voltro_stream_events` + `_voltro_stream_state` | 7 days | `VOLTRO_STREAM_LOG_TTL_HOURS` |\n| `_voltro_prompts` (by `lastUsedAt`) | 365 days | `VOLTRO_AI_PROMPTS_TTL_HOURS` |\n| `_voltro_workflow_admissions`, `_voltro_workflow_start_contexts` | 30 days | — |\n\n`_voltro_undo_log` is the one to look at first: it is the only table here that grows with\n**user traffic** rather than with a timer, and it backs the per-subject \"what can I undo\"\nfeed, so a user seeing their list truncated is a visible loss.\n\nMedia artifacts are **not** swept with an ai-flow run — a run's steps carry hosted URLs\nwhose blobs belong to the storage plugin. Keep the TTL at or above your media-purge\nwindow, or purge by run id before the row ages out.\n\n### Oversized change payloads are rehydrated\n\nUnder postgres CDC the NOTIFY trigger is the *sole* emitter, and `pg_notify` caps a payload\nat 8000 bytes. A wide row fell back to a payload with both images null — so the search\nindex permanently missed it, the analytics mirror returned early, `cdc-out` outboxed the\nnulls, `plugin-versioning` recorded no history row, and no retry could repair any of it,\nbecause the content was never delivered.\n\nThe trigger keeps the **primary key** in the oversized fallback now, and the LISTEN\nconsumer re-reads the row before the event reaches anything. `ChangeEvent.oversized` says\nwhat was recovered: `'rehydrated'` (the row as it is NOW, not necessarily the image that\nfired the event), `'tombstone'` (the primary key and nothing else — enough to remove the\nrow downstream), or `'unrecovered'` (logged at `error`, and the counter to alert on).\n\n**This lives in the database, so it has to be applied.** The notify function carries a\nversion marker; `voltro db apply` replaces an older one and `voltro dev` names it at boot.\nUntil then those changes report `'unrecovered'`. Tunables:\n`VOLTRO_CDC_REHYDRATE_TIMEOUT_MS` (5000) and `VOLTRO_CDC_REHYDRATE_RETRIES` (2).\n\n### Smaller behaviour changes worth knowing\n\n- **`voltro serve` honours `port` from `app.config.ts`.** It computed the port one line\n before it loaded the config, so an app declaring `port: 4130` ran on 4130 under\n `voltro dev` and 4000 under `voltro serve`. One precedence everywhere now:\n `VOLTRO_DASHBOARD_PORT` → `PORT` → `--port` → `app.config.ts` → 4000 (api) / 5173 (web).\n An unusable value (`PORT=`, `PORT=8080x`) is ignored with a warning instead of making\n node bind a random free port and report itself ready.\n- **Plugin `onHttpRequest` interceptors now run under `voltro serve`.** They ran under\n `voltro dev` only, so `ratelimitPlugin({ http })`'s pre-auth IP shield was dead in\n production and live in development. `/internal/liveness` and `/internal/readiness` are\n answered *before* the interceptor, so a shield cannot 503 a probe.\n- **`publicApi:` REST routes honour `Idempotency-Key` under `voltro serve`.** They\n honoured it in dev and ignored it in production, so a retried POST executed the mutation\n twice — no error, no log.\n- **A plugin's `extendSchema.migrations` now runs in production paths** (`voltro db apply`,\n `voltro migrate --create-only`), not only under `voltro dev`.\n- **`lifecycle: 'cron'` and `'onSchemaChange'` seeds actually fire.** Both were discovered,\n validated, ledgered and never run. Cron seeds now ride the coordinated scheduler and fire\n once fleet-wide.\n- **Stuck-run detection runs.** `sweepStalledRuns` had nine tests and no callers; it is\n armed on both boot paths and records a `run-stalled` event. It changes no run state.\n- **Reactive-trigger drift is repaired at boot** on postgres, on both boot paths, under the\n migration advisory lock (`reactiveTriggers: 'repair' | 'report' | 'off'`).\n- **`voltro data restore` compares databases** instead of refusing because *some* voltro is\n running on the machine, and a failed `voltro data backup` no longer leaves a partial file\n that looks like a backup.\n- **The undo wire surface reads only `VOLTRO_UNDO`**, not `NODE_ENV` — a build artefact\n must not freeze an environment answer.\n- **`insertMany` chunks at each engine's bind-parameter ceiling** (postgres 65 535, mssql\n 2 098) inside one transaction, so it stays all-or-nothing.\n- **Read-replica pools take the primary's settings**, TLS included. The same process could\n previously encrypt its writes and read in the clear.\n- **Waiting for a free pooled connection is bounded by default** — 10 s,\n `DB_ACQUIRE_TIMEOUT_MS` / `ConnectionConfig.acquireTimeoutMs`, `0` to opt back into the\n driver's unbounded wait. Postgres gives the full guarantee; mysql/mariadb can only bound\n the connect half, and its length-based alternative (`DB_ACQUIRE_QUEUE_LIMIT`) is\n **deliberately unset** — past the limit mysql2 rejects the acquire *synchronously*, so\n anything retrying without a delay spins in-tick and starves the event loop.\n- **A MySQL/MariaDB index no longer takes a 191-character prefix on a shorter column.**\n `text().maxLength(32)` renders `VARCHAR(32)`, which is directly indexable and *rejects* a\n prefix — `ERROR 1089`. MariaDB has no transactional DDL, so a migration stopped on a schema\n that was neither the old one nor the new one. Invisible on any environment that already had\n the column, because only the `ADD COLUMN → CREATE INDEX` path resolved the type that way.\n- **`voltro migrate --create-only` no longer bootstraps a database missing every plugin\n table.** It assembled its own table set while every other command used the shared one.\n- **`interactive: 'islands'` ships exactly the same JavaScript as `'full'`** — measured on\n one page, 195,231 B vs 195,229 B. The docs previously claimed \"no JS bundle\", \"strips the\n page's React runtime\" and a savings table that was fabricated. `interactive: 'none'` is the\n one mode that removes bytes. There is a committed bundle budget now — first load is\n 190.4 KB gzipped on the reference fixture.\n- **`GET /_voltro/inspect/migrations` is served on both boot paths.** It had no route at all,\n so `voltro db plan --against \u003curl>` — which fetches exactly that — had never worked\n anywhere. A dev-only inspect endpoint now also says *which* kind of 404 it is.\n- **The framework's own background-task spans are no longer persisted for being slow.** A\n saturated pooler made housekeeping reads slow, slow spans counted as \"interesting\", and\n persisting them cost more pool — one consumer reached 476 571 trace rows / 335 MB in three\n hours. Errors on a background span are still persisted, and `VOLTRO_TRACING_PERSIST=all`\n still keeps everything.\n- **The outbox delivery poll stops when the queue is empty**, where a change channel exists.\n Without one it keeps its fixed tick, because the poll is then the only thing that can\n notice another replica's row.\n- **A raw SQL read in a live query now warns when it declares no `dependsOn`** — it used to\n deliver its first snapshot and never update again, silently. And `dependsOn` now actually\n does something for a computed query: the declared tables join the query's own `source:` set.\n- **`webhooksPlugin()` exists** — add it to `plugins:` and webhook delivery appears in the\n boot permission report. Nothing else changes; the declaration buys visibility, not\n restriction.\n- **`voltro e2e` is documented as what it is** — a tsx script runner, not Playwright. The\n docs taught `@playwright/test` against a CLI that has no such dependency. A spec now also\n receives `API_URL` alongside `WEB_URL`. See [testing](/docs/testing/overview).\n- **`ctx.email` / `MockEmail` are deleted.** They had zero producers — a mail-sending\n handler under test could not run at all. `invoke` now provides every registered plugin's\n `services:` layer plus your app's `layers:`, so you assert through the mail plugin's own\n memory provider.\n- **`workflow({ messages: { queries } })` is removed.** There was never a send path. Use\n `updates` for request/response and `signals` for fire-and-forget; delete the `queries:`\n block.\n- **`HUMAN_RESPONSE_SIGNAL` is removed from `@voltro/plugin-ai-flows`.** One constant served\n every human step, so a flow's *second* review resolved itself with the *first* answer.\n Signal names are per-step now. **Runs already parked when you deploy were parked under the\n old name — answer them first, or cancel and relaunch.**\n- **`workosAuthorizationUrl` is replaced by `workosBeginLogin`**, returning\n `{ url, state, codeVerifier }`. `state` and PKCE are mandatory and the callback verifies\n both; `workosAuthenticateWithCode` gains three required fields.\n- **`voltro init` no longer scaffolds an app.** It initialises the current directory as a\n workspace root. `voltro init \u003cname> --api …` exits 2 and prints the `create-project` form.\n\n## 4. New capabilities you may want\n\nBriefly, with links — none of these is required to upgrade:\n\n- **Sign in with Google / GitHub / Apple** without an identity vendor —\n [`@voltro/plugin-auth-social`](/docs/plugins/auth-social). `state` and PKCE are mandatory\n and always ours; account linking defaults to refusing.\n- **Email verification, tenant invitations and user impersonation** in `@voltro/plugin-auth`.\n All three default to off; `emailVerification` defaults to `policy: 'off'` because the\n column arrives NULL on every existing row. Before switching to `'strict'`, set\n `exemptAccountsCreatedBefore` to your deploy instant — that is the difference between\n \"new signups must confirm\" and \"everyone is locked out until they check their mail\".\n- **An MCP client** — agents can consume external MCP servers, routed through the same\n policy layer as your own tools. [MCP clients](/docs/ai/mcp-clients).\n- **Prompt versioning and provenance** — `definePrompt({ id, template })`, a content digest,\n and spend attributed to a prompt revision. [Prompt versioning](/docs/ai/prompt-versioning).\n- **Typed hooks** — `createHooks\u003cAppProcedures>('app')` gives `useSubscription` /\n `useMutation` / `useAction` a literal-union tag with input and output inferred. Note the\n ordering consequence: `rpcGroup.generated.ts` is written by codegen, so a `tsc` on a fresh\n clone needs `voltro codegen .` in front of the api's `typecheck`.\n- **A request-level test harness** — `makeTestApp({ ctx, restRoutes, publicApi, strategies })`\n sends a real request through the framework's own REST pipeline, plus subject factories\n (`user` / `apiKey` / `serviceAccount` / `anonymous` / `system`) and `defineFactory` for\n row fixtures with their parent rows.\n- **A tamper-evident audit log** — a per-writer hash chain plus `verifyAuditChain`. Read the\n guarantee before quoting it: it attests that each chain is intact, not that the log is\n complete.\n- **Workflow `patch('marker')`** for in-body versioning, and a replay-nondeterminism\n tripwire that emits an event rather than failing a run.\n [Workflow versioning](/docs/workflows/versioning).\n- **Dunning** in `@voltro/plugin-billing` — a past-due sequence, a grace period and a\n lockout, composed on the provider's own outcomes.\n\n## 5. Why almost nothing is automatic\n\nEvery codemod in 0.34.0 is `manual`. That is a decision, and the reasoning is worth reading\nbecause it tells you which parts of this upgrade you must actually think about.\n\n**A transform could have stamped `openAccess` on every guardless descriptor.** It would be\nmechanical, it would be complete, and every app would boot immediately — having declared\nits entire surface open on purpose, in one commit nobody reads, with a reason the tool\ninvented. That is precisely the failure the marker exists to prevent, performed at scale and\nwith the framework's signature on it. The gate asks a question only you can answer.\n\n**A transform could have rewritten the WorkOS login call** — and left the callback\nunverified. Half a security migration is worse than none: a generated `state` that nothing\ncompares is worse than no `state` at all, because a code review, a screenshot of the\nauthorize URL and a pen test then all read as \"CSRF is handled\".\n\n**A transform could have stubbed the eleven new `UserStore` methods.** `acceptInvitation`\nreturning `null` compiles, ships, and means \"every invitation is invalid\";\n`markEmailVerified` as a no-op means an app on `emailVerification: 'strict'` refuses every\nlogin forever. A codemod that makes the build pass while turning a security feature into a\npermanent refusal is worse than none, because the red build is the only signal that\nanything is required.\n\n**A transform could not have rewritten your `ctx.email` assertions into anything better.**\nEvery one of them was vacuous — `MockEmail` had zero producers, so `sent` was always empty\nand `toHaveLength(0)` passed while meaning nothing. Rewriting the expressions would have\nturned a green vacuous test into a green vacuous test under a new name.\n\n**And several changes have nothing in your repository to rewrite at all.** The TLS one is\nthe clearest: the affected input is a connection URL in a deployment secret. A transform\ncannot look at a value it cannot see — which is a stronger reason to write the note, not a\nreason to skip it, because the failure mode of not reading it is a container that stops\nbooting on a deploy.\n\n## The short checklist\n\n```bash\nvoltro update # bump + install + print the manual notes\n# read every note it prints — they are gated to your app\n\nvoltro doctor # every undecided procedure, every unverified webhook\n# decide each one: guards: or openAccess:\n\n# check your mysql/mssql DB_URL for ?ssl= / ?sslmode= / ?encrypt=\n# declare security.allowedOrigins if web and api are on different origins\n# declare security.trustedProxies if you rate-limit or audit per IP\n# raise any retention TTL whose history you need, BEFORE deploying\n\nNODE_ENV=development voltro db apply . # locally\nvoltro db migrate . # in the pre-deploy job (schema + files)\n\nvoltro doctor # re-run: access decisions + webhooks now clean\n```\n\n`voltro doctor` covers the access-decision and webhook-verification gates from the same\nresolvers the boot uses, so green there really does mean those two will not refuse. It does\n**not** see the ones whose input is not in your repository — the TLS URL, pending file\nmigrations, `NODE_ENV`, the search backend, `SSR_CACHE`. Work those from the list above.\n\nThen run your own typecheck, and expect your users to sign in once more.\n"},"segments":{"0":{"toc":[{"id":"1-run-voltro-update-first","text":"1. Run voltro update first","level":2},{"id":"what-it-cannot-do-read-this-before-you-assume-the-upgrade-is-done","text":"What it cannot do — read this before you assume the upgrade is done","level":3},{"id":"one-database-side-action-that-is-easy-to-miss","text":"One database-side action that is easy to miss","level":3},{"id":"2-the-changes-that-break-your-boot","text":"2. The changes that break your boot","level":2},{"id":"every-wire-exposed-procedure-must-declare-who-may-call-it","text":"Every wire-exposed procedure must declare who may call it","level":3},{"id":"an-incoming-webhook-must-declare-how-it-authenticates-its-caller","text":"An incoming webhook must declare how it authenticates its caller","level":3},{"id":"a-mysql-mariadb-mssql-connection-that-asks-for-tls-now-gets-it-or-refuses","text":"A mysql / mariadb / mssql connection that asks for TLS now gets it — or refuses","level":3},{"id":"on-postgres-pg-ssl-is-finally-honoured-by-every-command","text":"On postgres, PG_SSL is finally honoured by every command","level":3},{"id":"voltro-serve-refuses-to-boot-with-pending-file-migrations","text":"voltro serve refuses to boot with pending file migrations","level":3},{"id":"an-unset-node-env-now-means-production-for-db-and-migrate","text":"An unset NODE_ENV now means production for db and migrate","level":3},{"id":"plugin-search-on-the-in-memory-backend-refuses-to-boot-in-production","text":"plugin-search on the in-memory backend refuses to boot in production","level":3},{"id":"ssr-cache-postgres-with-no-database-named-now-aborts-the-web-boot","text":"SSR_CACHE=postgres with no database named now aborts the web boot","level":3},{"id":"a-hand-written-userstore-no-longer-satisfies-the-interface","text":"A hand-written UserStore no longer satisfies the interface","level":3},{"id":"3-the-changes-that-alter-runtime-behaviour","text":"3. The changes that alter runtime behaviour","level":2},{"id":"cross-site-writes-are-refused","text":"Cross-site writes are refused","level":3},{"id":"x-forwarded-for-is-no-longer-trusted","text":"x-forwarded-for is no longer trusted","level":3},{"id":"security-response-headers-ship-by-default","text":"Security response headers ship by default","level":3},{"id":"every-existing-session-is-invalidated","text":"Every existing session is invalidated","level":3},{"id":"log-redaction-is-on-by-default","text":"Log redaction is on by default","level":3},{"id":"keyed-writes-are-confined-to-the-caller-s-tenant","text":"Keyed writes are confined to the caller's tenant","level":3},{"id":"crud-create-crud-update-refuse-a-serveronly-column-in-their-input","text":"crud.create / crud.update refuse a .serverOnly() column in their input","level":3},{"id":"typed-errors-reach-the-client-typed-on-namespaced-postgres-delete-your-workaround","text":"Typed errors reach the client typed on namespaced postgres — delete your workaround","level":3},{"id":"the-analytics-mirror-is-versioned-and-tombstoned","text":"The analytics mirror is versioned and tombstoned","level":3},{"id":"search-query-no-longer-trusts-the-caller-s-strings","text":"search.query no longer trusts the caller's strings","level":3},{"id":"billing-plan-no-longer-downgrades-on-the-first-decline","text":"billing.plan() no longer downgrades on the first decline","level":3},{"id":"a-resumed-workflow-re-resolves-its-caller-s-authority","text":"A resumed workflow re-resolves its caller's authority","level":3},{"id":"soft-re-auth-must-present-a-credential","text":"Soft re-auth must present a credential","level":3},{"id":"retention-sweeps-that-will-delete-existing-history","text":"Retention sweeps that will delete existing history","level":3},{"id":"oversized-change-payloads-are-rehydrated","text":"Oversized change payloads are rehydrated","level":3},{"id":"smaller-behaviour-changes-worth-knowing","text":"Smaller behaviour changes worth knowing","level":3},{"id":"4-new-capabilities-you-may-want","text":"4. New capabilities you may want","level":2},{"id":"5-why-almost-nothing-is-automatic","text":"5. Why almost nothing is automatic","level":2},{"id":"the-short-checklist","text":"The short checklist","level":2}]}},"ran":{"page":true,"segments":[0]}}

Upgrading to 0.34.0

The largest release in the framework's history — what breaks your boot, what changes at runtime, and why none of it is rewritten for you.

0.34.0 is the biggest release this framework has shipped. It closes a whole-framework security and reliability audit, and a lot of it lands as changed DEFAULTS. Several of those defaults will stop your app from starting until you make a decision the framework used to make for you.

Budget real time for this one. The largest single change — every wire-exposed procedure must declare who may call it — is a per-procedure judgement call across your whole API surface. An app with fifty procedures is a fifty-line diff and an afternoon of thinking. There is no flag that makes that work go away and still leaves you protected.

This page is ordered the way you will hit the changes: run the upgrade, fix the boot, then read what moved underneath you at runtime.

1. Run voltro update first

voltro update

It does four things, in order:

  1. Refuses on a dirty git tree. Use --dry-run to preview, --force to override. The refusal exists because you need a clean diff to review afterwards.
  2. Bumps every @voltro/* range to the target version across EVERY workspace member — not just the app you invoked it in — and installs once at the workspace root with the package manager your project actually uses. Mixed @voltro/* versions between an api and a web app are a wire- and type-contract hazard, so they move together.
  3. Aligns the @effect/* peer ranges. Those are peer dependencies your app declares directly, so a @voltro/*-only bump would leave you installed against the old ones — compiling and booting on a graph nobody tested.
  4. Re-executes the newly installed CLI to run the codemods that ship with 0.34.0. The old binary cannot know them; that re-exec is the whole reason it exists.

Useful flags: --to <version>, --from <version>, --codemods-only, --dry-run, --force, --exact. Full reference: voltro update.

What it cannot do — read this before you assume the upgrade is done

In 0.34.0 every shipped codemod is manual. All twenty-one of them. voltro update will not edit a single line of your source in this release. What it prints is a list:

Manual steps required (could not be automated):

▸ 0.34.0/03_procedure-access-decision — Every wire-exposed procedure declares an access decision (`guards:` or `openAccess:`)
  YOUR APP WILL NOT BOOT UNTIL EVERY WIRE-EXPOSED PROCEDURE DECIDES WHO MAY
  CALL IT. …

Each note is gated on whether your app is actually affected, so you see only the ones that apply to you. Read every one that prints. Section 5 explains why so much of this release refuses to automate itself — it is a deliberate choice, not missing work.

Two more things voltro update does not do:

  • It does not touch your database. Framework-owned _voltro_* tables ride the declarative differ. Run voltro db apply (or boot voltro dev) after the upgrade and they reconcile on every dialect. Several new tables arrive this way — _voltro_migration_ops, _voltro_cdcout_claims, _voltro_search_drift, _voltro_billing_dunning_notices, _voltro_prompts, plus new columns on the audit log and the auth tables.
  • It does not typecheck. Run your own typecheck afterwards. A few breaks in this release are compile errors rather than boot failures, and that is where you will meet them.

One upgrade trap is fixed in this release rather than introduced by it, and it is worth knowing you are past it. Upgrading the framework without changing any schema used to deadlock a deploy: the migrate job found nothing to do and went green, then every pod refused to boot with SCHEMA FINGERPRINT MISMATCH pointing at the command that had just no-opped. There was no path out through the documented commands — the gate could only be satisfied by an apply that had work to do. An apply with nothing to do now records its fingerprint, so the loop has an exit.

One database-side action that is easy to miss

On postgres, the change-notification trigger function gained a version marker. Until voltro db apply runs, oversized change payloads still report as unrecoverable — see oversized change payloads below. A voltro dev boot now names the outdated function; voltro db apply replaces it.

2. The changes that break your boot

These fire before any request is served. Fix them in this order — the first one is the one that will occupy you.

Every wire-exposed procedure must declare who may call it

This is the largest change in the release. guards: used to default to undefined, and the scope evaluator returns "allowed" for an empty guard list. So a discovered *.query.ts / *.mutation.ts / *.action.ts / *.stream.ts with no guards: was callable by any authenticated session — default-ALLOW at the procedure level. The framework's only structural answer was a voltro doctor scan, which is a report a human runs, not a gate a deploy passes.

security.defaultDeny now defaults to true, and both boot paths refuse:

[access] 3 wire-exposed procedures declare no access decision, and this app runs with `security.defaultDeny`:

    invoices.export  (action)
      apps/api/actions/invoices.export.action.ts
    invoices.list  (query)
      apps/api/queries/invoices.list.query.ts
    notes.create  (mutation)
      apps/api/mutations/notes.create.mutation.ts

  Each of these is callable by ANY authenticated session. Give each one a
  decision — the two are equally acceptable and they are not the same claim:

    guards: [{ scope: 'invoices:read' }]        the caller must hold a scope
    openAccess: 'public pricing, no user data'  anyone may call it, and why

The refusal lists every offending procedure, never a head and a count — the fix is one pass over the whole list, and a reader who fixes twenty and boots again to discover thirty more has been handed a ratchet instead of an answer.

Find them all in one command. voltro doctor prints the same set, from the same function, that the boot refuses on:

voltro doctor
access decisions · security.defaultDeny ON
  ✗ no access decision                       3
  ✓ openAccess, declared on purpose          1
      health.check — liveness probe, returns a constant

voltro doctor --json carries it as accessDecisions.undecided, so you can gate CI on it. Doctor exits non-zero when the next boot would refuse.

Then decide each one. There are exactly two answers, and they are not the same claim:

// apps/api/queries/invoices.list.query.ts
import { defineQuery } from '@voltro/protocol'
import { Schema } from 'effect'

export const listInvoices = defineQuery({
  name:   'invoices.list',
  source: 'invoices',
  guards: [{ scope: 'invoices:read' }],
  input:  Schema.Struct({}),
  output: Schema.Array(Schema.Struct({ id: Schema.String })),
})
// apps/api/queries/pricing.list.query.ts
import { defineQuery } from '@voltro/protocol'
import { Schema } from 'effect'

export const listPricing = defineQuery({
  name:       'pricing.list',
  source:     'plans',
  openAccess: 'public price list — no user data, no tenant scope',
  input:      Schema.Struct({}),
  output:     Schema.Array(Schema.Struct({ id: Schema.String, priceCents: Schema.Number })),
})

openAccess takes a reason, not a boolean. That reason is what a reviewer reads later, and it is what makes "we decided this is open" distinguishable from "nobody looked". It is mutually exclusive with guards:, is refused empty, and is refused on an internal: true procedure — which has no wire surface to decide about.

Three traps worth naming before you start:

  • Do not rubber-stamp. Reaching for a scope every caller already holds satisfies the gate, reads as protection, and enforces nothing. If the endpoint is open, say openAccess.
  • Do not write a guard nobody can satisfy. This is the mirror mistake, and it is a 100% outage wearing security's clothes. Rolling this change across our own reference app produced a first pass of correct-looking, well-reasoned guards where every one was unsatisfiable by the caller the app actually has — an anonymous browser session holds no roles, so the front page answered a scope error to everybody. Probe the posture from both sides against the callers your app really produces, not against a subject a test constructed.
  • A procedure only other server code calls wants neither. Mark it internal: true and it leaves the wire entirely.

If you need to ship before you can finish the pass, the old behaviour is one field:

// app.config.ts
export default {
  security: { defaultDeny: false },
}

That restores default-allow for the whole app, in one place a reviewer can see. There is deliberately no environment variable for it: the only direction anyone reaches for is off, and an env var is how that becomes permanent in one CI job with no diff. voltro doctor keeps listing the undecided procedures while it is off, marked advisory.

Not affected: internal: true procedures, and the procedures your plugins declare. The gate reads your app's own discovered files only — the first-party plugins declare 47 procedures through the same definers with no guards, and a gate that judged them would refuse to boot every app that installs any of them, listing procedures you cannot edit.

An incoming webhook must declare how it authenticates its caller

A defineIncomingWebhook({ … }) with no signature and no provider used to be mounted as an open, unauthenticated POST that runs your application code. No HMAC, no replay window, no warning. Two first-party plugins carry their own verification, which is exactly what made the gap invisible — every example was safe.

It is no longer mountable:

UnverifiedIncomingWebhook: incoming webhook 'orders.paid' declares no verification. It would
be mounted as a public, unauthenticated POST that runs your application code, so the
framework refuses to mount it. Pick one on the descriptor:
  · provider: stripeWebhookProvider()   — a preset (HMAC scheme + idempotency + body type)
  · signature: { _tag: 'hmac', algorithm: 'hmacSha256', header: 'X-Signature', encoding: 'hex', includeTimestamp: true }
  · verification: 'provider'            — the handler verifies with the provider's own SDK
  · verification: 'none'                — deliberately public; a gateway / IP allow-list owns it
A signature-verified webhook reads its shared secret from VOLTRO_WEBHOOK_SECRET_ORDERS_PAID.
The framework never invents that value — the sender holds the other half of it.

Declaring verification: 'signature' with no scheme to verify against is the same open endpoint and lands on the same refusal. verification: 'none' is legitimate — a gateway plus an IP allow-list can own the trust boundary — and is logged as a warning on every boot, on purpose.

The second half can bite a webhook you believed was already verified. A webhook that DECLARED a signature scheme but whose secret did not resolve used to set signatureOk = 'skipped' and run the handler anyway. One missing environment variable silently converted a verified webhook into an open one. It now answers 503 naming VOLTRO_WEBHOOK_SECRET_<UPPERCASED_ID> — a 5xx rather than a 401, because the fault is ours and most providers retry a 5xx. Dots and dashes in the id become underscores, so orders.paid reads VOLTRO_WEBHOOK_SECRET_ORDERS_PAID.

@voltro/plugin-billing's webhook inherits this: a deployment with no webhookSecret now 503s instead of applying anonymous POSTs to subscription state.

voltro doctor reports all of it before a deploy does, under an incoming webhook verification heading — the verified count, every verification: 'none' one by name, and a non-zero exit on any that declares nothing. It is webhookVerification in --json.

A mysql / mariadb / mssql connection that asks for TLS now gets it — or refuses

DB_URL=mysql://…?ssl=true used to connect unencrypted, with no warning and no error. The mysql dialect had no ssl field at all; the mssql one never read it either, while a hard-coded trustServerCertificate: true made the config LOOK TLS-aware and @effect/sql-mssql defaults encrypt to false. The request was not rejected — it was dropped.

Both dialects now follow the posture postgres has had for releases:

URL query Result
?sslmode=require, ?ssl=true, ?ssl=1 (mssql also ?encrypt=1) TLS, certificate not verified
?sslmode=disable, ?ssl=false, ?ssl=0 plaintext, explicitly
anything else throws at boot

"Anything else" is prefer, allow, verify-ca, verify-full, ?ssl=yes and a mysql2 CA-profile name. The cross-dialect ConnectionConfig.ssl is a boolean and cannot carry a verification mode, so answering a request for verify-full with something weaker would be the same defect in a politer form.

Error: DB_URL '?sslmode=verify-full' is not supported by the mysql/mariadb dialect — use
'require' (TLS without certificate verification) or 'disable' (plaintext).

Error: DB_URL '?ssl=yes' is not supported by the mysql/mariadb dialect — use 'true'/'1'
or 'false'/'0'.

Error: DB_URL '?sslmode=prefer' is not supported by the mssql dialect — use 'require'
(TLS without certificate verification) or 'disable' (plaintext).

Before you deploy, check the URL each environment uses. There is nothing in your repository for a tool to inspect here — the value lives in a deployment secret, which is why this cannot be automated and why not reading it looks like a container that stops booting.

  • No ?ssl= / ?sslmode= / ?encrypt= at all → nothing changes. The connection was plaintext and stays plaintext. If it crosses a network you do not own, this is the moment to add ?sslmode=require.
  • ?ssl=true or ?sslmode=require → you were being lied to; that connection has been plaintext. It is real now, so confirm your server accepts TLS before rolling out. MySQL 8.4 and MariaDB 11.4+ ship a self-signed certificate on by default; an older or hardened build may not.
  • Anything else → the app will not boot. Pick one of the two supported modes.

Confirm it from the database rather than from the config:

-- mysql / mariadb: empty = plaintext, a cipher name = encrypted
SHOW STATUS LIKE 'Ssl_cipher';

-- mssql: FALSE / TRUE
SELECT encrypt_option FROM sys.dm_exec_connections WHERE session_id = @@SPID;

On postgres, PG_SSL is finally honoured by every command

This one is not a refusal — it is a connection that starts doing what you configured. The CLI had four hand-written builders reading the same environment into a connection, so PG_SSL=require on a discrete-field connection (DB_HOST / PG_HOST rather than a DB_URL) negotiated TLS under voltro dev and voltro serve and connected in plaintext under voltro migrate, voltro db plan|apply|drift, and the web process's postgres ISR cache. The schema, the queries and the credentials went over the wire in the clear against a database the operator had explicitly configured to require TLS.

There is one resolver now, parameterised by purpose, and the purpose changes exactly two things: DB_STATEMENT_TIMEOUT_MS is runtime-only (a migration runs legitimately long statements) and DB_DIRECT_URL / DB_MIGRATE_URL override DB_URL for the migration path only. Everything else — pool size, DB_SCHEMA, TLS, the acquire bounds — means the same thing in every command.

What you will observe: if you set PG_SSL=require with discrete fields, your migration commands now negotiate TLS where they previously did not. Confirm the server accepts it before your next pre-deploy job runs. Read-replica pools take the primary's settings now too, TLS included — the same process could previously encrypt its writes and read in the clear.

voltro serve refuses to boot with pending file migrations

Serve's schema guard is a declarative fingerprint diff. A file-based *.migration.ts exists precisely for the changes a state diff cannot infer — a data move, a backfill, a cross-table rewrite — and the commonest of those move no fingerprint at all. So the guard passed and production ran un-migrated with nothing said.

serve: refusing to boot — 2 pending file-based migration(s) have never run against this
database. They perform the changes a schema diff cannot infer (data moves, backfills,
table splits), so the declarative fingerprint check below cannot see them and would have
let this process serve un-migrated data.

Run them from your pre-deploy job — `voltro db migrate .` (schema + files) or
`voltro db files .` (files alone) — or set VOLTRO_AUTO_MIGRATE=0 to bypass every boot
schema check. `voltro serve` never applies them itself: a rolling deploy would start N
replicas and each would try.

Add the step to the pre-deploy job you already run:

voltro db migrate .      # schema diff AND file migrations

…or, if you apply schema through the reviewed-plan path, run the files first and regenerate the plan afterwards — a file migration changes the shape the plan was computed against:

voltro db files .
voltro db plan --json > plan.json
voltro db apply --plan plan.json

Serve deliberately will not apply them for you: a rolling deploy starts N replicas, each would try, and the migration lock turns that into N-1 processes blocked on boot. This only fires on a real deploy environment (production / staging) against a SQL store — a local voltro serve is untouched, because voltro dev applies them there.

An unset NODE_ENV now means production for db and migrate

if (!process.env.NODE_ENV) process.env.NODE_ENV = 'production' used to live in three serving-command files and nowhere else, so voltro db apply and voltro migrate ran the DEV branch of every gate. One decider now covers all of them: undeclared resolves to production for serve, start, db and migrate, and to development for dev.

voltro db apply: refusing — auto-apply on prod is not allowed.
  NODE_ENV was not set, so this run resolved to PRODUCTION — the same way `voltro serve`
  resolves it, which is what keeps the declared schema of a pre-deploy job identical to
  the serving container's. Set NODE_ENV=development for a local database.
In production, schema changes go through `voltro db plan --json > plan.json`
then `voltro db apply --plan plan.json` as an explicit step in your deploy pipeline.

On a laptop, declare it:

NODE_ENV=development voltro db apply .

voltro dev is unaffected — it declares development itself. voltro db rollback-file refuses under the same rule.

This is not cosmetic, and the reason is the table set. _voltro_traces and _voltro_undo_log are created only when tracing / undo capture are on, and both are "on unless production". A pre-deploy voltro db apply with NODE_ENV unset therefore DECLARED those two tables and the voltro serve it fed did not. The declared set is what the schema fingerprint hashes, so the apply recorded a fingerprint the serving container could not reproduce, and serve refused with prod-mismatch — telling you to run voltro db apply, which you had just run. If you have ever seen that loop, this is why.

One caveat: the launcher runs before .env is loaded, so a NODE_ENV that exists only in .env cannot influence its "production requires a precompiled serve bundle" guard. Set it in the process environment for a non-production voltro serve. Everything downstream of the launcher does honour .env.

plugin-search on the in-memory backend refuses to boot in production

The zero-config search backend is an in-process Map, and it was wrong in production in two independent ways, both silent: per-process (N replicas hold N divergent indexes) and non-durable (the index starts empty after every restart and nothing re-seeds it — backfillIndex() is a function your app calls, not a boot step).

SearchBackendNotDurable: plugin-search refuses to boot in production on the in-memory backend.

The memory index lives in THIS process's heap. Two consequences, both silent:
  • every replica holds a different index, so a result depends on which replica served you;
  • the index starts EMPTY after every restart/deploy, and nothing re-seeds it automatically.

The durability half is why this is a refusal rather than a warning, and why it is not conditional on detecting a cluster: one replica does not make memory correct, it only removes one of the two ways it is wrong.

// app.config.ts — a durable engine, the normal answer
import { searchPlugin } from '@voltro/plugin-search'

export const search = searchPlugin({
  backend: { engine: 'meilisearch', url: process.env['MEILI_URL']!, apiKey: process.env['MEILI_KEY']! },
  indexes: [],
})

'typesense' and 'algolia' are the other two. Nothing else changes — the index specs, the sync and the query surface are identical.

If your deployment genuinely is one process that re-seeds at startup, say so:

// app.config.ts — a CLAIM about your topology, not a mute switch
import { searchPlugin } from '@voltro/plugin-search'

export const search = searchPlugin({ singleProcessMemoryIndex: true, indexes: [] })

Both halves of that claim have to be true: exactly one process serves search, and every index is re-seeded at startup via backfillIndex(). The second is the one people forget — without it search silently returns nothing after each deploy. The flag is not a mute switch either: if the instance-membership registry later reports a peer replica, the plugin logs that the claim has been contradicted, in any environment, because an observed peer is a fact rather than a guess about NODE_ENV.

voltro dev is untouched and completely silent. Note that backend: memoryBackend() now returns a tagged value, so it is recognised as the same deployment as backend: 'memory' — it used to read as an opaque custom backend and would have walked straight past this check. If that is how you configured it, you are in scope. See @voltro/plugin-search.

SSR_CACHE=postgres with no database named now aborts the web boot

voltro start gated the postgres ISR cache on PG_HOST, and the CDC invalidator on PG_HOST || PG_DATABASE — while the real resolver prefers DB_URL, which is what every template and the deployment docs configure. So an app configured the documented way, explicitly asking for SSR_CACHE=postgres, silently got the per-process memory cache, announced by an info line that reads like the default rather than a refusal. Every route declaring cacheInvalidatesOn got no live invalidation at all, at debug level.

SSR_CACHE=postgres, but nothing in the environment names a database (looked for DB_URL,
DB_PRIMARY_URL, DB_DIRECT_URL, DB_MIGRATE_URL, DB_HOST, PG_HOST). Refusing to fall back to
the per-process memory cache: it is not shared between instances and does not survive a
restart, so the pages this process serves would differ from its replicas' with nothing to
indicate it.

Error: [voltro start] SSR_CACHE=postgres with no database configured

Fatal on a deploy environment; a loud warning otherwise. Both the cache and the invalidator now read the same resolver every other connection uses, so PG_SSL, DB_SCHEMA and the pool-acquire bounds arrive with them.

A hand-written UserStore no longer satisfies the interface

This one is a compile error, not a boot refusal — but it stops your build, so it belongs here. UserStore gained eleven methods (email verification, tenant invitations, impersonation): markEmailVerified, latestToken, seven *Invitation* methods and three *ImpersonationGrant* methods.

If you use memoryUserStore or postgresUserStore, there is nothing to do — both implement everything, and one contract test runs against both so they cannot drift.

If you wrote your own, tsc will name the missing members and the codemod note spells out what each must do. Two of them must be a single statementacceptInvitation and endImpersonationGrant — because a read-then-write lets two clicks on one emailed link both succeed. And do not copy the orElseSucceed(() => null) pattern the read paths use: failing open on a read answers "not found", which is the refusal the caller would make anyway; failing open on an invitation write answers "invited" to someone who was not.

Also breaking, smaller: SendEmailInput['kind'] gained 'email-verify' and 'invitation'. A sender that switches exhaustively over it needs two more arms. See user stores.

3. The changes that alter runtime behaviour

Your app boots. These are what moved underneath it.

Cross-site writes are refused

Neither POST /rpc nor the WebSocket upgrade had any origin check. Framework-wide CSRF protection rested entirely on the SameSite=Lax cookie default — which still permits top-level navigation POST, is caller-overridable, and does nothing at all for a bearer/JWT flow.

Every request whose method can change state (anything but GET/HEAD/OPTIONS) is now origin-checked: the rpc endpoint, the /ws upgrade, every REST route projected from a publicApi: mutation, everything in apiConfig.restRoutes, and POST /v1/api-keys. A refused request gets a terse, identical answer regardless of reason:

HTTP/1.1 403 Forbidden

origin not allowed

The polarity is inverted from the obvious design on purpose. A list of guarded paths has to be extended every time a surface is added, and the one that gets forgotten is the one nobody remembered was reachable; forgetting to declare an exemption produces a 403 someone reports, while forgetting to add a guard produced nothing at all.

You must act if your web app is served from a different origin than your api — a split web/api deployment, or a browser dashboard on its own host. Otherwise every mutation, every REST write and every socket from that page now 403s.

// app.config.ts
export default {
  security: {
    allowedOrigins: ['https://app.example.com', 'https://admin.example.com'],
  },
}

Env overrides for a deployment you cannot rebuild: VOLTRO_ALLOWED_ORIGINS (comma-separated), VOLTRO_ORIGIN_GUARD=off. An unrecognised value falls back to enforcing, never to off.

What is not affected, because this matters more than the rule: a request carrying neither Origin nor Sec-Fetch-Site did not come from a browsing context and is allowed. That is your in-process SSR loaders, every mobile SDK, curl, every service-to-service caller and every webhook sender. Only a browser sends Origin, and a browser cannot be made to omit it on a cross-origin POST or a WS handshake.

Reads (GET) are not checked. The inspect surface is exempt (token-gated, and designed to be read cross-origin by both dashboards), as are incoming *.webhook.tsx mounts (signature-verified) and four first-party plugin routes whose authority is a signature, a signed ticket or a bearer token rather than the browser's cookie — SAML, the two storage uploads, the billing webhook and SCIM. If you author a plugin route a third party's browser legitimately POSTs to cross-site, declare originGuard: 'exempt' on it.

The loopback dev loop is unaffected — both sides loopback is accepted, which is exactly voltro dev's layout. One dev case does need a line: reaching a dev server from a phone on your wifi makes the page http://192.168.1.5:5190, which is not loopback, so the socket is refused. Add that origin to allowedOrigins while you test. Widening the carve-out to "both sides are private addresses" reads as the same argument one step out and is not: that shape is also a self-hosted internal deployment, where it would weaken production.

x-forwarded-for is no longer trusted

The pre-routing interceptor took the header's first token verbatim as the client address. x-forwarded-for is a request header, so any client can write it: one extra header per request defeated a per-IP rate limit, moved a geo-block, and put an attacker-chosen string into every audit row.

The default is inverted. With nothing configured, the client address is socket.remoteAddress and the forwarded chain is ignored entirely.

Act on this if you run behind a load balancer and rate-limit, geo-block or audit per IP. Without it every request counts (and is recorded) against your proxy's address instead of the caller's — the limiter still works, it just bins everyone together.

// app.config.ts
export default {
  security: {
    trustedProxies: ['private'],      // RFC1918 + CGNAT + link-local + ULA
    // ['loopback']                   — local / docker-compose
    // ['10.0.0.0/8', 'fc00::/7']     — explicit CIDRs
    // ['2']                          — trust 2 hops (express's convention)
    // ['*']                          — any peer; only when your ingress OVERWRITES XFF
  },
}

VOLTRO_TRUSTED_PROXIES (comma-separated) is the env override. The same setting decides whether x-forwarded-proto is believed, which gates HSTS below.

This reaches sessions.ipAddress too. @voltro/plugin-auth's sign-in and MFA-verify routes and @voltro/plugin-auth-social's OAuth callback used to record the raw header, so the one column a breach investigation leans on recorded whatever the caller typed. They read the resolved address now. If you wrote a plugin HTTP route that reads headers['x-forwarded-for'], switch it to req.remoteAddr — the header is still there, but it is not evidence of anything until you say whose proxy you believe.

Security response headers ship by default

The framework sent exactly one, and only on served storage blobs. Every response from the api listener now carries:

content-security-policy: default-src 'none'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'
x-frame-options: DENY
referrer-policy: no-referrer
x-content-type-options: nosniff
strict-transport-security: max-age=15552000; includeSubDomains   (https only)

A text/html response gets a relaxed policy instead (frame-ancestors 'none'; base-uri 'none'; object-src 'none') so a plugin docs page still renders. A route that sets a header itself always wins; the framework only fills gaps.

HSTS is emitted only over https and never carries preload — preload is effectively irreversible for a domain, so it must be a deployment decision. Cross-Origin-Opener-Policy and Cross-Origin-Resource-Policy are deliberately not defaulted; both are available through extra.

Configure with security: { headers: { mode, csp, cspHtml, hsts, extra } }; env overrides are VOLTRO_SECURITY_HEADERS=off|default|strict, VOLTRO_CSP, VOLTRO_CSP_HTML, VOLTRO_HSTS, each accepting off. mode: 'strict' applies the API policy to HTML too, which blanks @voltro/plugin-openapi's /docs page — it loads its viewer from a CDN.

Act on this if you serve HTML of your own through a plugin HTTP route on the api listener and it relies on framing or a <base> tag.

Every existing session is invalidated

The session cookie embedded the whole Subject, scopes included, signed at login. Verification was an HMAC check plus exp and nothing else. With a 7-day default lifetime and sliding-window renewal re-signing the old payload, a user whose role was narrowed kept the old authority in a cryptographically perfect cookie for a week — and indefinitely as long as they stayed active. Full-session revocation worked; revoking one permission did not take effect at all.

The cookie now carries identity only, and the payload is versioned. A cookie minted under the old contract fails to decode, because reading it leniently as "a subject with no scopes" would authenticate someone under a contract we no longer hold.

Every live session ends at deploy and your users sign in once more. Plan it like a secret rotation. Session rows are untouched.

Authority is re-resolved per request through auth.resolveScopes, which can now say which claim it is making:

  • { kind: 'authoritative', scopes } — this resolver is the complete answer, so narrowing works;
  • { kind: 'unavailable', reason } — the source could not be reached; the request fails closed;
  • a bare array — still means { kind: 'grant' } and is unioned exactly as before, so an already-written resolver keeps its exact meaning with no compiler error.

The framework caches the resolution for DEFAULT_SCOPE_CACHE_TTL_MS (30 s) — deliberately the same window the session revocation check already used, so an operator reasons about one number. Drive it to zero with VOLTRO_AUTH_SCOPE_CACHE_TTL_MS=0 or auth: { scopeCache: { ttlMs: 0 } }, or hold your own cache and invalidate it inline from the mutation that changes a role.

Not affected: api-key and JWT strategies already resolved scopes per request. What they gain is a way to be narrowed — an authoritative resolver overrides a token's own scope claim, which union-only could never do. See sessions and authorization.

Log redaction is on by default

It was opt-in with an empty default, so a handler that logged a request body or a header bag shipped password, authorization and set-cookie verbatim to stdout and to every registered sink. Every logger surface now installs a default redactor: exact key matches (normalised, so api_key = API-KEY = apiKey), key fragments for the compound names real code writes (newPassword, stripeApiKey), and a value-shape scan that masks Bearer …, a JWT, sk_/pk_/whsec_ keys, cloud key ids and PEM private keys.

Deliberately not an entropy heuristic — a trace id, a content hash and a git sha are all long and high-entropy, and a redactor that eats the fields an incident is read through gets switched off wholesale.

redactKeys now adds to that list instead of being the whole of it, and there is no way to subtract. The redact transform still exists as the escape hatch and runs after the built-in redactor, so it can mask more and structurally cannot unmask.

What you will observe: a field that used to print a secret now prints [redacted]. If a log line you relied on went quiet, rename the field — traceId, requestId and cookieName are untouched.

Keyed writes are confined to the caller's tenant

ctx.store.update(table, id, patch), delete(table, id), hardDelete(table, id) and patchJson(table, id, …) on a tenant()-scoped table now resolve the target row inside subject.tenantId before writing. Every other path was already enforced, so a mutation that took a row id from request input was the one way left to write across tenants — silently, and with nothing in the code to review.

Your code must change where it read the old return value as "not found". The call now fails with the new TenantRowNotFound instead of returning null / false:

import { Effect } from 'effect'

declare const program: Effect.Effect<void, { readonly _tag: 'TenantRowNotFound' }>

export const handled = program.pipe(
  Effect.catchTag('TenantRowNotFound', () => Effect.succeed('no such row in your tenant')),
)

Declare it in the mutation's error: union to surface it typed. The error is raised identically whether the row is missing or foreign and carries nothing that separates them: reporting forbidden-vs-not-found would make every keyed write a cross-tenant existence oracle.

Unaffected: non-tenant() tables, subjects with no tenant (schedules, resumed workflows, *.subscribe.ts), reads, inserts, updateMany/deleteMany, and the fluent update(t).where(…) / delete(t).where(…) builders — those were already scoped and are not scoped a second time.

@voltro/runtime's StoreError union gains TenantRowNotFound and ServerOnlyColumnWrite, so an exhaustive switch over it needs the new arms.

crud.create / crud.update refuse a .serverOnly() column in their input

.serverOnly() is the wire-exposure axis — the column never crosses the boundary. Redaction only ever enforced the outbound half, while the create path inserted the raw input, so a descriptor whose input schema happened to include a serverOnly column let a client set a column it is not allowed to read. That is mass assignment.

A payload that sets one now fails with ServerOnlyColumnWrite naming the offending columns, and nothing is written. Refused rather than silently stripped: a stripped field makes an attack indistinguishable from a no-op. A key present with the value undefined does not count as sent, so optional schema fields are unaffected.

Fix: drop those columns from the descriptor's input schema. When the server legitimately needs to write one, do it from the handler with ctx.store.insert / ctx.store.update — those are unchanged.

Typed errors reach the client typed on namespaced postgres — delete your workaround

A typed error thrown inside a multi-tenant (namespaced) transaction on postgres used to arrive as an untagged defect. runInNamespace() settled its program with a wrapper that copies message and a decorated name and nothing else — no _tag, no payload, no prototype — so the rpc encoder could not match the failure against a mutation's error: union and could only ship it as a defect. Every operation of a withNamespace() view routes through that entry point, so for an app using physical tenant isolation this was every typed mutation error, and the failure mode was silence: no crash, just an error._tag === 'NotFoundError' branch that was never taken.

If your app pattern-matched the boxed shapeerror.message === 'ValidationError', or a catch treating every namespaced mutation failure as a defect — that workaround now sits in front of a correctly tagged error. Delete it and branch on _tag.

Three more per-dialect drifts closed with it: write attribution is no longer dropped on postgres transactional() (so traceId / subjectId stop landing absent, which is a legal value meaning "no request behind this write"), namespaced postgres transactions now retry a serialization failure or deadlock, and a conflict raised at COMMIT is now retried on all four dialects rather than only sqlite/turso.

The analytics mirror is versioned and tombstoned

The CDC mirror said at-least-once in its own header and was at-most-once in its body: one forked promise per change, no retry, no reconcile path. A single warehouse blip lost that row permanently. The ClickHouse version was read from the clock inside the sink, so two rapid updates arriving out of order handed the stale image the higher version and let it win forever.

Three things you must do:

  1. A custom AnalyticsMirrorImpl is a compile error until updated. upsert and remove take one object now: upsert({ table, row, version }) / remove({ table, primaryKeyValue, version }). Deliberate — the old shape had nowhere to put the version.
  2. Drop your mirror tables once. They gained version and is_deleted, and are created with CREATE TABLE IF NOT EXISTS, so drop an existing voltro_mirror_<table> / _voltro_mirror_<table> and let the next boot re-create it.
  3. Add is_deleted = false to every analytical query that reads a mirror table — dashboards and notebooks included (= 0 / FINAL on ClickHouse). A delete writes a tombstone now, because a physical delete leaves a late stale insert nothing to lose against and the row silently returns.

The guarantee, stated exactly: at-least-once for the lifetime of the process, ordered per row. Not durable across a crash — the repair queue is in memory. Tunables: VOLTRO_ANALYTICS_MIRROR_RETRY_ATTEMPTS (5), …_RETRY_BASE_MS (100), …_RETRY_MAX_MS (30000), …_REPAIR_INTERVAL_MS (60000, 0 disables), …_REPAIR_QUEUE_LIMIT (10000).

One honest limit, logged at attach: under changeScope: 'fleet' the mirror runs on every replica and each stamps the version from its own clock. Duplicate writes are idempotent, but with clock skew larger than the gap between two changes to one row an older image can outrank the newer one. The three obvious fixes were each measured and rejected; the warning names the cost rather than implying the problem is gone.

search.query no longer trusts the caller's strings

Three ways a wire caller could reach past the tenant filter are closed as one change:

  • engineParams is an allowlist now. It used to be spread last into the engine params object, so run('posts', { engineParams: { filter_by: '' } }) replaced the tenant clause — a cross-tenant read from the browser. Paging, ordering, typo tolerance and highlight shaping still reach the engine; anything that could select a different document set is dropped and logged by key name. Widen it server-side with searchPlugin({ allowedEngineParams: ['query_by'] }); document-selecting keys stay refused even then.
  • An unknown index name fails with SearchIndexNotFound before the backend is called. A registry miss used to yield no tenant field, so the query ran unscoped — on a shared Typesense / Meili / Algolia instance, every collection outside searchPlugin({ indexes }) was readable by any authenticated caller.
  • Caller field names must be plain field paths. filters[].field, facets[] and highlight.fields[] must match ^[A-Za-z_][A-Za-z0-9_.]*$ or be listed in the new IndexSpec.queryableFields, else the call fails with SearchFieldRejected.

search.query now carries a wire error union (SearchIndexNotFound | SearchFieldRejected), which existing callers decode as a rejected promise exactly as they already do for any other typed error. If your app depended on one of the closed behaviours, the fix is a declaration — declare the index, or add the key to allowedEngineParams — not an edit to a call site.

Search also stopped losing an index update to a single engine failure: a transient failure is retried with backoff, and anything that outlives its retries is written to a _voltro_search_drift ledger that a coordinated sweep repairs by re-reading the row.

billing.plan() no longer downgrades on the first decline

plan() used to answer 'free' the instant a subscription went past-due — a bounced card downgraded the customer on the same second, with no grace at all, while the docs promised the opposite. It now answers the paid plan for the whole grace window (168 h by default) and falls back only once the lockout is real, and only under lockout: 'hard'.

If you wrote your own BillingProvider, it gained a required fetchSubscription returning the subscription's CURRENT status — it is what dunning refuses to lock a customer out without. BillingEvent gained occurredAt on every variant; Subscription gained pastDueSince and statusEventAt. See @voltro/plugin-billing.

A resumed workflow re-resolves its caller's authority

_voltro_workflow_start_contexts.subject held the caller's whole Subject, scopes included. A cluster runner read it back — a different process, possibly days later — and ran the workflow with the authority the caller had at start time.

Identity is persisted. Authority is re-resolved at resume, or absent.

Identity — type, id, tenantId, metadata — still survives, so a workflow started by tenant A still acts on tenant A's rows in three days' time. Authority comes from your own auth.resolveScopes on every execution attempt, with ctx.origin === 'workflow'. An app that wires no resolver gets runs with no scopes — the fail-closed direction, and exactly what a cookie-authenticated caller already gets on the request path. { kind: 'unavailable' } fails the attempt loudly rather than running with less authority than the caller has.

auth.resolveScopes's context grew a discriminator and lost a guarantee: ctx.origin is 'request' | 'workflow', and ctx.clientId is now number | undefined — a workflow execution has no connection, and ctx.headers is {}. That type change is where a resolver reading clientId sees the compile error.

No migration to run. Rows written by the previous version still contain scopes and are stripped on read.

Soft re-auth must present a credential

bindConnectionSubject(clientId, subject) stored a resolved Subject, and the auth middleware short-circuited on it for the life of the connection. Three things stopped happening, none audible: the session-revocation check, resolveScopes and the scope cache, and the credential-expiry record that stops a subscription outliving its token. And it existed only under voltro dev — the framework's own switch-tenant rebind worked in development and was a silent no-op in production.

bindConnectionCredential(clientId, { cookies, headers }) patches the connection's headers and the middleware runs the same chain a fresh request runs. A rebinder must now have a credential to present — pass the one you were already about to Set-Cookie (issueSession(...) returns { value, setCookie }), or a Bearer header for a token app.

onBindConnectionSubject and connectionSubjectsSnapshot are gone with no replacement, because nothing read them. unbindConnectionSubject is now unbindConnection.

Retention sweeps that will delete existing history

Several tables that nothing ever deleted from are now bounded. The first sweep runs shortly after boot and drains the backlog in batches. If you need any of this history, raise the window with the environment variable before you deploy — an app's own registerRetention also outranks a framework default, and app registrations now win over plugin and framework ones instead of losing silently.

Table Default Override
_voltro_ai_flow_runs (terminal runs only) 90 days VOLTRO_AI_FLOW_RUNS_TTL_HOURS
_voltro_undo_log 30 days VOLTRO_UNDO_LOG_TTL_HOURS
_voltro_ai_inferences (completed only) 30 days VOLTRO_AI_INFERENCES_TTL_HOURS
_voltro_stream_events + _voltro_stream_state 7 days VOLTRO_STREAM_LOG_TTL_HOURS
_voltro_prompts (by lastUsedAt) 365 days VOLTRO_AI_PROMPTS_TTL_HOURS
_voltro_workflow_admissions, _voltro_workflow_start_contexts 30 days

_voltro_undo_log is the one to look at first: it is the only table here that grows with user traffic rather than with a timer, and it backs the per-subject "what can I undo" feed, so a user seeing their list truncated is a visible loss.

Media artifacts are not swept with an ai-flow run — a run's steps carry hosted URLs whose blobs belong to the storage plugin. Keep the TTL at or above your media-purge window, or purge by run id before the row ages out.

Oversized change payloads are rehydrated

Under postgres CDC the NOTIFY trigger is the sole emitter, and pg_notify caps a payload at 8000 bytes. A wide row fell back to a payload with both images null — so the search index permanently missed it, the analytics mirror returned early, cdc-out outboxed the nulls, plugin-versioning recorded no history row, and no retry could repair any of it, because the content was never delivered.

The trigger keeps the primary key in the oversized fallback now, and the LISTEN consumer re-reads the row before the event reaches anything. ChangeEvent.oversized says what was recovered: 'rehydrated' (the row as it is NOW, not necessarily the image that fired the event), 'tombstone' (the primary key and nothing else — enough to remove the row downstream), or 'unrecovered' (logged at error, and the counter to alert on).

This lives in the database, so it has to be applied. The notify function carries a version marker; voltro db apply replaces an older one and voltro dev names it at boot. Until then those changes report 'unrecovered'. Tunables: VOLTRO_CDC_REHYDRATE_TIMEOUT_MS (5000) and VOLTRO_CDC_REHYDRATE_RETRIES (2).

Smaller behaviour changes worth knowing

  • voltro serve honours port from app.config.ts. It computed the port one line before it loaded the config, so an app declaring port: 4130 ran on 4130 under voltro dev and 4000 under voltro serve. One precedence everywhere now: VOLTRO_DASHBOARD_PORTPORT--portapp.config.ts → 4000 (api) / 5173 (web). An unusable value (PORT=, PORT=8080x) is ignored with a warning instead of making node bind a random free port and report itself ready.
  • Plugin onHttpRequest interceptors now run under voltro serve. They ran under voltro dev only, so ratelimitPlugin({ http })'s pre-auth IP shield was dead in production and live in development. /internal/liveness and /internal/readiness are answered before the interceptor, so a shield cannot 503 a probe.
  • publicApi: REST routes honour Idempotency-Key under voltro serve. They honoured it in dev and ignored it in production, so a retried POST executed the mutation twice — no error, no log.
  • A plugin's extendSchema.migrations now runs in production paths (voltro db apply, voltro migrate --create-only), not only under voltro dev.
  • lifecycle: 'cron' and 'onSchemaChange' seeds actually fire. Both were discovered, validated, ledgered and never run. Cron seeds now ride the coordinated scheduler and fire once fleet-wide.
  • Stuck-run detection runs. sweepStalledRuns had nine tests and no callers; it is armed on both boot paths and records a run-stalled event. It changes no run state.
  • Reactive-trigger drift is repaired at boot on postgres, on both boot paths, under the migration advisory lock (reactiveTriggers: 'repair' | 'report' | 'off').
  • voltro data restore compares databases instead of refusing because some voltro is running on the machine, and a failed voltro data backup no longer leaves a partial file that looks like a backup.
  • The undo wire surface reads only VOLTRO_UNDO, not NODE_ENV — a build artefact must not freeze an environment answer.
  • insertMany chunks at each engine's bind-parameter ceiling (postgres 65 535, mssql 2 098) inside one transaction, so it stays all-or-nothing.
  • Read-replica pools take the primary's settings, TLS included. The same process could previously encrypt its writes and read in the clear.
  • Waiting for a free pooled connection is bounded by default — 10 s, DB_ACQUIRE_TIMEOUT_MS / ConnectionConfig.acquireTimeoutMs, 0 to opt back into the driver's unbounded wait. Postgres gives the full guarantee; mysql/mariadb can only bound the connect half, and its length-based alternative (DB_ACQUIRE_QUEUE_LIMIT) is deliberately unset — past the limit mysql2 rejects the acquire synchronously, so anything retrying without a delay spins in-tick and starves the event loop.
  • A MySQL/MariaDB index no longer takes a 191-character prefix on a shorter column. text().maxLength(32) renders VARCHAR(32), which is directly indexable and rejects a prefix — ERROR 1089. MariaDB has no transactional DDL, so a migration stopped on a schema that was neither the old one nor the new one. Invisible on any environment that already had the column, because only the ADD COLUMN → CREATE INDEX path resolved the type that way.
  • voltro migrate --create-only no longer bootstraps a database missing every plugin table. It assembled its own table set while every other command used the shared one.
  • interactive: 'islands' ships exactly the same JavaScript as 'full' — measured on one page, 195,231 B vs 195,229 B. The docs previously claimed "no JS bundle", "strips the page's React runtime" and a savings table that was fabricated. interactive: 'none' is the one mode that removes bytes. There is a committed bundle budget now — first load is 190.4 KB gzipped on the reference fixture.
  • GET /_voltro/inspect/migrations is served on both boot paths. It had no route at all, so voltro db plan --against <url> — which fetches exactly that — had never worked anywhere. A dev-only inspect endpoint now also says which kind of 404 it is.
  • The framework's own background-task spans are no longer persisted for being slow. A saturated pooler made housekeeping reads slow, slow spans counted as "interesting", and persisting them cost more pool — one consumer reached 476 571 trace rows / 335 MB in three hours. Errors on a background span are still persisted, and VOLTRO_TRACING_PERSIST=all still keeps everything.
  • The outbox delivery poll stops when the queue is empty, where a change channel exists. Without one it keeps its fixed tick, because the poll is then the only thing that can notice another replica's row.
  • A raw SQL read in a live query now warns when it declares no dependsOn — it used to deliver its first snapshot and never update again, silently. And dependsOn now actually does something for a computed query: the declared tables join the query's own source: set.
  • webhooksPlugin() exists — add it to plugins: and webhook delivery appears in the boot permission report. Nothing else changes; the declaration buys visibility, not restriction.
  • voltro e2e is documented as what it is — a tsx script runner, not Playwright. The docs taught @playwright/test against a CLI that has no such dependency. A spec now also receives API_URL alongside WEB_URL. See testing.
  • ctx.email / MockEmail are deleted. They had zero producers — a mail-sending handler under test could not run at all. invoke now provides every registered plugin's services: layer plus your app's layers:, so you assert through the mail plugin's own memory provider.
  • workflow({ messages: { queries } }) is removed. There was never a send path. Use updates for request/response and signals for fire-and-forget; delete the queries: block.
  • HUMAN_RESPONSE_SIGNAL is removed from @voltro/plugin-ai-flows. One constant served every human step, so a flow's second review resolved itself with the first answer. Signal names are per-step now. Runs already parked when you deploy were parked under the old name — answer them first, or cancel and relaunch.
  • workosAuthorizationUrl is replaced by workosBeginLogin, returning { url, state, codeVerifier }. state and PKCE are mandatory and the callback verifies both; workosAuthenticateWithCode gains three required fields.
  • voltro init no longer scaffolds an app. It initialises the current directory as a workspace root. voltro init <name> --api … exits 2 and prints the create-project form.

4. New capabilities you may want

Briefly, with links — none of these is required to upgrade:

  • Sign in with Google / GitHub / Apple without an identity vendor — @voltro/plugin-auth-social. state and PKCE are mandatory and always ours; account linking defaults to refusing.
  • Email verification, tenant invitations and user impersonation in @voltro/plugin-auth. All three default to off; emailVerification defaults to policy: 'off' because the column arrives NULL on every existing row. Before switching to 'strict', set exemptAccountsCreatedBefore to your deploy instant — that is the difference between "new signups must confirm" and "everyone is locked out until they check their mail".
  • An MCP client — agents can consume external MCP servers, routed through the same policy layer as your own tools. MCP clients.
  • Prompt versioning and provenancedefinePrompt({ id, template }), a content digest, and spend attributed to a prompt revision. Prompt versioning.
  • Typed hookscreateHooks<AppProcedures>('app') gives useSubscription / useMutation / useAction a literal-union tag with input and output inferred. Note the ordering consequence: rpcGroup.generated.ts is written by codegen, so a tsc on a fresh clone needs voltro codegen . in front of the api's typecheck.
  • A request-level test harnessmakeTestApp({ ctx, restRoutes, publicApi, strategies }) sends a real request through the framework's own REST pipeline, plus subject factories (user / apiKey / serviceAccount / anonymous / system) and defineFactory for row fixtures with their parent rows.
  • A tamper-evident audit log — a per-writer hash chain plus verifyAuditChain. Read the guarantee before quoting it: it attests that each chain is intact, not that the log is complete.
  • Workflow patch('marker') for in-body versioning, and a replay-nondeterminism tripwire that emits an event rather than failing a run. Workflow versioning.
  • Dunning in @voltro/plugin-billing — a past-due sequence, a grace period and a lockout, composed on the provider's own outcomes.

5. Why almost nothing is automatic

Every codemod in 0.34.0 is manual. That is a decision, and the reasoning is worth reading because it tells you which parts of this upgrade you must actually think about.

A transform could have stamped openAccess on every guardless descriptor. It would be mechanical, it would be complete, and every app would boot immediately — having declared its entire surface open on purpose, in one commit nobody reads, with a reason the tool invented. That is precisely the failure the marker exists to prevent, performed at scale and with the framework's signature on it. The gate asks a question only you can answer.

A transform could have rewritten the WorkOS login call — and left the callback unverified. Half a security migration is worse than none: a generated state that nothing compares is worse than no state at all, because a code review, a screenshot of the authorize URL and a pen test then all read as "CSRF is handled".

A transform could have stubbed the eleven new UserStore methods. acceptInvitation returning null compiles, ships, and means "every invitation is invalid"; markEmailVerified as a no-op means an app on emailVerification: 'strict' refuses every login forever. A codemod that makes the build pass while turning a security feature into a permanent refusal is worse than none, because the red build is the only signal that anything is required.

A transform could not have rewritten your ctx.email assertions into anything better. Every one of them was vacuous — MockEmail had zero producers, so sent was always empty and toHaveLength(0) passed while meaning nothing. Rewriting the expressions would have turned a green vacuous test into a green vacuous test under a new name.

And several changes have nothing in your repository to rewrite at all. The TLS one is the clearest: the affected input is a connection URL in a deployment secret. A transform cannot look at a value it cannot see — which is a stronger reason to write the note, not a reason to skip it, because the failure mode of not reading it is a container that stops booting on a deploy.

The short checklist

voltro update                    # bump + install + print the manual notes
# read every note it prints — they are gated to your app

voltro doctor                    # every undecided procedure, every unverified webhook
# decide each one: guards: or openAccess:

# check your mysql/mssql DB_URL for ?ssl= / ?sslmode= / ?encrypt=
# declare security.allowedOrigins if web and api are on different origins
# declare security.trustedProxies if you rate-limit or audit per IP
# raise any retention TTL whose history you need, BEFORE deploying

NODE_ENV=development voltro db apply .   # locally
voltro db migrate .                      # in the pre-deploy job (schema + files)

voltro doctor                    # re-run: access decisions + webhooks now clean

voltro doctor covers the access-decision and webhook-verification gates from the same resolvers the boot uses, so green there really does mean those two will not refuse. It does not see the ones whose input is not in your repository — the TLS URL, pending file migrations, NODE_ENV, the search backend, SSR_CACHE. Work those from the list above.

Then run your own typecheck, and expect your users to sign in once more.