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 updateIt does four things, in order:
- Refuses on a dirty git tree. Use
--dry-runto preview,--forceto override. The refusal exists because you need a clean diff to review afterwards. - 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. - 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. - 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. Runvoltro db apply(or bootvoltro 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
typecheckafterwards. 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 whyThe 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 doctoraccess decisions · security.defaultDeny ON
✗ no access decision 3
✓ openAccess, declared on purpose 1
health.check — liveness probe, returns a constantvoltro 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: trueand 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=trueor?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.jsonServe 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 configuredFatal 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.
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 allowedThe 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 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:
- A custom
AnalyticsMirrorImplis a compile error until updated.upsertandremovetake one object now:upsert({ table, row, version })/remove({ table, primaryKeyValue, version }). Deliberate — the old shape had nowhere to put the version. - Drop your mirror tables once. They gained
versionandis_deleted, and are created withCREATE TABLE IF NOT EXISTS, so drop an existingvoltro_mirror_<table>/_voltro_mirror_<table>and let the next boot re-create it. - Add
is_deleted = falseto every analytical query that reads a mirror table — dashboards and notebooks included (= 0/FINALon 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:
engineParamsis an allowlist now. It used to be spread last into the engine params object, sorun('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 withsearchPlugin({ allowedEngineParams: ['query_by'] }); document-selecting keys stay refused even then.- An unknown index name fails with
SearchIndexNotFoundbefore 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 outsidesearchPlugin({ indexes })was readable by any authenticated caller. - Caller field names must be plain field paths.
filters[].field,facets[]andhighlight.fields[]must match^[A-Za-z_][A-Za-z0-9_.]*$or be listed in the newIndexSpec.queryableFields, else the call fails withSearchFieldRejected.
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 servehonoursportfromapp.config.ts. It computed the port one line before it loaded the config, so an app declaringport: 4130ran on 4130 undervoltro devand 4000 undervoltro 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
onHttpRequestinterceptors now run undervoltro serve. They ran undervoltro devonly, soratelimitPlugin({ http })'s pre-auth IP shield was dead in production and live in development./internal/livenessand/internal/readinessare answered before the interceptor, so a shield cannot 503 a probe. publicApi:REST routes honourIdempotency-Keyundervoltro 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.migrationsnow runs in production paths (voltro db apply,voltro migrate --create-only), not only undervoltro 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.
sweepStalledRunshad nine tests and no callers; it is armed on both boot paths and records arun-stalledevent. 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 restorecompares databases instead of refusing because some voltro is running on the machine, and a failedvoltro data backupno longer leaves a partial file that looks like a backup.- The undo wire surface reads only
VOLTRO_UNDO, notNODE_ENV— a build artefact must not freeze an environment answer. insertManychunks 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,0to 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)rendersVARCHAR(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 theADD COLUMN → CREATE INDEXpath resolved the type that way. voltro migrate --create-onlyno 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/migrationsis served on both boot paths. It had no route at all, sovoltro 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=allstill 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. AnddependsOnnow actually does something for a computed query: the declared tables join the query's ownsource:set. webhooksPlugin()exists — add it toplugins:and webhook delivery appears in the boot permission report. Nothing else changes; the declaration buys visibility, not restriction.voltro e2eis documented as what it is — a tsx script runner, not Playwright. The docs taught@playwright/testagainst a CLI that has no such dependency. A spec now also receivesAPI_URLalongsideWEB_URL. See testing.ctx.email/MockEmailare deleted. They had zero producers — a mail-sending handler under test could not run at all.invokenow provides every registered plugin'sservices:layer plus your app'slayers:, so you assert through the mail plugin's own memory provider.workflow({ messages: { queries } })is removed. There was never a send path. Useupdatesfor request/response andsignalsfor fire-and-forget; delete thequeries:block.HUMAN_RESPONSE_SIGNALis 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.workosAuthorizationUrlis replaced byworkosBeginLogin, returning{ url, state, codeVerifier }.stateand PKCE are mandatory and the callback verifies both;workosAuthenticateWithCodegains three required fields.voltro initno longer scaffolds an app. It initialises the current directory as a workspace root.voltro init <name> --api …exits 2 and prints thecreate-projectform.
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.stateand 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;emailVerificationdefaults topolicy: 'off'because the column arrives NULL on every existing row. Before switching to'strict', setexemptAccountsCreatedBeforeto 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 provenance —
definePrompt({ id, template }), a content digest, and spend attributed to a prompt revision. Prompt versioning. - Typed hooks —
createHooks<AppProcedures>('app')givesuseSubscription/useMutation/useActiona literal-union tag with input and output inferred. Note the ordering consequence:rpcGroup.generated.tsis written by codegen, so atscon a fresh clone needsvoltro codegen .in front of the api'stypecheck. - 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) anddefineFactoryfor 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 cleanvoltro 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.