# Releases

> 0.35 through 0.38 in one pass — the boot-breaking access declarations, the stricter input handling, and the runtime behaviour that moved underneath you.



---

<!-- source: en/releases/upgrading-to-0-38.md -->
## Upgrading to 0.38.0

_0.35 through 0.38 in one pass — the boot-breaking access declarations, the stricter input handling, and the runtime behaviour that moved underneath you._

0.35, 0.36, 0.37 and 0.38 landed within two days of each other, so this page covers
them as one upgrade. It is much smaller than [0.34](/docs/releases/upgrading-to-0-34) —
but two of the changes will stop your app from booting until you make a decision, and
one changes how every request is validated.

Run the upgrade first, fix the boot, then read what moved at runtime.

## 1. Run `voltro update`

```bash
voltro update
```

Every breaking change below either ships a codemod that rewrites your source or prints
written steps during the update. What a codemod cannot do is make an access decision on
your behalf — that part is yours.

## 2. What breaks your boot

### A declared event must decide who may listen (0.35)

`defineEvent`'s `guards:` was optional, and an empty list was skipped — so under
`security.defaultDeny` an event with **no access declaration was subscribable by anyone
who could open the socket**, while the identical shape was already refused for every
procedure. The boot gate now covers `defineEvent` too.

Declare an access decision on each event: a `guards:` list, or an explicit
`openAccess: '<reason>'` when it genuinely is public.

### Twelve first-party plugin routes now require a scope (0.35)

Every first-party plugin rpc route now declares an access decision, and `defaultDeny` is
enforced in the dispatch spine as defense in depth. Previously-open routes that now need
a scope:

| Route | Scope |
|---|---|
| `billing.startCheckout` / `portalUrl` / `previewChange` / `changePlan` / `changeSeats` / `invoices` | `billing:manage` |
| `billing.reportUsage` | `billing:report` |
| `governance.export` / `erase` | `admin:full` (was already enforced in-handler, now declared) |
| `storage.mintUploadUrl` / `ingestUrl` | `storage:manage` |
| `storage.listRefs` | `storage:browse` |

**Migration:** grant each scope to the role or subjects that legitimately hold that
capability — via an rbac role, `resolveScopes`, or api-key scopes. The codemod lists
every affected route plus the open-by-design surfaces that did **not** change.

### The framework table set no longer reads a runtime flag (0.35)

`CDC`, `VOLTRO_UNDO` and `VOLTRO_TRACING_PERSIST` used to move the *declared* table set,
which meant the migrate job's env and the pod's env could disagree — a green apply
followed by a crash loop. `app.config.ts` gained `schema: { traces?, undo? }` to declare
the two that still need a decision.

### A failing `*.startup.ts` now refuses the boot (0.38)

It used to warn and let the server come up. If your startup module is allowed to fail,
handle the failure inside it — the framework will no longer serve traffic behind a
startup that did not complete.

## 3. What changed at runtime

### An undeclared input field now rejects the call (0.37)

A field a procedure's input schema does not declare **rejects** the request. It used to
be silently discarded and the call ran with what was left. This is the change most likely
to surface in a client you did not update in lockstep: an extra property that used to be
ignored is now an error. Check any hand-built request payloads.

### `IMPERSONATION_METADATA_KEY` moved to `@voltro/protocol` (0.38)

It names the one reserved key in `Subject.metadata`, and `Subject` is protocol's type —
two packages need it (plugin-auth writes the mark, plugin-audit reads it) and a plugin
must not depend on another plugin. The codemod repoints the import, preserving an alias
and the type-only form. Everything else stays where it was: `impersonationOf`,
`isImpersonated`, `ImpersonationMark` and `impersonationAuditRedactor` are still exported
from `@voltro/plugin-auth`.

An audit row now also records when an action was taken through an **impersonated**
session, on the default settings, and no redactor can remove that mark.

### Upstream 401s report a different code (0.36)

A 401 from an upstream now produces `code: 'unauthorized'`, not `code: 'session_expired'`.
The connection vault's own failure — the case where we *do* know the credential is
unusable — becomes `code: 'credential_unusable'`. If you branch on these codes, update
the branch.

### `RunStepStatus` gained `'skipped'` (0.35, plugin-ai-flows)

A sixth member means an exhaustive `switch` stops compiling and a status-keyed lookup has
a hole. A manual codemod fires on any app that names the type or its literals. Everything
else in that release is additive.

### The analytics CDC mirror versions from the change, not the clock (0.35)

Under `changeScope: 'fleet'` (postgres CDC, mysql binlog) the mirror's version is derived
from the change's own position in the totally-ordered fleet stream instead of each
replica's clock. N replicas still issue N writes per change — that is the transport — but
they are now **byte-identical**, so the sinks' existing guards dedupe them for free.

This closed a real defect: under clock skew larger than the gap between two changes to one
row, a peer's duplicate of the *older* image could take the higher version and win in the
warehouse permanently and silently.

**If you wrote a custom sink**, `AnalyticsMirrorImpl.maxVersion` is now **required** — a
replica joining mid-stream seeds each key from the warehouse's high-water mark. All
shipped warehouse sinks implement it; the codemod note covers a custom one. New tunable:
`VOLTRO_ANALYTICS_MIRROR_VERSION_STATE_LIMIT` (default 100000).

## The short checklist

1. `voltro update` — let the codemods run.
2. Declare access on every `defineEvent`.
3. Grant the twelve plugin-route scopes to the roles that should hold them.
4. Move `CDC` / undo / traces decisions into `app.config.ts`'s `schema: { … }`.
5. Make sure your `*.startup.ts` cannot fail unintentionally.
6. Audit client payloads for fields your input schemas do not declare.
7. If you have a custom analytics sink, implement `maxVersion`.



---

<!-- source: en/releases/upgrading-to-0-34.md -->
## 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

```bash
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`](/docs/cli/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:

```text
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](#oversized-change-payloads-are-rehydrated) 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:

```text
[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:

```bash
voltro doctor
```

```text
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:

```ts
// 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 })),
})
```

```ts
// 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:

```ts
// 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:

```text
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.

```text
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:

```sql
-- 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.

```text
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:

```bash
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:

```bash
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`.**

```text
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:

```bash
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).

```text
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.

```ts
// 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:

```ts
// 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`](/docs/plugins/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.

```text
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 statement** — `acceptInvitation` 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](/docs/authentication/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:

```text
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.

```ts
// 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.

```ts
// 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:

```text
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](/docs/authentication/sessions) and
[authorization](/docs/authentication/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`:

```ts
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 shape** — `error.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`](/docs/plugins/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_PORT` → `PORT` → `--port` → `app.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](/docs/testing/overview).
- **`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`](/docs/plugins/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](/docs/ai/mcp-clients).
- **Prompt versioning and provenance** — `definePrompt({ id, template })`, a content digest,
  and spend attributed to a prompt revision. [Prompt versioning](/docs/ai/prompt-versioning).
- **Typed hooks** — `createHooks<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 harness** — `makeTestApp({ 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](/docs/workflows/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

```bash
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.
