@voltro/plugin-audit 0.32.0 → 0.34.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -39,6 +39,2012 @@ _Changes staged for the next release accumulate here (rolled up from
39
39
 
40
40
  ---
41
41
 
42
+ ## [0.34.0] — 2026-08-12
43
+
44
+ ### ⚠ BREAKING
45
+
46
+ - **@voltro/client, @voltro/cli** — **The derived admin gated writes on scope strings nothing produced, so every write affordance was hidden from every caller — and the shipped demo could not show it.**
47
+
48
+ `deriveEntityAdmins` invented three scopes per table (`<table>:create`, `<table>:write`, `<table>:delete`) and the admin template hid any action whose invented scope the subject did not hold. No plugin, role map or resolver ever grants those names. So for any app that named its scopes differently — nearly every app — the back-office rendered read-only for everybody. This is the unsatisfiable-guard failure moved one layer up: a total outage of the surface, wearing a permission check's clothes.
49
+
50
+ It stayed invisible for a precise reason worth recording. `frontend-admin` seeds `admin:full`, the blanket-bypass scope, so the scaffold shows every action. The bug appears only when a user follows the documented advice and feeds their session's real scopes — **the correct move made the admin worse**, which is the worst possible shape for a defect to have.
51
+
52
+ The answer had been on the wire since the manifest gained `guards`, added explicitly so "a UI gate and the server check cannot drift". The client mirror never read it.
53
+
54
+ **What changed:**
55
+
56
+ - `EntityAdminSpec`'s flat `listTag` / `createTag` / `updateTag` / `deleteTag` and the three `*Scope` strings are replaced by `list` / `create` / `update` / `delete`, each an `EntityAction` — `{ tag?, guards? }`, where `guards` is the procedure's OWN `guards:` / `openAccess:` declaration. - New `decideAccess(guards, scopes)` (pure) and `useAccessDecision(guards)` (hook) return **three** values: `allowed` | `denied` | `unknown`. `unknown` is the honest answer for a guard carrying a `resource` extractor — the real check is per ROW and a browser has no row — for a relationship guard, and for a procedure that declared nothing. Collapsing it to `denied` re-creates this exact outage for every multi-tenant app whose subjects are minted with `scopes: []`; collapsing it to `allowed` renders a control that always errors. The template shows those controls and lets the api answer with a typed `ScopeError`. `requiredScopes(guards)` renders the requirement so a refusal is readable. - **`openAccess` no longer vanishes on the wire.** `serialiseGuards` skipped the erased `{ open }` entry, so an intentionally-public procedure serialised identically to an undeclared one and a client's only safe reading was "hide it". It emits `{ kind: 'open', reason }` now — all three access states travel. - **The manifest carries the three exposure axes per column**, from the same classifier the inspect row-mask uses. `deriveEntityAdmins` acts on exactly one of them: `.serverOnly()` columns are EXCLUDED from `columns` (they never cross the wire, and the runtime already refuses them as mutation input) and named in `serverOnlyColumns`. `.encrypted()` and `.sensitive()` columns are KEPT — at-rest encryption is not wire exposure, and `.sensitive()` is the export axis; hiding either is the category error the three-axes rule forbids. `sensitiveColumns` is reported so an export path masks them. - **The manifest carries `pkColumn` + `editable`.** A UI hard-coding `row.id` sent an empty id on every delete for a table keyed on anything else.
57
+
58
+ Migration: `spec.createTag` → `spec.create.tag`; `useCan(spec.createScope)` → `useAccessDecision(spec.create.guards)` plus a decision about `unknown`. The codemod is `manual` because that last part is an authorization posture a tool must not pick on your behalf — see its note.
59
+ - **@voltro/plugin-ai-flows** — **A flow's SECOND human review used to resolve itself with the FIRST answer.** `awaitSignalSuspending` keys its `DurableDeferred` on workflow-name + signal-name per execution, and `plugin-ai-flows` used one constant — `HUMAN_RESPONSE_SIGNAL = 'flow-human-response'` — for every human step in every flow. The second review therefore awaited a deferred the first answer had already completed, and resolved instantly with that payload. Measured on the real engine before the fix, with a two-review flow and ONE answer sent: `status: 'succeeded'`, `output: { first: 'answer-one', second: 'answer-one' }` — an approval nobody was asked for, never shown as pending. (The same collision aliased the wait's durable timeout clock, so the second park also inherited the first's expiry.)
60
+
61
+ Signal names are per-step now — `flow-human-response:<stepIndex>`, derived from the step's position in the plan the run's journal pinned at step 0, so it is identical on every replay. `respondToFlow` reads the parked step off the run row's live timeline and addresses that step, so an app that calls it needs no change beyond dropping the removed import; it also returns the `stepIndex` it answered. Migration: `HUMAN_RESPONSE_SIGNAL` is removed — use `respondToFlow`, or `humanResponseSignalName(waitingStepIndex(runRow))` if you send the signal yourself. Runs already parked when you deploy were parked under the old name: answer them first, or cancel and relaunch.
62
+
63
+ **`voltro update` carries you across this** — codemod `0.34.0/11_ai-flows-per-step-human-signal`.
64
+ - **@voltro/runtime, @voltro/plugin-clickhouse, @voltro/plugin-duckdb, @voltro/plugin-analytics-postgres, @voltro/cli** — The analytics CDC-mirror said **at-least-once** in its own header and was at-most-once in its body: one forked promise per change, `catchAll → log.warn`, no retry, and no reconcile path anywhere — so a single warehouse blip lost that row from the mirror permanently. Two more defects rode along. The ClickHouse version was `Date.now() * 1000` read INSIDE the sink under `ReplacingMergeTree(version)`, and with no per-key ordering two rapid updates to one row could arrive out of order, which handed the STALE image the higher version and let it win forever (DuckDB and postgres-lite had the same race without the version veneer — plain last-writer-wins). And `catchAll` handles failures, not defects, so a sink that THREW escaped into the fire-and-forget tail as an unhandled promise rejection.
65
+
66
+ The mirror now:
67
+
68
+ - **orders per key.** Writes for one primary key are applied one at a time in commit order; different keys stay concurrent. - **versions from the change, not the clock.** Every write carries a `MirrorVersion` stamped when the change left the store. ClickHouse persists it as the `ReplacingMergeTree` version; DuckDB and postgres-lite apply the write only when it is newer (`ON CONFLICT … WHERE excluded.version > version`). - **retries and repairs.** Exponential backoff (`retryAttempts`, default 5), and a write that outlives its retries goes into a bounded repair queue whose timer re-reads the row's current state and re-applies it. Counted by `voltro_analytics_mirror_{forwarded,retries,repair_queued,dropped}_total`. - **handles defects.** `catchAllCause`, matching the search tap.
69
+
70
+ Delivery 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; a change still queued when the process dies is counted as dropped and needs a re-seed.
71
+
72
+ Migration (`voltro update` prints it):
73
+
74
+ - `AnalyticsMirrorImpl.upsert/remove` take one object: `upsert({ table, row, version })` / `remove({ table, primaryKeyValue, version })`. A custom sink is a compile error until updated — deliberately, since the old shape had nowhere to put the version. `AnalyticsMirrorChange` (declared, never used) is deleted. - Mirror tables gained `version` + `is_deleted`. They are created with `CREATE TABLE IF NOT EXISTS`, so drop an existing `voltro_mirror_<table>` / `_voltro_mirror_<table>` once and let the next boot re-create it. - A delete writes a **tombstone**, not a row removal (a physical delete leaves a late stale insert nothing to lose against, and the row silently returns). Add `is_deleted = false` (`= 0` / `FINAL` on ClickHouse) to every analytical query that reads a mirror table — dashboards and notebooks included.
75
+
76
+ Tunables, each with an env override: `VOLTRO_ANALYTICS_MIRROR_RETRY_ATTEMPTS` (5), `…_RETRY_BASE_MS` (100), `…_RETRY_MAX_MS` (30000), `…_REPAIR_INTERVAL_MS` (60000, `0` disables), `…_REPAIR_QUEUE_LIMIT` (10000).
77
+
78
+ **`voltro update` carries you across this** — codemod `0.34.0/05_analytics-mirror-versioned-writes`.
79
+ - **@voltro/plugin-billing** — **Dunning — the past-due sequence, a grace period, and a lockout — composed on the provider's own outcomes.** `invoice.payment_failed` has been mapped to an event since the beginning, and `setStatus` has described itself in-code as "the dunning state machine's transition primitive", but nothing connected a failed payment to the customer or to the app's entitlements. A Cashier switcher named this in the first conversation.
80
+
81
+ What did NOT come back is the retry schedule. A `dunning.ts` used to live here and was deleted because it retried on a fixed `[1,3,5,7]`-day cadence while Stripe retried on its own and the two drifted the moment they disagreed. Stripe still owns the cadence. What is new is the part Stripe does not do for an app: branded notices at most once each, a grace window, and one truthful answer to "is this tenant entitled right now".
82
+
83
+ **Two properties carry the whole design, and both exist because sending an email and locking a customer out are irreversible:**
84
+
85
+ - **The grace clock is a column, not a timer.** `pastDueSince` lives on the subscription row and the lockout is DERIVED at read time. There is no job to miss a tick, double-fire, or run twice across replicas — and no dunning job was reintroduced. - **`pastDueSince` is only ever written after the PROVIDER confirms.** A reconcile reads `stripe.subscriptions.retrieve` and writes the local row from that, never from the event body. So a `payment_failed` that arrives after the retry which succeeded — Stripe's delivery is at-least-once AND unordered — reconciles to `active` and sends nothing, and a provider we cannot reach leaves the tenant in grace rather than escalating on unverified data.
86
+
87
+ Idempotency is a `UNIQUE (tenantId, episode, stepId)` claim on the new `_voltro_billing_dunning_notices` ledger, taken BEFORE the send. The episode key IS the clock (`pastDueSince` epoch-ms), so recovery ends an episode and a later failure starts a genuinely new one with no counters to race. Claim-then-send is deliberate: its failure mode is one email that never arrives, where send-then-claim's is a customer getting the same dunning mail twice.
88
+
89
+ **A behaviour change worth reading even if you write no new code:** `plan()` used to answer `'free'` the instant a subscription went `pastDue` — 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, and falls back only once the lockout is real (and only under `lockout: 'hard'`).
90
+
91
+ New surface: `billing.entitlementStatus(tenantId)` (a pure read — no provider call, safe on the hot path), `billing.reconcileDunning(tenantId)`, `billing.dunningSweep()`, the `requireEntitled(ctx)` in-handler guard, the typed `SubscriptionLocked` error, the `billing.entitlementStatus` rpc query, and `dunningMailNotifier(mail)` bridging notices to `@voltro/plugin-mail` structurally (no dependency added).
92
+
93
+ Tunables, every one with a default and an env override — `dunning: { enabled, graceHours (168), steps (0h / 72h / 144h), lockout ('hard' | 'soft'), notify, resolveRecipient, portalReturnUrl } ` / `VOLTRO_BILLING_DUNNING`, `VOLTRO_BILLING_GRACE_HOURS`, `VOLTRO_BILLING_DUNNING_STEP_HOURS` (positional, and a count mismatch fails the BOOT rather than silently re-timing the wrong step), `VOLTRO_BILLING_LOCKOUT`. With no `notify` the sequence claims and logs and sends nothing, which is the right default for something the framework cannot address on the app's behalf.
94
+
95
+ Also fixed here, all the same class — a mirror that believed arrival order:
96
+
97
+ - `_voltro_billing_subscriptions` and `_voltro_billing_invoices` now carry a `statusEventAt` and DROP an event older than the state they already reflect. A redelivered `active` from before a decline used to silently un-do the past-due; a late `payment_failed` used to flip a paid invoice back to `open`. - `patchByTenant` declared only `plan | quantity | status`, so the `currentPeriodStart` / `currentPeriodEnd` that `changePlan` / `changeSeats` read back from Stripe were type-checked at the call site (conditional spreads dodge excess-property checking) and then dropped on the floor by the DataStore-backed store. The period bounds Stripe returned after a seat change were never persisted.
98
+
99
+ **Migration.** `BillingProvider` gained a required `fetchSubscription` and an optional `customerEmail`; `BillingEvent` gained `occurredAt` on every variant; `Subscription` gained `pastDueSince` + `statusEventAt`. The Stripe and mock adapters ship both. If you wrote your own `BillingProvider`, implement `fetchSubscription` — returning the subscription's CURRENT status, not a cached one, because it is what dunning refuses to lock a customer out without. A provider that genuinely cannot answer should fail rather than guess: an unreachable provider leaves the tenant in grace, which is the safe direction.
100
+
101
+ `_voltro_billing_dunning_notices` is registered with the retention sweep at ~400 days (`VOLTRO_BILLING_DUNNING_TTL_HOURS`). The bound is deliberately generous because the ledger IS the send gate — pruning a row belonging to a still-open episode would let its notice go out a second time — and an episode lasts weeks at the outside.
102
+
103
+ **`voltro update` carries you across this** — codemod `0.34.0/13_billing-provider-dunning`.
104
+ - **@voltro/runtime, @voltro/protocol, @voltro/plugin-auth, @voltro/cli** — **Soft re-auth presents a CREDENTIAL; it no longer hands the server a Subject — and it exists in production now (REL-23).**
105
+
106
+ A WebSocket's handshake headers are fixed for the socket's life, so a credential minted *during* the connection — `auth.signin` over the live socket, a tenant switch — cannot reach the server on that connection. That is a transport problem. It was solved as an authorization one: `bindConnectionSubject(clientId, subject)` stored a resolved `Subject`, and the auth middleware began
107
+
108
+ ```ts
109
+ const override = getConnectionSubject(clientId)
110
+ if (override) return override
111
+ ```
112
+
113
+ so for the **life of that connection** nothing after that line ran again. Three things stopped happening, none of them audible:
114
+
115
+ - the **session-revocation check** — it lives inside the auth strategy, so a user signed out from another device kept working on this socket; - **`resolveScopes` and the scope cache** — authority frozen at re-auth time, so a role removed afterwards was never observed; - the **credential-expiry record** that stops a subscription outliving the token that authorized it. There is no `exp` on a stored object; the only bound was a 24h idle sweep.
116
+
117
+ This is the frozen session cookie that 0.34.0 just removed, one layer down, on a channel with no expiry at all.
118
+
119
+ **And it existed only under `voltro dev`.** `serveApi.ts` had no such fast path, so the framework's own switch-tenant rebind worked in development and was a silent no-op in production. One defect facing two opposite directions: where it worked it bypassed the auth chain, and where it mattered it did nothing.
120
+
121
+ **The override now carries the credential.** `bindConnectionCredential(clientId, { cookies, headers })` patches the connection's headers, and the middleware runs the **same chain a fresh request runs**. There is no property a rebound connection has that a reconnecting one would not, because it is the same code path. `cookies:` sets or replaces named cookies *inside* the `Cookie` header — replacing the header wholesale would drop every other cookie the connection carries, and a caller minting a session knows its own and nothing about the rest.
122
+
123
+ The cost is honest and is the point: **a rebinder must have a credential to present.** A caller with nothing to present could not authenticate a fresh request either, so that connection was asserting authority no request could obtain and nothing could revoke.
124
+
125
+ **The dev/serve half is closed structurally, not by mirroring.** Both boot paths build their middleware from ONE function, `makeAuthMiddlewareLayers` (`@voltro/runtime`), which returns `AuthMiddleware` and `ConnectionInfoMiddleware` as a single merged Layer — they read each other's state (the chain *records* the credential expiry that `ConnectionInfo` *reports*), so neither can be half-adopted. `authMiddlewareParity.test.ts` scans every framework source file and fails if `AuthMiddleware.of(` is constructed anywhere but that builder. `serveCommand` also stopped recording the credential expiry itself: it hands `serveApi` the whole `SubjectResolution` and the shared layer records it once, for both paths.
126
+
127
+ **Two exports are gone with no replacement, because nothing read them.** `onBindConnectionSubject`'s doc claimed the dispatcher re-scoped subscriptions on rebind; it had zero subscribers in the entire framework, so nothing did. The behaviour is unchanged and now stated correctly: a rebind affects subsequent CALLS; a subscription opened under the previous credential runs until the client re-subscribes. `connectionSubjectsSnapshot` was a diagnostics surface with no reader. `unbindConnectionSubject` is `unbindConnection` (it clears the connection's credential *and* its recorded expiry).
128
+
129
+ `authRoutesPlugin({ rebind })` takes the new function; `handleSwitchTenant` mints the session first and hands the rebinder that cookie, so the socket and the next HTTP request cannot end up authenticating as different tenants.
130
+
131
+ Migration: `voltro update` prints the manual codemod. Pass the credential you were already about to `Set-Cookie` (`issueSession(...)` returns `{ value, setCookie }`), or a Bearer header for a token app.
132
+ - **@voltro/runtime, @voltro/voltro** — **`crud.create` / `crud.update` refuse a `.serverOnly()` column in their INPUT (SEC-14).** `.serverOnly()` is the WIRE-exposure axis — the column never crosses the boundary. Redaction only ever enforced the outbound half (`effectiveRedact` strips it from every returned row), 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, and it is the same violation mirrored.
133
+
134
+ A payload that sets one now fails with the new `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 and leaves an honest caller debugging a value that quietly did not land. A key present with the value `undefined` does not count as sent, so optional schema fields are unaffected.
135
+
136
+ Migration: drop those columns from the descriptor's `input` schema. When the server legitimately needs to write one (a hashed key, an internal flag), do it from the handler with `ctx.store.insert` / `ctx.store.update` — those are unchanged; the refusal is on the generated crud path, which is the one fed straight from client input.
137
+
138
+ **`voltro update` carries you across this** — codemod `0.34.0/02_keyed-writes-are-tenant-scoped`.
139
+ - **@voltro/plugin-governance, @voltro/database, @voltro/cli** — **A GDPR erasure is only as complete as the list of tables it walks, and that list was hand-written.** `subjectScopes: [{ table, subjectField }]` is wrong the day after someone adds a table, and nothing checked it — which is the whole compliance claim, unverified. The framework already knows the answer: `relations()` says which table belongs to which, and `reference()` columns carry the same fact at the column level.
140
+
141
+ ```ts
142
+ governancePlugin({ deriveSubjectScopes: { subjectTable: 'users' } })
143
+ ```
144
+
145
+ The scope is now DERIVED by walking that graph outward, so a DSAR finds rows two and three hops away (`users → posts → comments`) that a flat `{ table, subjectField }` entry cannot even express — and a table added tomorrow is in the DSAR tomorrow. Explicit `subjectScopes` are still first-class and are UNIONED on top, never replaced: a subject id in a plain column, a polymorphic `(type, id)` pair or an id inside JSON can only be declared.
146
+
147
+ **Only CHILD edges are followed** — a table holding a reference to the subject's row. Never a parent or lookup edge, and a `manyToMany` follows the JUNCTION only. Getting that backwards is not an over-broad export, it is an erasure that walks from one member into their organisation and deletes everybody else's rows.
148
+
149
+ **`GovernanceService.subjectGraph()` and `GET /_voltro/inspect/plugins/governance/subject-graph` report what the derivation CANNOT see**, in the same payload as the paths — every table nothing links to the subject, everything cut by the depth ceiling, everything excluded. A reachable-table list on its own reads as a completeness claim, and "found no rows" is otherwise indistinguishable from "never looked".
150
+
151
+ **`voltro privacy`** ships with it: `scope` derives and prints the graph plus its blind spots OFFLINE (schema only — it runs in CI and in a PR review), and `export` / `erase` run against the RUNNING app's admin-gated governance endpoint rather than opening their own connection, so the configured anonymize fields and the erasure log still apply. `erase` refuses without `--confirm`, without `--url`, and without an inspect credential.
152
+
153
+ **Scale.** The walk no longer does `store.all(table)` per scope. Every read is `WHERE <column> IN (<keys>)` on an indexed column, chunked at 500 keys, memoised per shared path prefix, capped at 50 000 rows per table — and a cap that is HIT is reported as `truncated` on the erasure-log entry (and exits non-zero from the CLI), because a short erasure presented as complete is the failure this exists to prevent. Erasure now runs DEEPEST-FIRST so a real foreign key neither refuses the delete nor cascades through rows the log never counted.
154
+
155
+ **Crypto-shredding is deliberately NOT implemented, and must not be faked.** The shipped cipher is ONE app-wide passphrase-derived key (`runtime/fieldCipher.ts`), so there is nothing subject-shaped to destroy — deleting it would make every subject's `.encrypted()` columns unreadable, which is an outage, not an erasure. Per-subject shredding needs envelope encryption (a DEK per subject, wrapped by a KEK, with every existing ciphertext re-wrapped), which is a re-architecture of the cipher rather than a mode of `eraseSubject`. What makes its absence affordable: `.encrypted()` columns are decrypted on read, so `delete` removes the ciphertext and `anonymize` overwrites it — both erase the data rather than the key guarding it.
156
+
157
+ **BREAKING** — `exportSubject` / `eraseSubject` take `ReadonlyArray<SubjectPath>` where they took `ReadonlyArray<SubjectScope>`. `codemod: none` because no user-authored code calls them: both are reached through `GovernanceService`, the `governance.*` rpc routes or the dashboard, all of which are unchanged. A direct caller converts with the exported `scopeToPath(scope, subjectTable)`.
158
+ - **@voltro/cli** — **`voltro create-project` now creates the workspace root when there is none, and `voltro init` means what its name says.**
159
+
160
+ The documented first run — `pnpx voltro create-project acme` then `pnpm install && pnpm dev` — failed at command two for anyone not already inside a prepared monorepo. `create-project` wrote only `apps/<name>/…`: no `pnpm-workspace.yaml`, no root `package.json`, no `.gitignore`, no `git init`. It then printed `pnpm install` (and, with a baseline, `pnpm db:up` / `pnpm dev:docker`) into a directory that had no manifest for any of them to live in.
161
+
162
+ Five separate defects fed one broken first contact, and all five are fixed:
163
+
164
+ - **No workspace root.** `create-project` (and the new `init`) write `pnpm-workspace.yaml` (`apps/*/*`, `packages/*`, `packages/*/*`), a root `package.json` with `dev`/`build`/`test`/`typecheck`, a `.gitignore` that covers `.env.local` (where `voltro dev` mints per-project secrets), and run `git init` unless something above is already a repo. Everything is additive: an existing workspace is left alone, and only root scripts you do not already define are filled in. - **`voltro init` was `create-project` under another name.** It is now the workspace-root command: it initialises the CURRENT directory, takes no arguments, scaffolds no apps, and is idempotent. `voltro init <name> --api …` exits 2 and prints `voltro create-project <name> --api …` instead. **Breaking** — see the codemod note. - **The root `dev` script named a tool nothing installed.** All four baselines wrote `turbo run dev` / `turbo run build` / … while no baseline shipped a `turbo.json` or a turbo devDependency, so `pnpm dev` failed even in a correctly prepared workspace. The scripts are plain pnpm now — `pnpm -r --parallel dev`, `pnpm -r build`, `pnpm -r test`, `pnpm -r typecheck` — identical in the scaffolder and in every baseline, so applying a baseline cannot change what `pnpm dev` means. `pnpm -r` selects by "has this script", so an Expo app or a serverless bundle opts out of `dev` by construction rather than by config. - **`pnpm install` still exited 1 — on a question about packages you never chose.** pnpm 11 does not warn about an undecided postinstall script, it fails the install: `ERR_PNPM_IGNORED_BUILDS`. A greenfield scaffold pulls exactly three, all transitive (`esbuild` via vite, `@parcel/watcher`, `msgpackr-extract` via `@effect/rpc`), so the first command after the scaffolder printed it came back red asking about `@parcel/watcher`. The scaffolded `pnpm-workspace.yaml` now ANSWERS all three, each with its reason on the line: `esbuild: true` (vite's compiler binary — the web app does not build without it), the two optional native accelerators `false` (both have pure-JS fallbacks, so a first install needs no C++ toolchain). pnpm 10 ignores the key and is unaffected. Anything new pnpm finds still stops and asks — this decides the framework's own tree, not yours. - **A baseline could drop all its scripts in silence.** `patchPackageJson` returned quietly when the root `package.json` was absent, so a compose baseline reported success having written none of its 18 scripts — including the ones the CLI printed as next steps. It refuses now, before writing or removing any file, and names `voltro init` as the fix.
165
+
166
+ `findWorkspaceRoot` no longer falls back to the current directory when the walk up finds nothing; `add-app` and `voltro cloud project link` say so instead of operating on an invented root.
167
+ - **@voltro/plugin-webhooks, @voltro/runtime, @voltro/voltro, @voltro/plugin-billing** — **An incoming webhook must declare how it authenticates its caller (SEC-17).** Incoming-webhook routes were mounted raw and signature checking was delegated entirely to whatever the descriptor happened to declare. A hand-written `*.webhook.tsx` with no `signature` and no `provider` shipped as an **unauthenticated public POST that runs application code** — no HMAC, no replay window, no warning. Two first-party plugins carry their own verification (atlassian, billing), which is exactly what made the gap invisible: every example was safe.
168
+
169
+ `mountIncomingWebhook` now THROWS at mount — i.e. at boot, on both boot paths — for a descriptor with no effective signature scheme and no explicit `verification`. The new field takes `'signature'` (the default when a `signature` / `provider` is present), `'provider'` (your handler verifies with the provider's own SDK), or `'none'` (deliberately public because a gateway + IP allow-list owns the trust boundary, logged as a warning on every boot). Declaring `'signature'` with no scheme to verify against is the same open endpoint and lands on the same refusal.
170
+
171
+ Fail-closed rather than a boot warning, for the same reason a missing session secret refuses to boot: a warning about an endpoint that already works is read once, and this one only matters in production, where nobody is reading the boot log.
172
+
173
+ **Second half of the same defect, and the one that can bite a webhook you believed was verified:** a webhook that DECLARED a signature scheme but whose secret did not resolve set `signatureOk = 'skipped'` and ran the handler anyway. One missing env var silently converted a verified webhook into an open one. It now answers 503 naming `VOLTRO_WEBHOOK_SECRET_<ID>` — 5xx, not 401, because the fault is ours and most providers retry a 5xx. The framework mints no value for it: the sender holds the other half, so a generated secret would authenticate nobody. `@voltro/plugin-billing`'s webhook inherits this — a deployment with no `webhookSecret` now 503s instead of applying anonymous POSTs to subscription state.
174
+
175
+ `startRpcServer` carries the transport half of the gate: it refuses to mount a `webhookRoutes` entry whose handler carries no verification declaration at all, which covers an embedder wiring routes by hand rather than through the mounter. `mountIncomingWebhook`'s return type is now `MountedIncomingWebhook` (the same callable, plus the stamped declaration).
176
+
177
+ **`voltro doctor` reports it before a deploy does.** A new `incoming webhook verification` section counts the verified endpoints, NAMES every `verification: 'none'` one (a deliberately public URL belongs in a review, not only in a boot log), and fails non-zero on any that declares nothing — the same verdict the boot reaches, from the same resolver (`incomingWebhookVerification`, now exported), so a green doctor is the claim that the app starts. It is in `--json` as `webhookVerification` too: a finding that exists only in the human view cannot be acted on by CI.
178
+
179
+ Migration: add one of `provider:` / `signature:` / `verification:` to every `defineIncomingWebhook`, and set `VOLTRO_WEBHOOK_SECRET_<UPPERCASED_ID>` for the signature-verified ones. The codemod prints the four options and the env-var naming rule; it does not rewrite, because choosing between "verify this" and "this is deliberately public" is precisely the decision that was not being made.
180
+ - **@voltro/cli** — **The web process's postgres ISR cache and CDC invalidator understood `PG_*` only, and every template teaches `DB_URL` (PROD-5).**
181
+
182
+ `start.ts` gated the postgres ISR cache on `SSR_CACHE === 'postgres' && process.env.PG_HOST`, and `isrCdcInvalidator.ts` gated its `LISTEN` client on `PG_HOST || PG_DATABASE`. The real resolver prefers `DB_URL` / `DB_PRIMARY_URL` and only then `DB_HOST ?? PG_HOST` — and `DB_URL` is what every `voltro-templates` app and the deployment docs configure.
183
+
184
+ So an app configured the documented way, explicitly asking for `SSR_CACHE=postgres`, silently got the **per-process memory cache**, announced by `isr cache backend: memory (per-process)` — an `info` line that reads like the default rather than a refusal. And every route declaring `cacheInvalidatesOn` got **no live invalidation at all**, announced by `log.debug('skipped — no PG_* env vars set')`, which is invisible at the default level and reads as an unconfigured deployment rather than a misread one.
185
+
186
+ Both now go through `resolvePgClientConfig()` / `databaseConfiguredInEnv()` in `connectionConfig.ts` — the same resolver every other connection uses, which also means `PG_SSL`, `DB_SCHEMA` and the acquire bounds arrive with them.
187
+
188
+ **The fallbacks are LOUD now, and one of them is a refusal:**
189
+
190
+ - `SSR_CACHE=postgres` with no database named anywhere **aborts the boot** on a deploy environment (`NODE_ENV=production` / `staging`), and warns loudly otherwise. Memory is not shared between instances and does not survive a restart; serving it under an `info` line is how two replicas came to disagree about a page with nothing to indicate it. Same reasoning as `plugin-search`'s memory-backend refusal. - The CDC invalidator **warns**, naming the routes, when pages declare `cacheInvalidatesOn` and no database is configured. "No page asked for this" stays at `debug` — a non-event. "A page asked for this and it is not happening" is the case that was also `debug`, and that was the defect.
191
+
192
+ `connectionConfigResolver.test.ts` grew the rule that would have caught it: the discrete host/credential variables (`PG_HOST`, `DB_HOST`, `PG_USER`, …) are read in exactly ONE file. Its existing rules asked whether each builder honours `PG_SSL`, which both of these did — they called `sslFromEnv()` and then looked at the wrong database.
193
+
194
+ **`voltro update` carries you across this** — codemod `0.34.0/17_isr-postgres-refuses-without-a-database`.
195
+ - **@voltro/runtime, @voltro/voltro, @voltro/plugin-multitenancy** — **Keyed-by-primary-key writes are now confined to the caller's tenant (SEC-5).** `ctx.store.update(table, id, patch)`, `delete(table, id)`, `hardDelete(table, id)` and `patchJson(table, id, …)` on a `tenant()`-scoped table resolve the target row INSIDE `subject.tenantId` before writing. They previously addressed the row by primary key alone — every other path was already enforced (reads AND-merge `eq('tenantId', …)`, inserts auto-stamp and default-deny, `updateMany`/`deleteMany` go through `scopeManyWhere`), 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. `assertOwnTenant` only ever compared a *claimed* `input.tenantId`, so a mutation with no tenantId in its input never reached it.
196
+
197
+ Migration: the call now fails with the new `TenantRowNotFound` instead of returning `null` / `false`. A handler that read that return as "not found" needs to decide what a refusal should do — catch `_tag === 'TenantRowNotFound'` (or `Effect.catchTag`), and 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` — they still span tenants by design), reads, inserts, `updateMany`/`deleteMany` and the fluent `update(t).where(…)` / `delete(t).where(…)` builders, which were already scoped and are NOT scoped a second time.
198
+
199
+ `@voltro/runtime`'s `StoreError` union gains `TenantRowNotFound` (and `ServerOnlyColumnWrite`, below), so an exhaustive `switch` over it needs the new arms.
200
+
201
+ **`voltro update` carries you across this** — codemod `0.34.0/02_keyed-writes-are-tenant-scoped`.
202
+ - **@voltro/logger, @voltro/protocol** — **Log redaction is ON by default (SEC-10).** It was opt-in with an EMPTY default: `resolveRedactor` returned `undefined` unless an app passed `redactKeys`, 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 (the CLI buffer, logship, datadog). The justification on the old code — "a logger that faithfully echoes its input is the right default" — is wrong in one specific way: what is being faithfully echoed is whatever the CALLER put in the bag, and the commonest bag is a request. A default that is safe only for the users who already knew the option existed is not a default.
203
+
204
+ Every logger surface (`createLogger`, `makeLogger`, `LoggerLayer`) now installs `makeDefaultRedactor()`:
205
+
206
+ - `DEFAULT_REDACT_KEYS` — exact key matches, normalised (case-insensitive, `_` / `-` / spaces ignored, so `api_key` = `API-KEY` = `apiKey`): credentials, bearer/API tokens, `authorization` / `cookie` / `set-cookie` / `x-api-key`, session + second-factor values, and the payment/government identifiers (`creditCard`, `cvv`, `iban`, `ssn`, …) that must never reach a log index. - `DEFAULT_REDACT_KEY_PARTS` — substring matches for the compound names real code writes: `newPassword`, `oldPassword`, `stripeApiKey`, `userAccessToken`. - A value-SHAPE scan that masks a credential regardless of its key: `Bearer …`, `Basic …`, a JWT, `sk_`/`pk_`/`whsec_`-prefixed keys, GitHub / Slack / AWS key ids, a PEM private key. Deliberately NOT an entropy heuristic — a trace id, a content hash, a git sha and a base64 thumbnail are all long and high-entropy, and a redactor that eats the fields an incident is read through gets switched off wholesale.
207
+
208
+ `redactKeys` now ADDS to that list instead of being the whole of it, and there is **no way to subtract**. The only reason to un-mask `password` is to read it while debugging, and the answer to that is to log a non-secret projection; a subtraction knob would also apply to every dependency logging under that key, which is a blast radius an app cannot assess. The `redact` transform still exists as the deliberate escape hatch and runs AFTER the built-in redactor, so it can mask more and structurally cannot unmask.
209
+
210
+ `codemod: none`: no user-authored code changes. Existing `redactKeys` config keeps working (it just adds), and the only observable difference is that fields which used to print a secret now print `[redacted]`. If a log line you relied on went quiet, rename the field — `traceId`, `requestId`, `cookieName` are all untouched, and `@voltro/protocol`'s own session diagnostic was renamed from `cookie` to `cookieName` for exactly that reason.
211
+ - **@voltro/testing, @voltro/plugin-mail** — **`ctx.email` / `MockEmail` are deleted; `invoke` provides plugin service layers instead (TEST-1).** The framework shipped a mail-assertion API with **zero producers**: `MockEmail` existed, `makeTestContext` created one and hung it on `ctx.email`, the shipped docs taught `ctx.email.sent` / `ctx.email.lastTo(...)` as THE way to assert mail — and the only callers of `MockEmail.send` in all eight repos were the testing package's own tests. Nothing routed a handler's send into it, and nothing could: mail is not a `ctx` field, it is an Effect **service** a handler resolves with `yield* MailService`, and `invoke` provided only the `EffectStore` layer and `SubjectService`. So a mail-sending handler under test did not go un-asserted — it could not RUN. It died with `Service not found`.
212
+
213
+ Which makes every existing assertion against it one of two things, and this is why the codemod is `manual`: vacuous (`sent` was always empty, so `toHaveLength(0)` passed and meant nothing) or already failing. A transform could rewrite the expressions and every file would compile, having changed a green vacuous test into a green vacuous test under a new name.
214
+
215
+ **The fix is the general one, not a mail special case.** `invoke` now provides every registered plugin's `services:` layer plus the app's own `app.config.ts` `layers:`, merged in the serve pipeline's order (user layers last, so an app layer overrides a plugin layer declaring the same Tag). So the assertion surface for mail is the mail plugin's own memory provider:
216
+
217
+ ```ts
218
+ const ctx = makeTestContext({
219
+ subject: user('u1'),
220
+ plugins: [mailPlugin({ provider: 'memory', from: 'app@acme.com' })],
221
+ })
222
+ await invoke(sendWelcome, sendWelcomeHandler, { to: 'ada@acme.com' }, ctx)
223
+ expect(readMailBuffer()[0].to).toEqual(['ada@acme.com'])
224
+ ```
225
+
226
+ That is strictly more than the mock could ever have been: the whole send path runs — the default `from`, the dev allowlist, suppression, per-send idempotency, the template render — so "the allowlist dropped this message" is now a testable outcome. A double living in `@voltro/testing` would have had to model a weaker message than `MailService` accepts (`SentEmail` had `to`/`template`/`props` and no `subject`, `html`, `cc`, `bcc` or attachments), and a test asserting against it asserts against the double. The plugin owns the message shape, so the plugin owns the assertion surface.
227
+
228
+ `makeTestContext` gains `layers?:`, and `MakeTestContextOptions.plugins` now does two jobs (interceptors, as before, and services). A Tag nobody provided still fails with `Service not found` — deliberately: that is what the handler does at runtime, and stubbing it would be asserting against the stub. `Cache`, `Kv`, analytics, the outbound `HttpClient` and the aggregate registry stay unprovided for the same reason plus a structural one: the CLI builds them at boot from an app's config, and `@voltro/testing` does not depend on the CLI.
229
+
230
+ `ProcedureExecutor` gains a fourth type parameter `R = never` (the services the handler resolves) — additive, existing three-argument uses are unchanged.
231
+
232
+ Covered by `testing/src/invokeServiceLayers.test.ts` (the layer mechanism, both directions of the override, and the no-silent-stub refusal) and `plugin-mail/src/mailUnderTest.test.ts` (the end-to-end send, the allowlist refusal, and mail-survives-a-rolled-back-mutation). The mail test lives in the plugin because `@voltro/plugin-mail` carries react/react-dom for react-email, so devDepping it from `@voltro/testing` resolves a second copy of React into that package's tree and breaks every `client.test.tsx` case — the trap `testing/src/storeForTenant.test.ts` already documents.
233
+ - **@voltro/sql-mysql, @voltro/sql-mssql** — **A mysql/mariadb or mssql connection that asks for TLS now gets TLS — or refuses to boot. It no longer connects in plaintext.**
234
+
235
+ `DB_URL=mysql://…?ssl=true` used to connect **unencrypted, with no warning and no error**. `MysqlConnection` had no `ssl` field, `connectionFromConfig` read neither `ConnectionConfig.ssl` nor the URL query, and the layer passed the driver host/port/user/password/database and nothing else. The request was not rejected; it was dropped. mssql had the same hole with an extra twist: a hard-coded `trustServer: true` made the config LOOK TLS-aware while `@effect/sql-mssql` defaults `encrypt` to `false` (it overrides tedious' own `true`), so every mssql session was plaintext too.
236
+
237
+ Both dialects now follow the posture postgres has had since F6:
238
+
239
+ - `?sslmode=require` / `?ssl=true` / `?ssl=1` (mssql also `?encrypt=1`) → TLS, certificate not verified. mysql2 gets `{ rejectUnauthorized: false }` explicitly rather than `{}`, which would silently mean *verify* — a different, stricter mode than the flag names. mssql gets `encrypt: true` + `trustServer: true`. - `?sslmode=disable` / `?ssl=false` → plaintext, explicitly. - Anything else — `prefer`, `allow`, `verify-ca`, `verify-full`, `?ssl=yes`, a mysql2 CA-profile name — **throws at boot**. The cross-dialect `ConnectionConfig.ssl` is a boolean and cannot carry a verification mode, and answering a request for `verify-full` with something weaker is the same defect in a politer form. - `ConnectionConfig.ssl` wins over the URL query, in both directions.
240
+
241
+ **Why the break is correct.** Three groups of users are affected and all three are better off. An app whose URL said `?ssl=true` was being lied to — it now gets what it asked for, or a boot failure if the server cannot provide it. An app that wrote `?sslmode=verify-full` was getting plaintext — the weakest possible answer to the strictest possible request — and now finds out. An app with no TLS in its URL is unaffected. A boot failure is loud, immediate and happens on a deploy; a plaintext connection to a database you believed was encrypted is none of those things.
242
+
243
+ No user-authored SOURCE changes — the affected input is a connection URL / env var, and there is nothing in a repo for a transform to rewrite. That is the case FOR a `manual` codemod rather than against one: a transform cannot look at a value it cannot see, and the failure mode of not reading this is a container that stops booting on a deploy. `voltro update` prints the operator step — *check whether your `DB_URL` carries `?ssl=` / `?sslmode=` / `?encrypt=`, and whether your server actually accepts TLS* — with the two queries that answer it from the database rather than from the config (`SHOW STATUS LIKE 'Ssl_cipher'`, `sys.dm_exec_connections.encrypt_option`).
244
+
245
+ Proven on the wire, not just in config: the live suites assert MySQL's `Ssl_cipher` is empty for a plaintext session and names a cipher under `ssl: true`, and SQL Server's `sys.dm_exec_connections.encrypt_option` reports `FALSE` / `TRUE` respectively.
246
+ - **@voltro/cli** — **One place decides what environment a `voltro` process is in, and the migration commands stopped guessing (PROD-2).**
247
+
248
+ `if (!process.env.NODE_ENV) process.env.NODE_ENV = 'production'` existed in exactly three files — `serveCommand.ts`, `start.ts`, `webDev.ts` — each with a comment saying an unset `NODE_ENV` in a serving container means production. Nothing else agreed, in three separate ways:
249
+
250
+ - **The launcher decided first.** `bin/voltro.mjs` read `NODE_ENV` in the `serve` / `start` fast paths, which run BEFORE the command's own default. So a `NODE_ENV`-less production deploy with a missing or unloadable serve bundle skipped the "production requires a precompiled serve bundle" guard, fell through to the tsx path, and — in a `--prod --no-optional` image, which has no tsx — died as `Cannot find package 'tsx'`, naming a package the user never asked for instead of the real cause. - **`voltro db apply` / `voltro migrate` ran the DEV branch of every gate.** In a pre-deploy job in the same image with `NODE_ENV` unset, the "auto-apply on prod is not allowed, go through `db plan` + `db apply --plan`" refusal did not fire, `db rollback-file`'s prod refusal did not fire, and the plan ledger recorded `environment: 'dev'` for a production apply. - **The framework TABLE SET disagreed with the process that serves it.** `traceTableEnabled()` and `undoCaptureEnabled()` are "on unless production", so a `NODE_ENV`-less `voltro db apply` declared `_voltro_traces` + `_voltro_undo_log` and `voltro serve` 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's boot gate refused with `prod-mismatch`, telling the operator to run `voltro db apply`. Which they had just run. The loop has no exit.
251
+
252
+ `bin/nodeEnvironment.mjs` is the one decider (plain ESM, because the launcher runs before the tsx loader and cannot import TypeScript; `src/nodeEnvironment.ts` is its typed face). **An undeclared `NODE_ENV` resolves to `production` for `serve`, `start`, `db` and `migrate`, and to `development` for `dev`.** Every other command is left undeclared on purpose.
253
+
254
+ The polarity is argued per gate rather than flipped once:
255
+
256
+ - for a **refusal** (`db apply` auto-apply, `db rollback-file`), the cost of a false positive is one environment variable and the cost of a false negative is an un-reviewed DDL applied to a production database — so it fails closed; - for a **table-creation** decision, the safe direction is not "production", it is *the same answer the serving process will compute*. Serve resolves an undeclared environment to production, so every migration command must too, or the two declare different schemas; - `voltro dev` declares `development` so the ambiguity never reaches the boot auto-migrate at all — dev's table set becomes a stated fact rather than the absence of one.
257
+
258
+ **What changes for you:** `voltro db apply` and `voltro migrate` on a machine with no `NODE_ENV` now refuse, with a message that names `NODE_ENV` as the reason. Set `NODE_ENV=development` for a local database (`voltro dev` itself is unaffected — it declares its own). Read-only `db` subcommands (`plan`, `status`, `drift`) are unaffected except that they now compute the same declared schema the deploy will.
259
+
260
+ The `dev | staging | prod` ledger name is one reader now (`deployEnvironmentName`). It said "one reader" and there were five in `dbCommand.ts`, two of which mapped the same input differently — they agreed only because the prod refusal made the divergent branch unreachable.
261
+
262
+ The launcher deliberately does NOT write `process.env.NODE_ENV`: it runs before `.env` / `.env.local` are loaded, and a value written there would silently outrank the app's own dotenv file. It reads. That also means a `NODE_ENV` set only in `.env` cannot influence the launcher's bundle guard — set it in the process environment for a non-production `voltro serve`.
263
+
264
+ **`voltro update` carries you across this** — codemod `0.34.0/16_declare-node-env-for-migration-commands`.
265
+ - **@voltro/runtime, @voltro/protocol, @voltro/voltro, @voltro/plugin-sso-saml, @voltro/plugin-storage, @voltro/plugin-billing, @voltro/plugin-scim** — **The origin guard covers EVERY state-changing surface, not `POST /rpc` and the WS upgrade.** SEC-6/SEC-7 landed the check against two path literals, which read as complete because `/rpc` is where mutations live. It is not the only place they live. A `publicApi:` annotation projects the SAME mutation to `POST /v1/<route>`; `apiConfig.restRoutes` mounts hand-authored ones; `POST /v1/api-keys` mints credentials. All three reach the listener as plugin HTTP routes, and all three resolve their subject through the same auth chain — the built-in session-cookie strategy included. So the framework shipped a guarded `/rpc` and an UNGUARDED REST projection of the same writes: `evil.example` could POST a victim's `voltro:session` cookie at `/v1/orders` and the guarded twin next door would refuse the identical call.
266
+
267
+ **The polarity is inverted.** Every request whose method can change state (anything but GET/HEAD/OPTIONS) is origin-checked, and a route that genuinely cannot be CSRF'd declares itself exempt. 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. The check still lives in `wrapHttpApp`, the single point `voltro dev` and `voltro serve` share, so neither boot path can have a different answer.
268
+
269
+ **Cookie-auth versus bearer-auth is NOT distinguished per request, on purpose.** The check runs before routing and before the auth chain, so "would this request have been authenticated by a cookie" is not knowable there — and a route that accepts BOTH a bearer token and the cookie has to be guarded regardless. The distinction is made once, by declaration, where someone can reason about it:
270
+
271
+ - `PluginHttpRoute.originGuard: 'exempt'` — the route's authority is something a browser will not attach cross-site. Four first-party routes qualify and now say so: `@voltro/plugin-sso-saml`'s `/saml` (the IdP delivers a signed assertion by making the browser form-POST it — a legitimately cross-site POST), `@voltro/plugin-storage`'s `/_voltro/storage/upload` and `…/upload/resumable` (a signed upload ticket, with the plugin's own CORS allowlist, because a cross-origin upload is the point), `@voltro/plugin-billing`'s `/billing/webhook` (HMAC-verified) and `@voltro/plugin-scim`'s `/scim/v2` (bearer-only, refuses to mount untokened). - The inspect surface and incoming `*.webhook.tsx` mounts are exempt STRUCTURALLY: inspect is token-gated and designed to be read cross-origin by both dashboards, and a webhook route cannot boot without declaring how it verifies its caller.
272
+
273
+ A path shared by several routes is exempt only when EVERY route on it declares it — the pre-routing check cannot know which member will own the method.
274
+
275
+ **SSR still works, and it is asserted on the REST surface too.** The `no-browser-origin` terminal case is unchanged: a request with neither `Origin` nor `Sec-Fetch-Site` did not come from a browsing context. That is the in-process SSR loader, every mobile SDK, curl, and every service-to-service caller — including every webhook sender, which is why the blast radius of guarding by default is close to nil. Only a BROWSER sends `Origin`.
276
+
277
+ Migration: the same one SEC-6 already asks for, now reaching further. **A split web/api deployment must declare `security: { allowedOrigins: [...] }`** — if you already did so for `/rpc`, your REST routes are covered by the same list and there is nothing to do. A cross-origin browser client calling a `publicApi:` route with a bearer token needs its origin in that list. `originGuard: 'off'` still disables the whole check.
278
+
279
+ **`voltro update` carries you across this** — codemod `0.34.0/06_origin-guard-and-trusted-proxies`.
280
+ - **@voltro/runtime, @voltro/voltro, @voltro/cli** — **Cross-site protection on `POST /rpc` and the WebSocket upgrade (SEC-6, SEC-7).** Neither surface had any check. `@voltro/plugin-auth` ships a real signed double-submit CSRF token, but it is wired only onto plugin-auth's OWN routes — so framework-wide CSRF protection for the general mutation surface rested entirely on the `SameSite=Lax` session-cookie default. Lax still permits top-level navigation POST, is caller-overridable, and does nothing at all for a bearer/JWT flow. Any page on the internet could open a live socket to a logged-in user's app, or drive a mutation from a form.
281
+
282
+ Both are now one decision, made in `wrapHttpApp` — the single point `voltro dev` and `voltro serve` share, so the two boot paths cannot diverge on it. A browser request is accepted when its `Origin` matches the `Host` it was addressed to, or appears in the configured allowlist; `Origin: null` (sandboxed iframe, `data:` document) is refused rather than treated as absent; and when `Origin` is missing but `Sec-Fetch-Site` says `cross-site`, that alone refuses. Everything else on the listener is deliberately unguarded: an incoming webhook is called by a third party and is authenticated by signature, and the inspect surface carries its own host + token guard.
283
+
284
+ **The exemption that keeps SSR working, stated because it is load-bearing:** a request carrying NEITHER `Origin` NOR `Sec-Fetch-Site` is not from a browsing context and is allowed (`reason: 'no-browser-origin'`). That is the in-process SSR loader — `voltro dev` / `voltro start` render pages server-side and their loaders `fetch('<origin>/rpc')` from node, which attaches no origin and no fetch metadata — plus every mobile SDK, curl and service-to-service caller. A CSRF attack needs the victim's ambient credentials, which only a browser attaches, and a browser cannot be made to omit `Origin` on a cross-origin POST or a WS handshake. An attacker's own server can POST without one, but it carries no session to ride: that is simply an unauthenticated request, and auth + guards still apply.
285
+
286
+ **Both sides on loopback is accepted**, and that rule is measured rather than convenient: `voltro dev` proxies the api through the web dev server with `changeOrigin: true` (`webDev.ts`), so the api receives `Host: localhost:4000` while the browser correctly reports `Origin: http://localhost:5190`. Compared strictly those are different origins and every dev session would lose its websocket — and a security control that breaks the default dev loop gets turned off rather than configured around. It is narrow in the direction that matters: BOTH sides must be loopback, so a production api on a public host still refuses `Origin: http://localhost:…`, and `localhost.evil.example` is not loopback.
287
+
288
+ **One dev case is refused and it is a decision, not an oversight:** reaching a dev server from a phone on the same wifi makes the page `http://192.168.1.5:5190`, which is not loopback — add that origin to `allowedOrigins` while you test. Widening the carve-out to "both sides are private addresses" would read as the same argument one step out and is not: the loopback case says the attacker already runs code on this machine, a LAN case says some other host on the network does, and that shape is also a self-hosted internal deployment, where it would weaken production.
289
+
290
+ Comparison is otherwise by AUTHORITY (host + port), not scheme. Behind a TLS-terminating ingress the app sees plain http while the browser reports `https://…`, and there is no unforgeable way to learn the external scheme — requiring a scheme match would reject every correctly-configured production deployment, which is how a security control ends up switched off.
291
+
292
+ Both knobs are `app.config.ts` fields — `security: { originGuard, allowedOrigins }` — read by `voltro dev` and `voltro serve` through one shared resolver, with `VOLTRO_ORIGIN_GUARD` / `VOLTRO_ALLOWED_ORIGINS` as env OVERRIDES for a deployment you cannot rebuild. An embedder passes the same object as `RpcServerOptions.security`.
293
+
294
+ Migration: **a split web/api deployment must declare its origins** or every mutation and socket from that page 403s — `security: { allowedOrigins: ['https://app.example.com'] }`. Same-origin apps need no change. `originGuard: 'off'` disables it; an unrecognised value falls back to ENFORCING, never to off.
295
+
296
+ **`voltro update` carries you across this** — codemod `0.34.0/06_origin-guard-and-trusted-proxies`.
297
+ - **@voltro/runtime, @voltro/protocol, @voltro/voltro, @voltro/plugin-auth, @voltro/plugin-auth-social** — **A plugin HTTP route gets the RESOLVED client address — and the auth routes stop writing the raw header into your audit trail.** SEC-8 inverted the `x-forwarded-for` default for the rate limiter, the geo-block and the pre-routing interceptor, and stopped there. Three first-party routes that record WHO signed in were left reading `req.headers['x-forwarded-for']` verbatim: `@voltro/plugin-auth`'s `/auth/sign-in` and `/auth/mfa/verify`, and `@voltro/plugin-auth-social`'s OAuth callback. So `sessions.ipAddress` — the one column a breach investigation leans on — recorded whatever the caller typed, in a framework that had just built the machinery to prevent exactly that.
298
+
299
+ `PluginHttpRouteRequest` carries `remoteAddr` now: the listener resolves it once per request through `resolveClientAddress` and the app's `security.trustedProxies`, so a plugin route reads the same address the limiter acts on. With no trusted proxy declared the forwarded chain is ignored entirely and the socket peer wins; with one declared, the immediate peer must itself be trusted and the chain is walked right-to-left past every declared hop.
300
+
301
+ **If you wrote a plugin HTTP route that reads `headers['x-forwarded-for']`, read `req.remoteAddr` instead.** The header is still there — it is a request header and we do not strip it — but it is not evidence of anything until you say whose proxy you believe.
302
+
303
+ Migration: **if you run behind a load balancer and rely on `sessions.ipAddress`, set `security: { trustedProxies: ['private'] }`** (or the CIDRs / hop count your ingress needs). Without it the column records your proxy's address rather than the caller's — the same configuration SEC-8 already asks for, now with one more consumer. Apps with no proxy need no change; the column already held the socket peer's address in everything but name.
304
+
305
+ **`voltro update` carries you across this** — codemod `0.34.0/06_origin-guard-and-trusted-proxies`.
306
+ - **@voltro/plugin-presence, @voltro/ui** — Presence has no table, and its roster now pushes only when it actually moves.
307
+
308
+ **`_voltro_presence` is deleted**, along with the read model that outlived the data: `presenceTable`, `PresenceStore`, `PresenceEntry`, `memoryPresenceStore`, `isOnline`, `filterOnline`, `staleKeys`. Counted across every repo here before removing — each reference was the plugin's own barrel or that module's own test file; nothing called `filterOnline`, including the file that imported it. The table was declared and never written to, purely to own a name; `presence.list` now declares a `reactivityChannel('presence')` as its `source:` instead, and the plugin drops the `store:write` permission it only ever held to authorise a table. **An existing `_voltro_presence` is NOT dropped for you** — the differ never plans a drop for a framework table no app declares — so remove the empty table by hand when convenient.
309
+
310
+ **A roster member is `{ key, meta }` — `lastSeen` is gone from the wire.** It was the OWNING replica's clock ("compared only against other timestamps from THAT owner"), so rendering it as a time was already wrong by the skew between two pods on any multi-replica deployment, with nothing to say so. Nothing in `@voltro/ui` rendered it.
311
+
312
+ **Two defects, and they were one.** The heartbeat pushed unconditionally, and a REMOTE change pushed nothing at all: a member who joined on replica A reached replica B's screens only because one of B's own clients heartbeated within 15 seconds and pushed regardless. So the waste was hiding the gap — making the heartbeat conditional alone would have turned a bounded 15-second delay into a permanent one. `tracker.track()`/`.untrack()` now return `{ delta, changed }`, `.apply()` returns whether the roster moved, and `attachPresenceBus` takes an `onRosterChanged` fired on a peer's delta and a peer's death.
313
+
314
+ The asymmetry is deliberate and load-bearing: a heartbeat is ALWAYS broadcast (a peer's sweep drops a member it has not heard from) and pushed only on a change.
315
+
316
+ Measured on the fixed code (`packages/plugin-presence/scripts/rosterFanoutBody.ts`): 2.7 µs per subscriber per publish, so an unconditional push cost a steady room of N clients N² × that per heartbeat interval — ~107 ms of CPU per 15 s at N=200, and a per-node ceiling around 750 subscribers that nothing in the app could influence. That term is now gone; what remains is linear in real roster churn.
317
+
318
+ **`voltro update` carries you across this** — codemod `0.34.0/25_presence-has-no-table`.
319
+ - **@voltro/protocol, @voltro/cli, @voltro/voltro** — **A wire-exposed procedure must declare an access decision, and the default is now DENY (SEC-1).** `guards:` defaulted 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 `voltro doctor`'s authz scan, which is a report a human runs, not a gate a deploy passes.
320
+
321
+ Two halves shipped together, because either alone is worse than neither:
322
+
323
+ - **The marker.** `defineQuery` & co. take `openAccess: '<why>'` — a declared decision that this procedure needs no authorization check, with the reason in the source. Without it, the only way to satisfy a default-deny gate is to add a guard, so every genuinely open endpoint (health check, public price list, signup precheck) grows a scope every caller already holds. That rubber stamp reads as protection and enforces nothing, which is a worse state than the hole it replaces. `openAccess` is mutually exclusive with `guards:`, is refused empty, and is refused on an `internal: true` procedure (no wire surface to decide about). It normalises INTO the descriptor's `guards` array, so every enforcement path — which is handed that array and nothing else — sees the decision; it does not widen the wire error union, since an open procedure can never produce a `ScopeError`.
324
+
325
+ - **The gate.** `assertProcedureAccessDecisions` (`cli/src/procedureAccessGate.ts`) refuses the boot, naming EVERY offending procedure with its file — never a head and a count, because the fix is one pass over the whole list. ONE function, called by `voltro dev`, by `voltro serve` (in `serveApi`, before anything is bound) and by `voltro doctor` (non-zero exit, `accessDecisions` in `--json`), so a green preflight means the app boots.
326
+
327
+ **`security.defaultDeny` in `app.config.ts` defaults to `true`.** The audit that produced this proposed default-false-for-now; the argument against is in the same document — the default nobody turns on IS the shipped posture, and the shipped posture is what a security review reads. Pre-1.0 the cost of a break is not a factor, and satisfying the gate is a one-line, honest edit per procedure. An app that wants the old behaviour declares it once, where a reviewer can see it: `security: { defaultDeny: false }`. There is deliberately **no env override** — the only direction anyone reaches for is off, and an env var is how that becomes permanent in one CI job with no diff to review.
328
+
329
+ Enforcement is at BOOT and covers the app's OWN discovered procedures, not the dispatch spine and not plugin routes. That is a measurement, not an oversight: the first-party plugins declare 47 procedures through the same definers with zero `guards:`, and nothing in the dispatch spine can tell an app's procedure from a plugin's — a process-global default-deny there would refuse every plugin route in every app, and a security default whose first act is to break the framework's own surface gets switched off. `@voltro/protocol`'s `checkGuards` / `checkGuardsEffect` gain an explicit `GuardCheckOptions` (`defaultDeny`, `procedure`) for a caller that already knows it holds an app procedure; the plugin surface declaring its own decisions is the follow-up that lets it move into the spine.
330
+
331
+ Migration: run `voltro doctor`, which lists every undecided procedure with its tag and file, and give each one `guards:` or `openAccess:`. The codemod is `manual` on purpose — a transform could stamp `openAccess` onto every guardless descriptor and every app would boot, having declared its whole surface open in one commit nobody reads, with a reason the tool invented. That is the failure the marker exists to prevent, performed at scale.
332
+ - **@voltro/mcp** — `routeHttp` (@voltro/mcp) returns a `Promise<HttpOutcome>`.
333
+
334
+ Two MCP methods now reach the app — `tools/list` folds in the app's agent tools and `tools/call` executes one — so the pure router has to await. Everything else still resolves without touching anything, and the function is still pure in the sense that matters: it binds no socket, and a test supplies a `live` double.
335
+
336
+ Migration: `await routeHttp(...)`, and make the enclosing function async if it is not. Nothing to do if you use the shipped `serveHttp` transport — it already awaits.
337
+
338
+ **`voltro update` carries you across this** — codemod `0.34.0/26_route-http-is-async`.
339
+ - **@voltro/plugin-sso-saml, @voltro/cli** — **SAML assertion replay was a live gap on the default configuration, and the defence was already built, tested against both backends, and switched off.**
340
+
341
+ `replayProtection` defaulted to absent, which left node-saml at `validateInResponseTo: 'never'` — a captured `SAMLResponse` could be POSTed to the ACS again for as long as its assertion was valid. The one-time request-id cache, its in-process and DataStore backends, the `_voltro_saml_replay` table and the `store:write` declaration all existed. The default is now `{ store: true }`.
342
+
343
+ **Store-backed, not the in-process cache, and that choice is the interesting one.** `replayProtection: true` is not a milder version of the same protection — it is a different failure. Under more than one replica a login lands on process A and its ACS on process B, B has never seen the request id, and NOBODY can log in. Defaulting to it would have traded a replay window for an outage, so the default is the mode that is correct at every replica count. `true` stays available for a single process that would rather not have the table.
344
+
345
+ **What it costs, stated plainly because it is not optional: IdP-initiated SSO stops working.** `validateInResponseTo: 'always'` refuses a response with no `InResponseTo`, and that is exactly the Okta / Azure dashboard app tile. There is no configuration that keeps both, and the reason is structural rather than a missing feature — the protection IS the requirement that the response answer a request this SP issued, and an unsolicited response answers none. node-saml's `'ifPresent'` looks like the compromise and is not one: an attacker replaying a captured response deletes the attribute and the check declines to run.
346
+
347
+ So `replayProtection: false` stays reachable as the deliberate opt-out for the app-tile flow, boot now warns when it is set (naming both what it bought and what it cost), and the A/B control asserting that a capture IS replayable at `false` is kept — it is what an operator choosing that is actually buying.
348
+
349
+ **Two things fixed on the way, both consequences of the default moving:**
350
+
351
+ - The two defaults interact, so it is asserted rather than assumed: with `wantAuthnResponseSigned` now `false` the response-level `InResponseTo` is attacker-editable, and the replay check does not rest on it — node-saml cross-checks it against `SubjectConfirmationData/@InResponseTo` inside the SIGNED assertion and refuses a mismatch. - `ensureSaml` wrapped its whole construction in one `catch` that reported everything as `SAML SSO requires the optional dependency @node-saml/node-saml`. Store-backed replay is the default now, which makes "the DataStore is not bound yet" reachable — and that message would have sent an operator to install a package they already have. Only the dynamic import reports the install hint; a construction failure reports itself, carries its cause, and is NOT sticky (unlike a missing dependency it can resolve on its own, and pinning it would let one early request take the plugin down for the life of the process).
352
+
353
+ **`voltro update` carries you across this** — codemod `0.34.0/23_saml-signature-and-replay-defaults`.
354
+ - **@voltro/plugin-sso-saml, @voltro/cli** — **An Okta deployment on the default application could not log in, and there was no option to say otherwise.**
355
+
356
+ `buildSamlOptions` set `wantAssertionsSigned: true` and passed nothing for `wantAuthnResponseSigned` — so node-saml's own default of `true` applied and the ACS required BOTH the response envelope and the assertion to be signed. Okta's default application signs the assertion and leaves the envelope unsigned; so does Azure AD's. Those responses were refused with `401 SAML assertion rejected: Invalid document signature`, and because the option was never surfaced there was nothing an operator could set.
357
+
358
+ **The default is now `false`, and the option exists.** The reasoning, since the direction is a relaxation:
359
+
360
+ - The floor did not move. The ASSERTION signature is still required unconditionally and is still not configurable. It is the one that matters: node-saml reads only signature-covered XML (`getVerifiedXml`), and the assertion is what carries the NameID, the attributes, the audience restriction, the validity window and the `SubjectConfirmationData`. - What an envelope signature adds is coverage of the response-level `Status`, `Destination` and `InResponseTo` — real, and smaller than the name suggests. - A refusal an operator cannot configure their way out of is not a stronger posture. It is a wall people climb by forking the plugin or dropping it, and both of those are worse than the checkbox they could not tick. - `wantAuthnResponseSigned: true` is one line for any deployment whose IdP does sign responses, and the docs now say so in both languages.
361
+
362
+ **One edge, measured and pinned rather than glossed:** at the default, an envelope signature that does NOT verify is treated the same as no envelope signature — discarded, with the assertion signature deciding. That is not an auth bypass, and the test says why: an attacker holding a validly signed assertion would simply send no envelope signature, and an attacker-signed *assertion* is refused at every setting. What is genuinely lost is a diagnostic — an IdP misconfigured to sign responses with the wrong key stops being visible — and `wantAuthnResponseSigned: true` gets it back.
363
+
364
+ `samlSignature.test.ts` drives all of this through real RSA-signed fixtures and the real node-saml: the Okta shape is accepted, the same bytes are refused with `wantAuthnResponseSigned: true`, and a fully signed response is still accepted under it (so the option is not a blanket refusal). The boot log reports the two signature requirements separately, because they are separate.
365
+
366
+ **`voltro update` carries you across this** — codemod `0.34.0/23_saml-signature-and-replay-defaults`.
367
+ - **@voltro/plugin-search** — **`plugin-search` REFUSES to boot in production on the in-memory backend (PLUG-2).** The zero-config backend is an in-process `Map`, and it was wrong in production in two independent ways, both silent:
368
+
369
+ - **per-process** — N replicas hold N divergent indexes, so which results you get depends on which replica served the request, including "no hits" for a document that demonstrably exists; - **non-durable** — the index lives in the heap, so every restart and every deploy starts EMPTY and nothing re-seeds it (`backfillIndex` is a function an app calls, not a boot step). This half is why the refusal 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.
370
+
371
+ Migration — pick the one that matches your deployment:
372
+
373
+ ```ts
374
+ // app.config.ts — a durable engine (the normal answer)
375
+ searchPlugin({ backend: { engine: 'meilisearch', url: process.env.MEILI_URL!, apiKey: … }, indexes })
376
+
377
+ // …or assert the ONLY shape in which memory is correct outside dev:
378
+ // exactly one process, re-seeding every index at startup via backfillIndex().
379
+ searchPlugin({ singleProcessMemoryIndex: true, indexes })
380
+ ```
381
+
382
+ `singleProcessMemoryIndex` is a claim, not a mute switch: when the instance membership registry reports a peer replica, the plugin logs that the claim has been contradicted — in any environment, because that is an observation rather than a guess about `NODE_ENV`. The same observation-based warning covers the box where several replicas run with `NODE_ENV` unset, which the production check alone would miss.
383
+
384
+ `voltro dev` is untouched and completely silent: memory is exactly right there, and a warning that fires on every dev boot is a warning nobody reads.
385
+
386
+ Also: `memoryBackend()` now returns a tagged `MemoryBackend`, so `backend: memoryBackend()` is recognised as the same deployment as `backend: 'memory'` (it used to read as an opaque custom backend and would have walked straight past the refusal), and `GET /_voltro/inspect/plugins/search/ indexes` reports `durable` + `singleProcessMemoryIndex` alongside the backend name.
387
+
388
+ **Not shipped here: a postgres/SQL-backed durable floor.** It is the better product and it stays open. It needs seams this package does not have — the plugin reaches its store through descriptor `query`/`insertIgnore`/`update`/ `deleteMany` only, which cannot express doc-attribute predicates over a JSON column, facet `GROUP BY`s, or the per-dialect highlight functions (`ts_headline` / `snippet()` / …) that `SearchQuery` promises. Degrading those silently would replace one lie with another.
389
+
390
+ **`voltro update` carries you across this** — codemod `0.34.0/14_search-memory-backend-refuses-production`.
391
+ - **@voltro/plugin-search** — `plugin-search` no longer loses a row's index update to a single engine failure, and its `/indexes` counters no longer multiply by the replica count.
392
+
393
+ **The loss.** The post-commit tap was one bare `Effect.tryPromise(applyChange)`. One engine 500 — the DB commit having already happened — left that row missing from (or stale in) the index **forever**, unless an operator noticed a `log.warn` and hit the manual `/reindex` backfill. Every vendor adapter had been setting `SearchBackendError.transient` for exactly this decision, and nothing read it.
394
+
395
+ **Stage 1 — retry, driven by that flag.** The tap now retries a `transient` failure with capped exponential backoff inside its own Effect (which is where the tap contract puts durability). A permanent failure — an unsupported query shape, a `map(row)` that throws on one row — is NOT retried: repeating it cannot succeed. New tunables under `searchPlugin({ sync })`, all with defaults: `retries` (5), `retryBaseDelayMs` (200), `retryMaxDelayMs` (10 000), `resyncIntervalMs` (60 000, `0` disables the sweep), `resyncBatchSize` (200).
396
+
397
+ **Stage 2 — a change that outlives the retry is written down, not logged.** It goes into the new `_voltro_search_drift` ledger, one row per `(index, sourceRow)` under a UNIQUE pair, so N replicas failing on one change collapse to ONE repair unit. A cluster-coordinated sweep (`search.resync`) repairs entries by **re-reading the row from the database and re-deriving the doc** — never by replaying the stored event — so a row updated three times during an outage converges in one pass, a row deleted since converges to a removal, and repair is order-free and idempotent. `POST /_voltro/inspect/plugins/search/resync` runs a pass on demand; `GET …/drift` lists the entries. Unlike a leader-gated *enqueue*, a missed sweep tick cannot lose anything: the ledger row is written by whichever replica saw the failure and stays until somebody repairs it. The one remaining loss case — the engine failed AND the ledger write failed — now fails the tap loudly instead of reporting success.
398
+
399
+ **Drift is observable without reading logs.** `GET …/indexes` reports per index `dropped`, `pendingDrift`, `lastDriftAt`, `drifted`, a fleet-wide `pendingDrift` total, and the retry policy actually in force. The framework tables (`_voltro_search_drift`, plus the new `dropped` / `scope` columns on `_voltro_search_stats`) are reconciled by the declarative differ on `voltro db apply` and on a `voltro dev` boot, on every dialect — no codemod.
400
+
401
+ **The counters (REL-22).** `stats.bump` ran on every replica per change, so the panel whose stated purpose is being truthful multi-instance inflated ~N×. Index WRITES and stats COUNTS are now treated differently on purpose: the write still runs on every replica (`upsert`/`remove` are idempotent, so a duplicate costs write amplification while suppressing it costs a lost update whenever the one elected replica dies — and electing one would import a leadership gap), while the count is corrected without any leader at all. Under `changeScope: 'fleet'` every replica counts and the read takes the MAX (each replica's row is already a fleet-wide count); under `'local'` only the replica that made the write counts (`origin !== 'injected'`) and the read SUMs. Note an `origin` guard alone cannot carry the fleet case: under postgres CDC the NOTIFY echo is the sole delivery, so the writer's own event comes back stamped `injected` too and such a guard would count zero.
402
+
403
+ **Breaking, and why `codemod: none`.** `StatsStore.bump` takes a third `scope` argument and a `'dropped'` kind; `IndexStats` carries `dropped`; `StatsStore.recordReindex` no longer takes a scope. These exports exist for the plugin's own internals and its tests — there is no option through which an app supplies a stats store, and no documented use of them — so no user-authored code is rewritten. Nothing in `searchPlugin({ … })` changes for an existing app: the new `sync` block is optional and every value defaults.
404
+ - **@voltro/plugin-search** — `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, because they were one defect: the action's input carries strings that become the search engine's CONTROL PLANE, and a Schema cannot say "this string is safe to splice into a query language" — so the server decides it now.
405
+
406
+ **1. `engineParams` was spread LAST into the engine params object** (all three vendor adapters), so `run('posts', { engineParams: { filter_by: '' } })` replaced the `eq(tenantField, tenantId)` clause the plugin injects — a cross-tenant read from the browser. It is an **allowlist** now, per engine: paging, ordering, typo tolerance and highlight shaping reach the engine; everything that could select a different document set (`filter_by`, `filter`, `facetFilters`, `query_by`, `restrictSearchableAttributes`, `preset`, `pinned_hits`, `enableRules`, …) is dropped and logged with the key name.
407
+
408
+ Merging it *first* would have been the obvious fix and is not one — it stops the overwrite and leaves every other filter-by-another-name intact. An app that genuinely needs one more key opts in server-side: `searchPlugin({ allowedEngineParams: ['query_by'] })`. Document-selecting keys stay refused even then.
409
+
410
+ **2. An unknown index name queried with NO tenant filter.** A miss in the registry yielded `tenantField === undefined`, so the filters passed through unchanged and the query ran unscoped against that collection — on a shared Typesense / Meili / Algolia instance, every collection outside `searchPlugin({ indexes })` was readable by any authenticated caller. It fails with a typed **`SearchIndexNotFound`** now, before the backend is called.
411
+
412
+ **3. `filters[].field` was interpolated raw into each engine's filter DSL.** On Typesense — one flat `filter_by` string supporting `||` — a crafted field name re-groups the boolean tree around the tenant clause appended after it. Caller field names (in `filters[].field`, `facets[]` and `highlight.fields[]`) must now be plain field paths (`^[A-Za-z_][A-Za-z0-9_.]*$`), or be listed in the new optional `IndexSpec.queryableFields` allowlist; otherwise the call fails with a typed **`SearchFieldRejected`**. Algolia's filter *values* are escaped too (a value of `-open` used to invert `status:open` into "not open"), and its range operands are coerced to numbers instead of interpolated.
413
+
414
+ Migration — **`codemod: none`, and here is why no user-authored code needs rewriting**: nothing in the new refusals is reachable from code a codemod could find. `queryableFields` and `allowedEngineParams` are additive options. What changes is what the SERVER answers at runtime, in three cases that were all bugs: querying an index you never declared, naming a field that is not a field, and passing an `engineParams` key that overrode a filter. If your app depended on one of them, the fix is a declaration, not an edit to a call site — declare the index in `searchPlugin({ indexes })`, or add the key to `allowedEngineParams`. `search.query` also carries a wire error union now (`SearchIndexNotFound | SearchFieldRejected`), which existing callers decode as a rejected promise exactly as they already do for any other typed error.
415
+
416
+ The vendor backend factories take an optional second argument (`typesenseBackend(cfg, hooks)`) carrying the app's allowlist widening and the warn sink; the plugin wires it from `app.config.ts`, so a hand-constructed backend keeps working unchanged with the defaults.
417
+ - **@voltro/runtime, @voltro/voltro** — **Security response headers ship by default (SEC-9).** The framework sent exactly one, and only on served storage blobs (`X-Content-Type-Options: nosniff`, `@voltro/plugin-storage`). No HSTS, no CSP, no `X-Frame-Options`, no `Referrer-Policy` anywhere on the general serve path — while `plans/product/08-security-and-compliance.md` claimed all four as day-one defaults. Every response from the api listener now carries:
418
+
419
+ 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)
420
+
421
+ **Why `default-src 'none'` is safe here, and what the exception is.** `startRpcServer` is the API surface: `POST /rpc`, the `/ws` upgrade, the inspect JSON + SSE routes, incoming webhooks and plugin HTTP routes. It does NOT serve the web app's HTML — that is a different listener — so the strict policy never meets the web client's inline styles or its module graph. The one thing on this listener that IS a document is `@voltro/plugin-openapi`'s `/docs` page (CDN viewer with SRI + two inline scripts), which `default-src 'none'` would blank. So a `text/html` response gets a relaxed policy instead — `frame-ancestors 'none'; base-uri 'none'; object-src 'none'`, which closes clickjacking, base-tag injection and plugin embedding without constraining the page — and `mode: 'strict'` (opt-in) applies the API policy to HTML too, with that consequence documented rather than discovered.
422
+
423
+ HSTS is emitted only over https (a TLS socket, or `x-forwarded-proto: https` from a trusted proxy — see the `x-forwarded-for` entry), and never carries `preload`: preload is effectively irreversible for a domain, so it must be a deployment decision, not a framework default. `Cross-Origin-Opener-Policy` and `Cross-Origin-Resource-Policy` are deliberately NOT defaulted — guessing either breaks a legitimate cross-origin dashboard — and are available through `extra`.
424
+
425
+ A route that sets a header itself always wins; the framework only fills gaps. That is what keeps plugin-storage's `nosniff` + `content-disposition` pairing and the inspect routes' CORS bag intact.
426
+
427
+ Configured in `app.config.ts` as `security: { headers: { mode, csp, cspHtml, hsts, extra, … } }` — one field, read by both boot paths — with the env overrides `VOLTRO_SECURITY_HEADERS` (`off|default|strict`), `VOLTRO_CSP`, `VOLTRO_CSP_HTML`, `VOLTRO_HSTS` for a deployment you cannot rebuild; each accepts `off` to drop just that one. An embedder passes the same object as `RpcServerOptions.security.headers`.
428
+
429
+ Migration: 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, set `cspHtml` for it or send your own `content-security-policy` from the route.
430
+
431
+ **`voltro update` carries you across this** — codemod `0.34.0/06_origin-guard-and-trusted-proxies`.
432
+ - **@voltro/cli** — **`voltro serve` refuses to boot with pending `*.migration.ts`, instead of saying nothing (PROD-7).**
433
+
434
+ Serve's schema guard is a DECLARATIVE fingerprint diff: it compares the declared schema against the last applied plan and refuses on a mismatch. A file-based migration 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.
435
+
436
+ `voltro dev` applies pending migrations at boot; `voltro db migrate` and `voltro db files` apply them; `voltro serve` did neither and did not mention them. That is exactly the gap `bootLifecycle.ts` closed for boot seeds — not doing it was the right call, and the difference being SILENT was the defect — and the reasoning had not been carried across.
437
+
438
+ **Serve still does not APPLY them, and that stays deliberate:** a rolling deploy starts N replicas, each would try, and the migration lock turns that into N-1 processes blocked on boot. It refuses instead, under the same conditions and with the same bypass as the fingerprint check it sits beside — a real deploy environment (`production` / `staging`), a SQL store, and `VOLTRO_AUTO_MIGRATE=0` to opt out of every boot schema check. The cause is identical in both cases: the pre-deploy migrate step did not run.
439
+
440
+ The refusal names the pending ids and the two commands that run them. A local `voltro serve` is untouched: `voltro dev` applies migrations there, so a preview serve has nothing to report.
441
+
442
+ **`voltro update` carries you across this** — codemod `0.34.0/18_serve-refuses-pending-file-migrations`.
443
+ - **@voltro/protocol, @voltro/plugin-auth, @voltro/cli** — **A session cookie carries IDENTITY; authority is resolved per request (REL-5).** The cookie embedded the whole `Subject`, `scopes` included, signed at login. Verification was an HMAC check plus `exp` and nothing else — nothing re-resolved authority from anywhere — the default lifetime is 7 days, and the sliding-window renewal re-signed the **old payload** while sliding the revocation row forward. So 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.
444
+
445
+ The escape hatch did not escape. `resolveScopes` ran on every request and could only UNION onto the cookie's scopes — "a resolver cannot silently remove a scope either" — so even an app that wired it could not narrow. There was no supported way to revoke a single permission short of killing the whole session, which undercut the mid-stream re-authorization machinery the subscription path already had.
446
+
447
+ Three changes, and none of them works alone:
448
+
449
+ - **The payload cannot hold authority.** `SubjectIdentity` (`@voltro/protocol`) is the `Subject` union with `scopes` omitted at the SCHEMA level, and it is what the payload's `subject` field is typed as. `signSession` **throws** on a subject carrying scopes rather than stripping them: silently dropping a scope is an authorization change with no error, no log line and no diff, discovered later as "permissions randomly stopped working". `verifySession` / `verifySessionKeyed` return `SubjectIdentity`, so the guarantee is a type fact and not a convention — nothing downstream can read authority out of a cookie, because the value it gets back has no field for it.
450
+
451
+ - **The resolver says WHICH claim it is making.** `resolveScopes` may now return `{ kind: 'authoritative', scopes }` (this resolver is the complete answer — narrowing works), `{ kind: 'unavailable', reason }` (the source could not be reached: the request fails closed with `Unauthenticated` and the reason reaches `onStrategyFailed`), or a bare array, which still means `{ kind: 'grant' }` and is unioned exactly as before — an already-written resolver keeps its exact meaning, with no compiler error to warn it otherwise. The empty array is what forced the split: under the old shape it had to mean "no extra scopes", under a replace shape it would mean "no scopes at all", and a failed lookup produces it under both. Three meanings, one value, and the union-only rule was really guarding against the third. Narrowing is auditable: `onScopesNarrowed` reports what was removed, once per resolution rather than once per request.
452
+
453
+ - **The framework caches the resolution, with a seam that drives staleness to zero.** `makeScopeCache` / `scopeCacheKey` (`@voltro/protocol`), wired by default in `composeAuthStrategies`. The window is `DEFAULT_SCOPE_CACHE_TTL_MS` = 30s — deliberately the same window the session revocation check already used, so the two per-request store reads miss together and an operator reasons about ONE number instead of discovering later that the second one was 7 days. The honest cost: the revocation check was already a per-request DB hit through a cache of exactly this shape, so this adds one miss per subject per window. An app makes the window zero either with `VOLTRO_AUTH_SCOPE_CACHE_TTL_MS=0` / `auth: { scopeCache: { ttlMs: 0 } }` (resolve every request) or by holding its own `makeScopeCache()`, passing it as `auth: { scopeCache }`, and invalidating inline from the mutation that changes a role — instant on that process, `ttlMs` on other replicas, the same wording the revocation checker already ships. The cache key includes `tenantId`, because a user keeps their id across a tenant switch and their authority does not.
454
+
455
+ **The whole seam is one `app.config.ts` type, not three fields.** `auth.resolveScopes`, `auth.scopeCache` and `auth.onScopesNarrowed` are `AuthorityResolution`, and both boot paths forward `apiConfig.auth` WHOLE. That is not tidiness: the first cut of this change plumbed `resolveScopes` alone with its OLD `=> ReadonlyArray<string>` return type, so `{ kind: 'authoritative' }` did not typecheck in a user's config and the cache and the audit hook had no config surface at all — every protocol test passed over a feature no app could reach. Forwarding an object rather than fields is what makes that half-application unrepresentable.
456
+
457
+ **In-flight sessions are invalidated, by construction.** The payload carries `v: SESSION_PAYLOAD_VERSION` and a payload without it fails to decode. A cookie minted under the old contract asserts authority; reading it leniently as "a subject with no scopes" would authenticate someone under a contract we no longer hold, which is the defect itself in a smaller shape. Every live session ends at deploy and users sign in once more — plan it like a secret rotation. Session rows are untouched.
458
+
459
+ **Not affected:** api-key and JWT strategies already resolved scopes per request from the key record or the token claims. 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.
460
+
461
+ Migration: `voltro update` prints the manual codemod for any app that touches the session surface. Move the scopes off your mint sites and into `auth.resolveScopes`, pick `authoritative` when that resolver is the complete answer, and return `unavailable` rather than `[]` when the lookup fails.
462
+ - **@voltro/plugin-auth** — **`UserStore` gained eleven methods, so a HAND-WRITTEN store no longer satisfies the interface.** Email verification, tenant invitations and impersonation all need storage, and this is the one interface the plugin has for it: `markEmailVerified`, `latestToken`, seven `*Invitation*` methods, and three `*ImpersonationGrant*` methods.
463
+
464
+ **If you use `memoryUserStore` or `postgresUserStore`, nothing changes** — both implement everything, and `userStoreContract.test.ts` runs the same contract against both (live postgres included) so the two cannot drift.
465
+
466
+ The alternative was splitting the store into four narrower interfaces so the addition would be invisible, and it was rejected: each feature would then need its own `store:` field on its own config block, an app would pass the same object three times, and the plugin would carry a duck-typed fallback for "the main store happens to satisfy this". One interface for one concept, with a break, is the smaller thing.
467
+
468
+ **Also breaking, smaller:** `SendEmailInput['kind']` gained `'email-verify'` and `'invitation'`. A sender that switches exhaustively over it needs two more arms.
469
+
470
+ The codemod is `manual`, and deliberately so. The missing members are eleven queries against tables only the app knows the shape of, and a transform could only stub them — which is the worst available outcome: `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. The note spells out what each method must do, including the two that must be a SINGLE statement (`acceptInvitation`, `endImpersonationGrant`) and the one rule that is easy to get wrong by copying the neighbours: the read paths in `postgresUserStore` fail OPEN, and an invitation write must not.
471
+ - **@voltro/plugin-webhooks, @voltro/plugin-billing** — **Outbound webhooks speak Standard Webhooks v1.0.0 by default — verified against the spec's published interop vector, not against ourselves. Three delivery defects fell out on the way.**
472
+
473
+ `plugin-webhooks` already had declared outbound events, durable per-target fan-out, retries, idempotency, rate limits, auto-disable, a management API and Stripe/GitHub/Slack signing. What it did not have was an INTEROPERABLE signature: the house format (`X-Webhook-Signature: t=…,v1=<hex>`) is one more thing every receiving team has to implement by hand.
474
+
475
+ **BREAKING — a scheme renders HEADERS, plural.**
476
+
477
+ ```ts
478
+ signPayload(scheme, body, secret) → signRequest(scheme, { rawBody, secret, messageId })
479
+ { headerName, headerValue } { headers }
480
+ verifySignature(scheme, body, secret, value) → verifyRequest(scheme, { rawBody, secret, headers })
481
+ ```
482
+
483
+ Two things did not fit through the old one-header hole, and both were visible in the code as workarounds: Standard Webhooks needs three headers out and signs over a message id the old signature could not receive, and `slackSignature()` shipped as a **stub whose `verify` returned `false` unconditionally**, usable only via a `"<ts>|<sig>"` packing convention spliced in by the route mounter. Slack's verifier is real now, and the splice is gone.
484
+
485
+ `codemod: manual` rather than a transform, for one reason: the new signature requires a `messageId`, which is also the consumer's idempotency key, and there is no correct value a transform could invent. `randomUUID()` per call site would compile, pass, and silently make every retry a distinct message to the receiver.
486
+
487
+ **The spec, and how the claim was checked.** A round-trip test passes perfectly for a construction that is entirely wrong — base64 vs hex, the printable secret vs its decoded bytes, `:` vs `.` — because both halves are wrong the same way. So the conformance suite runs the published interop vector (`whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw` / `msg_p5jXN8AQM9LWM0D4loKWxJek` / `1614265330`), independently reproduced with `openssl dgst -sha256 -mac HMAC -macopt hexkey:$(base64 -d)` before the implementation was written. Implemented: the three lowercase `webhook-*` headers, `msg_id.timestamp.payload` signed content with the `.`-delimiter constraint ASSERTED, base64 `v1,` signatures, space-delimited multi-signature rotation, `whsec_` + base64(24..64 bytes) keys HMAC-ing the DECODED bytes, constant-time compare, a 300s tolerance (the spec requires a tolerance and names no number). NOT implemented: the asymmetric half (ed25519 / `v1a` / `whsk_`), which `verifyRequest` refuses by name rather than reporting a generic mismatch.
488
+
489
+ A `whsec_` prefix is REQUIRED and a bare hex secret is refused, at subscribe rather than at the first delivery. Leniency would be worse than useless here: a 64-char hex secret is also valid base64, so a "decode if it looks like base64" rule silently keys the HMAC on 48 bytes of garbage — self-consistently, so our own round-trip passes while every conformant consumer rejects the delivery.
490
+
491
+ **Three delivery defects, found by reading the spec's delivery section against the code:**
492
+
493
+ - **Redirects were FOLLOWED.** `fetch` follows by default. The spec says a 3xx is a failure and must not be followed — and `assertPublicUrl` validates the URL we POST to, so a 302 walked the request past the SSRF guard to wherever the receiver pointed. Now `redirect: 'manual'`. - **There was NO timeout.** A receiver that accepted the connection and never answered held the durable workflow — and its rate-limit slot — indefinitely. Now 30s per attempt (`VOLTRO_WEBHOOK_TIMEOUT_MS` / `timeoutMs`), the top of the spec's 15–30s band. - **`410 Gone` waited for the failure streak.** The receiver has ANSWERED the question; counting to `autoDisableAfter` first (and never, when it is unset) is ignoring the answer. It disables the target immediately now.
494
+
495
+ All three are asserted end-to-end against a real receiver, per dialect.
496
+
497
+ `defaultOutgoingSignature()` returns the spec scheme; the house format is `genericHmacSignature()`, a named choice alongside `stripeSignature()` / `githubSignature()`. Existing target rows are untouched — each stores the scheme it was created with. A signer handed an unrecognised persisted scheme now throws instead of emitting a request header literally named `"undefined"`.
498
+ - **@voltro/runtime, @voltro/cli** — **A durable workflow re-resolves its caller's authority when it runs; the start-context row carries identity only (REL-24).**
499
+
500
+ `_voltro_workflow_start_contexts.subject` is a `json()` column that held the caller's whole `Subject`, `scopes` included. A cluster runner reads it back — a different process, possibly days later — and ran the workflow with the authority the caller had at START time. Remove a role on Monday and Thursday's resume still asserts it, out of a row nothing re-validates, on a path with no request, no cookie and no expiry. Same class as the session cookie 0.34.0 removed, one layer down and made **durable**.
501
+
502
+ **The guarantee:**
503
+
504
+ > **IDENTITY IS PERSISTED. AUTHORITY IS RE-RESOLVED AT RESUME, OR ABSENT.**
505
+
506
+ - **Identity** — type, id, tenantId, metadata — is written *and read* through `subjectIdentity()`, the same total, lossy function the session cookie mints through. It must survive: `applyTenantScope` reads `tenantId`, the run row is attributed to `id`, and a plugin service resolving a per-user credential reads `metadata`. A workflow started by tenant A still acts on tenant A's rows in three days' time.
507
+
508
+ - **Authority** comes from the app's own `auth.resolveScopes` on every execution attempt — the same seam the request path uses — with `ctx.origin === 'workflow'`. An app that wires no resolver gets runs with no scopes, which is the fail-closed direction and exactly what a cookie-authenticated caller already gets on the request path.
509
+
510
+ - **`{ kind: 'unavailable' }` fails the attempt**, loudly, instead of running with less authority than the caller has. A run that silently skips the branch it was not allowed to take is indistinguishable from one whose business logic said no; cluster retry and `voltro workflows redrive` already exist for the recoverable case.
511
+
512
+ **Why not `SYSTEM_SUBJECT`.** It is the right answer for a run with **no** recorded caller — a bootstrap, an orphan whose row aged out — and the engine still uses it there, unchanged and deliberately *not* put through the app's resolver. It is the wrong answer for a run that HAD one: `SYSTEM_SUBJECT` carries `tenantId: null`, which the tenant scope reads as "no filter", so promoting a tenant-owned workflow to it would trade frozen authority for cross-tenant **visibility**. That is a worse defect wearing the fix's clothes.
513
+
514
+ **`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 `{}`. Empty rather than fabricated: a resolver that reads headers must be able to branch on `origin` instead of silently receiving a bag that is always empty. That type change is where a resolver reading `clientId` sees the compile error. `scopeCache` applies to the request path only — one resolution per run attempt is not a hot path, and a run that lasts days must not inherit a window sized for a burst of requests.
515
+
516
+ **Both boot paths were already wrong in a second way, and it is fixed in the same change.** `dev.ts` and `serveApi.ts` each carried a hand copy of the persist/load functions, and the copies had drifted: dev `JSON.stringify`s the subject for the raw store path — which applies no `json()` codec, so mysql2 binds a raw object and the statement fails — and parses it back; serve did neither. The durable handoff therefore threw on mysql in production, and every resumed run silently became a bootstrap run. There is one module now (`workflowStartContext.ts`), called by both, with the call sites asserted — a behavioural test cannot see a call site that stopped calling.
517
+
518
+ **No migration to run.** The column is framework-owned, so `voltro db apply` and a `voltro dev` boot carry it on every dialect. Rows written by the previous version still contain scopes and are **stripped on read**: a resumed run cannot re-assert authority an older build persisted. That is data handling, not back-compat — honouring authority we no longer write, because we no longer write it, would be the defect outliving its own fix.
519
+
520
+ **`voltro update` carries you across this** — codemod `0.34.0/20_workflow-authority-resolves-at-resume`.
521
+ - **@voltro/workflow** — Two workflow APIs were declared and read by nothing. Both are resolved, in opposite directions.
522
+
523
+ **`patches` is now live.** `patch('marker')` (from `@voltro/workflow` / `@voltro/workflow/define`) answers whether a marker declared in `workflow({ patches: [...] })` was in effect **when the current run started** — Temporal-style in-body versioning, so a body can branch and let in-flight runs finish on the old path while new runs take the new one. The answer is read back from `_voltro_workflow_runs.workflowPatches`, which is stamped at start, so it cannot change under a redeploy. Previously `patches` was accepted, stored and rendered as a dashboard tooltip, with no primitive that could read it.
524
+
525
+ **`messages.queries` is REMOVED.** There was never a send path — no `sendWorkflowQuery`, no `awaitQuery`, nothing to receive one — while the codegen projected the channel into the generated rpcGroup and the client's `WorkflowState` carried it, so a declared query was a fully typed record no caller could invoke. Migration: delete the `queries:` block from `workflow({ messages })`. Use `updates` for a synchronous request/response and `signals` for fire-and-forget; a read-only projection of workflow state belongs in a normal `*.query.ts` over `_voltro_workflow_runs` / your own tables. Declaring `queries` no longer produces metadata, so it is silently ignored rather than rejected — the codemod's note is what surfaces it.
526
+
527
+ **A replay nondeterminism tripwire.** Journal entries are keyed by activity name/attempt with no shape check, so editing a workflow body while runs are in flight replays cached results for names that still match and freshly executes the ones that do not, silently; bumping `version` instead terminally fails every in-flight run. Neither branch was safe. A run re-entering its body now compares the steps it REACHES against the steps it recorded on earlier attempts and emits a `nondeterminism-suspected` run event on divergence — set membership (`unreached-step`: a recorded step the current code never reaches) and per-name sequence (`extra-step-occurrence`: a recorded step reached more often than recorded). Never a total order, which would false-positive on concurrent steps. It is an EVENT and never a failure: the checks sit on a best-effort recorder, so a false positive that killed a run would be worse than the divergence it suspects. Covers `voltro workflows redrive`, which re-drives an old journal against current code through the same path.
528
+
529
+ Also fixed on that path: a re-entry used to re-INSERT the run row, which the unique `executionId` rejected, so a resumed run lost its run id and never recorded steps or a terminal status again — it sat at `suspended` forever.
530
+ - **@voltro/plugin-auth-workos** — **WorkOS hosted login: `state` is mandatory and PKCE (S256) is required, and the callback verifies both (SEC-12).** `workosAuthorizationUrl` appended `state` only `if (o.state)` and generated no `code_verifier` at all — so the DEFAULT flow had no CSRF token, and adding one was an integration's idea rather than the framework's. An attacker who gets a victim's browser to hit the callback with an authorization code they obtained logs the victim into the ATTACKER's account; `state` is the only thing that stops it.
531
+
532
+ - `workosAuthorizationUrl` is **replaced** by `workosBeginLogin`, which returns `{ url, state, codeVerifier }` instead of a string. Both secrets are minted from `randomBytes` on every call; the authorize URL carries `state`, `code_challenge` (S256 of the verifier) and `code_challenge_method`. It no longer accepts a caller-supplied `state` — an application payload mixed into a CSRF nonce is what made the nonce guessable. - `workosAuthenticateWithCode` gains three REQUIRED fields — `state`, `expectedState`, `codeVerifier` — compares the two states in constant time BEFORE any network call, and sends `code_verifier` with the exchange. A mismatch throws the new `WorkosStateMismatchError` (a `WorkosExchangeError`, status 400). The check lives inside the only function that can redeem a code on purpose: a generated `state` that nothing verifies is worse than none, because a code review, a screenshot of the authorize URL and a pen test all then read as "CSRF is handled".
533
+
534
+ Unaffected: `workosStrategy` (the JWKS verify side, the more common integration), Magic Auth (no redirect, no `state` to forge), and the organizations helpers. Nothing to configure in the WorkOS dashboard.
535
+
536
+ **`voltro update` carries you across this** — codemod `0.34.0/04_workos-oauth-state-pkce`.
537
+ - **@voltro/client, @voltro/cli** — **Every typed-error branch on the write side had been dead since the hooks were written, and it failed silently by construction.**
538
+
539
+ `useMutation.mutate` / `useAction.run` / every `useWorkflow` send / `useUpload.upload` all called `runtime.runPromise(...)` inside a bare `try/catch`. `runPromise` rejects with Effect's `FiberFailure` WRAPPER — an Error subclass that keeps the `Cause` behind a symbol key — so the value a caller catches has `_tag === undefined`, and the branch the docs teach never ran:
540
+
541
+ catch (err) { if (err._tag === 'EntitlementExceeded') showPaywall() }
542
+
543
+ Nothing throws, nothing logs, nothing goes red. The paywall simply never appears. `frontend-saas` shipped with exactly that branch and it could not have worked in any app that scaffolded from it.
544
+
545
+ **The asymmetry is the whole diagnosis.** The READ path already did the right thing, and said why in a comment next to it: `subscriptionCache`'s stream-failure observer reduces the `Cause` with `Cause.squash` "because that value is what `_tag`-style pattern-match consumers expect". Two other places in the framework had learned the same lesson independently — `@voltro/database`'s `settleTransactionExit` (after the same defect shipped in three of four dialect stores) and `@voltro/testing`'s `invoke`. The client's write path was the one side that never got it.
546
+
547
+ `runRpc.ts` is now the single seam all four hooks call. It reads the `Exit` rather than sniffing for a wrapper after the fact, which is what the read path does and is why no second mechanism was invented: `runPromiseExit` + `Cause.squash`, so the wrapper is never minted in the first place. A defect still rejects with the defect, an interrupt still rejects — only the value gets better.
548
+
549
+ Migration: if you never worked around this, your existing `_tag` branches start working and there is nothing to change — but re-read whatever sits in FRONT of a branch that has been dead for its whole life, because a compensating hack there now runs alongside it. If you DID unwrap the wrapper yourself (`Cause.squash` over `Runtime.FiberFailureCauseId`, an `isFiberFailure` test), delete the unwrap — it is a double-unwrap now and stops matching. The codemod is `manual` because a transform cannot tell a workaround from a deliberate `Cause` inspection, and guessing wrong replaces a working error path with a silent one.
550
+
551
+ **Why a green suite let it ship, recorded because the shape recurs.** Every existing test asserted on `onError`'s ARGUMENT or on a mocked hook's `mockRejectedValue({ _tag: 'X' })`. A wrapper is a perfectly good argument, and a mock never produces one — so both styles pass identically in the broken and the fixed framework. The new `writeErrorIsTagged.test.tsx` awaits the REAL hook's REJECTED promise from a real `ManagedRuntime` running a real `Effect.fail`, and carries a control asserting the raw `runPromise` entry point still wraps, so it cannot go quietly green if Effect changes underneath it. `frontend-saas`'s paywall test stopped re-implementing `isEntitlementExceeded` inside its own mock and gained the untagged-failure negative control.
552
+ - **@voltro/runtime, @voltro/voltro** — **`x-forwarded-for` is no longer trusted by default (SEC-8).** The pre-routing interceptor took the header's first token verbatim as the client address and only fell back to `socket.remoteAddress` when it was absent — `fromXff ?? fromSocket`, with no trusted-proxy configuration anywhere in the framework. `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's `remoteAddr`.
553
+
554
+ The default is INVERTED. With nothing configured, the client address is `socket.remoteAddress` and the forwarded chain is ignored entirely — the only value nobody upstream of the kernel can forge. An operator who genuinely terminates at an ingress declares it in `app.config.ts`, and only then does the chain become evidence:
555
+
556
+ security: { trustedProxies: ['private'] } // RFC1918 + CGNAT + link-local + ULA security: { trustedProxies: ['loopback'] } security: { trustedProxies: ['10.0.0.0/8', 'fc00::/7'] } security: { trustedProxies: ['2'] } // hop count (express's convention) security: { trustedProxies: ['*'] } // any peer — only correct when the // ingress OVERWRITES rather than appends
557
+
558
+ `VOLTRO_TRUSTED_PROXIES` (comma-separated) is the env override for a deployment you cannot rebuild, and an embedder passes `RpcServerOptions.security.trustedProxies`. With a list configured, the immediate peer must itself be trusted (otherwise the header is just something the client typed) and the chain is walked right-to-left past every declared proxy — so a spoofed prefix cannot push the resolved address further left. IPv4-mapped IPv6 (`::ffff:10.0.0.7`, what node hands you for a v4 client on a dual-stack listener) is normalised before matching, because without that every v4 CIDR silently misses and the operator's config appears to do nothing.
559
+
560
+ The same configuration now gates `x-forwarded-proto`, which decides whether a response is treated as https (and therefore whether HSTS is announced).
561
+
562
+ Migration: **if you rate-limit or geo-block per IP behind a load balancer, set `security.trustedProxies`.** Without it every request counts against your proxy's address instead of the caller's — the limiter still works, it just bins everyone together. Apps with no proxy, and apps not using per-IP limits, need no change.
563
+
564
+ **`voltro update` carries you across this** — codemod `0.34.0/06_origin-guard-and-trusted-proxies`.
565
+
566
+ ### Added
567
+
568
+ - **@voltro/workflow, @voltro/ai, @voltro/runtime, @voltro/cli** — **A budget breach can now SUSPEND a durable run instead of destroying it or merely being observed.** The framework had two answers to "this tenant is over budget" and neither is a control: `finops.ts` said flatly that a cost budget "never BLOCKS compute … an observability-grade signal over work that already happened", and `requireAiBudget` hard-fails one call — which does stop the spend, by killing a run that may be nine steps in, losing the work and losing it again on every retry. A ceiling whose only expression is destruction gets set high, or turned off.
569
+
570
+ `aiStep` / `aiObjectStep` take `budget: { limitUsd, estimateUsd?, onExceeded: 'fail' | 'suspend' }`. On `'suspend'` the run parks on a durable `_voltro_budget_holds` row, frees its worker, and resumes when the budget has headroom — then continues from where it stopped. `defineCostBudget` gains the matching `onExceeded: 'observe' | 'suspend'` (default `'observe'`, so nothing changes for an existing budget).
571
+
572
+ **The ordering is the feature.** The gate reads the RESERVATION counter (`requireAiBudget` with `addUsd: 0`) BEFORE the journaled `step()` and before the offload enqueue — not a SUM over `_voltro_ai_usage`, which by definition only knows about money already gone. On the suspend path no provider is contacted and no queue row exists for a dispatcher to pick up. Under the cap, the estimate is RESERVED atomically before the call, which is the difference between a ceiling and a speed bump.
573
+
574
+ **A release wakes a run; it does not authorise a spend.** The hold re-reads the budget on every wake and parks again if it is still over, so an operator lifting the wrong hold, or a window rolling over for a tenant that immediately spends again, cannot spend through the ceiling. Three things can wake it: the hold's own durable recheck clock (15 min, so a tumbling window nobody notifies us about is still noticed), an explicit `releaseBudgetHolds` call from app or operator code (the framework does NOT subscribe a `defineCostBudget` `recovered` signal to it for you — whether a recovered compute budget should wake AI holds is an app decision, and the recheck clock already means no run is stranded either way), and its total timeout (7 days, after which the run fails having spent nothing).
575
+
576
+ The hold key is `<executionId>/<stepName>/<budget>#<generation>`, and the generation is load-bearing rather than decorative: a run can be held more than once at one step, and re-parking on the same key would await the deferred the previous release already completed, resolve instantly, and spend straight through — the AI-Flows constant-signal-name collision, one level up, with money on the other side of it. The generation counts holds already recorded, so it is stable under a crash-replay that took no new hold and different under one that did.
577
+ - **@voltro/local-first** — **`useCrdtText` — a `crdtText()` column bound to a running app, as one hook.** The column type, the Yjs-backed merge, the offline sync queue with bounded retry, durable IndexedDB persistence and the authoritative server-side merge on the write path all already shipped. What every consumer still had to write by hand was the React half, and it is the half with a trap in it.
578
+
579
+ ```tsx
580
+ const row = useSubscription('app', 'documents.byId', { id })
581
+ const save = useMutation('app', 'documents.setBody')
582
+
583
+ const body = useCrdtText({
584
+ cell: { table: 'documents', id, column: 'body' },
585
+ remote: row.data?.body ?? null,
586
+ push: (w) => save.mutate({ id: w.id, body: w.update }),
587
+ })
588
+
589
+ <textarea value={body.text} onChange={(e) => body.setText(e.target.value)} />
590
+ ```
591
+
592
+ The hook owns one `SyncClient` per `(table, id, column)` cell — created in an effect and closed with the component, never in a memo that React's double render turns into two clients, one of them discarded still holding a transport subscription — re-renders on a local edit, an ack or incoming merged state, and folds the streamed row back in. It returns `text`, `insert`, `delete`, `setText`, `state`, `outstanding` / `synced` and `setOnline`.
593
+
594
+ **`setText` is a span diff, and that is the reason this is framework code rather than a docs snippet.** A `<textarea>` hands you the whole new string, so the obvious binding is "clear the document, insert the new text" — a delete-all/insert-all, which is precisely the last-write-wins behaviour a CRDT is chosen to prevent: two people editing different paragraphs each erase the other's, and it looks correct on whichever peer typed last. `crdtTextEdit` (exported, pure, tested on its own) narrows to common prefix + common suffix and emits ONE delete plus ONE insert, so a concurrent edit outside the changed span survives. The regression test converges a second peer's insert against the pushed state and asserts both edits are present; it is RED for the naive implementation.
595
+
596
+ Two smaller decisions worth knowing: a `remote` of `null`/`undefined` is folded as NOTHING rather than as an empty document (treating a loading row as empty lets the first keystroke race the load and push a state that erases the stored text), and the edit buffer is ONE document per mounted cell rather than a fresh one per keystroke — every throwaway document is a new CRDT actor, and a typing session would encode hundreds of authors for one person.
597
+
598
+ What stays app glue, deliberately: **which** mutation writes the column and **which** query streams the row. Voltro generates no per-table CRUD surface, so there is nothing to derive those two names from — the same reason `usePresence` takes an injected `channel`. `seams.ts` now records the sync wire's React binding as implemented and narrows the remaining seam to exactly those two tags.
599
+ - **@voltro/runtime, @voltro/plugin-flags** — **A flag can carry an experiment — and the composition refuses to boot when it would report the uplift of a split nobody was served.**
600
+
601
+ All three pillars were already in code: `plugin-flags`, `defineExperiment` (standing A/B/holdout experiments as live IVM aggregates recomputed per-write from CDC deltas — real-time uplift with no batch pipeline), and `plugin-analytics-postgres`/`-posthog`. Composing them is one sentence with one trap in it, and the trap is the entire reason this is more than a string field.
602
+
603
+ **The trap.** `plugin-flags` assigns a variant by hashing FNV-1a over `${key}:variant` and the subject. `defineExperiment` in `subject` mode hashes FNV-1a over the EXPERIMENT name and the subject. Both are stable, both are uniform, and they are INDEPENDENT — roughly half the subjects served `green` land in the experiment's `control` arm. Wiring the two together by name gives a number that is live, per-write, precise, and measuring an assignment nobody ever experienced. It looks exactly like a working experiment.
604
+
605
+ So the composition is not "point a flag at an experiment":
606
+
607
+ 1. the FLAG assigns (it is what the user experiences); 2. the app PERSISTS the served arm on the row it wants to measure — `flagVariant(ctx, flag)`; 3. the experiment READS that column instead of assigning — **`defineExperiment({ variantFrom: 'checkoutArm', … })`**, new in this release, which is also the general primitive for measuring any externally assigned arm; 4. `flagsPlugin({ typedFlags, experiments })` **refuses to construct** when the link is wrong.
608
+
609
+ ```ts
610
+ export const checkoutButton = defineFlag({
611
+ key: 'checkout.button',
612
+ value: Schema.Literal('blue', 'green'),
613
+ default: 'blue',
614
+ variants: [{ name: 'control', value: 'blue' }, { name: 'green', value: 'green' }],
615
+ experiment: 'checkout-colour',
616
+ })
617
+
618
+ export default defineExperiment({
619
+ name: 'checkout-colour',
620
+ on: { table: 'orders' },
621
+ variantFrom: 'checkoutArm', // the arm the flag served
622
+ variants: [{ name: 'control' }, { name: 'green' }],
623
+ metric: { kind: 'conversionRate', column: 'completed' },
624
+ })
625
+ ```
626
+
627
+ Four checks, each of which is otherwise a wrong number rather than an error: the experiment must exist; it must be in `variantFrom` mode (the trap above); it must declare no holdout of its own (a holdout is carved AT ASSIGNMENT, and this experiment does not assign); and the arm NAMES must match exactly — a row whose arm the experiment does not declare is EXCLUDED, so a mismatch surfaces as a permanently-empty arm beside a permanently-full one, which reads as "the treatment has no effect".
628
+
629
+ `defineExperiment` now requires exactly one of `subject` / `variantFrom`; declaring both is refused, because it is two answers to one question. Persisting the arm is also the only version that survives a weight change — a re-hash at read time silently re-labels every historical row.
630
+ - **@voltro/protocol, @voltro/runtime, @voltro/cli** — `reactivityChannel(name)` — a declared push target with no schema behind it, usable anywhere a query's `source:` accepts a table name.
631
+
632
+ ```ts
633
+ export const jobQueue = reactivityChannel('job-queue')
634
+
635
+ defineQuery({ name: 'jobs.depth', source: jobQueue, ... })
636
+ publishReactivity(ctx.store, jobQueue) // every subscriber re-runs its executor
637
+ ```
638
+
639
+ Until now a feature whose state was not in the database had two options and both were bad: declare a table it never writes, purely to own a name the reactivity layer routes on — or point `source:` at a name that resolves to nothing and exempt it from the boot audit. The framework shipped the first for a release (`_voltro_presence`, an empty table in every user's database, created by every migration and diffed on every boot); the second is worse, because that audit is the only signal for a subscription that has gone permanently quiet.
640
+
641
+ Everything downstream is unchanged. The descriptor still stores a STRING — the channel collapses to its routing key (`channel:<name>`) at declaration — so the capability manifest, the api goldens and the browser client's subscription cache learn no new shape. `useSubscription` cannot tell the difference.
642
+
643
+ **Pass the channel, not its key.** Authoring against the object creates an import edge from the query to the declaration, which removes the stale-source class for channels entirely: a table `source:` is a string, so a rename leaves the old one behind and `tsc` cannot see it, while a channel that is not imported does not exist to be named. Both boot source-audits resolve a `channel:` key against the registry and report an undeclared one with its own message — never as a table typo, which would have pointed the reader at inventing a table.
644
+
645
+ The key namespace is disjoint from table names by construction rather than by convention: `validateTableName` refuses anything outside `/^[a-zA-Z_][a-zA-Z0-9_]*$/`, so no table can carry a `:` at all. That property is pinned by a test against the validator itself, so widening the identifier class goes red in the file that depends on it being narrow.
646
+ - **@voltro/plugin-ai-flows** — **`@voltro/plugin-ai-flows/web` had no hooks** — it was 30 lines of cadence helpers and IR type re-exports, so driving a flow from a UI meant hand-rolling every subscription and action. It now ships the launch → observe → respond surface: `useLaunchFlow`, `useFlowRun` (the reactive run row projected into a timeline: steps, status, the pending review, `done`), `useFlowRuns`, `useFlows`, `useRetryFlow`, `useCancelFlow`, `useRespondToFlow` (`approve`/`reject`/`choose`/`submitText`) and `useFlowReview`, which is the whole review widget in one call. Because this plugin ships no fixed RPC routes (its procedures are helpers an app wires into its own thin rpc files), each hook takes the tag set — defaulting to `aiFlows.launch` / `.run` / `.respond` / … — plus an `apiName`. The module stays browser-safe: `@voltro/client` + the pure cadence predicate + type-only IR imports, verified against the real `browserSafetyGuard` walker.
647
+
648
+ **`structured` steps now get a real schema.** The JSON Schema on a step lowered to an open `Record<string, unknown>`, so the model was told nothing about the shape it should emit and the result was never validated. `jsonSchemaToEffectSchema` adapts it: objects + `required`, arrays, the four scalars, `enum`, `const`, both nullability spellings, and `anyOf`/`oneOf` unions, with `description`/`title` carried through as annotations. A construct it does not model (`$ref`, `allOf`, …) degrades to `Unknown` for THAT NODE rather than failing the step, and a step with no schema behaves exactly as before.
649
+ - **@voltro/cli, @voltro/mcp** — **The framework's invariant checks are now a tool an agent can call, in dev AND against a deployed app.** Every one of them already existed and already ran — `assertBrowserSafeRpcGroup` aborts a dev boot with the exact import chain, `assertProcedureAccessDecisions` refuses a boot whose procedures decide nothing, the differ knows whether the live schema converged, the `.serverOnly()` audit knows which query ships a secret column. What did not exist was a way for the agent that just edited your app to ASK. It could read the manifest and could not verify its own work, so the loop stopped at "generated, looks plausible".
650
+
651
+ `GET /_voltro/inspect/checks` (both boot paths) and the `voltro_check_invariants` MCP tool return, per check, `pass | fail | unavailable` plus machine-readable findings and a concrete fix:
652
+
653
+ - **browser-safety** — does the generated rpcGroup transitively value-import a server-only module. The finding carries the full IMPORT CHAIN, which is the whole value: a bare specifier says a rule broke, the chain says which shared `lib/` file broke it. - **procedure-access** — does every wire-exposed procedure declare `guards:` or `openAccess:`. Runs the same `accessGateVerdict` + `resolveDefaultDeny` the boot gate runs, so a check and a boot cannot disagree about whether the app opted out. - **schema-convergence** — drift plus pending operations, read from the same snapshot `voltro db plan --against` reads. - **server-only-exposure** — the discovery audit's leaks, verbatim.
654
+
655
+ Nothing is re-implemented; a second implementation would be a second answer, and the one an agent trusts would be the one that never refuses a boot.
656
+
657
+ **`unavailable` is a first-class verdict and is never a pass.** Two of these read the source tree, and a deployed `voltro serve` has no generated rpcGroup to walk — frequently no `src/` at all after a `pnpm deploy`. Omitting them in production would hand an agent three green checks it cannot distinguish from "nobody looked", so every check is always present, `unavailable` carries the reason, and `summary.unavailable` is a number a caller can act on. A check that throws degrades to `unavailable` with the failure text rather than failing the response: an agent asking "did I break anything" must not get a 500 that reads like "yes".
658
+
659
+ Deliberately NOT exposed: `voltro doctor`'s rule set (a source-tree scan with its own allowlist file and exit-code contract — re-hosting it behind HTTP would be a second implementation of a large thing, and it would answer `unavailable` on the one deployment shape this surface exists to reach; run the command, on the machine that has the source) and the `*.client.ts` marker check (same walker, but it needs the discovered file list rather than one entry point — left out rather than half-wired, and the dev boot already refuses on a refuted claim).
660
+ - **@voltro/database, @voltro/protocol** — **A write made by an agent on your behalf is now distinguishable from one you made yourself.** `agentActor` has always stated the rule — the actor id stays the calling subject's, an agent never escalates identity — and that rule is exactly what made an agent write invisible: `subjectId` is the person either way, so "Anna archived this invoice" and "an agent archived it while acting as Anna" were the same row.
661
+
662
+ `via?: 'agent'` joins the write-attribution spine, in all THREE copies the existing comments require to stay in step: `WriteAttribution` (and `attributionFields`, so it reaches a `ChangeEvent` at creation like every other attribution field), `ChangeEvent` itself, and `PluginChangeEvent` — the tap shape, which is the audience a "who changed this" consumer actually reads. Absent means a direct call, which is a fact rather than a gap, exactly as an absent `traceId` means "no request behind this write".
663
+
664
+ Set today by the app-tool surface reached over MCP (`@voltro/cli`'s `agentToolSurface`, which sets it inside the shared candidate builder so neither boot path can forget it). Note what this does NOT change: `_voltro_audit_log` already records an agent-invoked mutation, because the agent path runs the same plugin interceptor chain as the socket path — the marker rides the attribution spine, not a new audit column, and the `audit()` mixin's `createdBy`/`updatedBy` still stamp the person.
665
+ - **@voltro/cli, @voltro/mcp, @voltro/ai** — **An external agent can now CALL your app's procedures over MCP, with your app's own permission model as the ceiling.** `appTools`/`exposeAsTool` already synthesised an agent toolset whose ceiling is the calling subject's permissions by construction — the tool body IS the real rpc handler under a resolved `Subject`. `@voltro/mcp` already spoke MCP. What did not exist was the transport between them: the MCP server was read-only manifest introspection, so Claude Desktop / Cursor could read what your app exposes and could not run any of it.
666
+
667
+ `voltro dev` and `voltro serve` now both serve two routes:
668
+
669
+ - `GET /_voltro/inspect/agent/tools` — the admitted toolset, with each procedure's JSON Schema taken from the capability manifest (not a second rendering), plus everything that did NOT mount and why. - `POST /_voltro/inspect/agent/call` — execute one.
670
+
671
+ The MCP server folds those into `tools/list` as native tools (`app_todos_create`) and dispatches `tools/call` to the app, so an agent sees them alongside the manifest tools.
672
+
673
+ **Admission is the SAME function `appTools()` filters on.** `appToolDecision` was extracted from `@voltro/ai`'s `appTools` for this — a second policy path is how a ceiling gets bypassed, and the way that happens is a copy that stops tracking the original. The transport re-runs it immediately before executing, against the live policy, so a toolset a client cached does not authorise anything.
674
+
675
+ **Five gates, every one default-closed:**
676
+
677
+ 1. `agents: { mcp: true }` in `app.config.ts`. Not implied by anything else — having an inspect token is not consent to let an agent execute procedures. 2. `VOLTRO_INSPECT_TOKEN` (fail-closed, minted only by `voltro dev`). 3. `VOLTRO_INSPECT_WRITE_TOKEN` + the `x-voltro-inspect-write` header. Not new vocabulary: a tool call is a POST, and the existing resolver already demands a second credential for any non-GET inspect request. An existing deployment with only the read token therefore executes nothing. 4. An APP credential in `x-voltro-agent-authorization` — REQUIRED. The inspect bearer is an operator credential; letting it double as an app identity would be the second authorization path, and falling back to the anonymous subject would run the call as a principal nobody chose. `apiKeys: true` already mints exactly the scoped credential this wants. 5. `agents.tools` — `AppToolPolicy` verbatim (the same object `appTools()` takes), then the procedure's own guards.
678
+
679
+ **`confirm` tools are not mounted over MCP**, and are reported in `dropped` with that reason. `confirm` means a human approves the concrete call, there is no human in this process, and a confirmation carried in the tool's arguments is written by the model. An app that wants a write callable unattended says so per descriptor (`exposeAsTool: { confirm: false }`) or app-wide (`agents.tools.requireConfirmForWrites: false`) — both edits a reviewer sees.
680
+
681
+ The candidates are built by ONE shared builder both boot paths call, which owns the write attribution: an agent call records `via: 'agent'` with the subject id of the PERSON the agent acted as. A path that assembled its own would compile, run, and record an agent write as a human one.
682
+
683
+ Not defended against, stated rather than implied: prompt injection that steers the model into misusing a tool it IS permitted to run; a client holding all three credentials calling any admitted tool with any arguments (the bound is the subject's permissions, so scope the app credential to the agent's job); and per-tool call rate (`maxPerRun` is reported for a client to honour — use the rate-limit plugin for a bound that holds regardless of who calls).
684
+ - **@voltro/plugin-audit** — **The audit log is tamper-EVIDENT, not merely append-only (SEC-15).** `_voltro_audit_log` was append-only by convention and by nothing else: an actor with `UPDATE` could rewrite what a call did, or `DELETE` the row recording a refusal, and no read of the table would notice. Four nullable columns — `chainId`, `seq`, `prevHash`, `hash` — put every row in a hash chain, plus a `byAuditChain` index and `verifyAuditChain(store, { chainId?, limit? })`, which recomputes every chain and names what does not add up: `tampered` (content changed), `broken-link` (reordering / substitution), `gap` (a row is missing). Rows written before this shipped are counted as `unchainedRows` rather than passed over. No codemod and no migration: a `_voltro_*` shape change rides the declarative differ on `voltro db apply` and on a `voltro dev` boot, on every dialect.
685
+
686
+ **The chain is per WRITER, and that is the whole concurrency story.** A global chain needs every insert to know the current tip, i.e. a serialization point across every process writing audit rows — and two replicas racing on one chain fork, which is indistinguishable from tampering. A per-TENANT chain has the identical problem one level down. So `dataStoreAuditSink` mints a `chainId` per process and allocates `seq`/`prevHash`/`hash` in a SYNCHRONOUS, `await`-free block, which is atomic against any number of concurrent events on a single-threaded runtime. The cost is stated rather than hidden: N replicas produce N chains, verification attests "every chain is intact" and not "the log is complete", and a chain's tail cannot be distinguished from one that was truncated.
687
+
688
+ **Read the guarantee before quoting it.** Unkeyed (the default) it detects any change that does not recompute the chain — a hand-run `UPDATE`, a botched migration, corruption, a script that scrubs one row. It does NOT stop an adversary who knows the scheme and rewrites the chain forward, because SHA-256 is public. Two things close that, both shipped: set `VOLTRO_AUDIT_CHAIN_SECRET` and the chain becomes HMAC-SHA256 (an actor with the database but not the key cannot forge a link — no default value, nothing is minted for you), and/or publish the tip hash `verifyAuditChain` returns to somewhere append-only you do not control, which is also the only defence against tail truncation.
689
+
690
+ The hash covers a canonical, recursively key-SORTED serialisation — load-bearing rather than stylistic, since postgres `jsonb` and mysql `JSON` do not preserve key order, so a naive `JSON.stringify` would not reproduce from the value read back.
691
+ - **@voltro/cli** — **`voltro <command> --help` answers with flags and examples instead of one summary line.** Only 14 of the 53 commands printed their own help; for the other 39 the dispatcher fell back to a single sentence and a docs link — and `--help` is the second thing a user types after a command surprises them.
692
+
693
+ There is now ONE renderer (`renderCommandHelp`) fed by a `help` block on the command spec: usage, flags, environment variables, worked examples, notes. 40+ commands are covered, including every command in the *Start a project*, *Develop* and *Build & run* groups.
694
+
695
+ The interesting part is the guard. A help page that names a flag the command does not parse is worse than no help, and the repo-wide `check-message-apis` rule cannot catch it: its parsed-flag set is the union of ALL CLI source, so it would happily accept `voltro test --create-only` because `migrate` parses `--create-only`. `commandHelp.test.ts` derives each command's implementation module from the `import('./x')` in its OWN dispatch entry and fails if a documented flag is not read there. It found four wrong flags on its first run — in a summary string that had been shipping them.
696
+ - **@voltro/cli, @voltro/database** — **`voltro db branch` — provision a branch of the live schema, rehearse the pending migration on it, and report what it would do.** Copy-on-write branching is a commodity now: Neon and Supabase sell one, and Prisma Compute's public beta advertises "database branches". None of them can tell you what YOUR migration does to that branch, because none of them owns the declarative diff. That half is the command.
697
+
698
+ ```
699
+ voltro db branch --pr 128 [--seed empty|copy] [--keep] [--json]
700
+ ```
701
+
702
+ It branches the LIVE schema, plans the declared schema against the branch, executes the plan THERE, re-plans, and prints the classified result — then drops the branch. Exit `0` clean, `2` when the plan destroys data or the planner refuses part of it (a REVIEW signal, not a build failure), `1` when the rehearsal could not answer.
703
+
704
+ **Lossy operations are EXECUTED on the branch, and reported.** Production refuses a `drop-column` until a human acknowledges it; a branch that is about to be dropped has no such reason, and refusing there means the one operation most likely to fail is the one operation never rehearsed. So the rehearsal unblocks them, runs them, and leads the report with every one.
705
+
706
+ **The verdict is CONVERGENCE, not exit 0.** A migration that applies and then re-proposes itself forever is not a migration, so the rehearsal re-plans afterwards and reports whatever the re-plan still wants.
707
+
708
+ **What it does NOT claim.** The branch PLAN is dialect-agnostic; the shipped namespace EXECUTOR is Postgres-only (`CREATE SCHEMA`, `LIKE … INCLUDING ALL`, `"`-quoting), so the command refuses on MySQL / MariaDB / SQLite / SQL Server instead of emitting postgres syntax at them. A connection that resolves to Neon's copy-on-write mechanism is refused too, with `--prefer namespace` as the way through: a Neon CoW branch is a call to Neon's branch API and the CLI holds no token. There is no Supabase or template-DB mechanism in this codebase.
709
+
710
+ **Two fidelity bugs in the branch primitive were found by pointing this at a real database, and both are fixed.** `CREATE TABLE … (LIKE parent INCLUDING ALL)` does NOT copy foreign keys — there is no `INCLUDING` clause that does — and it RE-DERIVES every copied index's name from the table and columns (`byApiKeyTenant` came back as `_voltro_api_keys_tenantId_idx`). So a namespace branch came up with no referential integrity and a renamed catalog: a PR preview bound to it accepted writes production rejects, and a rehearsal on it rehearsed a different schema. `provisionBranch` now takes `foreignKeys` + `indexNames` and replays both (`BranchExecutor.addForeignKey` / `.restoreIndexName`), and the rehearsal ABORTS with `outcome: 'infidelity'` if the branch and the parent still disagree — rather than making claims about a copy that is not one.
711
+ - **@voltro/database, @voltro/sql-postgres, @voltro/sql-mysql, @voltro/sql-sqlite, @voltro/sql-mssql** — **The database layer had no metrics at all.** ~20 `voltro_*` series existed — RPC latency, live-query counts, workflow, AI cost — and not one of them said anything about the database, which is the component every one of those requests is waiting on. There was no query-duration histogram, no error rate, no concurrency signal; connection-pool state existed only as a boot LINE, which is configuration, not telemetry.
712
+
713
+ Five series now, emitted by **every** dialect store, labelled `dialect` and `op`:
714
+
715
+ - `voltro_db_queries_total` — query rate by operation kind - `voltro_db_query_duration_seconds` — the histogram, bucketed from 0.5 ms - `voltro_db_errors_total` — statement failure rate - `voltro_db_operations_in_flight` — concurrency the framework is holding - `voltro_db_eager_fallback_total` — see the eager-fallback entry
716
+
717
+ They land in Effect's global `MetricRegistry` like every other `voltro_*` series, so `@voltro/plugin-prometheus`, the OTLP exporter and `GET /_voltro/inspect/metrics` pick them up with nothing to wire.
718
+
719
+ Three decisions worth knowing, because each has a wrong answer that looks reasonable:
720
+
721
+ **No table name is ever a label.** It is the obvious next label and it is how a scrape target falls over — Prometheus cardinality grows with the user's schema. `dialect` × `op` is at most 6 × 8. The table is in the log line instead.
722
+
723
+ **`voltro_db_operations_in_flight` is deliberately NOT the driver's pool queue.** node-postgres exposes `waitingCount`; mysql2, tedious and better-sqlite3 each expose something different or nothing, and `@effect/sql-pg` builds its pool internally on the default path so there is no handle to read. Four driver-specific probes, three of them absent, is a metric that means a different thing per dialect — worse than one meaning the same thing everywhere. In-flight operations are an upper bound on connections held, measurable identically on all four, and the histogram's tail carries the acquire wait. Do not read the gauge as `waitingCount`; the docs say so too.
724
+
725
+ **The instrumentation is measured, not assumed.** It sits in front of every statement, so it was benchmarked before it shipped: `node packages/sql-postgres/scripts/db-path-cost.mjs` prints its per-operation cost next to the round trip it wraps on every run. The first version cost 8.1 µs; two fixes took it to 3.1 µs — tagging each `(dialect, op)` triple ONCE instead of per call, and using `metric.unsafeUpdate` instead of `Effect.runSync(Metric…)`, which spins up a fiber to perform a pure state mutation (measured 15× difference, and verified to land in the same `Metric.snapshot` the exporters read). This repo has already had observability become ~90% of the cost of the thing it observed, on the event-publish path; that is why the number is printed rather than trusted.
726
+
727
+ Shared rather than per-dialect on purpose. Four hand-written stores have twice drifted apart on a subtle decision copied four times, so WHICH series exist and what an `op` is called is decided once in `@voltro/database`; `dbMetricsParity.test.ts` fails if a store stops calling it.
728
+ - **@voltro/plugin-auth** — **Email verification — and the policy is yours, not ours.** Classic password signup never proved the address: there was no `emailVerified` column and no verify endpoint anywhere, so magic-link was the only flow in the plugin that knew an inbox existed. `users` now carries a nullable `emailVerifiedAt`, and `POST /auth/verify-email` + `/auth/verify-email/callback` mint, send and redeem a link over the existing single-use hashed-token table (a fourth `purpose`, `email-verify`, beside magic-link, password-reset and mfa-pending).
729
+
730
+ **What an unverified account may do is a product decision, so it is a config field with three values** rather than a behaviour picked on everyone's behalf:
731
+
732
+ - **`'off'` (DEFAULT)** — the column, the endpoints and the emails exist; nothing is gated. Verification is a fact your app can read and act on. - **`'soft'`** — login succeeds and the session is MARKED. `isEmailVerified(subject)` drives a banner and lets an app gate the specific actions it cares about. - **`'strict'`** — login is refused with `403 email_not_verified`, on every path that issues a session.
733
+
734
+ **The default is `'off'` because the column arrives NULL on every existing row.** Under `'strict'` as a default the first boot after upgrading would refuse the next login of every user an app already has — a total authentication outage caused by missing data rather than by anything a user did. `exemptAccountsCreatedBefore` is the adoption seam: set it to your deploy instant and only accounts created from then on have to prove anything, with no backfill.
735
+
736
+ Three properties worth knowing:
737
+
738
+ - **Three links prove an address, not one.** A magic link and a completed password reset are inbox round-trips exactly as a verification link is, so both stamp `emailVerifiedAt`. Without that, `'strict'` deadlocks a magic-link-only user: they can prove their address by signing in, and are refused the sign-in for not having proved it. - **A verification link is NOT a credential.** Redeeming one marks the address and issues no session. The tempting shortcut would turn a link that sits 24 hours in a mailbox into a day-long sign-in token. - **`'strict'` rides the existing subject-guard seam**, so it covers password, MFA verify, magic-link and passkey sign-in from one wiring rather than four. `authRoutesPlugin` installs the guard when you set the policy; an app hand-wiring the handlers adds `emailVerificationGuard(config)` itself.
739
+
740
+ The resend endpoint answers a uniform 202 (unknown address, verified address and cooled-down resend are indistinguishable) and mints at most one mail per `resendCooldownSeconds` (default 60) — without that bound, "resend" is a mail-bomb primitive aimed at any address known to have an account.
741
+
742
+ `emailVerifiedAt` rides the declarative differ on `voltro db apply` and on a `voltro dev` boot, on every dialect.
743
+ - **@voltro/plugin-auth-social** — **Sign in with Google / GitHub / Apple, without an identity vendor** — `@voltro/plugin-auth-social`. Until now the only way to offer a social login was to adopt Clerk, Auth0 or WorkOS: five of the six `@voltro/plugin-auth-*` adapters are enterprise-IdP token VERIFIERS, and the sixth runs its flow through WorkOS-hosted AuthKit. This package runs the whole thing itself — authorize URL, code exchange, identity verification, account linking, session — and mounts it as two routes (`GET /auth/social/<provider>` and its `/callback`).
744
+
745
+ The session is issued by `issueUserSession` from `@voltro/plugin-auth`, the same function password sign-in, sign-up, magic-link, MFA and passkeys use, so a social login gets the sessions row (device list + server-side revocation), keyed secret rotation, sliding-window renewal and the post-authentication subject guards with no second implementation to drift.
746
+
747
+ Security posture, since a half-built OAuth flow is a vulnerability rather than a feature:
748
+
749
+ - **`state` and PKCE (S256) are mandatory and always ours.** There is no option to supply a `state` and no branch that omits the challenge — including for GitHub, whose OAuth app flow ignores PKCE, because the branch that skips it is how the next provider silently lands on the PKCE-free path. The `state` comparison is constant-time and happens BEFORE any network call, so a forged callback never reaches a token endpoint. - **ID tokens are verified through `@voltro/protocol/jwt`** — signature against the provider's JWKS on the existing ES256/RS256 allowlist (HMAC deliberately excluded), plus `iss`, `aud`, `exp`/`iat` and a `nonce` binding that makes a token captured from another login fail. - **Account linking defaults to refusing.** Attaching a social identity to a pre-existing local account on an email match is the classic pre-authentication takeover, so `linkPolicy: 'never'` is the default: an unknown email creates a user, a known one is refused with instructions to sign in normally and connect the provider from account settings. `'verified-email'` is available as a deliberate, documented risk; a policy that links on an *unverified* email does not exist. The always-sound linking — from inside an authenticated session — is `linkSocialIdentity`. - **GitHub's self-declared profile email is never trusted.** Only the `primary && verified` entry from `GET /user/emails` counts, and an entry without an explicit `verified: true` is treated as unverified. - **Apple's three deviations are handled explicitly**: the name that arrives on the first authorization and never again (surfaced as `nameIsFirstAuthorizationOnly` so it can be persisted then), the client secret that is an ES256 JWT you sign from a `.p8` (minted per exchange with a 15-minute lifetime, so nothing is stored and nothing expires in production six months later; a TTL above Apple's cap is rejected), and the private-relay email (flagged, and refused for linking even under `'verified-email'`). Apple's callback is a cross-site POST, so the login-state cookie is written `SameSite=None; Secure` for it — which means Apple needs HTTPS locally.
750
+
751
+ No secret values ship anywhere: credentials come from `providers.*` or `VOLTRO_GOOGLE_* / VOLTRO_GITHUB_* / VOLTRO_APPLE_*`, are declared for the env manifest, and a missing one fails the boot rather than falling back.
752
+
753
+ The plugin contributes `_voltro_oauth_identities` via `extendSchema` — it rides the declarative differ on `voltro db apply` and on a `voltro dev` boot, on every dialect, so no codemod is involved. It is deliberately NOT registered for retention: the rows ARE the credential and are bounded by users × providers, so a TTL sweep would silently un-enrol people rather than reclaim space.
754
+ - **@voltro/ai** — **An MCP CLIENT — agents can now consume external MCP servers.** `@voltro/mcp` only ever pointed outward (this app AS an MCP server); an agent's toolset was `defineTool` + `appTools` and could not reach the MCP ecosystem at all. `mcpToolset({ namespace, transport }, policy)` connects to an external server over Streamable HTTP (`httpMcpTransport`) or stdio (`stdioMcpTransport`), lists its tools, and returns them as `AnyTool`s an agent loop can call — plus `specs` (the same `SynthesizedTool` inventory app tools produce, so one confirm-UI covers both) and `dropped` (what did not mount, and why).
755
+
756
+ **External tools route through the SAME policy layer as `exposeAsTool` descriptors**, because an external tool that bypassed it would be a hole through the framework's best security property. The tag is `<namespace>.<toolName>` and it goes through `passesPolicy` — the same function, so `deny` still beats `allow` and the globs mean the same thing. Two deliberate differences, both because an external server has no descriptor behind it: `allow` is **required** (external tools are default-deny; omitting it is refused, not defaulted), and the read/write split comes from the APP's `readOnly` list, never from the server's `annotations.readOnlyHint` — believing that hint would be a way to talk past `includeWrites: false`. `trustToolHints: true` delegates it explicitly. The gate is re-run inside every tool body, so a tool spliced into the record after mount still cannot reach the server.
757
+
758
+ **An external server is untrusted input, and every channel from it is bounded** — `maxTools` (64), `maxDescriptionChars` (1024), `maxSchemaBytes` (32 KiB), `maxResultBytes` (256 KiB), `maxResponseBytes` (4 MiB, enforced while READING so an unbounded stream is cancelled rather than buffered), `requestTimeoutMs` (30 s) — each an option with an env override. Tool names must be `[A-Za-z0-9_-]`; descriptions and every string in the input schema are stripped of invisible characters (zero-width, bidi overrides, the Unicode tags block) and carry a provenance prefix telling the model the text is third-party. The tool set is snapshotted at mount and nothing re-reads `tools/list` on its own, so a server that renames or re-describes its tools between calls changes nothing until `refreshMcpToolset`. What is NOT defended against is documented next to the gate and on the docs page: instructions the model chooses to obey, a server that lies about a tool's effect, exfiltration through arguments, and the transport target (`url`/`command` are app configuration, not model output).
759
+
760
+ `defineTool` gained `inputJsonSchema` — a foreign parameter schema shown to the model instead of a rendered `input`, which is what makes an external tool's own arguments fillable. Docs: `/docs/ai/mcp-clients` (en + de).
761
+ - **@voltro/testing** — **`MockClock` tracked only its own `currentMs`, so a test that advanced it and then asserted on a timestamp was comparing two unrelated clocks.** `Date.now()` and `new Date()` were untouched — and almost nothing stamps through the clock you passed it. `MockWebhooks.emittedAt` calls `new Date()`. The mail plugin's memory provider calls `new Date()`. So does every `createdAt` default and most user code. The assertion that "passed" was measuring the machine, and the trap shipped *with the harness* rather than being something a user invented.
762
+
763
+ `@voltro/testing` gains the Rails `travel_to` / Laravel `Carbon::setTestNow()` ergonomic:
764
+
765
+ - **`withFrozenTime(at, body)`** — global `Date` is the mock instant for the body, then restored. An ASYNC body is awaited *before* the restore; a plain `finally` around a promise-returning call puts the real clock back underneath a still-running test, which is the failure this exists to make impossible. - **`frozenTime(at)`** — the Effect-native form. `acquireRelease`, so the scope releases on success, failure and interruption alike. - **`clock.install()` / `.uninstall()` / `.installed`** — the manual escape hatch, returning its own uninstall. - **`clock.set(at)`** — jump to an ABSOLUTE instant (`advance` is relative), and the constructor now also takes an ISO string.
766
+
767
+ Four decisions that are the substance of it:
768
+
769
+ - **Only the ZERO-ARGUMENT readings change.** `new Date(0)`, `new Date('2020-05-05')`, `new Date(2020, 0, 2)`, `Date.parse` and `Date.UTC` all mean exactly what they say — an argument is the caller naming an instant, which a clock fake has no business rewriting. It is a `Proxy` over the real constructor rather than a subclass for precisely this: `Date` has four overloads and `new Date(2020, 0, undefined)` is NOT `new Date(2020, 0)`, so a subclass that normalises an argument list gets the component form wrong. - **Opt-in.** Constructing a `MockClock` still fakes nothing. - **A second install THROWS.** Nesting two would make the inner uninstall restore the *outer fake*, leaving the realm frozen with nothing pointing at why. The install slot lives on `globalThis` under a `Symbol.for` key so the guard still fires with two copies of the module loaded — the same reasoning as `coreTablesRegistry`. Uninstall is idempotent, so a `finally` is safe. - **Timers are NOT faked**, and neither is a module that captured `Date` into a local before the install. Wall-clock stamps are the target; `vi.useFakeTimers()` is still the tool for the timer wheel.
770
+
771
+ `mockWebhooks.ts` is unchanged — that is the point of faking the clock instead of threading one through every double. `mockClockGlobal.test.ts` pins the reported shape (two emits either side of a `clock.advance('1h')`, asserted against the mock instants); 11 of its 18 cases are red against the previous clock.
772
+ - **@voltro/plugin-audit, @voltro/plugin-notifications, @voltro/plugin-ai-flows** — **`tables: false` on the three table-carrying plugins where an app can safely take the tables over — and deliberately not on the rest.**
773
+
774
+ The seam lets an app that ALREADY has equivalent tables keep them: the plugin contributes no DDL through `extendSchema`, the declarative differ never proposes its tables, and everything else (routes, inspect, interceptors, retention) is unchanged. It existed on `plugin-rbac` alone; it is now also on `plugin-audit`, `plugin-notifications` and `plugin-ai-flows` — the plugins whose overlaps carried real data in the report that asked for this (notifications 14 670 rows, audit 886, rbac 5, ai-flows 4/7).
775
+
776
+ Each states what the app takes over, because a boolean that silently transfers an obligation is the problem, not the feature. The plugin keeps writing to those tables BY NAME through the bound store: nothing validates that they exist, so a missing or mis-shaped table fails at the first write rather than at boot.
777
+
778
+ **The omissions are the decision, not the unfinished part.** A `tables: false` that disables a table carrying an AUTHORIZATION or SAFETY guarantee is a security regression shipped as an ergonomics feature, so it is withheld from:
779
+
780
+ | Plugin | What its tables guarantee | |---|---| | `plugin-sso-saml` | the assertion replay cache | | `plugin-scim` | provisioning state | | `plugin-billing` | the usage counters the quota gate reads | | `plugin-cdc-out` | the delivery outbox | | `plugin-governance` | the consent ledger | | `plugin-search` | tenant-scoped index rows |
781
+
782
+ Those need a NAMED store seam (`store:` / `adapter:`) with a stated contract first — per-plugin work, not one field applied eleven times. The withholding is pinned by a test rather than left to a comment, so adding one later is a deliberate edit that has to argue with the reasoning.
783
+
784
+ Note what `tables: false` does NOT solve, since the two get conflated: an rpc NAME collision is `alias`'s job. Turning off a plugin's tables leaves its tags exactly where they were.
785
+ - **@voltro/ai** — **Prompts are versioned artefacts now, and a run says which version produced it.** A framework with a schema-versioned database and row-level provenance was not versioning the one thing in an AI feature that changes weekly.
786
+
787
+ `definePrompt({ id, template, system?, label? })` computes a content `digest` from the template + system at definition time — so a prompt version is identifiable before any database exists — and `.render(vars)` substitutes `{{name}}` placeholders, failing on a missing variable rather than sending the literal `{{body}}` to a model. The digest is NOT a new identity scheme: it is the same `promptDigest` `aiStep` already stamped on step rows, generalised (moved to `prompts.ts` so a hash no longer needs `@voltro/workflow` to be importable; still re-exported from `@voltro/ai/workflow`).
788
+
789
+ `aiStep` / `aiObjectStep` accept a rendered prompt wherever they accepted a string, and one call then writes the same `promptId` + digest to three places: the step row (`_voltro_workflow_run_steps.input`), the spend ledger (`_voltro_ai_usage` gains nullable `promptId` / `promptDigest` / `promptRevision` + an index on the digest), and `_voltro_prompts`. Offloaded calls are covered too — the stamp rides across the suspend on `_voltro_ai_inferences`, so the dispatcher attributes the spend to the same version. `recordPrompt: 'none'` still records the provenance: the reason to suppress a prompt is its TEXT, and an id plus a digest is neither the text nor derivable from it.
790
+
791
+ Read it back with `promptVersionByDigest` (step row → the artefact), `promptVersionsFor` (history, newest first) and `aiSpendUsd({ promptDigest })` ("did revision 4 cost more than revision 3"). `_voltro_prompts` stores the TEMPLATE (code, already in your repository) and never a rendered prompt (data).
792
+
793
+ **The table is bounded in the same change** — `registerRetention` on `lastUsedAt`, 365-day default, `VOLTRO_AI_PROMPTS_TTL_HOURS`, `framework` precedence so an app's own window wins. It is self-healing under the sweep: a version that ages out is one nothing has run in a year, and the next run re-registers it. `_voltro_*` schema changes need no codemod — the declarative differ reconciles them on `voltro db apply` and on a `voltro dev` boot, on every dialect. Docs: `/docs/ai/prompt-versioning` (en + de).
794
+ - **@voltro/cli** — **Reactive-trigger drift was detected on one boot path and repaired on neither.** On postgres, "this table is reactive" is carried by a per-table `framework_changes_<table>` trigger, and the only thing that installed one was `voltro db apply`. Boot only ever WARNED — and only under `voltro dev`, only on the fingerprint fast-path. `voltro serve` never looked at all.
795
+
796
+ So the check lived exclusively on the boot path where the failure it names cannot occur. Cross-instance reactivity is a property a FLEET has: a single process still sees its own writes through the inline path, so a missing trigger is invisible in development and shows up in production as "subscriptions sometimes stop updating", with nothing in the logs. The states that produce one all leave the fingerprint MATCHING, which is why the schema check has nothing to say about them — a schema-only restore or a `--no-triggers` dump, a `CDC=0` → `CDC=1` boot, a hand-run `DROP TRIGGER` during an incident.
797
+
798
+ `voltro dev` and `voltro serve` now run the same check-and-repair at startup, through one shared builder (`reactiveTriggerBoot.ts`) that `voltro db apply` shares the planner with — so which drift dimensions get repaired cannot differ between a boot and an apply. Four properties are deliberate:
799
+
800
+ - **It does not queue.** The repair takes the migration advisory lock with `pg_try_advisory_lock` and SKIPS if anything holds it: N replicas booting together give one repairing and N-1 logging that somebody else is. A concurrent `voltro db apply` holds the same key, so the two can never run each other's DDL. A boot that waits 30 s for a lock is worse than one that re-checks on the next start. - **It never fails a boot** — `catchAllCause`, not `catchAll`, because a driver-level problem arrives as a defect and `catchAll` cannot see one. - **It is a tunable**: `reactiveTriggers: 'repair' | 'report' | 'off'` in `app.config.ts` (default `'repair'`), `VOLTRO_REACTIVE_TRIGGERS` overriding the field. `VOLTRO_AUTO_MIGRATE=0` downgrades `'repair'` to `'report'` — that variable means "this boot issues no DDL", and it is deliberately not read as "and say nothing". - **A boot MAY do this, where it may not migrate.** The note in `reactiveTriggerDrift.ts` said a boot that re-created triggers would be "a boot doing migrations". That is right about user schema and wrong about this: a trigger carries no data, its DDL is idempotent, and it is not something the operator declared — it is the framework's plumbing for a property they DID declare. `voltro db apply` already refuses to make it a planner operation for the same reason.
801
+ - **@voltro/testing** — **A request-level test harness, subject factories, and row factories (TEST-3, TEST-5).**
802
+
803
+ **`makeTestApp({ ctx, restRoutes, publicApi, strategies })`** sends a real request through the framework's own REST pipeline — no server, no port, no docker. It closes the gap `invoke`'s header names and does not apologise for: "connection info, rate limiting, the tenant header". Before it there was no way to test that an anonymous caller without `x-tenant` is refused, that a REST route's path params decode, that a `publicApi` mutation is gated by the scope its annotation declares, that an `Idempotency-Key` replay returns the first response, or that the auth strategy chain resolves the subject it thinks it does.
804
+
805
+ Nothing in it re-implements the request path; it CALLS the same functions `voltro serve` does — `restRoutesToHttpRoutes` (method gate, sunset gate, `{ query, params, body }` assembly, input decode, guards, HTTP idempotency, output encode, the `{ status }` error mapping), `collectPublicApiRoutes` (the descriptor → REST projection with `scopes` → `requireScope`), `dispatchSharedPath` (the per-path dispatcher that lets a GET and a POST share one route), `composeAuthStrategies`, and `invoke` beneath a `publicApi` route so the procedure's own guards / decode / transaction / interceptors still apply under the transport. It owns exactly three things a server would: route selection, request-body encoding, response-body decoding.
806
+
807
+ The two ways to say who is calling are kept apart because they are different questions: `actingAs(subject)` fills the same `resolveSubject` seam the serve pipeline fills ("what may this identity do"), while `withHeaders({...})` with no `actingAs` runs the real strategy chain ("how is this identity resolved").
808
+
809
+ Two deliberate non-features. A strategy that REFUSES (`anonymousTenantRequired` with no tenant) makes the request call reject rather than return a status — mapping an auth refusal onto an HTTP code is the server's job, and a number invented here would be a number the harness made up. And there is no WebSocket / live-subscription lifecycle and no `POST /rpc` wire: those are dispatcher concerns the CLI owns, and a `publicApi:` annotation is how a procedure gets an HTTP surface this package can reach.
810
+
811
+ **Subject factories — `user` / `apiKey` / `serviceAccount` / `anonymous` / `system`, plus `TEST_TENANT_ID`.** The shipped `invoke` docstring taught `makeTestContext({ subject: user('A', { scopes: [...] }) })` for several releases while `@voltro/testing` exported no `user()` at all — a shipped type definition teaching a helper that does not exist, the same defect class as the mail API above. It exists now, and 146 files across this repo, the templates and the starter hand-write the literal it replaces. Two decisions worth knowing: the default tenant is SHARED, so `user('a')` and `user('b')` are two members of one tenant and a cross-subject read is a row-level question (a unique-per-call tenant would make the store hide everything and every such test would pass for the wrong reason); and `scopes` defaults to EMPTY rather than to a bypass, so `user('b')` is refused by any `guards:` — the assertion most negative tests exist to make.
812
+
813
+ **`defineFactory(table, { defaults, traits, associations })`.** `fixtureRow` makes one row valid; what it cannot do is the part a fixture actually costs you, the PARENT ROWS. It fills a `reference()` column with a placeholder string that satisfies the required-column validator and points at nothing — invisible in the in-memory store, a constraint violation against a real database, and an eager `.with({ author: true })` that resolves to nothing either way. `create()` inserts the ancestors a row requires, in dependency order, threading real ids through; an ancestor is created only for a required reference the overrides leave unset, so passing `{ authorId: existing.id }` writes nothing extra. Traits are named override bundles (`with('a','b')`, later wins) and an undeclared trait throws rather than quietly building the base row. A cyclic reference is refused, naming the path and the column to pass by hand, because no insertion order satisfies one. `build()` stays pure. Ordering inside `create` is load-bearing and silent if inverted: ancestors resolve against the RAW row and `fixtureRow` runs LAST, because a placeholder is indistinguishable from a caller-supplied id to `missingRequiredColumns` — run the filler first and every association quietly becomes a dangling FK. `nextSequence()` is exported so a caller's own unique default shares the one counter.
814
+ - **@voltro/protocol, @voltro/runtime, @voltro/cli** — **A mutation can now declare that it needs a second human before it takes effect** — `requiresApproval: { approvers: [{ scope: 'invoices:approve' }] }` on `defineMutation` / `defineAction`. Human-in-the-loop existed inside a durable workflow (`awaitSignalSuspending`, AI-Flows' park/resume); an ORDINARY rpc call had no way to ask for one, so every app that needed four-eyes built it by hand as a status column and a second mutation, with the self-approval rule living in whichever handler remembered it.
815
+
816
+ The first call records a durable `_voltro_approvals` row and fails with a typed `ApprovalRequired` carrying the approval id, its expiry and the scopes an approver needs — the transaction never opens, and an action's external I/O never happens. Once an authorised, **different** subject approves, the identical call succeeds exactly once (the approval is CONSUMED, so a replay is a new request, not a free second execution). Two built-ins ship with it: `__voltro.approvals.pending` (reactive on the approvals table — the requester watches their own row flip and the approver's queue appears with no poll) and `__voltro.approvals.decide`.
817
+
818
+ The pending intent's identity is **content-addressed** — a length-prefixed sha256 over (procedure, requester, tenant, canonicalised input, optional `nonce`) — and that is the whole design rather than an implementation detail. An identity too COARSE lets approving one intent execute another's payload; one too FINE (a uuid per attempt) mints a second approval on every page refresh, client re-send or deadlock replay, and asks the human twice for one decision. A `UNIQUE` on that key holds "at most one LIVE intent per content"; a terminal row is rekeyed off it so the same request can legitimately be made again.
819
+
820
+ Refusals, all typed and all tested: **self-approval is refused unconditionally** — no opt-out flag, and it is checked BEFORE authority so a requester who happens to hold the approver scope is told the accurate reason rather than being let through; an unauthorised approver gets `ApprovalForbidden`; an anonymous decider is refused (every anonymous caller compares equal to every other, so the identity the control rests on does not exist); expiry **fails closed**, at the read as well as at the decision, so an approval that aged out between the decision and the retry does not execute. `openAccess` + `requiresApproval` and an empty `approvers` list are both refused at DECLARATION, where the author can still see both fields.
821
+
822
+ Tunable: `approvals: { expiresIn: '4h' }` in `app.config.ts` (env `VOLTRO_APPROVAL_EXPIRY_HOURS`, default 24 h), overridden per descriptor. The gate is installed by one shared builder both boot paths call and FAILS CLOSED if a path ever stops calling it — for this one capability, "it silently works" is the wrong direction for a parity miss.
823
+ - **@voltro/cli** — **sqlite and the memory store behind several replicas is silently wrong, and nothing said so.** turso already got a boot warning, mssql has fleet-scope Change Tracking, postgres and mysql/mariadb fan out natively. What was left is the pair that cannot have cross-instance capture even in principle — a local file and an in-process Map — where the failure is worse than stale subscriptions: the replicas do not share a database at all, so each is reading its own data. Nothing errors; the app looks healthy and serves divergent views.
824
+
825
+ The reason it stayed unreported is that on a laptop this is the CORRECT configuration. So the trigger is not the dialect, it is evidence of an ORCHESTRATOR: `REPLICA_COUNT > 1`, `KUBERNETES_SERVICE_HOST`, `POD_IP`, `POD_NAME`, `FLY_ALLOC_ID`, `FLY_MACHINE_ID`, `ECS_CONTAINER_METADATA_URI[_V4]`, `K_REVISION`, `CONTAINER_APP_REPLICA_NAME`, `RENDER_INSTANCE_ID`.
826
+
827
+ `HOSTNAME`, `NODE_ENV` and `PORT` are deliberately NOT evidence and are pinned as such: every single-instance container sets them, and one false positive per boot is how a real finding gets filtered out. `REPLICA_COUNT=1` is the only positive evidence AGAINST a fleet that exists, so it outranks every platform signal — and it is the documented way to silence the line. A declared cross-replica broadcast bus also silences it, not because a bus fixes it (the databases are still separate) but because declaring one means the question was already asked.
828
+
829
+ The check sits ABOVE the pre-existing `changeStrategy !== 'cdc'` early return, which is what had kept this case unreported: `CDC=0` on sqlite is the same silence. And `crossReplicaBus` was added to the audit's input as a REQUIRED field, so `tsc` — not a reviewer — is what forces `voltro dev` and `voltro serve` to both supply it.
830
+ - **@voltro/cli** — **Stuck-run detection now RUNS.** `sweepStalledRuns` shipped with nine tests and no callers: a run parked on a signal that will never arrive — the commonest durable-workflow failure there is — was visible only to whoever happened to open the dashboard.
831
+
832
+ It is armed on both boot paths through the shared `wireFlowControl` builder, as a coordinated tick beside the admission drainer and the `cancelOn` sweep. A newly stalled run records a `run-stalled` event and calls your handler. The sweep changes NO run state, deliberately: "no progress for 30 minutes" is a suspicion, and every suspicion here has a legitimate shape it cannot tell apart from a wedge — a sweep that cancelled what it thinks is stuck would be a far worse bug than the one it detects.
833
+
834
+ ```ts
835
+ // app.config.ts
836
+ export default defineApiApp({
837
+ workflows: {
838
+ staleness: {
839
+ stallAfterMs: 30 * 60_000, // default; set it above your slowest STEP
840
+ onStalled: async (run) => { await page(run.tag, run.runId, run.reason) },
841
+ },
842
+ },
843
+ scheduling: { stalenessSweepMs: 5 * 60_000 }, // or VOLTRO_STALENESS_SWEEP_MS
844
+ })
845
+ ```
846
+
847
+ **Two decisions worth knowing before you tune it:**
848
+
849
+ - **It never disarms when idle.** Every other framework task stops ticking when its queue is empty because an arrival wakes it. A run going stale WRITES NOTHING, so a disarmed staleness sweep has no channel to come back on — it would stop detecting permanently, and only on multi-replica deployments, which are the ones that need it. The cadence is therefore an unconditional cost, and it defaults to five minutes rather than one second: on a thirty-minute threshold that is the difference between 12 coordination rows an hour and 3 600. - **Runs inside a durable timer are excluded outright.** Without that exclusion every scheduled workflow in the deployment reports stalled, and the feature is noise on its first day.
850
+
851
+ `voltro doctor` gained the one-shot version, for the moment someone is standing in front of a deployment asking whether anything is wedged. It is the one doctor rule that reads the DATABASE rather than your source — a wedged run is a row, not a shape — so "no database reachable" is a normal outcome and prints as a NAMED skip rather than a clean tick. It passes neither `recordEvent` nor `onStalled`: a diagnostic must not write the row that makes the background sweep's dedup skip the next real report, and must not page whoever is on call because an engineer ran a check.
852
+ - **@voltro/plugin-auth** — **Tenant invitations — invite → email → accept → membership, first-party.** The `memberships` table has always been there; nothing wrote to it except sign-up. The one existing invite flow is `plugin-auth-workos`'s `workosCreateInvitation`, which delegates to a paid identity vendor — so every B2B app not on WorkOS rebuilt this by hand. `invitations` joins `authTables`, and six routes mount when you configure `invitations` on `authRoutesPlugin`.
853
+
854
+ **An invitation is a credential that grants access to someone ELSE's data**, and that framing decides every choice in it. It gets the token discipline of a credential — 32 CSPRNG bytes, only the SHA-256 stored, single-use via one conditional `UPDATE … RETURNING`, an expiry (7 days by default) — plus three properties a login token has no need for:
855
+
856
+ - **It is ADDRESSED.** The invited address is compared against the accepting user's. A link that is forwarded, leaked into a channel or intercepted is refused with `invitation_email_mismatch` instead of silently granting whoever opened it first. - **It carries its own authority, chosen by the INVITER.** The accept request is `{ token }` and nothing else — there is no field an invitee could use to name their own role, so "accept as owner" has no input to travel in. - **It is REVOCABLE**, and revocation is tenant-scoped in the SQL predicate rather than in a check above the call, so an admin of one tenant cannot withdraw another's by id.
857
+
858
+ **The one thing that is NOT configurable is the direction: an inviter can never grant a role above their own.** An `admin` who can mint an `owner` invitation and accept it from a second address has promoted themselves, which makes every role boundary in the product advisory. Who may invite (`inviterRoles`, default `['owner','admin']`) and the ranking (`roleRank`, default `owner > admin > member > viewer`) are tunables; `canGrantRole` replaces the rule entirely for a model that is not a line. A role the ranking does not know is grantable only by the top role — treating an unfamiliar `superadmin` as probably-harmless is how it gets handed out by an `admin`.
859
+
860
+ Both "the invitee already has an account" cases are handled: signed in as the invited address, accepting writes the membership; signed out, `POST /auth/invitations/sign-up` creates the account with the address taken from the INVITATION (never from the request) and marks it already verified, since the invitation arrived in that mailbox and came back.
861
+
862
+ Also: re-inviting SUPERSEDES (the previous link stops working the moment a new one is issued, so a resend cannot accumulate live tokens), `maxPending` bounds a tenant at 500 by default so a compromised admin account is not a mail cannon, and the admin list never publishes `tokenHash` — a hash is a verifier for a guessed plaintext, and an admin list is not a place to publish one.
863
+
864
+ `invitations` is registered with the retention sweep at 90 days (`VOLTRO_INVITATIONS_TTL_HOURS`), armed only when the feature is configured.
865
+ - **@voltro/plugin-flags, @voltro/devtools-ui** — **`defineFlag()` — a per-flag VALUE Schema, so a wrong default is a compile error; plus dead-flag detection that says what it cannot see.**
866
+
867
+ `plugin-flags` already had targeting, weighted multivariate variants, ramping schedules, deterministic FNV-1a bucketing, a postgres tier, web hooks and a dashboard panel. What it had no type for was the VALUE a flag serves: `FlagVariant.value` is the `FlagVariantValue` union, so `{ name: 'big', value: 'lots' }` on a flag every reader treats as a number typechecked, and the mistake surfaced at the call site as `NaN`.
868
+
869
+ ```ts
870
+ export const pageSize = defineFlag({
871
+ key: 'search.pageSize',
872
+ value: Schema.Number,
873
+ default: 20,
874
+ // default: 'twenty', ← Type 'string' is not assignable to type 'number'
875
+ })
876
+
877
+ const size: number = flagValue(ctx, pageSize) // server
878
+ const size = useFlagValue(pageSize) // browser, same type
879
+ ```
880
+
881
+ **Be precise about which half a Schema reaches.** Authored values (`default`, every `variants[].value`) are checked by `tsc` — pinned by `@ts-expect-error` cases that fail `typecheck` if they ever start compiling. Values that arrive at RUNTIME cannot be: a postgres-tier override, or a dashboard edit, is JSON long after `tsc` ran. So the same Schema is the runtime gate, and an override whose variant values do not decode is **refused whole** — the code-declared definition stands and the refusal is logged and surfaced in the panel. Not partially applied: dropping one bad arm re-normalises the weights of the rest, silently reallocating every subject.
882
+
883
+ The declaration also decodes the authored default, which catches what a type cannot: `Schema.Int` has the TS type `number`, so `default: 20.5` typechecks and is a value the flag could never legally serve.
884
+
885
+ ### Dead-flag detection — two axes, and only some of it is a proof
886
+
887
+ `GET /_voltro/inspect/plugins/flags/list` now carries a lifecycle report, and the dashboard panel renders it.
888
+
889
+ `shape` is decided from the DEFINITION alone — no observation, no window. A flag with `enabled: false`, or `rollout: 100` with no targeting/variants/live schedule, is a CONSTANT: it resolves identically for every caller forever. That is a proof.
890
+
891
+ `usage` is decided from observed evaluations (`_voltro_feature_flag_usage`, retention-swept, `VOLTRO_FLAG_USAGE_TTL_HOURS`), and exactly one of its states is a proof:
892
+
893
+ - `stale` — targeted reads exist in the window and the newest is older than the threshold. **Provable**: it WAS consulted, and has not been since. - `neverObserved` — no targeted read at all. Consistent with "dead" AND with "declared last Tuesday". Reported, never asserted, never a removal candidate. - `evaluated` / `untracked` — alive, or nothing is recording.
894
+
895
+ **What it cannot see ships in the payload**, not in a docs page next to a number somebody is about to act on:
896
+
897
+ - **Reachability is not decided.** "Not evaluated since <date>" is a measurement; "this code path is dead" is not decidable in general. A seasonal flag, a flag behind a route nobody visited this month, and a flag whose call site was deleted are indistinguishable. - **Only SERVER-side reads count.** `useFlag()` in the browser reads from the bulk set the server already sent, so the key never reaches us as a named read. Bulk deliveries are recorded SEPARATELY and never counted as use — one `useFlags()` poll evaluates the whole registry and would otherwise mark every flag in the app alive forever. - **The window is finite and known** (retention-bounded). A flag last read before the window has no observation at all and reads `neverObserved` — exactly what a flag declared this morning reads.
898
+
899
+ On the day you turn tracking on, nothing has been observed, so every flag is `neverObserved` and nothing is proposed for removal. It is that SPLIT that prevents the day-one "everything is dead" report. Worth recording because the first version of this shipped a second guard on top — "withhold `stale` until the observed window is at least as long as the threshold" — which reads as the real protection and is UNREACHABLE: `observedDays` is derived from the same rows the staleness test reads, so an observation old enough to date a stale flag already makes the window long enough. A mutation test found it (deleting the condition changed no result); it is gone, and the property it was pretending to enforce is asserted directly instead.
900
+
901
+ Tunables with defaults: `usage.track` (on with `store: 'postgres'`), `usage.flushIntervalMs` (5 min — the report resolves to a DAY), `usage.staleAfterDays` (30), `usage.retentionDays` (90). `track: true` on the memory tier is refused at construction rather than silently observing nothing, and `staleAfterDays > retentionDays` is refused because staleness could then never be proven.
902
+ - **@voltro/client, @voltro/cli** — **Typed hooks.** `createHooks` (`@voltro/client`) turns an api's generated procedure map into `useSubscription` / `useMutation` / `useAction` whose **rpc tag is a literal union** and whose **input and output types are inferred**. Codegen now emits that map as `AppProcedures` in `rpcGroup.generated.ts` — a TYPE (`import type` is erased, so it costs the browser bundle nothing).
903
+
904
+ Bind it once per api, at module scope:
905
+
906
+ ```ts
907
+ // src/lib/api.ts
908
+ import { createHooks } from '@voltro/client'
909
+ import type { AppProcedures } from '@acme/api/rpcGroup'
910
+
911
+ export const { useSubscription, useMutation, useAction } = createHooks<AppProcedures>('app')
912
+ ```
913
+
914
+ ```tsx
915
+ const { data } = useSubscription('projects.list') // rows inferred — no <T>
916
+ const create = useMutation('projects.create') // input + output inferred
917
+ ```
918
+
919
+ Four mistakes that used to be runtime-only are now compile errors: a typo'd tag, the wrong hook for the tag's kind, a missing required input field, and a wrongly-shaped input. A fifth is now unexpressible — the old `useSubscription<ReadonlyArray<Project>>('app', 'projects.list')` annotation was an assertion nothing compared against the server, so a stale row type could never be detected. Row types include the client's auto-optimistic `optimistic` marker, so `row.optimistic` type-checks without a hand-written row mirror.
920
+
921
+ Not breaking, and deliberately not a rename: the tag-taking hooks remain the primitive `createHooks` is built on, because a plugin's web bindings and any library shipped against an unknown app take the tag as a runtime value and have no app-specific procedure map to type against. They are no longer the documented app-facing form — app code binds once and imports from its own `src/lib/api.ts`.
922
+
923
+ One ordering consequence, because it bites on a tree that has never booted: `rpcGroup.generated.ts` is written by CODEGEN, so `tsc` on a fresh clone (or a freshly scaffolded project, or a CI job that only typechecks) reports `Cannot find module '@acme/api/rpcGroup'`. `voltro dev` generates it on boot; the scaffolded api templates now regenerate it in their own `typecheck` script, and `pnpm -r` runs the api before anything that depends on it. In an existing project, add `voltro codegen .` in front of the api's `typecheck`.
924
+
925
+ Destructure the result rather than exporting the object: `react-hooks/rules-of-hooks` only treats a member call as a hook when the object is PascalCase, so `api.useSubscription(...)` silently disables the React hook lint at every call site.
926
+ - **@voltro/plugin-auth** — **User impersonation ("log in as") — marked, bounded, and unable to escalate.** Zero hits repo-wide before this. It is table stakes for a support team and it is the most dangerous feature in the parity list, because an impersonated session that is indistinguishable from a real one does not merely lack something — it retroactively destroys the audit trail of the whole product. Every row an agent touches is attributed to the user, so afterwards nobody can answer "did the customer delete this, or did we?", including for the incident where it matters.
927
+
928
+ `POST /auth/impersonate/start` and `/stop` mount when you configure `impersonation`. What that config REQUIRES is the design:
929
+
930
+ - **`authority` — a function, never a role.** No default, no `'admin'` fallback, and deliberately not a scope check: the subject these routes resolve comes from the session cookie, which since 0.34.0 carries identity only, so a scope-based rule would be unsatisfiable by every caller — a feature that refuses everyone. It receives both `UserRecord`s and answers from wherever the app's authority actually lives. - **`audit` — a required sink.** Impersonation's whole risk is an unrecorded action, so a config that let you switch it on while leaving the destination unset would make the dangerous half optional. It fires for `started`, `stopped` AND `refused` — a turned-away attempt is the row an investigator wants most.
931
+
932
+ **The session is marked in two places with different failure modes.** `subject.metadata.impersonation` travels with the cookie, reaches the client (so a banner needs no extra endpoint) and reaches an audit interceptor; an `impersonationGrants` ROW is written BEFORE the session exists, so a session can never be reachable without the record naming who is behind it, and no redaction policy can drop it. Read `impersonationAuditRedactor()` before assuming the mark is in your `_voltro_audit_log`: `auditPlugin`'s default `redactSubject: 'metadata'` replaces the whole bag — correct in general, and it takes the mark with it. That redactor is the composition that keeps the mark and nothing else.
933
+
934
+ **Time-bounded means the COOKIE expires**, not that a row says it should: the grant duration (default 15 minutes, clamped, never extended by a caller asking for more) is the cookie's `Max-Age`, the session row's `expiresAt` and the grant's `expiresAt`, minted from one number. Stopping closes the grant, DELETES the impersonated session row and drops it from the revocation cache — so a copy of that cookie taken during the grant dies immediately — then re-issues the impersonator's own, never-revoked session for its REMAINING lifetime. Restoring does not extend their login; an actor whose own session died meanwhile is signed out rather than left as somebody else.
935
+
936
+ **Four escalation refusals**, each with a test that goes red without it:
937
+
938
+ - **SELF** — impersonating yourself launders the trail into noise. - **NESTED** — A→B then as B→C. The mark carries one actor, so a chain attributes C's session to B: an agent reaching any account with a FORGED attribution, which is worse than no feature at all. - **PEER** — impersonating someone who can themselves impersonate. The probe is `authority` evaluated with the identities SWAPPED ("could the target impersonate me?"), so there is no second policy to keep in step, and a throwing probe refuses. - **CREDENTIALS** — while impersonating, MFA enrolment/removal, recovery-code regeneration, passkey registration, switch-tenant, revoke-other-sessions and starting another impersonation are refused at the door. Without this a 15-minute grant converts to permanent access in one request: enrol a passkey as the user and the time bound is decoration.
939
+
940
+ The impersonator also gains no authority the target lacks, by construction — the cookie IS the target's identity, carries no scopes, and authority is re-resolved per request from that identity. And being impersonated does not clear the target's brute-force lockout: a support action must not undo the protection on the account someone is hammering.
941
+
942
+ `impersonationGrants` is registered with the retention sweep at 365 days (`VOLTRO_IMPERSONATION_GRANTS_TTL_HOURS`) — matching the audit log, because it answers the same class of question — armed only when the feature is configured.
943
+ - **@voltro/cli** — **`voltro webhooks consumer` — generate the verification package your SUBSCRIBERS install, from your own event schema.**
944
+
945
+ Nobody generates the outbound webhook surface from its own schema; you rent Svix. The framework already knows every event a subscriber can register for, every payload's shape, and the exact scheme it signs with — so the package the receiving team writes by hand, and gets wrong, is derivable.
946
+
947
+ ```
948
+ voltro webhooks consumer --out ../partner-sdk
949
+ voltro webhooks events --json
950
+ ```
951
+
952
+ **The dependency question is the whole design**, because this code does not run in a Voltro app. It runs in the subscriber's service — a different codebase, usually a different company, frequently not a Voltro app at all. So the generated package:
953
+
954
+ - has **no `dependencies` key at all**, asserted by a test. The moment one appears, "npm i and paste this in" stops being true and the receiving team's answer becomes "we'll write our own". - imports exactly `node:crypto`. Web Crypto was the alternative and was rejected: its HMAC is async, which would make `verify()` return a Promise and force every Express/Fastify handler using it to be async too. The cost is stated in the generated README rather than left implicit — Node 18+, not Workers. - ships `index.js` + `index.d.ts`, not TypeScript source: a consumer may be plain JavaScript, and one that is not should not have to add our file to their build. - carries payload types generated from each event's Schema through the same JSON-Schema → IR the Swift/Kotlin SDK generators use. **Types only, and the README says so** — no decoder ships; the types describe what we send, the signature is what proves it.
955
+
956
+ The generated verifier is a SECOND, independent implementation of Standard Webhooks, which is exactly the kind of thing that drifts silently — and the drift would land in a partner's integration rather than in our CI. So it is executed in the test suite against the spec's published interop vector AND against a delivery this repo's own signer produced. The generated `.d.ts` is TYPECHECKED with the compiler API rather than grepped, which is what caught the first version emitting a field typed `OrdersPaidPayloadCurrency` and never declaring it: every string assertion passed and the package would not have compiled in a consumer's project.
957
+
958
+ The README leads with the raw-body trap (the actual integration failure — a JSON body-parser re-serialises and the signature never matches) with the per-framework recipe, names `webhook-id` as the idempotency key, and lists every event with its payload version.
959
+
960
+ An app that declares no outbound events gets a refusal that names the missing `webhook:` block, not an empty package whose event union is `never`.
961
+ - **@voltro/web** — <!-- apiSurface: compatible — `LinkProps.prefetch` widens from `boolean` to `PrefetchMode = boolean | 'hover' | 'visible'`. Every existing call site compiles unchanged: the previously-valid values are a subset of the new ones, and this is an INPUT position, so widening what we accept cannot reject anything we accepted before. The one shape it could disturb is a consumer that READS the prop type back out (`const b: boolean = props.prefetch`) — obscure for a JSX prop, and a one-word fix if anyone hits it. Not worth a codemod; recorded here so the golden diff is not mistaken for a removal. -->
962
+
963
+ The web bundle is a **pinned number** instead of an anecdote, islands mode says what it actually costs, and `prefetch` warms the page chunk as well as the loaders.
964
+
965
+ **A bundle-size gate.** `node packages/web/scripts/bundle-budget.mjs` builds the reference web fixture and compares gzipped first-load + per-chunk sizes against the committed `packages/web/bundle-budget.json`. Wired into CI's Build job, selftest first. It fails in **both** directions: over budget is a regression, and materially under is also red with "run `--update`", because a ceiling nobody ratchets down silently re-permits inflating back to the old number. First load today is **190.4 KB gz** on that fixture.
966
+
967
+ The number that motivated the work — "252 KB, Effect ~43%" — was half right and nothing could re-derive it. It came from a stale, gitignored fixture build carrying **development** React (a 392 KB raw react chunk); a production build of the same fixture ships 185.8 KB raw / 57.6 KB gz of React. The ratio survived the correction: `--attribute` attributes shipped bytes through the build's own sourcemaps and puts the Effect runtime at 78.3% of the `index` chunk — ~41% of the whole first load. Two earlier attempts at that attribution were wrong in ways that still printed a table (vite externalises a scratch entry's imports; rollup's `renderedLength` is pre-minify and sums to 221% of the emitted chunk), so the working method is documented in the script and its selftest pins the inlined VLQ decoder.
968
+
969
+ **That floor is structural, and the fixture proves it**: it declares zero rpc procedures and still ships 85 KB gz of Effect, because `@voltro/web` re-exports `@voltro/client` at value level. One non-structural item is measured and deliberately left: `msgpackr` is 9.8 KB gz of every browser bundle for a serializer the framework never selects — `@effect/rpc` imports it at module top level and it declares no `sideEffects: false`. Fixing that is a dependency patch.
970
+
971
+ **Islands mode no longer reads as something it is not.** `interactive: 'islands'` scopes hydration and ships **exactly the same JavaScript** as `'full'` — measured on the same page, 195,231 B gz vs 195,229 B. The docs said the opposite ("no JS bundle", "strips the page's React runtime", a savings table claiming 10-30 KB); they now carry the measured table and point at `interactive: 'none'`, the one mode that removes bytes. `mount()` also logs the limitation once per document in dev.
972
+
973
+ **`prefetch()` warms the page chunk.** It started the page + layout loaders and never called `route.load()`, so a hovered link had its data in flight and still paid a full dynamic-import round trip at click time. `<Link prefetch>` also takes `'visible'` now (IntersectionObserver, 128px lead) alongside the existing hover/focus behaviour.
974
+ - **@voltro/plugin-webhooks** — **`webhooksPlugin()` — webhook delivery finally declares itself to the boot permission audit (PLUG-1).** `plugin-webhooks` was a DSL + a service + a delivery workflow with no `definePlugin` entry anywhere, so the one subsystem that POSTs to arbitrary subscriber-supplied URLs was the only outbound caller the audit could not see. `plugin-mail` and `plugin-storage` have declared `network:outbound:*` all along.
975
+
976
+ Add it to `app.config.ts` — `plugins: [webhooksPlugin()]` — and webhooks appears in the boot permission report and the plugin manifest with its declaration. Nothing else changes: `*.webhook.tsx` discovery, delivery, signing, retry and the incoming routes are wired by the CLI on file discovery exactly as before, and the entry contributes no `extendSchema` (the webhook tables ride the framework's feature-mix assembly).
977
+
978
+ **What the declaration buys, said plainly: visibility, not restriction.** A webhook target is a URL a subscriber chose at runtime, so there is no host set to enumerate at boot and `network:outbound:*` is the only truthful thing to declare — the same wildcard, for the same reason, as mail. Nothing consults it before a delivery. It puts the ecosystem's most promiscuous outbound caller on the list an operator reads when answering "what may this deployment reach?". A permission that reads like a control but controls nothing would be worse than none, so the package comment, the API doc and the docs page all say so.
979
+ - **@voltro/workflow** — Two workflow failure modes that previously had no detector at all.
980
+
981
+ **A crash-loop breaker.** A step that kills its process (OOM, a native crash) leaves the shard lease to age out; a surviving replica claims it and executes the same payload, forever, across every replica in turn. Nothing counted those reclaims — poison handling existed only at ADMISSION, which is the wrong side of the boundary. `_voltro_workflow_runs` now carries `runnerEnteredAt` (set on every body entry, cleared on every clean exit) and `reclaimCount`. A body entry that finds the previous entry's marker still set counts a crash; at `maxRunReclaims` (default 3, `VOLTRO_WORKFLOW_MAX_RECLAIMS`) the run is parked as `suspended` with a `run-crashlooped` event and the body is not entered. The counter is CONSECUTIVE — any clean re-entry resets it, so it measures a loop and not a lifetime, and an operator resume re-arms it with a fresh budget.
982
+
983
+ **A staleness sweep.** `sweepStalledRuns(deps, options)` reports live runs that have made no progress for longer than `stallAfterMs` (default 30 minutes), emitting a `run-stalled` event and calling an optional `onStalled` handler. It changes no run state. Two exclusions decide whether it is usable: a run inside a durable timer that has not come due is waiting by design and is never reported, and a run already reported since its last progress is counted separately rather than re-reported every tick. Modelled on the `cancelOn` sweep — bounded page, never throws, failures collected.
984
+
985
+ Also added: `resolveRunGuardTuning()` reads `VOLTRO_WORKFLOW_MAX_RECLAIMS` and `VOLTRO_WORKFLOW_REPLAY_SHAPE_LIMIT`, mirroring `resolveFailoverTuning`.
986
+
987
+ ### Changed
988
+
989
+ - **@voltro/ai, @voltro/cli** — **`exposeAsTool: { confirm: true }` was reported, not enforced.** The inventory carried it, `appTools()` mounted the tool anyway, and the file said out loud that enforcement was "the agent loop / UI"'s job — which is the shape this repo keeps paying for: a control that reads as enforcement in every surface that displays it and enforces nothing in any caller that forgets to look. The MCP transport was the only place it meant anything, and there it meant "dropped".
990
+
991
+ There is somewhere for it to mean something now. `appToolDecision` gains a fourth step: a `confirm` tool is admitted only when its descriptor also declares `requiresApproval:`, and is otherwise REFUSED with the fix in the message. A backed tool executes through the real handler, which parks the call in the app's own approval queue — so the second human is a person in the app rather than a prompt in a harness we do not control, and the agent gets the typed `ApprovalRequired` refusal with an id.
992
+
993
+ That also un-drops confirm tools over MCP: `agentToolSurface` no longer refuses them wholesale, because "there is no human in this process" stopped being the whole picture — the human is not in the transport, they are behind the shared serve pipeline's gate, and nothing here has to trust the client. An unbacked confirm tool is still dropped, with its reason in `dropped[]`.
994
+
995
+ `SynthesizedTool` gains `approvalBacked`. It is reported ALONGSIDE `confirm` rather than replacing it because the two answer different questions, and "why is this tool not executable" is answered only by the pair. An EXTERNAL MCP tool's `approvalBacked` is always false and that asymmetry is documented in `mcpTools.ts`: their handler is behind an HTTP boundary we do not own, so parking our side would park a call the far side never receives — the human decision for an external write stays the declaration-time one (`allow` is required, `includeWrites` is opt-in).
996
+
997
+ Behaviour change to expect: a write tool that relied on the default `confirm: true` is no longer mounted. Add `requiresApproval` if the human step is real, or `exposeAsTool: { confirm: false }` if an agent may run it unattended.
998
+ - **@voltro/plugin-ai-flows** — **`_voltro_ai_flow_runs` grew without bound.** Every other run-family table is registered with the framework retention sweep; this one never called `registerRetention`, while its rows store each step's full output plus review payloads — so it grows with traffic AND with the size of what the models generate. The fourth table of this class found in recent audits.
999
+
1000
+ It is registered now at **90 days**, `VOLTRO_AI_FLOW_RUNS_TTL_HOURS` / `aiFlowsPlugin({ runsTtlMs })`, and the policy is announced in the boot line with every other one. Two properties are deliberate:
1001
+
1002
+ - **Only TERMINAL runs are swept** (`succeeded | failed | cancelled`). A run parked on a human review is live state, not history, and a plain time-TTL would delete pending approvals — the same trap `_voltro_notification_inbox` documents for unread items. - **Media artifacts are NOT swept with the row.** A run's steps carry hosted URLs whose blobs belong to the storage plugin; deleting the row orphans them (persistence in this plugin is app-injected by design). Keep the TTL at or above your media-purge window, or purge by run id before the row ages out.
1003
+
1004
+ **Effect on a live table when you upgrade:** the first sweep runs ~30 s after boot and DELETES every terminal run older than the TTL, in 20 000-row batches until the backlog is drained. An app that has been running flows for longer than 90 days loses that history at once. If you need it, set the env var (or `runsTtlMs`) BEFORE deploying — an app's own `registerRetention` also outranks this plugin's. `_voltro_ai_flows` (the DEFINITIONS) is deliberately left unbounded: it is user-authored configuration whose size tracks how many flows a team writes, not traffic.
1005
+
1006
+ Also bounded here: the `/flows` and `/runs` inspect endpoints took a fixed 200/100 rows with no ceiling and no way to ask for fewer — a `?take=` clamped by `aiFlowsPlugin({ inspectPageMax })` (default 200) now decides, which matters because a run row carries every step's full text output.
1007
+ - **@voltro/plugin-ai-flows** — **A human review left over a weekend used to fail the whole flow, and the failure did not even reach the run row.** The park inherited `@voltro/workflow`'s 24 h `DEFAULT_TIMEOUT_MS` — a number no flow author chose or could change, since the IR had no timeout field and the engine passed none.
1008
+
1009
+ It is a tunable now, resolved most-specific-first: `flowStep.human({ timeoutMs })` → the flow's `humanTimeoutMs` (code, or the new `_voltro_ai_flows.humanTimeoutMs` column) → `EngineDeps.humanReviewTimeoutMs` → `aiFlowsPlugin({ humanReviewTimeoutMs })` in `app.config.ts` → `VOLTRO_AI_FLOW_HUMAN_REVIEW_TIMEOUT_HOURS` → 7 days. `0` at any level means wait forever (the park is slot-free, so an unbounded wait costs no worker).
1010
+
1011
+ Second, smaller defect fixed with it: the expiry arrives as a DEFECT (`awaitSignalSuspending` dies on timeout) while the engine only caught typed failures, so an expired review killed the run and left its row reading `waiting` forever — the "human review timed out" branch was unreachable for the one event it names. The engine now catches the cause (re-raising an interrupt-only cause untouched, because that is how `Workflow.suspend` parks) and writes `status: 'failed'` with the bound that expired. The schema column addition rides the declarative differ on `voltro db apply` / a `voltro dev` boot — no codemod.
1012
+ - **@voltro/database, @voltro/sql-postgres, @voltro/sql-mysql, @voltro/sql-mssql, @voltro/cli** — Waiting for a free pooled connection is BOUNDED by default on every dialect that can bound it — 10 s, `DB_ACQUIRE_TIMEOUT_MS` / `ConnectionConfig.acquireTimeoutMs`, `0` to opt back into the driver's unbounded wait.
1013
+
1014
+ The bound already existed on postgres. It applied to one of the two layer paths: the hand-built `pg.Pool` branch, reached only when `DB_SCHEMA` or `DB_STATEMENT_TIMEOUT_MS` is set. The DEFAULT configuration went through `PgClient.layerConfig` with no `connectTimeout` at all, which is node-postgres' `connectionTimeoutMillis` unset, which is `0`, which is **wait forever**. So the deployment most likely to hit pool exhaustion was the one with no protection against it, and the file documented the failure it was not preventing.
1015
+
1016
+ The fix is not "add a timeout to the other branch" — that is how the first copy drifted. Both paths now read one resolver, and the default itself is a single constant in `@voltro/database` (`DEFAULT_ACQUIRE_TIMEOUT_MS`) that every dialect imports, so the number cannot diverge per engine either.
1017
+
1018
+ What each driver can actually enforce differs, and is stated rather than smoothed over:
1019
+
1020
+ - **postgres** — the full guarantee. `connectionTimeoutMillis` bounds the wait in the pool's pending queue as well as the connect itself. - **mysql/mariadb** — mysql2 has **no time-based acquire bound**: its pool pushes the waiting callback onto a queue with no timer anywhere near it. The connect half is bounded by `connectTimeout`. The waiting half is **not bounded, by choice** — a new `ConnectionConfig.acquireQueueLimit` (mysql2's `queueLimit`) exposes the only bound the driver has, and it is opt-in. See below. - **mssql** — `@effect/sql-mssql` pools through an Effect `Pool` whose `get` takes no timeout, so only establishment (and the boot probe) is bounded. Named, not faked. - **sqlite/turso** — one in-process connection, no pool to exhaust.
1021
+
1022
+ Why bounding is right even when the pool would have freed up eventually: a bounded failure names the pool as the cause at the moment it IS the cause. The unbounded version surfaces as an unexplained latency spike somewhere with no connection information in it — which is exactly how it was reported.
1023
+
1024
+ **Why the mysql queue length is the exception, and defaulting it to 100 was wrong.** It was in this change set for a day and wedged a process. A LENGTH bound is not a TIME bound and the asymmetry is the whole of it: a time bound self-throttles, because the acquire fails only after the wait has elapsed, so nothing can retry it faster than the timeout. A length bound is free — past the limit mysql2 answers the acquire **synchronously** (`lib/base/pool.js`, the one error return in `getConnection` that skips `process.nextTick`) — so a caller that retries an acquire failure without a delay retries in the same tick, forever. The event loop is never reached again: no timer fires, and the queue whose depth caused the rejection can never drain, because draining it needs the event loop.
1025
+
1026
+ That caller ships with the framework. `@effect/cluster`'s `Sharding` releases shards one statement per shard (300 by default) wrapped in `Effect.eventually` — retry until success, no schedule, no delay, logged at debug. On a runner using row-based shard locks (`shardLockDisableAdvisory`, the Galera / PXC path, where node-local `GET_LOCK` cannot coordinate) each release is a pooled `DELETE FROM cluster_locks` rather than a `RELEASE_LOCK` on the one reserved lock connection, so every graceful shutdown queued ~290 acquires against a 10-connection pool, crossed the ceiling, and span at 100% CPU with no output and no error. Found as a hang in `sql-mysql`'s mariadb cluster-engine suite that survived `--testTimeout` — a timeout is a timer, and there were no timers left.
1027
+
1028
+ So `acquireQueueLimit` is unset by default and there is no `DEFAULT_ACQUIRE_QUEUE_LIMIT` to import: an operator who knows nothing in their process retries an acquire without backoff can set one, above the fan-out of anything that might. `acquireQueueDrains.integration.test.ts` pins both halves — a 300-deep queue on the default config drains, and an explicit limit still rejects (the knob is opt-in, not deleted).
1029
+
1030
+ `ConnectionConfig` gains `acquireTimeoutMs` + `acquireQueueLimit` (pure additions), and an operator can actually reach them: `DB_ACQUIRE_TIMEOUT_MS` and `DB_ACQUIRE_QUEUE_LIMIT` are read by **both** `connFromEnv`s — the runtime one (`voltro dev` + `voltro serve`) and the migration one (`voltro db …`) — through one shared `acquireBoundsFromEnv`, so the variable means the same thing in every command. Unlike `DB_STATEMENT_TIMEOUT_MS`, which is runtime-only on purpose because a migration runs legitimately long STATEMENTS: an acquire bound fires when no connection is free at all, and a migration has no more reason to wait forever for one than a request does.
1031
+
1032
+ `0` survives the parse — it is the opt-out, not a typo — while a negative or non-numeric value falls back to the framework default. For `acquireTimeoutMs` both fallbacks point at the bounded outcome; neither can silently produce the unbounded one. (That is the opposite treatment of the same literal from the sibling parser next door, where `0` is postgres' spelling of *unlimited*; the two are pinned apart by test.) For `acquireQueueLimit` there is no framework default to fall back to, so `0`, a negative, a typo and an unset variable all mean the same thing — mysql2's unbounded queue.
1033
+
1034
+ They are env vars rather than `app.config.ts` fields, unlike most framework tunables. The reason is the SHAPE of this particular knob, not precedent: the connection config is assembled by `connFromEnv` at fifteen call sites across four commands, several of them (the workflow SqlClient, the analytics mirror, the replica pools) nowhere near a loaded `app.config.ts`. A field readable at some of those sites and not others would produce exactly the per-call-site divergence the rest of this change set is removing — the DB connection is one decision per deployment, and the environment is where the rest of it (`DB_URL`, `DB_MAX_CONNECTIONS`, `DB_SCHEMA`, `PG_SSL`) already lives.
1035
+ - **@voltro/plugin-cdc-out** — `cdc-out`'s enqueue guarantee is stated correctly. It read "exactly-once enqueue per observed change, fleet-wide, across leadership handovers", and the wiring does not support the "exactly-once": `drainHandoff` wins the claim and inserts the outbox row in **two** statements, with no transaction and no orphan rescan around them, so a replica that dies between them leaves a claim every survivor reads as "already enqueued" and the change is gone. The claim's re-entrancy (reading `claimedBy` back) recovers a failed insert only within the same process.
1036
+
1037
+ The guarantee, everywhere it is written — `src/index.ts`, the package's maintainer note, this changelog, and the docs page — is now: **Enqueue is de-duplicated per observed change, fleet-wide, across leadership handovers — with one hole: a replica that dies between winning a change's claim and inserting its outbox row loses that change, because the claim survives and nothing rescans orphan claims.** No behaviour changed; the claim did. Closing the hole needs the claim and the insert to become one statement (fold `changeKey` onto the outbox row under `unique(pipe, changeKey)`, or wrap both in a transaction) — an orphan-claim rescan alone cannot recover the change, since a claim row carries no payload and the only copy sits in a replica's in-memory handoff buffer.
1038
+ - **@voltro/cli** — **Every `voltro` command paid for every other command's module graph before it printed anything.** `commands.ts` statically imported all ~50 `run*` functions, so `voltro version` loaded `dev.ts` (~7 000 lines), the Effect runtime, vite, chokidar and the build toolchain in order to print one line.
1039
+
1040
+ Measured end to end with `node packages/cli/bin/voltro.mjs version`, both `dist` builds present at once and the two invocations INTERLEAVED so they share the same machine load (a 12-core dev machine at load ~24 — a full gate was running alongside, which inflates both columns and not the ratio). Two independent batches, 9 and 11 alternating pairs, agreed to within 3 ms:
1041
+
1042
+ | | median | min | max | |---|---|---|---| | before | 2 388 ms | 2 188 ms | 2 817 ms | | after | 560 ms | 482 ms | 675 ms |
1043
+
1044
+ **4.3× by median, 4.5× by minimum.** The bundled `commands` chunk went from 567 kB to 35 kB.
1045
+
1046
+ Each dispatch entry is now `run: (args) => import('./x').then((m) => m.runX(args))`. Nothing about the command surface changes. The win is not only interactive: the `voltro dev` supervisor respawns a FRESH CLI process on every debounced file save, so the floor was being paid on the inner loop too.
1047
+
1048
+ Two rules now guard it, because either alone is satisfiable by a broken state: `lazyDispatch.test.ts` asserts behaviourally that importing the registry does not load `./dev`, and structurally that this module's top-level imports stay inside a node-builtin allow-list — a behavioural test can only see the one module it names, and there are fifty.
1049
+ - **@voltro/cli** — **A stale `rpcGroup.generated.ts` produced a GREEN test run against last week's contract.** `generateRpcGroup` was called by `voltro dev` and `voltro codegen` and by nothing else. `voltro build` self-heals `routes.generated.ts` (with a comment saying exactly why) and never touched the rpc group; `voltro test` ran no codegen at all. Edit a descriptor, run the tests from a clean checkout, watch them pass — the generated file is valid TypeScript describing the code you had before.
1050
+
1051
+ The generated header now carries a `source-fingerprint` — a hash of the descriptor source tree by path and bytes, computable WITHOUT importing any app module, which is what makes it affordable at the top of `voltro test`. (It sits beside the existing `descriptor-fingerprint`, which keys on rpc TAG for Vite's re-optimize trigger and therefore requires the imports this check exists to avoid.)
1052
+
1053
+ - **`voltro build`** regenerates a stale group, matching the neighbouring `routes.generated.ts` precedent. A build already imports the app's modules and its config, so this adds no new side effect. - **`voltro test`** REFUSES, naming the file and the command: regenerating means running app code as a side effect of asking to run tests, and a test command that silently rewrites a checked-in source file is worse than one that stops.
1054
+
1055
+ **This can turn a green CI red** — that is the point, and it is why this is a minor. A project whose committed rpc group is out of date with its descriptors now fails `voltro test` until `voltro codegen` runs. An unstamped group (written by an older build) also reads as stale: "I cannot tell" and "it is stale" have the same fix and the same cost, and guessing does not.
1056
+
1057
+ `voltro build`'s regeneration goes through the same `regenerateRpcGroup` the `codegen` command uses, so the plugin error-union and route inputs cannot differ between the two — a second, weaker copy of that assembly is precisely the defect that shipped once already.
1058
+ - **@voltro/sql-postgres** — **A namespaced read on postgres cost four round trips and held a pooled connection for all four.** Every operation on a physical-tenant store ran `BEGIN` + `SET LOCAL search_path TO "tenant_<id>"` + the statement + `COMMIT`, while mysql / mssql / sqlite qualify the identifier and pay one. Measured against the docker fixture, a namespaced read cost **2.2×** a shared-schema read — and the same factor applies to CONNECTION-HOLD time, so effective pool capacity under tenant isolation was materially below what the pool size suggested.
1059
+
1060
+ A read no longer takes any of it. `compileSelect` already qualifies EVERY table reference — FROM, JOIN, sub-query, CTE, set-op — to `"tenant_<id>"."table"`; that is the mechanism the other three dialects have always used and it is a security gate in `namespaceCompile.test.ts`. One statement, no transaction.
1061
+
1062
+ **The obvious alternative was rejected, and the reason is the interesting half.** `SET search_path` on connection CHECKOUT would also remove the round trips, and it is the exact connection-state leak the store's own docstring warns against: a connection returned to the pool still carrying tenant A's search_path serves tenant B's next read. Closing that needs a reset-on-return discipline whose failure mode is silent cross-tenant data. Qualifying the identifier has no discipline to get wrong — nothing is set on the connection, so nothing has to be unset. The leak surface is removed rather than managed.
1063
+
1064
+ **Writes and `raw()` deliberately keep the transaction.** The write path builds statements with a bare `sql(table)` and wants a transaction anyway; `raw()` executes the caller's own SQL TEXT, which cannot be qualified on their behalf. `namespacePool.test.ts` pins both halves — the read as a statement COUNT (a regression back to four is otherwise invisible), the write as the ordered `BEGIN` → `SET LOCAL` → op → `COMMIT` stream it always was.
1065
+
1066
+ One consequence: an EAGER read under namespace isolation now uses the walker rather than the JSON aggregate, because that compiler does not qualify relation tables. That has always been true on the other three dialects — postgres was relying on the search_path to cover it — and it surfaces honestly as `voltro_db_eager_fallback_total{reason="not-compilable"}`.
1067
+
1068
+ Reproduce the numbers: `node packages/sql-postgres/scripts/db-path-cost.mjs`.
1069
+ - **@voltro/runtime** — **One change event is now delivered to subscribers CONCURRENTLY, and two more per-subscriber passes are shared instead of repeated (PERF-22, PERF-5).**
1070
+
1071
+ The delivery loop already shared the READ and the DIFF across subscribers of one descriptor. Three things it did not:
1072
+
1073
+ **It ran serially.** `reauthorize` → `refilter` → read → emit, one subscriber after the next, so a `guards:` resource resolver or a row-filter loader that hits the database put its round-trip in front of every later subscriber's latency — and the mutation's awaiter waits on all of it. Measured A/B in one process, 50 subscribers behind a 5 ms guard: **517 ms serially, 72 ms with the new default of 8 lanes — 7.2×.** Bounded rather than unbounded on purpose: one round-trip per subscriber at the same instant is slower than serial on a 10-connection pool and starves the request path that shares it.
1074
+
1075
+ The header's sync-coupling guarantee is unchanged and slightly stronger: lanes start synchronously, so the first 8 subscribers now begin in the same microtask where one did before, no subscriber starts later than it used to, and `handleChange` still settles only when every subscriber has been served.
1076
+
1077
+ **It re-decided "did anything change?" once per subscriber.** The no-op suppression compare JSON-stringifies both sides row by row — 2N serialisations per subscriber per change, and the equal case it exists for is the expensive one: 12.5 µs at 50 rows, 86 µs at 500, **2.0 ms at 5000**. Fifty screens on a 5000-row live list spent ~100 ms per change concluding, fifty times, that nothing had moved. It is now computed once per `(query, base)` — keyed by descriptor AND `prev` object identity, the same key and the same safety argument as the diff share, so a subscriber on a different base still gets its own answer.
1078
+
1079
+ **It re-canonicalised the memo key per subscriber per change.** Without a row filter a subscription reads one descriptor for its whole life, so the key is a constant (1.0 µs each, 52 µs per change across 50 subscribers). Cached on the subscription. A refiltering subscription still recomputes it — its descriptor is deliberately fresh per delivery.
1080
+
1081
+ New tunable, `reactive: { deliveryConcurrency }` on the dispatcher's dependencies, overridable at runtime with `VOLTRO_REACTIVE_DELIVERY_CONCURRENCY` (default 8). Raise it when deliveries are dominated by per-subscriber I/O the framework performs for you; set it to `1` for the previous fully-serial behaviour.
1082
+
1083
+ **What was measured and deliberately NOT built: a re-authorization memo (PERF-4).** `reauthorize`/`refilter` are still O(subscribers) round trips on a guarded table. A single-dispatch memo cannot hit — a dispatch visits each subscription exactly once, and the only key that proves two subscribers share an answer is the per-subscription closure. The keys that WOULD hit are unsound: on `(query, subject)` two live subscriptions from one subject with different inputs collide, and a resource-scoped guard answers differently for each, so the "hit" serves a document whose share was withdrawn. A cross-dispatch TTL buys hits by trading away the revocation-closes-stream guarantee. The round trips stay; their LATENCY is what the concurrency above removes.
1084
+ - **@voltro/client, @voltro/web** — A reconnect degrades to last-known-good data instead of to skeletons.
1085
+
1086
+ A dropped WebSocket rebuilds the whole client stack — new runtime, new socket, new `SubscriptionCache` — and the new cache started empty. Every live `useSubscription` therefore read `data: undefined` → `loading: true`, so the blessed `if (loading) return <Skeleton/>` fired across the entire app and every populated screen blanked until each stream's first snapshot round-tripped. On a flaky connection that is the worst UX in the framework: the data was on the client the whole time.
1087
+
1088
+ `SubscriptionCache.seedStaleFrom(previous)` carries the outgoing cache's `base` rows into the replacement, as unclaimed entries the next `subscribe()` promotes in place — the same seam `initialSnapshot` already uses for SSR. The first snapshot on the new stream replaces the seed. Errors and optimistic patches are NOT carried (the patches belong to mutations that died with the old transport, so nothing could ever retract them), and an unclaimed seed evicts on the normal inactive TTL rather than pinning rows forever.
1089
+
1090
+ **It is gated, and the gate is a security boundary.** The api supervisor seeds only when the rebuild was driven by the TRANSPORT (socket close / error / connect-timeout). A rebuild requested through `reconnect()` exists precisely because the connection's SUBJECT changed — a cookie login, a logout, a tenant switch — and the next subject may be entitled to strictly less. Seeding across that swap would paint the previous subject's rows onto the new subject's screens, so there the screen still blanks, and that is correct. It is the same reasoning that makes `refreshAll()` clear `base` on the soft (same-socket) re-auth path. The flag is one-shot and survives intervening retries: a reconnect that failed twice before landing is still a reconnect.
1091
+
1092
+ Both directions are pinned — `apiSupervisor.test.ts` for the gate, `useSubscriptionRebind.test.tsx` for what a mounted component actually renders across each kind of swap.
1093
+ - **@voltro/cli** — **The scaffolder described a network call it had not made.** `voltro create-project` / `voltro add-app` printed `→ registering with the cloud control plane (self-hosted tracking)` and then, two lines later, a `⚠` reporting that registration had been skipped because nobody is logged in.
1094
+
1095
+ The policy is unchanged and deliberate — registering a project is how SELF-HOSTED use is counted, and `--no-register` opts out. What changes is that the CLI decides before it speaks:
1096
+
1097
+ - **Logged out** (every evaluator, on their first scaffold): it says plainly that nothing was sent from this machine, states the Terms-of-Service expectation, and shows both ways forward (`voltro cloud login` + `voltro cloud scan`, or `--no-register`). No control-plane call is attempted — and none ever was; the old wording just implied one. - **Logged in**: before the call it names the destination host, what is transmitted (the project slug, and per app its name, kind, framework version and the NAMES of declared primitives plus a page count) and what is not (source code, row data, environment values, secrets).
1098
+
1099
+ The inventory carrying primitive NAMES rather than counts is the part a reader would not assume, so it is said out loud at the moment it goes.
1100
+ - **@voltro/plugin-auth** — **Password hashing: scrypt `N` raised from `2^14` to `2^15`, and stored parameters now have a derivation ceiling (SEC-13).** Measured on node v26.3.0 / Apple M2 Pro, r=8 p=1 keyLen=32, median of 5: `2^14` 39 ms / 16 MiB → `2^15` 73 ms / 32 MiB (`2^16` 156 ms / 64 MiB, `2^17` 271 ms / 128 MiB).
1101
+
1102
+ Migration-free by construction: the hash string encodes its own parameters (`scrypt$N$r$p$salt$derived`), so hashes minted at `2^14` still verify, and `verifyPasswordWithRehash` re-mints them at the current cost on the next successful sign-in. No backfill, no forced reset.
1103
+
1104
+ **Not OWASP's `2^17`, deliberately.** Every row of that table is a cost the SERVER pays per attempt, on an endpoint an anonymous caller controls. The default brute-force lockout is keyed by EMAIL (so an unknown address locks like a real one and the lock is not an existence oracle), which means an attacker who rotates the email field is not rate-limited at all, and general per-IP limiting is still opt-in. Against the documented 0.25-vCPU deployment target, `2^17` would be over a second of CPU and 128 MiB per anonymous attempt. Availability is part of security; this number should go up once default rate limiting ships.
1105
+
1106
+ Also: `scrypt` is now called with an explicit `maxmem` derived from the parameters in play (`2^15` sits exactly at Node's 32 MiB default, and the next bump would otherwise fail every hash with `ERR_CRYPTO_INVALID_SCRYPT_PARAMS`), and `parseScryptParams` — the single parser both the verify and the rehash path now share — rejects non-integer, negative and absurd parameters. The verify path takes `N`/`r` from a stored string, and parameters that arrive as data need a resource ceiling.
1107
+ - **@voltro/cli, @voltro/runtime** — **The undo wire surface no longer depends on `NODE_ENV`, because half of it is a build artefact (PROD-10).**
1108
+
1109
+ `codegen.ts` baked `undoCaptureEnabled()` — "on unless production" — into `rpcGroup.generated.ts`, and `voltro build` never regenerates that file. So the shipped client bundle froze the developer machine's answer (on) and went to a process that answered off and bound none of the three `__voltro.undo.*` procedures. The bundle declared three procedures the server did not have, and the only way to find out was to press undo in production.
1110
+
1111
+ The gate is split:
1112
+
1113
+ - **`undoSurfaceEnabled()`** (new, `@voltro/runtime`) — the descriptors in the generated group and the routes on the server. Reads ONLY the explicit `VOLTRO_UNDO` declaration, so codegen and both boot paths compute the same answer wherever and whenever each ran. `VOLTRO_UNDO=off` still removes the whole surface. - **`undoCaptureEnabled()`** — unchanged. Whether mutations are RECORDED, and whether `_voltro_undo_log` is created at all, stays the environment-aware cost decision it was meant to be.
1114
+
1115
+ With capture off the procedures answer honestly rather than failing on an unknown tag: nothing was captured, so `__voltro.undo.log` returns an empty list (without touching a table that was never created) and apply/redo answer `UndoNotFound` — an error the descriptors already declare and clients already handle. Both boot paths log once at startup saying the surface is served while capture is off, so a permanently empty undo list is not a mystery.
1116
+
1117
+ ### Fixed
1118
+
1119
+ - **@voltro/cli** — **`voltro db apply --plan plan.json` read the plan FILE as the app root** — `no schema files found, root: …/plan.json` — while `--plan=plan.json` worked.
1120
+
1121
+ `flagValue` was never the broken half; it has accepted both spellings since it replaced the per-command readers. The positional reader was: `resolveRoot` took "the first argument that does not start with `-`", and a flag's VALUE does not start with `-`.
1122
+
1123
+ **It was wrong in eleven commands, not one.** `positionals()` / `firstPositional()` now take the valued-flag list as a REQUIRED argument and consume the space-form value; every command that reads both a positional and a valued flag declares its list. What that fixed beyond the reported case:
1124
+
1125
+ - `voltro db rollback-file --root /tmp <id>` rolled back `/tmp`; - `voltro db encrypt-column --key-env MY.KEY` parsed the dotted value as a `<table>.<column>` target; - `voltro check --diff removeTable:notes` read `removeTable:notes` as the app root; - `voltro dormancy --port 9000`, `voltro schedule-manifest --provider vercel`, `voltro typecheck --project tsconfig.build.json`, `voltro baseline status --at …`, `voltro data export --tables notes …`, `voltro cloud import --dir …`, `voltro package create --scope @acme …` and `voltro test -t slow` — same shape.
1126
+
1127
+ Two local flag parsers were deleted on the way (`dormancyCommand`'s `argValue` accepted ONLY the space form, so `--port=9000` was silently ignored there; `packageCommand`'s and `scheduleManifestCmd`'s duplicated `cliArgs` outright).
1128
+
1129
+ **The unit test asserted the defect.** `positionals(['--port','4000','app','x'])` was expected to return `['4000','app','x']`, the flag's value included, and it passed for as long as it existed. A test can pin a bug as firmly as it pins a feature. `cliPositionalFlagSafety.test.ts` replaces it with a DERIVED rule: for every file that reads a valued flag, the positional read must go through `cliArgs` and declare every flag the file reads. A new `flagValue(args, '--x')` joins the rule by itself, so it cannot be satisfied by a curated list that stops being complete.
1130
+ - **@voltro/cli** — **`voltro serve` refused to boot any app with an `*.agent.tsx`, and the same hazard was one line-move away in `voltro dev`.** SEC-1's access gate is documented as judging "the app's discovered procedures and nothing else", because the alternative is unsatisfiable: the first-party plugins declare 47 procedures with no `guards:`, an agent's `<name>.send` / `<name>.messages` are synthesised by the framework, and the undo built-ins have no file at all. An app author cannot add an access decision to any of them.
1131
+
1132
+ That property held by ACCIDENT on both paths, and on one of them it had already broken:
1133
+
1134
+ - **serve (live defect).** `serveCommand` merges the synthesised agent routes into `discovered` before handing it to `serveApi`, whose gate then judged them. Every app with an agent refused production boot, naming two procedures per agent — while `voltro dev` started the same app, because dev's gate happened to run before synthesis. Textbook dev/serve divergence, in the silent-in-prod direction. - **dev (latent).** `dev.ts` holds the four procedure lists as mutable arrays and pushes the plugin routes, the agent routes and the undo built-ins into those same arrays further down. The gate saw the app alone purely because the CALL sat above the pushes. Moving it one block down would have been an invisible edit that refused the boot of every app installing any first-party plugin.
1135
+
1136
+ Both paths now snapshot the app surface with `appProcedureSurface` immediately after `loadDiscovered` and hand the gate THAT, so the property is a data-flow one rather than a line-order one. `ServeApiOptions.appProcedures` carries it across the boundary. Nothing about which procedures need a decision changed: an app procedure with neither `guards:` nor `openAccess:` still refuses to boot.
1137
+ - **@voltro/runtime** — **Under `changeScope: 'fleet'` the analytics mirror runs on every replica, and each stamps the version from its own clock. That is now SAID at boot instead of being silent — and the two obvious fixes are recorded as wrong.**
1138
+
1139
+ The pg CDC echo is the SOLE delivery, so every replica sees every change, including its own writes (they come back stamped `injected` exactly like a peer's). Duplicate writes are idempotent — the sinks are versioned (`ReplacingMergeTree(version)`, `excluded.version > version`) — but the versions are NOT identical, because `nextVersion()` reads the local clock. With skew larger than the gap between two changes to one row, an older image can outrank the newer one and win permanently.
1140
+
1141
+ Nothing said so. It does now: one `warn` at attach naming the cost, the consequence and the tracking id.
1142
+
1143
+ **Both proposed fixes are wrong, and the reasoning is the deliverable:**
1144
+
1145
+ - **`origin !== 'injected'` counts ZERO under fleet scope.** The writer's own event returns as `injected` too, so the guard does not deduplicate the mirror — it turns it off. - **Leader-only is what `plugin-cdc-out` already retracted on this same channel.** Its `changeIdentity.ts` header says it plainly: the enqueue "used to be single-writer (elect a leader, everyone else drops), and the leadership gap silently lost changes". The mirror would be worse — its repair loop only re-drives keys THIS process observed, and a new leader never observed the gap. - **Read-time correction (REL-22's answer for search stats) is structurally unavailable.** That worked because the double-counted rows live in a table the framework READS. This mirror writes into a third-party warehouse queried by the user's BI tool; there is no read of ours to correct at, and a latest-wins upsert has no associative reduction that recovers the right row from N duplicates with skewed versions.
1146
+
1147
+ The right fix is `changeIdentity.ts`'s insight one module over — derive the version from the CHANGE rather than from the receiving process, so N duplicate writes are byte-identical and no leader is needed. It waits on two real things, both written into the module header: a replica that JOINS mid-stream has nothing to seed a fleet-stable counter from, and the natural carrier is a wider NOTIFY payload while REL-17 (an oversized NOTIFY is an unretryable loss) is open on that exact payload.
1148
+ - **@voltro/cli** — A graceful shutdown now DRAINS the analytics CDC-mirror on both boot paths, instead of abandoning every queued and in-flight warehouse write.
1149
+
1150
+ `AnalyticsMirrorHandle.flush()` and `.stats()` shipped documented and with ZERO production callers. `voltro dev` and `voltro serve` both tore the mirror down with a bare `detach()` — which stops new changes arriving and drops everything already queued — while the very next line of serve's shutdown carefully awaited `analytics.dispose()` to drain the batching SINK those writes were headed for. So on every SIGTERM the mirror lost whatever was in flight, silently, and nothing re-drives it: the repair queue is in memory and dies with the process.
1151
+
1152
+ Both paths now call one shared `drainAnalyticsMirror` (`analyticsBuild.ts`): detach → bounded `flush()` → report `stats()`. Three things are load-bearing and are asserted rather than described:
1153
+
1154
+ - it runs strictly BEFORE `analytics.dispose()` — the mirror's writes go INTO that sink, so a sink disposed first drops them anyway; - the flush is bounded (3 s of the 10 s `VOLTRO_SHUTDOWN_GRACE_MS` teardown budget), for the same reason `drainForShutdown` is bounded: once a signal listener is installed, nothing but the drain reaching `exit()` ends the process, and a warehouse that has stopped answering must not be able to hold the container open until SIGKILL; - a drain that COMPLETED and one that was CUT log differently. The cut case names what was lost (`pending`, `awaitingRepair`, `dropped`) at `warn`, which is the only place those counters can still be read — the `voltro_analytics_mirror_*` metrics keep counting but nothing scrapes a process that has exited.
1155
+
1156
+ `analyticsMirrorShutdownParity.test.ts` covers both halves: the behaviour (a queued write settles; a hung warehouse comes back bounded) and the call sites (both boot paths call it, neither keeps a bare `detach()`, and the order against `analytics.dispose()` holds).
1157
+ - **@voltro/runtime** — The analytics CDC-mirror's repair pass stamps its version BEFORE it reads the row, not after — closing an ordering hole the repair path had opened in the versioning scheme it was supposed to be protected by.
1158
+
1159
+ `repairOne` re-reads a key's current row from the store and re-applies it under a fresh version. It took that version AFTER the read, and the read is a round-trip to the OLTP store, so a genuine change committing while it was in flight got a LOWER version than the repair's now-stale image. The sink's `newer wins` guard (ClickHouse `ReplacingMergeTree(version)`, DuckDB `ON CONFLICT … WHERE excluded.version > version`) then kept the stale row — **permanently**, because a key that has landed is not re-driven and no later write exists to correct it. That is exactly the failure the per-change version stamp exists to prevent, re-introduced one layer down.
1160
+
1161
+ Stamping before the read is strictly safe in the other direction: the worst case is a version taken slightly before a read that turns out to be fresh, and a later genuine change only has to exceed it — which it will, because `nextVersion()` is monotonic.
1162
+
1163
+ `analyticsMirror.test.ts` pins the sequence that produces it (a commit interleaved between the repair's read and its write); the test is red against the previous order.
1164
+ - **@voltro/data-transfer** — `voltro data backup` connects over TCP when it was given a port, and a failed dump no longer leaves a file that looks like a backup.
1165
+
1166
+ Two failures of one command, reported together because they compound: the first produces the artifact, the second is what makes the artifact dangerous.
1167
+
1168
+ **`localhost` silently meant "unix socket", and the port was dropped.**
1169
+
1170
+ ```
1171
+ DB_URL=mysql://app:app@localhost:3307/… voltro data backup ./out .
1172
+ ✗ mysqldump: Got error: 2002: Can't connect to local MySQL server
1173
+ through socket '/tmp/mysql.sock' (2)
1174
+ ```
1175
+
1176
+ There is no socket on that machine; the server is a container published on 3307. This is documented client behaviour — `-h localhost` selects a socket and ignores `-P` — and it is still wrong here, because we were handed an explicit port, so the intent is not in doubt. `--protocol=TCP` on the mysql-family dump AND restore removes the class. Postgres is untouched: `-h localhost` is TCP for libpq, and adding a flag it does not take would break the dialect that was working.
1177
+
1178
+ **A correction to the client-mismatch half, from the reporter.** Oracle's `mysqldump` fails against **MariaDB 11** and works against **10.11** — 10.11's version string starts with `5.5.5-`, and `mysqldump` then does not send the `information_schema.COLUMN_STATISTICS` query at all. So "the Oracle client is broken against MariaDB" was too broad: it is broken against the versions that stopped carrying the legacy prefix. Worth knowing before anyone concludes their own working setup disproves the report.
1179
+
1180
+ **The failed dump stayed on disk, unmarked.** Both failures left a `db.sql`: 0 bytes for the socket error, and 20 000 bytes ending mid-`INSERT` on the second table alphabetically for a client mismatch. The process exits non-zero, so a careful operator is fine — but the artifact is indistinguishable from a good one by inspection, and *it is the artifact, not the exit code, that gets carried to the restore three days later*.
1181
+
1182
+ It is removed on failure rather than marked: a truncated dump has no use, and a missing file is the one state that cannot be mistaken for a backup. The error says the partial output was removed, so the absence is not itself a mystery. The delete is best-effort — one that fails must not replace the real error, which is the one the operator needs.
1183
+ - **@voltro/plugin-cdc-out** — `cdc-out` no longer drops fleet change events during a leadership gap. On `changeScope: 'fleet'` stores (postgres LISTEN/NOTIFY cdc, mysql binlog) the enqueue used to be gated on the leader lease and fail closed — so between a leader's death and the next replica winning its lease (a full `leaseTtlMs`, 15 s by default) **no** replica wrote an outbox row and those changes were gone for good, silently.
1184
+
1185
+ Fail-closed was chosen to avoid DUPLICATE outbox rows, so the fix had to remove the loss without buying duplication back. Enqueue is now **idempotent per change identity**: every replica buffers the fleet stream in memory (`handoffBufferMs` / `handoffBufferSize`) keyed by a `changeKey` that all replicas compute alike — a digest of `(pipe, op, row id, new image, old image)` plus an occurrence counter that keeps two byte-identical changes to one row apart — and an enqueue first claims that key in the new `_voltro_cdcout_claims` table under `unique(pipe, changeKey)`. The lease holder drains as it goes; a replica that WINS the lease first drains the window its predecessor never got to, and anything the dead leader already wrote collapses on the claim instead of duplicating.
1186
+
1187
+ The guarantee, stated exactly: **Enqueue is de-duplicated per observed change, fleet-wide, across leadership handovers — with one hole: a replica that dies between winning a change's claim and inserting its outbox row loses that change, because the claim survives and nothing rescans orphan claims.** It is deliberately not called "exactly-once": the claim and the outbox insert are two statements with no transaction around them. Also bounded by: a change no surviving replica observed is still gone, a handoff longer than `handoffBufferMs` drops what aged out (counted and reported on `GET /_voltro/inspect/plugins/cdc-out/sinks` as `handoff.dropped`, never silent), and the pre-existing post-commit window stands. New tunables: `dedupWindowMs` (default `max(60_000, 4 × leaseTtlMs)`, rejected at boot if it does not exceed `leaseTtlMs`), `handoffBufferMs` (default `dedupWindowMs`) and `handoffBufferSize` (default 10 000). The new `_voltro_cdcout_claims` table is applied by the declarative differ on `voltro db apply` and on a `voltro dev` boot, on every dialect — no codemod.
1188
+ - **@voltro/plugin-cdc-out** — `cdc-out` no longer drops a change on a replica that joins a running fleet. On `changeScope: 'fleet'` stores each replica keys every observed change with a `changeKey` of `<content digest>:<occurrence>`, and a replica that boots into an established fleet has to adopt the fleet's occurrence counters from `_voltro_cdcout_claims` — otherwise its first sighting of an already-claimed digest keys `…:0`, collides with the incumbent's claim, and is dropped as a duplicate it is not. That seed existed but did not run in time: it rode the first heartbeat fiber (`Effect.runFork`) while the change tap minted keys synchronously, and in `voltro dev`, where plugins activate BEFORE the store is bound, the gap could be a full `leaseTtlMs / 3`.
1189
+
1190
+ The seed is now ordered on both ends. It is **issued from `bindDataStore`**, which precedes the `store.onChange` tap in both boot paths, so the claims snapshot predates every change this process observes — that matters because the claims table cannot say which sighting a claim belongs to, so counting a claim for a change you also observed would duplicate it on takeover; a snapshot only reachable later is refused (with a warning) rather than guessed at. And the **tap awaits it**, so no key is minted before it lands — observations meanwhile queue un-keyed and are keyed in observation order the moment it resolves. A failed seed is retried instead of latched (it used to record "seeded" before awaiting, so one failed query left a replica mis-keyed and silent for the life of the process). `GET /_voltro/inspect/plugins/cdc-out/sinks` now reports `handoff.seeded` and `handoff.awaitingSeed`, and `handoff.dropped` totals both bounded queues.
1191
+
1192
+ The two orderings are pinned by two SEPARATE tests, because one test does not cover both and looked like it did. "A replica joining an established fleet keys from where the fleet is" goes red only against the old ISSUANCE point — its `await` lets the seed resolve before the change is fired, so deleting the un-keyed queue outright leaves it green. "A change observed WHILE the seed query is in flight" holds the claims read open across the observation, and it is the one that goes red against the queue. Both were red-verified by reverting exactly the half they name.
1193
+ - **@voltro/cli** — **Every CLI invocation opened with two lines of Node internals.**
1194
+
1195
+ (node:12345) ExperimentalWarning: localStorage is not available because --localstorage-file was not provided. (Use `node --trace-warnings ...` to show where the warning was created)
1196
+
1197
+ That was the literal first thing a new user saw from the tool, on `voltro version` as much as on `voltro dev`.
1198
+
1199
+ Traced: `@voltro/runtime` → `crdtMerge.ts` → `@voltro/local-first` → `yjs` → `lib0/storage.js`, which reads `globalThis.localStorage` at module load. It is a third-party module touching a global at import time, and the framework needs `mergeCrdtStates` synchronously, so deferring the import would mean making `crdtText()` async — a public API change to silence a log line.
1200
+
1201
+ Two things fix it. Lazy command dispatch keeps `@voltro/runtime` out of the graph entirely for commands that do not need it, so `voltro version` is quiet on its own. For the commands that legitimately load the runtime, the launcher spawns its child with `--disable-warning=ExperimentalWarning`, which is narrow: `DeprecationWarning` — the class that matters when a dependency is about to break — still prints, and the flag rides in `execArgv`, so the dev supervisor's respawned grandchild inherits it without a second place remembering to.
1202
+ - **@voltro/cli** — **The `@effect/cluster` mssql patch is declared in five places, and the audit named the wrong one as the risk.** `@effect/cluster` is pinned at 0.60.0 because `cli/src/mssqlClusterPatch.ts` carries four mssql-only upstream fixes that upstream has fixed none of, and because 0.60.2 breaks 3 of the patch's 6 hunks. The stated worry was that the pin goes stale.
1203
+
1204
+ That is not the hazard. A pnpm `patchedDependencies` key is VERSION-EXACT, so a key that no longer matches the resolved version fails `pnpm install` **loudly**, in our own workspace, before anything ships. pnpm already guards that half.
1205
+
1206
+ The unguarded half is the copy that leaves the building. `patchedDependencies` is workspace-local and cannot travel in an npm tarball, so the published CLI ships the `.patch` under `templates/` and `voltro add mssql` writes it — plus a `patchedDependencies` entry keyed by the `CLUSTER_PATCH_KEY` constant — into the user's workspace. The version therefore has five homes: the two constants, the shipped asset, `voltro/pnpm-workspace.yaml`, and the meta-root `pnpm-workspace.yaml`. Bump the last two, leave the first three, and **every install we run is green, every test passes, the release ships, and the first person to find out is a user running `voltro add mssql`** — whose workspace now declares a patch for a version they do not have. The drift is silent on exactly the side that runs CI.
1207
+
1208
+ Its minimal form is why a reviewer misses it: pnpm keys a patch by version but the file NAME is arbitrary, so
1209
+
1210
+ '@effect/cluster@0.61.0': packages/cli/templates/patches/@effect__cluster@0.60.0.patch
1211
+
1212
+ is a one-line edit that installs perfectly here. Measured, not assumed: under exactly that drift, all 13 tests across `mssqlClusterPatch.test.ts`, `clusterPatchDialectGuard.test.ts` and `addMssql.test.ts` stay green.
1213
+
1214
+ `scripts/check-cluster-patch-sync.mjs` (CI + `pnpm gate`) now compares all five declarations against the CONSTANT — the one the consumer actually receives, so it is the one that defines correct — plus two adjacent packaging facts: that the workspace paths point at the CLI-shipped asset rather than a second copy, that no superseded `.patch` is left beside the current one, that the catalog range still admits the patched version, and that `templates` is in the CLI's published `files` (an asset that misses the tarball produces the same consumer-visible failure by a different route).
1215
+
1216
+ It ships a `--selftest` that runs first, for the reason every check here has one, and it earned it immediately: the selftest caught a destructuring bug in the range comparator on its first run, which would have made the range rule answer confidently and wrongly.
1217
+
1218
+ What it deliberately does NOT check: whether the patch still APPLIES to the named version. That needs an install, and `pnpm install` answers it definitively. The one content invariant that has actually regressed — the `deliver_at` cast must stay mssql-conditional or the sqlite workflow engine hangs — is already owned by `clusterPatchDialectGuard.test.ts`.
1219
+
1220
+ The meta-root `pnpm-workspace.yaml` is one of the five and lives one repo up, so a voltro-only checkout cannot see it. That is a LOUD skip (`::warning::`), and the printed declaration count drops from 9 to 6 so the reduced reach is visible rather than implied.
1221
+ - **@voltro/cli** — **`voltro test --coverage` could not work in a scaffolded project, and the failure named nothing.** The flag has been forwarded to vitest and documented for several releases, but vitest declares its coverage providers as *optional* peer dependencies and resolves the chosen one with a bare `await import('@vitest/coverage-v8')` from inside its own package. The only place `@vitest/coverage-v8` was declared in the whole tree was the framework's own root `devDependencies`, so it resolved for us and for nobody who ran `voltro create-project`. What a user got was:
1222
+
1223
+ MISSING DEPENDENCY Cannot find dependency '@vitest/coverage-istanbul'
1224
+
1225
+ — no mention of `voltro test`, of coverage, or of what to install. This is the `--flag` half of the message-API gap the repo already tracks: the flag was real, the docs were right, and the thing behind it was not installed.
1226
+
1227
+ Two halves, because either alone leaves someone stuck:
1228
+
1229
+ - **Every app template ships the provider.** All 46 templates that carry a `test` script now declare `@vitest/coverage-v8` beside vitest, so a fresh scaffold's `--coverage` works with no install step. The starter's api + web apps too. - **`voltro test` preflights it and names the remedy.** Before booting vitest it resolves the provider *from vitest's own location* (the CLI and the app are two resolution roots under strict pnpm, so probing from the CLI would answer a question nobody asked) and refuses with the exact `pnpm add -D @vitest/coverage-v8`.
1230
+
1231
+ Three details that are deliberate rather than incidental:
1232
+
1233
+ - **The trigger is the raw argv, not the parsed options.** `forwardedVitestOptions` degrades to `{}` when vitest's parser throws, and a preflight reading only the parsed result would go silent in exactly the run that is already going wrong. The parsed options only refine *which* provider (`--coverage.provider=istanbul`) and carry the one explicit off-switch (`--coverage=false`), which wins. - **A probe that cannot run answers "present".** This exists to improve an error message; refusing a run that would have worked is strictly worse than the raw failure it replaces. - **A custom `coverage.customProviderModule` is the user's own module** and is not vetted, and coverage enabled from a project's own `vitest.config.ts` is out of scope — that file is unreadable without booting vitest, at which point the preflight has no earlier moment to run in, and a project that wrote the config has already decided to own the dependency.
1234
+ - **@voltro/cli** — **A `lifecycle: 'cron'` seed was discovered, validated, ledgered, shown in the dashboard — and never ran.** `seedCronSchedules` projected every cron seed into a real `ScheduleDefinition` and had ZERO callers. `bootLifecycle.ts` warned about it once per boot naming the ids, which was the correct shape for a missing seam and the wrong thing to still be doing now that the seam has one; that warning is deleted in the same change.
1235
+
1236
+ Both boot paths now merge the projection through one shared builder (`wireSeedCronSchedules`), so a cron seed rides the framework's COORDINATED cron scheduler — the `_voltro_schedule_claims` INSERT-wins arbiter — and fires once fleet-wide instead of once per replica. A seed rewrites reference data; ten replicas each running it on a local timer is exactly the amplification that arbiter exists to prevent.
1237
+
1238
+ **Three things about this were not obvious, and each was a way to ship it looking wired:**
1239
+
1240
+ - **The merge has to happen ABOVE the start gate.** Both paths skip `startScheduler` entirely on `schedules.length === 0`, so an app whose only schedules are seeds arms nothing if the merge lands after that check — and looks identical to one where it landed before. `serveCommand` ran `runBootLifecycle` ~180 lines BELOW its scheduling block, so the seeds did not exist yet at the point the list is built; it now runs above it, and both the test and a comment at the call site pin the ordering. - **The ClusterCron layers read the same list.** Under `scheduling.coordination: 'cluster'` the in-app timer does not arm at all (`armSelf: false`), so a schedule missing from those layers never fires — the silent half of the same defect, in the one mode nobody runs locally. - **The scheduler's ledger tables have to exist.** They were gated on a `*.cron.tsx` FILENAME, so a cron-seed-only app got a scheduler armed against tables nobody created: the claims read fails, every replica reads that as "lost the claim", and nothing fires. A `*.seed.ts` now implies them too. That is deliberately over-inclusive — an app with seeds and no cron seed gains two empty ledger tables — because `voltro migrate` never imports an app module, so a FILENAME is the only signal all four schema-declaring paths can compute, and the four must agree or `voltro serve` refuses to boot with `prod-mismatch`.
1241
+
1242
+ `onSchemaChange` seeds fire too (the applier gained the call); `onTenantCreate` already did.
1243
+ - **@voltro/cli** — **`voltro db apply --plan` could not apply its own plan on a new database — so the documented route to production was broken for the FIRST deploy of every one of them.** One second after generating the plan:
1244
+
1245
+ ```
1246
+ plan.from=cb2b03b8… live=d8ebc8d5… → refusing: "the live schema has drifted"
1247
+ ```
1248
+
1249
+ `db apply` created `_voltro_migration_plans` / `_voltro_migrations` / `_voltro_seeds` BEFORE it fingerprinted the live schema, so the act of preparing to apply changed the thing the plan had been fingerprinted against. The drift guard was right to refuse, and it is untouched — a real out-of-band change still aborts with exit 2, and that case is now pinned by a test.
1250
+
1251
+ **The bootstrap is gone from both apply paths, because the plan already contains those tables.** `loadDeclaredSet` puts the framework tables in the DECLARED set, so a virgin database plans their `create-table` like any other table's. Exactly one table has to exist before the DDL starts — `_voltro_migration_ops`, the crash-resume ledger — and `applyPlan` has always created that itself, under the migration lock, for precisely this reason.
1252
+
1253
+ The alternatives were worse in ways worth recording. Fingerprinting first and bootstrapping afterwards fixes the symptom and leaves a second DDL emitter racing the planner on the same tables through `CREATE TABLE IF NOT EXISTS` — that is `emitFrameworkBootstrapSql`, the "second, weaker path" whose per-dialect divergence already cost a release, rebuilt one layer up. Excluding `_voltro_*` from the fingerprint weakens the guard and collapses the asymmetric live filter (a framework table nobody declares is never dropped; one we DO declare diffs like any other), which is the collapse that produced that divergence.
1254
+
1255
+ Two user-visible consequences, both wanted:
1256
+
1257
+ - a first-deploy plan is ~20 operations larger, because the framework's tables are now IN the plan you review rather than created beside it; - bare `voltro db apply` and `voltro db plan` finally describe the same work on an empty database. They did not before, and only one of the two was reviewed.
1258
+
1259
+ `dbApplyPlanVirginDb.integration.test.ts` runs the documented two commands as real subprocesses against a real, empty postgres schema, and asserts the property the fix is FOR rather than the exit code: the re-plan is EMPTY. Verifying statements is not verifying the plan. It was red on all four cases before the fix, with the reported message.
1260
+ - **@voltro/cli** — **"The database is not reachable" now reads like a condition, not a framework crash.** With postgres down, `voltro dev` printed a good, specific warning during boot ("falling back to localhost:5432/app — set DB_URL…") and then ended on `fatal unhandled cli error (FiberFailure) SqlError: PgClient: Failed to connect` plus ten frames of `fiberRuntime.ts`. Every frame belongs to Effect. The hint and the fatal were never connected, and the hint had scrolled past.
1261
+
1262
+ `describeConnectFailure` (an extension of `describeSqlFailure`'s existing cause-chain walk, not a parallel mechanism) maps refused / unresolvable / timed-out connections and authentication + missing-database failures into a curated message that LEADS:
1263
+
1264
+ voltro: the database is not reachable at 127.0.0.1:5432 (ECONNREFUSED).
1265
+
1266
+ No database is configured — none of DB_URL / DB_HOST / PG_HOST is set in the environment or in a loaded `.env`, so the framework used its local dev default.
1267
+
1268
+ Either start one: pnpm db:up or point at your own: DB_URL=postgres://user:pass@host:5432/dbname
1269
+
1270
+ On this path `log.fatal` is not called at all — a refused connection is not an unhandled defect, and dressing it as one is what sent people to read Effect internals. `--debug` (or `VOLTRO_DEBUG=1`) restores the full stack. When a variable IS set, the message names it and says the address resolved and nothing answered, which is a different fix from "is postgres running". Every other SQL failure reports exactly as before.
1271
+ - **@voltro/cli** — **`restart complete` timed the `spawn` call, not the restart.** The supervisor started a clock, stopped the child, called `startChild` — which FORKS, returning as soon as the fiber is scheduled — and logged the elapsed time. It reported single-digit milliseconds for a reboot the user experienced as seconds. That is worse than printing nothing: a number was there, and it was reassuring.
1272
+
1273
+ The child now signals the supervisor over an IPC channel at the exact point it flips its boot-health surface to `ready` (after `awaitServerListening`), and the clock stops there. First boot logs `dev server ready`; a save logs `restart complete — api ready`. A boot that never gets there logs the crash instead, which is the honest outcome for it.
1274
+
1275
+ This is instrumentation of the EXISTING two-process path — no in-process reload was added, so no third boot path joins the dev/serve pair. The audit's staged plan for an in-process module-graph reload stays open, with its stated condition: it would be a third boot path and must join `bootPathParity`'s derived set.
1276
+
1277
+ `voltro codegen` also imports discovered descriptors concurrently now instead of one at a time. The results are consumed in file order, so the emitted file is byte-identical — which matters, because codegen skips the write when content is unchanged and a reordered file would make every dev boot rewrite it and trip the watcher. `dev.ts`'s discovery loop is deliberately NOT parallelised: it imports schema/entity/relations modules whose evaluation order feeds the table registry, and the declared table set is what the schema fingerprint hashes.
1278
+ - **@voltro/cli** — **`voltro dev` (api) did not restart when you edited a workspace package it imports.** The watcher watched the api project directory and nothing else, so a change to `packages/shared/src/x.ts` in a user monorepo left the api serving the old code with ZERO signal — no restart, no warning, nothing to distinguish it from your edit being wrong. The web side fixed this class already and documents why; the resolution is now SHARED (`workspaceDeps.ts`) rather than copied, so both dev servers resolve a workspace dep the same way.
1279
+
1280
+ Also fixed, and a real bug of its own: the watcher's ignore list matched by PREFIX (`filename.startsWith('dist')`), so `distribution/orders.query.ts` and `distTools.ts` were silently unwatched — the same "your edits have no effect" failure, aimed at anyone who named a directory that way. It matches whole path segments now.
1281
+
1282
+ The path a watch event is relativised against changed with it: against the api directory a dependency's file reads `../../packages/shared/dist/x.js`, which no ignore rule matches — so the ignore set would have stopped applying to exactly the trees this change adds. Events are relativised against the nearest watched root, and a dependency edit logs as `shared/src/x.ts` rather than a bare filename that reads as a file in the app you are looking at.
1283
+ - **@voltro/database, @voltro/sql-postgres, @voltro/sql-mysql, @voltro/sql-sqlite, @voltro/sql-mssql** — **An eager query silently dropping off the one-roundtrip fast path was a permanent performance cliff with no observable.** When the JSON-aggregate compiler returns `null`, or the compiled statement throws, every dialect store falls back to the portable multi-query walker: correct, and one round trip per relation level, on every call, forever. The only signal was a `log.warn` — fine for whoever is watching a terminal at that moment, useless three weeks later, and the metric family that would have surfaced it did not exist.
1284
+
1285
+ It is counted now: `voltro_db_eager_fallback_total{dialect, reason}`.
1286
+
1287
+ The `reason` label is the part to get right, and conflating the two arms would have made the alertable case unfindable:
1288
+
1289
+ - `not-compilable` — the shape can never take the fast path (an unregistered relation, an ambiguous inferred FK, an eager read under physical tenant isolation). Steady state. Not an alert. - `execute-failed` — the fast path compiled, **ran and threw**, so the query paid for BOTH paths. This is the one to page on; it usually means a database or driver upgrade changed something under the JSON-aggregate query.
1290
+
1291
+ The log side is rate-limited and the counter is not, which is the whole separation: `warn` on the first occurrence per (table, reason), then again at most every 5 minutes while it persists — `VOLTRO_DB_EAGER_FALLBACK_WARN_INTERVAL_MS`, `0` for once-only. A once-ever line scrolls out of the log and the cliff becomes invisible again, which is the state this fixes; a line per query is a flood. The reporter is instance-scoped, so a second store in one process cannot swallow the first store's only warning.
1292
+ - **@voltro/database, @voltro/cli** — An apply with nothing to do records its fingerprint — `voltro serve` could otherwise refuse to boot with no documented way out.
1293
+
1294
+ Upgrade the framework, change no schema, deploy. The migrate job finds nothing to do and goes green. Every pod then refuses to boot, pointing at the command that just did nothing. Reproduced by a consumer in isolation on a restored database:
1295
+
1296
+ ```
1297
+ 1 voltro serve ✗ SCHEMA FINGERPRINT MISMATCH declared=8aaa5c9b live=72c2305a
1298
+ 2 db files → nothing pending · db plan → 0 operations · db apply --plan → "up to date"
1299
+ _voltro_migration_plans: unchanged
1300
+ 3 voltro serve ✗ the identical refusal
1301
+ 4 introduce ANY real delta → apply has work → records
1302
+ 5 voltro serve ✓ boots
1303
+ ```
1304
+
1305
+ **There is no path from 1 to 5 through the documented commands.** The gate can only be satisfied by an apply that has work to do.
1306
+
1307
+ `applyPlan` is the only writer of `_voltro_migration_plans`, and every caller short-circuits before it on `operations.length === 0`. Both rules are individually reasonable; together they deadlock. The value the gate wants is already computed and sitting in the plan file the job was handed — `toFingerprint` is exactly the `declared=` value.
1308
+
1309
+ `db drift --accept` is not the escape hatch and they checked: drift compares the LIVE side against the recorded `liveFingerprint`, and the live side had not moved. What moved is the DECLARED side, which drift never reads.
1310
+
1311
+ `recordUpToDate` writes a zero-operation row when the plan is empty AND the fingerprint is not already the latest recorded one — wired into all three paths that short-circuited (`db apply`, `db apply --plan`, and the boot diff). The idempotence is not a nicety: `voltro dev` re-boots on every file save, and a row per boot would put this table on the list of things that grow without bound.
1312
+
1313
+ Two properties kept deliberately. `liveFingerprint` is the plan's own `from` side — the live schema did not move, so `db drift` compares against exactly what it did before and clearing the boot gate does not quietly re-baseline drift. And `runApply`'s comment arguing against "inventing a history entry for a migration that did not happen" stays true of the LIVE side, which is what `backfillDriftBaseline` handles; it was wrong only about the declared side.
1314
+
1315
+ Verified against live postgres by driving the whole reported sequence through the real boot entry point, including the assertion the consumer could never reach: step 5 boots.
1316
+
1317
+ **Why it matters more than its blast radius.** The failure lands after the deploy is green. The error names the command that just no-opped. And the remaining exit is its own suggestion — `VOLTRO_AUTO_MIGRATE=0`, a safety gate disabled to work around bookkeeping, which then stays off. `voltro dev` masks it entirely, so an app can sit in this state from the moment it upgrades.
1318
+ - **@voltro/cli** — The framework's own background-task spans are no longer persisted for being SLOW — they are slow exactly when persisting them costs the most.
1319
+
1320
+ `voltro dev` persists "interesting" spans: errors, roots, and anything over `VOLTRO_TRACING_SLOW_MS`. A consumer measured three hours of that on a saturated pooler:
1321
+
1322
+ ```
1323
+ sql.execute 236 441 spans
1324
+ SELECT "id" FROM "_voltro_schedule_claims" 52 461 · avg 58.5 s · max 1 191 s
1325
+ INSERT INTO "_voltro_schedule_claims" … 12 434 · avg 52.6 s
1326
+ SELECT * FROM "_voltro_workflow_pauses" … 4 377 · avg 6.2 s
1327
+ their own queries, same window:
1328
+ todos.listWith 56 · avg 0.77 s
1329
+ ```
1330
+
1331
+ `_voltro_traces` reached **476 571 rows / 335 MB** writing ~11 INSERTs/s, onto the same 15-slot pooler the application reads through. The loop closes on itself: pool pressure makes these spans slow → slow spans are "interesting" → persisting them costs pool. Their sentence is the one that named it: *they are loudest exactly when there is least room.* Moving to `VOLTRO_TRACING_PERSIST=errors` took them from 476 571 rows to 58.
1332
+
1333
+ This is `isTraceSelfSpan` one level out — the same argument that already keeps the trace layer from tracing its own writes, applied to the framework's own housekeeping reads.
1334
+
1335
+ Narrow on purpose: the excluded set is the four tables a TIMER reads (`_voltro_schedule_claims`, `_voltro_workflow_pending`, `_voltro_workflow_pauses`, `_voltro_ai_inferences`), not everything named `_voltro_*`. `_voltro_api_keys` is read while serving a request, and a slow lookup there is a real user waiting. An ERROR on a background span is still persisted, and `all` mode still keeps everything — that mode is an explicit request for the firehose.
1336
+ - **@voltro/plugin-governance** — **A GDPR erasure run as `mode: 'anonymize'` from the dashboard or the rpc route wrote nothing, and logged that it had erased N rows.** Two defects on the same path, and the surface is the one enterprise buyers pen-test first.
1337
+
1338
+ **1. Per-call options REPLACED the configured defaults instead of merging.** Both callers that can pick a mode send `{ mode }` and nothing else — the `governance.erase` route (`mode ? { mode } : undefined`) and the panel's `POST /erase`. An app configuring `governance({ erasure: { mode: 'delete', anonymizeFields: ['email','name'] } })` therefore lost `anonymizeFields` the moment an operator chose "anonymize": the patch built from it was `{}`, `store.update` wrote an empty object, every row survived intact — and the erasure-log entry recorded `mode: 'anonymize', affected: [{ table: 'users', count: 1 }]`. A subject-erasure request answered with evidence of an erasure that did not happen. Options are now `{ ...options.erasure, ...opts }`, so a mode override keeps the app's fields while an explicit `mode: 'delete'` still overrides a configured `anonymize`.
1339
+
1340
+ **2. An `anonymize` with no fields is refused rather than performed.** The merge fixes the configured case; an app that configured NO erasure defaults at all and an operator who picks "anonymize" still has nothing to null, and the honest answer there is not a silent no-op. `eraseSubject` (and `runRetention`, which had the identical shape — `affected: N`, nothing written) now throws naming the missing `anonymizeFields`; the inspect endpoint surfaces it as a 500 with that message instead of a 200 and a false log entry.
1341
+
1342
+ **Effect on a live app when you upgrade:** `governancePlugin()` now REFUSES TO BOOT if a retention policy declares `action: 'anonymize'` without `anonymizeFields`, or if `erasure.mode` is `'anonymize'` without them — checked at construction rather than on the first sweep tick, because a throw inside the coordinated sweep callback is not where you want to learn this. Such a policy was already doing nothing; the change is that it now says so. Add the fields, or switch the policy to `delete`.
1343
+ - **@voltro/ai** — **`_voltro_ai_inferences` grew without bound.** Found by sweeping `@voltro/ai` for siblings of the unbounded-table class after adding `_voltro_prompts` — the same class this backlog has now hit five times. `_voltro_ai_usage` and `_voltro_ai_budget` were both registered with the retention sweep; the offloaded inference queue was not, and it is the AI table that stores the **prompt verbatim** (the dispatcher has to, to perform the call), so it was both the fastest-growing and the most sensitive one to leave forever.
1344
+
1345
+ `dispatchInferences` now registers it (idempotent per tick, from the dispatcher rather than the enqueue side — the dispatcher is what both boot paths arm, so the bound exists before the first `offload: true` call rather than after it). 30-day default, `VOLTRO_AI_INFERENCES_TTL_HOURS`, `framework` precedence.
1346
+
1347
+ Swept on **`completedAt`**, and that column is the load-bearing part: a pending or running row has `completedAt = null`, a NULL never satisfies the sweep's `< cutoff`, and every one of those rows has a durable run parked on it waiting to be resumed. So terminal rows age out and a waiting run is never deleted out from under itself — without the sweep needing to know what a workflow is.
1348
+ - **@voltro/database, @voltro/sql-postgres, @voltro/sql-mysql, @voltro/sql-mssql, @voltro/sql-sqlite** — **`insertMany` emitted ONE statement for the whole array, and every engine caps what one statement may carry.** Past the cap the user got the driver's own text about a limit they never chose: postgres refuses at 65 535 bind parameters, mssql at **2 098** — six columns × 350 rows — and, independently, at 1 000 row constructors. A twelve-column bulk insert caps at 5 461 rows on postgres and at 174 on mssql.
1349
+
1350
+ `insertMany` chunks at the boundary now, on all four stores. Three things about how, because each was a way to get it wrong:
1351
+
1352
+ - **The limits and the chunker are ONE shared decision** (`bulkInsertLimits.ts` in `@voltro/database`), not four per-dialect constants. Three of the four stores need a live server to test, so a hand-rolled copy reads exactly like a clean sweep — the same shape as the write-attribution and typed-error-unwrap drifts this repo has already paid for twice. - **Atomicity is preserved.** The single statement was all-or-nothing; N loose statements are not. A multi-chunk insert runs inside one transaction when the caller holds none, so a duplicate key in the last chunk still leaves zero rows. The dialect-parity harness asserts exactly that, on every dialect — and it was RED first: without the wrapper, mssql leaves 524 rows behind a rejected call. - **A fitting array is still one statement**, byte for byte. Chunking a 3-row insert would be a correct implementation of the wrong thing.
1353
+
1354
+ Two dialect details the naive version would have missed, both found by running it: mssql's **1 000-row** `VALUES` cap binds first for narrow tables (2 columns × 1 200 rows is under the parameter cap and still refused), and the parameter cap is **2 098, not the documented 2 100** — tedious sends every parameterised statement through `sp_executesql`, which spends two of them on `@stmt` and `@params`. Measured against SQL Server 2022: a bound `IN` list of 2 098 succeeds, 2 099 is refused. At 2 100 the chunk size came out as exactly 525 × 4 = 2 100 and every chunk was one parameter over the line.
1355
+
1356
+ mysql's read-back is chunked too. It re-selects the post-images with `WHERE id IN (…)`, which binds one parameter per id and has the SAME ceiling as the write — chunking only the INSERT would have moved the failure from the write to the read and left the rows written.
1357
+ - **@voltro/cli** — `GET /_voltro/inspect/migrations` is served, on BOTH boot paths — `voltro db plan --against` had never worked anywhere.
1358
+
1359
+ Three layers, and only one of them was the one the consumer could see:
1360
+
1361
+ 1. **`manifest.migrations` was set by `voltro dev` and nothing dispatched to it.** The provider had been on the interface for as long as the DevTools Migrations page had; there was no route. So the path 404'd on every boot path. 2. **`voltro serve` did not build the provider at all.** 3. Everything that names it was therefore pointing at nothing: `db plan --against <url>` (which fetches exactly this and reads `drift.liveSnapshot`), the docs site, the shipped agent guide, the cloud-UI page, and the devtools dashboard's own `inspectMigrations()`.
1362
+
1363
+ They measured the 404 under `serve` with a valid token and concluded the feature must target dev instances only. Checking that rather than accepting it is what turned up (1) — it had never worked against a developer's laptop either.
1364
+
1365
+ **What that buys them, in their words:** it answers *"would the migrate job be blocked on staging?"* with no data leaving the data centre, which is strictly better than the database dump they were moving instead.
1366
+
1367
+ The 178-line snapshot builder moved out of `dev.ts` into `migrationsInspect.ts` and both paths call it. Stubbing `serve` with an empty snapshot was the obvious shortcut and would have been worse than the 404: an empty history is indistinguishable from an app with no migrations, which is the silent-zero shape this codebase keeps removing. A parity test asserts, against a SET of boot paths, that each builds it, sets it, and dispatches the async seam — the layer that hid longest was the data being wired while the door was not.
1368
+
1369
+ The route sits on a separate async entry point (`handleInspectAsyncRequest`) rather than widening `handleInspectRequest`'s return type: twenty-odd branches there do no I/O, and every caller already runs this exact try-async-then-sync shape for the framework actions. It is fail-closed like the rest — 401 without a token, verified.
1370
+
1371
+ **And a dev-only endpoint now says which kind of 404 it is.** `logs`, `traces`, `workflows`, `storage`, `aggregates` and `flags` answered a bare 404 on a production `serve` while `voltro logs --process <name>` and `voltro traces` are documented as the way to debug a running app. The status is right — the route is genuinely not mounted — but "never heard of it" and "this exists and your deployment does not mount it" are different facts, and only one tells a reader whether to keep looking. The message also rules out the reading an operator reaches for first: it is not a missing token. `cache` and `routes` are deliberately left alone — they already answer `endpoint is web-only`, which is more specific than anything a generic message could say.
1372
+ - **@voltro/plugin-mail** — **A multi-tenant app re-mailed every hard-bounced address.** The docs say "omit `tenantId` for app-global suppression" and the store keyed a null tenant as `'*'` — but `isSuppressed` only ever read the bucket the CALLER named, so `'*'` was not a global, it was a tenant literally called `*`, and it applied to nobody.
1373
+
1374
+ That is the exact shape of the default deployment. A provider bounce/complaint webhook carries no tenant — it cannot, the provider does not know your tenancy — so `handleMailEvents(provider, payload, suppression)` records under `'*'`, while the app sends with `tenantId: 'acme'` and misses it. Every hard bounce and every spam complaint kept being mailed, silently, with a suppression list that looked populated. Deliverability damage first, compliance exposure behind it.
1375
+
1376
+ `isSuppressed(tenant, email)` now returns true when the address is on the tenant's own list **OR** on the app-global one. Both backends changed together — the memory store checks both keys, the postgres store's predicate became `tenant IN (<tenant>, '*')` — because a suppression semantics that differs between the single-node and multi-node backend is the worse version of this bug.
1377
+
1378
+ Two properties held deliberately:
1379
+
1380
+ - **A tenant-scoped suppression still does NOT leak across tenants.** Only the tenant-less bucket is global. An app that calls `suppress('acme', …)` gets exactly what it asked for. - **A tenant-scoped `unsuppress` does not lift a global entry.** One tenant cannot re-enable an address the app (or the provider) suppressed for everyone; lifting a global suppression takes a global `unsuppress(null, …)`.
1381
+
1382
+ **Effect on a live app when you upgrade:** addresses already on the `'*'` list — everything every bounce webhook has recorded since you mounted it — start being suppressed for tenant-scoped sends. That is the intended behaviour and it may visibly reduce send volume on the first deploy. If any of those entries are stale, lift them with `mail.unsuppress(null, address)`.
1383
+ - **@voltro/cli** — **`voltro migrate --create-only` bootstrapped a database with no plugin tables (PROD-8).**
1384
+
1385
+ It called `frameworkTablesFor` directly, which returns the FEATURE-MIX tables only. Every other command — `voltro dev`'s boot auto-migrate, `voltro db plan`, `voltro db apply` — goes through `assembleFrameworkTablesFrom`, which also adds the agent-thread tables (`_voltro_agent_threads`, `_voltro_agent_messages`, `_voltro_ai_usage`, `_voltro_ai_budget`) and every plugin's `extendSchema.tables`. So the documented way to bootstrap a fresh database produced one missing all of them — under a header comment claiming the two paths "can never disagree about which `_voltro_*` tables exist".
1386
+
1387
+ `migrate.ts` now calls `assembleFrameworkTables({ root })`, which also brings the feature-mix walk (its local copy had no `*.connection.ts` case until it was patched once already), the plugin load with its loud `AppConfigLoadError`, and the `_voltro_cdc_offsets` dialect gate (its local copy handled mariadb and not mssql).
1388
+
1389
+ `schemaApplyParity.test.ts` asserts `frameworkTablesFor` has exactly one caller, which is the form of the claim the header was making all along.
1390
+ - **@voltro/database** — A migration interrupted on mysql / mariadb / sqlite / turso RESUMES instead of re-planning blind — and the batched backfill it resumes now runs on the mysql family at all.
1391
+
1392
+ **The old state.** `applyPlan` wraps the plan in one transaction only on postgres and mssql. Everywhere else it cannot: mysql/mariadb implicit-commit every DDL statement (an engine property — no amount of `BEGIN` fixes it), turso rejects DDL inside its default transaction, and sqlite shares turso's dialect token. Even on postgres the `online-required` operations run AFTER the commit, because `CREATE INDEX CONCURRENTLY` cannot be inside one. The `_voltro_migration_plans` row is written only once the WHOLE apply succeeds, so a crash in any of those windows left the schema partially applied with **no record of how far it got**. The next boot re-planned against a half-migrated live schema with no way to tell that from a normal first apply.
1393
+
1394
+ **What lands.** A per-operation resume ledger, `_voltro_migration_ops`, covering every operation that runs outside a transaction:
1395
+
1396
+ - the whole intended sequence is inserted `pending` BEFORE any DDL, so a crash on operation 0 still leaves a durable plan; - each row flips to `started` immediately before its statement and `applied` immediately after; - the rows are deleted once the apply converges and the audit row lands. It is a work queue, not an audit log — that history already lives in `_voltro_migration_plans.operations`, and an append-only per-operation table would join the framework tables that grow without bound.
1397
+
1398
+ **The gap that cannot be closed, and the rule that covers it.** On mysql the ledger write and the DDL cannot be atomic with each other, so there is always a window where the statement landed and the `applied` flip did not. That window is not eliminated — it is BOUNDED: the ledger is written strictly sequentially, so at most ONE operation can be `started`, and it is the only one whose outcome is unknown. Recovery resolves it by asking the planner rather than guessing — the plan handed to `applyPlan` was diffed against the live database moments ago, so an operation it no longer mentions has taken effect. Matching is by TARGET, not by kind, which is load-bearing: a NOT NULL add on a populated table is ADD nullable → backfill → SET NOT NULL, and interrupted after step one the planner proposes `alter-column-nullability`, not `add-column`. A kind-keyed match would read that as "the add landed", skip it, and drop the backfill on the floor.
1399
+
1400
+ **Two operations get repaired rather than re-diffed**, because their intermediate state means something else entirely to a planner. The postgres online `alter-column-type` (shadow swap) renames the real column to `<col>__old` and then `<col>__shadow` over it; interrupted between those two the declared column exists under no name the planner knows, so a fresh diff proposes `add-column` — which succeeds, and destroys the data sitting in `<col>__old`. The sqlite/turso table rebuild drops the original and renames `<table>__voltro_rebuild` into place; interrupted between those two a fresh diff sees a MISSING table and proposes `create-table`, an empty one, with the rows orphaned in the temp table. Both are now reconciled from the artefacts, which say unambiguously which statement was reached, before anything else reads the live schema.
1401
+
1402
+ **The convergence proof is untouched.** The ledger decides WHICH operations run; `applyPlan` still re-plans afterwards and still refuses to record a fingerprint while anything remains. A resumed run is held to exactly the same standard as a fresh one, and an apply that does NOT converge keeps its ledger — an unfinished run's record is the only thing that tells the next boot it is looking at a half-migrated schema.
1403
+
1404
+ **And a defect the ledger's own test surfaced: the batched backfill had never run on mysql or mariadb.** Every batched loop bound its page size (`… LIMIT ?`), which mysql2's prepared-statement path cannot execute — the server answers `Incorrect arguments to mysqld_stmt_execute`. So the three-step `add-column`, the batched SQL backfill and the JS backfill failed on their FIRST select, before a row was written, on exactly the operation whose interruption this work has to survive. Postgres bound it happily, which is why it read as working. The limit is a literal integer clause now.
1405
+
1406
+ `_voltro_migration_ops` needs no codemod and no user action: it is created by the applier itself, under the migration lock, on every dialect and both boot paths.
1407
+
1408
+ Which dialect applies a plan atomically is now ONE table keyed by `DialectId` (`MIGRATION_ATOMICITY`), so `mariadb` and `turso` are stated rather than inherited from whichever sibling shares their driver token — and `dialectOf` is one function instead of two copies. Proven against real mysql 8.4 and mariadb 11 (an interrupted backfill resumes, completed operations are not re-applied, the re-plan is EMPTY) and against real postgres 17 (the same interruption rolls back whole and writes no per-operation rows).
1409
+ - **@voltro/plugin-moderation** — **A denied term ending or starting in punctuation matched NOTHING, with no error.** `keywordProvider` compiled each term as `` `\b${escaped}\b` ``, and `\b` is a transition between a word and a non-word character — so `\bc\+\+\b` can never match `c++`: after the final `+` (non-word) at end-of-string (non-word) there is no boundary to find. Every term whose own edge is punctuation was on the denylist and matched nothing: `c++`, `(evil)`, `@spam`, and — the one that matters for a moderation denylist — the punctuation-masked profanity people actually put in these lists.
1410
+
1411
+ The word boundary is now applied PER EDGE: an edge gets `\b` only when the term's own character there is a word character (`\p{L}`/`\p{N}`/`_`). `ass` still compiles to `\bass\b`, so `assembly` still passes; `c++` compiles to `\bc\+\+` and matches. Terms are compiled once per provider rather than on every call — this runs in the write interceptor.
1412
+
1413
+ Also fixed alongside it: an EMPTY string in the denylist compiled to an empty pattern, which matches every input, so one stray entry flagged all content. Empty terms are dropped at compile time.
1414
+
1415
+ Escaping is unchanged — a term is still a literal, so `a.b` does not match `axb`.
1416
+ - **@voltro/database** — A MySQL/MariaDB index key no longer takes a 191-character prefix on a column that is shorter than that — a migration died half-applied on it.
1417
+
1418
+ ```
1419
+ CREATE INDEX `apiKeys_keyPrefix_idx` ON `apiKeys` (`keyPrefix`(191));
1420
+ ERROR 1089: the used length is longer than the key part
1421
+ ```
1422
+
1423
+ `keyPrefix` is `text().maxLength(32)` → `VARCHAR(32)`. Two of these in one migration (`auditLogs.traceId` has the same shape).
1424
+
1425
+ **The SQL error is not what made it expensive.** The plan reported `0 blocked`, so the migrate job started; roughly thirty operations committed; and MariaDB has no transactional DDL, so the deploy stopped on a schema that is neither the old one nor the new one. It is invisible on any environment that already HAS the column — only the `ADD COLUMN → CREATE INDEX` path resolved the type this way, and the reporter's dev database carried both indexes with `SUB_PART NULL`, created correctly by the CREATE TABLE path.
1426
+
1427
+ **The cause is not that the constant was wrong.** `declaredColumnIsTextLikeMysql` asked what SQL type the column is by synthesising `{ type: col.type }` — throwing away `maxLength`, `hasDefault` and `oneOf`, which are exactly what decide between `VARCHAR(n)` and `LONGTEXT`. Stripped to `{ type: 'text' }`, a bounded column renders as `LONGTEXT`, ends in TEXT, and takes a prefix it cannot have.
1428
+
1429
+ The rule against that was already written twenty lines up, on `declaredColumnSnapshot`: *"any renderer that needs a real SQL type must go through this rather than synthesising `{ type }` from the op."* One caller did not, and that is the whole defect.
1430
+
1431
+ Both halves are fixed:
1432
+
1433
+ - the caller goes through `declaredColumnSnapshot`, so a bounded column is correctly not text-like and takes **no** prefix (a `VARCHAR(32)` is directly indexable — a prefix on it is not merely unnecessary, it is rejected); - `mysqlIndexPrefixFor` is the one decision both emitters now share, and it cannot return a prefix longer than the column — `min(191, declaredLength)`, which is what the reporter proposed. It is a second guard on purpose: losing a column's parameters on the way to a type decision is a mistake a future path can make again.
1434
+
1435
+ The unbounded case is unchanged and asserted: `text()` is `LONGTEXT`, genuinely cannot be indexed whole, and still takes the full 191. A fix that simply dropped prefixes would have produced `ERROR 1071: key too long` instead — the same class of outage with a different error number.
1436
+ - **@voltro/cli** — **`PG_SSL` is honoured by every command now.** It was not, and the shape of the miss is the point: `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.
1437
+
1438
+ Nobody decided that. The CLI had **four** hand-written builders reading the same environment into a connection — `dev.ts`, `dbCommand.ts`, a third in `migrate.ts` that nothing accounted for, and two raw `pg.Client` configs in `start.ts` / `isrCdcInvalidator.ts` — and a growing list of knobs was added to some of them and not the others. `DB_ACQUIRE_TIMEOUT_MS` / `DB_ACQUIRE_QUEUE_LIMIT` were ignored by `voltro migrate` for the same reason.
1439
+
1440
+ There is ONE resolver now (`connectionConfig.ts`), parameterised by PURPOSE, and the purpose changes exactly two things, each stated with its reason in the file:
1441
+
1442
+ - `DB_STATEMENT_TIMEOUT_MS` applies to the runtime only — a migration runs legitimately long statements (backfills, index builds) that must not be cancelled by the app's query ceiling; - `DB_DIRECT_URL` / `DB_MIGRATE_URL` override `DB_URL` for the migration path only — the pooler escape hatch for large catalog reads.
1443
+
1444
+ Everything else — pool size, `DB_SCHEMA`, TLS, the acquire bounds — means the same thing in every command.
1445
+
1446
+ **The guard that should have caught this is replaced, not patched.** It asserted that "BOTH `connFromEnv`s" thread the acquire bounds, with the pair hard-coded — so the third copy was invisible to it, and it printed green over the defect for its whole life. `connectionConfigResolver.test.ts` DISCOVERS builders instead of naming them: any function returning `ConnectionConfig`, plus any file reading the discrete `process.env.PG_*` fields (which is what found the two raw `pg.Client` builders that the type-based detector cannot see). Each must route through the resolver; `PG_SSL` and the acquire bounds must be read in exactly one file; and finding ZERO candidates is a hard failure, because a check that has stopped examining anything prints the same green as a clean one.
1447
+ - **@voltro/database, @voltro/sql-postgres, @voltro/sql-mysql, @voltro/sql-sqlite, @voltro/sql-mssql** — **A typed error thrown inside a MULTI-TENANT (namespaced) transaction on postgres now reaches the client typed.** It arrived as an untagged `Die` defect. This is the same defect the previous release fixed for the shared-schema path — and it was live next door the whole time, because postgres is the only dialect with a separate entry point for physical tenant isolation. `runInNamespace()` settled its program with `runtime.runPromise`, which rejects with Effect's `FiberFailure` wrapper; the wrapper 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 descriptor'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 on postgres this was every typed mutation error, and the failure mode is silence: no crash, just an `error._tag === 'NotFoundError'` branch that is never taken. The function-form `upsert()` had the same shape on its no-ambient-transaction path (worse — it used `Effect.promise`, which turns a rejection into a defect before the settle even runs).
1448
+
1449
+ **If your app pattern-matched the boxed shape** — `error.message === 'ValidationError'`, or a `catch` that treated every namespaced mutation failure as a defect — that workaround now sits in front of a correctly tagged error. Delete it and branch on `_tag`.
1450
+
1451
+ Three more per-dialect drifts closed in the same change, all in transaction handling:
1452
+
1453
+ - **Write attribution was dropped on postgres `transactional()`.** The value was captured and then not passed to the transactional view, so every write in a shared-schema transaction fell back to the ambient async-local scope — which a connection-pool handoff can empty. `traceId`/`subjectId` then landed absent, and absent is a LEGAL value there meaning "no request behind this write", so the gap reads as a schedule rather than a defect. sqlite, mysql and mssql threaded it; postgres did not. - **Namespaced postgres transactions had no retry.** A `serialization_failure` (40001) or `deadlock_detected` (40P01) failed hard under tenant isolation while the same app was retried on the shared schema. - **A conflict raised AT COMMIT was retried on sqlite/turso only.** `@effect/sql` runs COMMIT as `Effect.orDie`, so a 40001/deadlock raised by the COMMIT itself arrives as a defect that `Effect.retry` cannot see. sqlite promoted it back to a failure first; postgres, mysql and mssql did not, so their retry schedule silently never fired for commit-time conflicts.
1454
+
1455
+ **The fix is one shared bracket, not four patches.** `runStoreTransaction` (`@voltro/database`) now owns the whole decision — attribution capture and threading, the connection handshake, the retry schedule, commit-defect promotion, exit settling and the post-commit event drain — and all four stores plus both postgres entry points call it. A dialect injects only what is genuinely its own: its `withTransaction`, its runtime, its retryable-failure predicate, its span labels, an optional per-attempt preamble (postgres' `SET LOCAL search_path`) and an optional program wrapper (turso's `BEGIN CONCURRENT` flag). The parity guard that was supposed to catch this now discovers EVERY method taking a caller `work` callback in all four stores instead of asserting inside one hand-written source window — the old window excluded both of postgres' unfixed copies — and fails loudly if it ever scans zero candidates.
1456
+
1457
+ No API is removed or narrowed; the new bracket is additive to `@voltro/database`.
1458
+ - **@voltro/cli, @voltro/database** — **`onSchemaChange` seeds were dead AND silent.**
1459
+
1460
+ The hook was installed on both boot paths (`runBootLifecycle` → `installSeedLifecycleHooks`), `seed.ts` documented the migration applier as the place the event happens, and `fireSchemaChangeSeeds` had **no caller**. A declared `lifecycle: 'onSchemaChange'` seed was discovered, listed, ledgered and never run, with nothing said. Compare the `cron` lifecycle — equally unwired, and it warns on every boot naming the seed ids and the workaround. That is the shape a missing seam is supposed to have.
1461
+
1462
+ `applyPlan` fires it now, after the audit row and the ledger clear, so it is strictly post-apply — on a `plan-transactional` dialect the DDL transaction has already committed, and a seed runs against its own `DataStore`. It cannot fail the apply: the schema landed, and a data fixture that threw is recorded in `_voltro_seeds` by the runner. `changedTables` is derived from the operations that were actually EXECUTED (a `rename-table` contributes its new name).
1463
+
1464
+ Wiring the applier alone would have been the same defect in a nicer costume: `runBootLifecycle` installs the hook on the two BOOT paths and `voltro serve` never applies a schema, so the seeds would have fired under `voltro dev` and nowhere else. `voltro db apply` installs the hook for the duration of the apply — gated on the app actually declaring an `onSchemaChange` seed, so nothing changes for an app that does not.
1465
+
1466
+ `schemaApplyParity.test.ts` derives the seam set from `seed.ts`'s `fire*Seeds` exports and fails when one has no caller, so a new lifecycle joins the rule on its own.
1467
+ - **@voltro/runtime, @voltro/cli** — The outbox delivery poll stops when the queue is empty — the third framework poller, and the only one that was not even coordinated.
1468
+
1469
+ Found by someone reading a service's logs and noticing it kept saying it was looking for webhooks on a deployment that has none.
1470
+
1471
+ The module's own header already made the argument, one paragraph long, and stopped a step short:
1472
+
1473
+ > Why a poll loop AND a nudge: the nudge makes the common case immediate. The > poll is what makes it CORRECT: it picks up rows whose nudge was lost because > the process died, rows enqueued by another replica, and rows waiting out a > backoff. The nudge is an optimisation; the poll is the contract.
1474
+
1475
+ All true, and none of it justifies a CONSTANT rate. It was a bare `setInterval` at 5 s on every replica — one `SELECT` per replica per five seconds whether or not anything had ever been enqueued.
1476
+
1477
+ The three reasons are now taken one at a time, and only the third needs a clock:
1478
+
1479
+ - **a nudge lost to a dead process** → the first pass at boot finds it, and that pass is not deferred behind a tick; - **a row enqueued by another replica** → a change event on `_voltro_outbox`, the same reactivity the rest of the framework runs on; - **a row waiting out a backoff** → nobody can be notified that a retry became due, so `drainOutbox` reports `nextAttemptAt` and the runner arms for exactly that instant instead of polling until it passes.
1480
+
1481
+ With a change channel and an empty queue the timer therefore stops entirely. Without one it keeps the fixed tick — the honest fallback, because the poll is then the only thing that can notice another replica's row, and a durable outbox that stops looking is the one failure this module may not have.
1482
+
1483
+ `drainOutbox` gained `scanned` and `nextAttemptAt`, both out of the read it already did: dropping the `nextAttemptAt <= now` bound and splitting in JS costs nothing (the order is `nextAttemptAt ASC`, so due rows are always at the front and can never be starved by future ones) and saves a second round trip on exactly the pass that has no work to justify one.
1484
+ - **@voltro/cli** — The transactional-outbox delivery worker is now stopped when a serving process shuts down, and its in-flight delivery is awaited rather than abandoned.
1485
+
1486
+ `OutboxRunner.stop()` carried the words "Invoked by the SIGTERM cleanup" in its own doc comment. `voltro dev` invoked it. `voltro serve` started the runner (`serveApi.ts`) and stopped it nowhere — not in `close()`, not in `serveCommand`'s shutdown hook. So every production SIGTERM left the poll timer and the `store.onChange` subscription alive past `store.close()`: a tick landing in that window talks to a disposed pool, and a delivery already running is cut off mid-flight. On every rolling deploy.
1487
+
1488
+ Two changes, and the first is the one that generalises:
1489
+
1490
+ - **`startOutboxRunner` now takes a REQUIRED `onShutdown`.** An optional hook would have been the same defect with a nicer name — the serve path's mistake was omission, and an optional field is omissible. Required means the compiler asks the question at every call site, including ones nobody has written yet. dev passes `onProcessShutdown`; `serveApi` registers into the teardown list its `close()` drains, which `serveCommand`'s SIGTERM hook awaits BEFORE it closes the pool. - **`stop()` SETTLES.** It returns a promise that resolves once the pass in flight has finished. Clearing the timer stops the NEXT pass; the delivery already talking to the database is the half a `clearTimeout` cannot reach — the same abandoned-work shape as the analytics mirror's bare `detach()`.
1491
+
1492
+ `bootPathParity.test.ts` now models SHUTDOWN as parity, which it did not, although `packages/cli/CLAUDE.md` has said it is for a long time: every receiver dev tears down inside an `onProcessShutdown` body must be torn down by the serve path too, or aliased to the serve-side expression (asserted to exist), or listed with a reason. Red-verified against the shipped state — it names `outboxRunner`.
1493
+ - **@voltro/runtime** — An over-cap `POST /rpc` body sent with `Transfer-Encoding: chunked` now returns `413 Payload Too Large` instead of dropping the connection. The client saw `curl: (56) Recv failure: Connection reset by peer` (or `curl: (52) Empty reply from server`) where the same bytes with a `Content-Length` got a clean 413.
1494
+
1495
+ The memory bound was never the problem — the body was cut, nothing buffered without limit, the process stayed healthy and the health probes kept answering. The 413 was *produced* and had nowhere to go: the streaming ceiling (`withMaxBodySize`) destroys the request stream when the count is exceeded, destroying an incomplete Node `IncomingMessage` destroys its socket, and the refusal was then written into a closed connection. Nothing was logged either, so from outside there was no way to tell a refusal from a crash.
1496
+
1497
+ `POST /rpc` now reads its body under the cap itself: it stops accumulating the moment the running total crosses `maxRpcBodyBytes`, DRAINS the remainder instead of destroying the stream, logs the refusal (`voltro:security`, with the cap and the byte count at which it stopped), and answers 413 on the still-open connection. Under the cap the buffered body is handed to the rpc layer unchanged. The declared-`Content-Length` refusal is unchanged and still fires before the client uploads anything.
1498
+ - **@voltro/sql-postgres, @voltro/database, @voltro/protocol, @voltro/cli, @voltro/plugin-versioning** — **An oversized postgres NOTIFY was silent, unretryable data loss for every tap — and the docs described the fix that was missing.** Under `changeStrategy: 'cdc'` (the postgres default) the writer does not emit inline: the NOTIFY trigger is the SOLE emitter. `pg_notify` caps a payload at 8000 bytes, so a wide row — a document, a `json()` blob, an embedded array — fell back to a payload with both images null. Subscriptions were fine (they re-query). Nothing else was:
1499
+
1500
+ - `@voltro/plugin-search` upserts only `if (event.new)` and removes only via `event.old?.['id']`, so the index permanently missed that row; - the analytics mirror's `routeChange` returned on a null image; - `@voltro/plugin-cdc-out` outboxed the nulls, durably delivering nothing; - `@voltro/plugin-versioning` recorded no history row; - the DevTools data viewer never showed the change.
1501
+
1502
+ All five silent, and **no retry can repair any of them** — the content was never delivered, so re-delivering delivers nothing. That is what separates this from a failed write, and it caps what a search-retry or a mirror-reconcile can achieve.
1503
+
1504
+ The trigger now keeps the **primary key** in the oversized fallback, and the LISTEN consumer re-reads the row before the event reaches anything. One place, because it is the single point every consumer is downstream of; teaching five taps to cope with null images is five chances to forget, one per new tap.
1505
+
1506
+ `ChangeEvent.oversized` / `PluginChangeEvent.oversized` say what was recovered, and the guarantee is exactly this — no more:
1507
+
1508
+ - `'rehydrated'` (insert/update) — `new` is the row **re-read from the database**. It is the row as it is NOW, not necessarily the image the write that fired the event produced: a second write landing in between means this event carries the newer state and the second event carries it again. The stream is convergent, not point-in-time, and no re-read can be otherwise — postgres keeps no copy of an image the transport dropped. - `'tombstone'` (delete) — `old` is the **primary key and nothing else**. The row is gone, so the pre-image is unrecoverable. Enough to REMOVE the row downstream; never a record of what it contained, which is why `plugin-versioning` writes `data: null` for one instead of fabricating an empty snapshot from `{ id }`. - `'unrecovered'` — the key was absent, the re-read failed, or the row was already gone. Both images stay null (subscriptions still re-query, taps still miss it), it is logged at `error`, and it is the counter to alert on.
1509
+
1510
+ One consequence worth stating: `@voltro/plugin-cdc-out` derives a change's identity from its content, and replicas re-read independently — so a second write landing between two replicas' re-reads produces two outbox rows instead of one. A rare duplicate in an at-least-once stream, in exchange for ending a guaranteed permanent loss.
1511
+
1512
+ **Observability (the half that was missing entirely — the `oversized` flag existed and nothing read it).** Every fallback is counted as `voltro_cdc_oversized_total{outcome="rehydrated"|"tombstone"|"unrecovered"}`, which `@voltro/plugin-prometheus` and `GET /_voltro/inspect/metrics` pick up from the shared registry with no wiring. The first oversized change per table logs a `warn` naming the table and what it costs — a metric is invisible to a deployment that scrapes nothing, which is exactly where a quietly-short search index goes unnoticed longest — and every `unrecovered` one logs an `error`.
1513
+
1514
+ Tunable, with defaults: `VOLTRO_CDC_REHYDRATE_TIMEOUT_MS` (5000 — the total budget for one recovery, which also bounds how long an oversized row can hold up the serial LISTEN consumer) and `VOLTRO_CDC_REHYDRATE_RETRIES` (2), or `cdcRehydrateTimeoutMs` / `cdcRehydrateRetries` on the postgres store.
1515
+
1516
+ **This lives in the database, so it has to be applied.** The notify function is DDL, and a repair that only ran when a TABLE was missing a trigger would have left every existing database emitting keyless payloads while the code claimed to handle them — a fix that ships and reaches nobody. The function body now carries a version marker, `detectReactiveTriggerDrift` reports an older one (`functionOutdated`), `voltro db apply` replaces it, and `voltro dev` names it at boot. Until then those changes report `'unrecovered'` with the remedy in the log line.
1517
+
1518
+ Also fixed in passing: `PluginChangeEvent.procedure` was declared, documented as "the THIRD copy of this shape", serialised by the manifest — and dropped by the mapper, so no tap in either boot path had ever received one. The mapper now has a test, and the parity half of it is derived from protocol's source rather than a list somebody remembers to extend.
1519
+ - **@voltro/protocol, @voltro/cli, @voltro/plugin-notifications, @voltro/plugin-search, @voltro/plugin-flags, @voltro/plugin-presence, @voltro/plugin-versioning, @voltro/plugin-billing, @voltro/plugin-audit, @voltro/plugin-rbac, @voltro/plugin-ai-flows, @voltro/plugin-scim, @voltro/plugin-sso-saml, @voltro/plugin-governance, @voltro/plugin-cdc-out, @voltro/plugin-mail, @voltro/plugin-moderation, @voltro/plugin-storage, @voltro/plugin-ratelimit, @voltro/plugin-licensing, @voltro/plugin-atlassian, @voltro/plugin-datadog, @voltro/plugin-prometheus** — **`alias` moved the inspect slug and left every rpc tag where it was — so the one thing it exists for did not work.** The field answers exactly one question: an app already publishes `notifications.*` and cannot install a plugin that wants the same namespace, because an exact tag collision is fatal at codegen.
1520
+
1521
+ `effectiveRouteTag` prefixes a route with the plugin's alias UNLESS the route name already contains a dot — an escape hatch for a plugin wanting a deeper namespace. Every route-carrying first-party plugin declares its routes fully qualified (`name: 'notifications.inbox'`): **96 such declarations across seven plugins**. So the escape hatch fired on all of them, and the alias moved nothing. Aliasing to escape a collision left you colliding — and additionally cost you the dashboard panel, which fetches the default slug. The one plugin that HAD `alias` (`plugin-ai-flows`) contributes no routes at all, so its existence proved nothing about the rpc half.
1522
+
1523
+ `VoltroPlugin.baseName` carries the plugin's canonical name — the one before any app-supplied alias — so the derivation can strip a redundant leading `<default-alias>.` and re-apply the effective one. Derived rather than declared per route, so those 96 lines stay as they are and cannot drift out of step with the strip. Three cases, and the middle one is why this is not a one-liner:
1524
+
1525
+ 'inbox' → '<alias>.inbox' relative, the documented convention 'notifications.inbox' → '<alias>.inbox' the plugin's OWN namespace, re-namespaced 'acme.legacyBridge' → 'acme.legacyBridge' foreign: the escape hatch, untouched
1526
+
1527
+ The escape hatch surviving is not a detail — dropping the `startsWith` guard is what a "just always prefix" version would do, and it has its own failing test.
1528
+
1529
+ **The client and the server now derive the tag through ONE function.** They were computed independently in `pluginRoutes.ts` and `pluginCodegen.ts`, and a disagreement is a generated client calling a procedure the server never registered, with nothing in the tree comparing the two strings.
1530
+
1531
+ **`pluginInstanceName` (`@voltro/protocol`) is the one implementation of plugin naming.** The `#suffix` instance ternary had been copy-pasted verbatim into eleven plugins and `alias` existed once with a hand-rolled shape, so the two mechanisms had no defined interaction at all. They are orthogonal now and both are documented on every plugin that takes them: `alias` REPLACES the namespace, `name` discriminates a second installation within it, and they compose (`alias: 'alerts', name: 'ops'` → `alerts#ops`). A blank or whitespace alias falls back to the canonical name rather than producing an empty tag prefix.
1532
+
1533
+ `alias` is now on the sixteen plugins with a namespaced surface for it to move: `notifications`, `search`, `flags`, `presence`, `versioning`, `billing`, `audit`, `rbac`, `ai-flows`, `scim`, `sso-saml`, `governance`, `cdc-out`, `mail`, `moderation`, `storage`.
1534
+
1535
+ **It is deliberately NOT on `ratelimit`, `licensing`, `atlassian`, `datadog` or `prometheus`** — they contribute no routes and no inspect endpoints, so an `alias` there would move nothing. That is precisely the defect this change set removes, and adding the field for symmetry would reintroduce it. They still lose their copy of the ternary: they call `pluginInstanceName({ base, instance })`, so there is one implementation and not eleven.
1536
+
1537
+ **Two latent defects came out of the same read:**
1538
+
1539
+ - **`voltro doctor`'s rpc-overlap findings could never fire.** It read `r.descriptor?.name ?? r.name` off `rpcClientDescriptors`, which carry `tag` — neither field exists, so every entry mapped to `''` and was filtered out and the tag list was ALWAYS empty. `pluginSurface.test.ts` covers the pure `findPluginOverlaps` with hand-built input, so the rule was right and only the wiring into it was wrong, which is exactly why it read as shipped. - **The doctor's `alias` advice set listed `@voltro/plugin-ai-flows`**, and that plugin's default name is literally `aiFlows` — the one entry it had could never match. Both sets are keyed on `baseName` now (an aliased install carries a name a static set can never contain) and `pluginOptionSets.test.ts` DERIVES them from the plugins' own source, failing in both directions.
1540
+
1541
+ `codemod: none` — no user-authored code is affected. Every default is unchanged: a plugin with no `alias` and no `baseName` behaves exactly as before, which the pre-existing escape-hatch case in `pluginRoutes.test.ts` pins.
1542
+
1543
+ `apiSurface: compatible` — the four golden REMOVALS the gate sees are the comment markers `// (undocumented)` and `// @public`, deleted because `readonly name?: string` gained a doc comment on notifications, presence, rbac and versioning. No type narrowed, no symbol left, nothing that compiled stops compiling. Every genuine change in these goldens is an ADDITION (`alias`, `tables`, `baseName`), which the gate already ignores.
1544
+ - **@voltro/cli** — Plugin `onHttpRequest` interceptors now run under `voltro serve`. They ran under `voltro dev` only — which is the inversion of where a pre-auth HTTP shield matters.
1545
+
1546
+ `composePluginHttpRequest` was correct, tested, and called by `dev.ts` and nothing else. `serveApi.ts` builds the only `startRpcServer` on the serve path and passed no interceptor; `ServeApiOptions` had no field to pass one through. So `ratelimitPlugin({ http })`'s per-IP shield — the one the docs recommend for production, because it rejects before auth and before routing — was dead in production and live in development.
1547
+
1548
+ Nothing said so. The plugin manifest reported `onHttpRequest: true`, accurately: the plugin does declare the hook. The boot permission audit granted `http:intercept`, accurately: the plugin does ask for it. Every surface an operator could check read as wired, and the wire simply had no consumer.
1549
+
1550
+ The fix is a shared `wirePluginHttpInterceptor` that returns the composition, its boot log and the option NAME as one spreadable `startRpcServer` fragment, spread by both boot paths. A composer both paths COULD call is not one both paths DO call, which is the whole of what went wrong; the fragment removes the step where one of them forgets.
1551
+
1552
+ Covered behaviourally against a real listener (`serveWiringParity.test.ts`: a short-circuiting hook answers 429 before routing, a pass-through hook is consulted and lets the request through, and a hook-less app installs no interceptor at all), and structurally by `bootPathParity.test.ts`, whose new rule (a) compares the OPTIONS two boot paths pass to a shared builder — the class this defect belongs to, which no rule in that file could previously see.
1553
+
1554
+ Note the ONE deliberate exemption, which is not new and is now asserted: `/internal/liveness` and `/internal/readiness` are answered before the interceptor, so a rate-limit shield cannot 503 a k8s probe.
1555
+ - **@voltro/cli** — **A plugin's `extendSchema.migrations` ran in development and in NO production path (PROD-6).**
1556
+
1557
+ `runPluginMigrations` had exactly one caller: the `voltro dev` boot path. Not `voltro serve` (which never applies a schema, correctly), not `voltro db apply`, not `voltro migrate` — including the pre-deploy `voltro db apply` the deployment docs tell you to run. A plugin shipping custom SQL steps would have had them applied on every developer machine and on no deployed database, and the symptom would have presented as a bug in that plugin.
1558
+
1559
+ Latent, because no first-party plugin ships migrations today. It was loaded and waiting for the first one that did.
1560
+
1561
+ `applyPluginMigrations` (`pluginMigrations.ts`) is the shared seam now, called by `voltro db apply` (both the bare and the `--plan` path) and by `voltro migrate --create-only`, after the schema so a plugin's steps can reference its own `extendSchema.tables`. A failure is reported and turns the command non-zero; the ledger row for the failed step is not written, so a fix + re-run retries exactly it.
1562
+
1563
+ The four `db apply` return points now share ONE tail (`finishSchemaApply`: plugin migrations, then the reactive-trigger convergence). Adding a second call beside each `await convergeReactiveTriggers(...)` would have reproduced the shape that lost the step in the first place; `schemaApplyParity.test.ts` fails if the convergence call escapes that tail again.
1564
+ - **@voltro/plugin-presence** — **Every short description of `@voltro/plugin-presence` described the architecture it replaced, and softened the one condition that matters.** No code changed; the claims did.
1565
+
1566
+ Two errors, both propagated from the package's own `description` field into the generated README, the maintainer note, the docs plugin index in both languages and the voltro.dev real-time feature page:
1567
+
1568
+ - **"Backed by a swept presence table."** It is not. Presence lives in the owner-partitioned in-memory `PresenceTracker`; `_voltro_presence` is DECLARED and deliberately never written, because the name is the reactivity key — `presence.list` declares `source: '_voltro_presence'` and the plugin injects a synthetic change on that name so every subscribed roster re-runs through the path a real write used to take. The sweep sweeps the tracker. A reader reasoning about write amplification, retention or a table scan was reasoning about a table that has no rows. - **"Works cross-instance", unqualified.** It works cross-instance *with* `@voltro/plugin-broadcast`. Without a broker every replica keeps a CORRECT roster of its own clients — nothing errors, and a single-replica staging box is indistinguishable from a working fleet, while in production each screen shows a fraction of the room and which fraction depends on the load balancer. The plugin's own boot already warns about exactly this, and the presence docs page already carried the callout; the index rows and the marketing page did not, so the surfaces a reader hits FIRST were the ones that overclaimed.
1569
+
1570
+ Also documented, because two hooks share one name and are not interchangeable: `@voltro/plugin-presence/web`'s `usePresence(channel, options)` is the server-backed roster, and `@voltro/local-first/react`'s `usePresence(roomId, self, { channel })` is peer-to-peer awareness for high-frequency cursor state. Each page now points at the other — the same treatment the local-first docs already give the two `useConnectionStatus` hooks.
1571
+
1572
+ The cross-instance path itself was already proven: `presenceCrossInstance.integration.test.ts` runs two `attachPresenceBus` instances against a real Redis broker.
1573
+ - **@voltro/cli** — `publicApi:`-projected REST routes now honour `Idempotency-Key` under `voltro serve`. They honoured it under `voltro dev` and ignored it in production, so a retried POST executed the mutation TWICE — no error, no log, and money and mail do not un-send.
1574
+
1575
+ The two boot paths assembled the same surface differently. dev merged all three sources of app-owned REST routes — `config.restRoutes`, the api-key management routes, and the descriptor projections — into ONE list and ran ONE `restRoutesToHttpRoutes` over it carrying the app's `idempotency:` binding. serve did it in two halves in two files: `serveCommand` converted the first half WITH the binding and `serveApi` converted the projections WITHOUT one. Both halves typecheck. Both mount. The entire difference was one absent option on the second call.
1576
+
1577
+ `ServeApiOptions.idempotency` also carried only `{ store, ttlMs }` — the header could not travel at all, which is part of why the second conversion was written with no binding rather than a wrong one.
1578
+
1579
+ There is one conversion now, `buildAppRestSurface`, called once per boot path, and one binding, `makeAppIdempotencyBinding`, read by BOTH the REST projection and the WS-rpc mutation dedup. Detection was never going to close this — `bootPathParity.test.ts` says in its own header that a differing ARGUMENT to a call both paths make is invisible to any source rule — so the split is now unrepresentable instead: that file additionally forbids a boot path from reaching `restRoutesToHttpRoutes` or `collectPublicApiRoutes` on its own.
1580
+
1581
+ Its new rule (b) is what would have caught this one: a shared builder that one path calls once and the other calls twice is a surface SPLIT, and the split is where the option goes missing. Rule (a) alone could not — it unions the two serve files, and `serveCommand`'s half DID pass the binding.
1582
+
1583
+ `serveWiringParity.test.ts` drives it over a real socket: the same key replays with `Idempotency-Replayed: true` and the mutation runs once, a different key runs again, and an app with no `idempotency:` configured still runs twice.
1584
+ - **@voltro/runtime, @voltro/database** — **A raw-SQL read in a live query says so now, instead of going quietly stale (REL-14).** `ctx.store.raw(...)` is opaque to everything that makes a query live — the matcher fingerprints a predicate, the dependency graph walks an eager spec, and a raw fragment is a string neither can parse. `dependsOn` is how you tell us which tables it touched, and it had to be written by hand. Leave it out and the subscription opened, delivered its first snapshot and then never updated again: nothing threw, nothing logged, and the symptom read as broken reactivity in the framework.
1585
+
1586
+ Two things changed.
1587
+
1588
+ **A warning at subscribe time**, naming the query and the SQL:
1589
+
1590
+ ```
1591
+ [voltro] reports.summary: a raw SQL read in this live query declares no
1592
+ dependsOn, so no write can invalidate it — every subscriber keeps its first
1593
+ result until it reconnects. Declare the tables it reads:
1594
+ ctx.store.raw(fragment, { dependsOn: ['orders'] }) — or on the fragment itself.
1595
+ The read: SELECT sum(total) FROM orders WHERE tenant = ?
1596
+ ```
1597
+
1598
+ Once per query, not once per subscriber. It is emitted from the dispatcher — the one seam every reactive subscription passes through — so `voltro dev` and `voltro serve` cannot end up with different answers; a check wired into one boot path is the drift this framework has paid for repeatedly.
1599
+
1600
+ **And `dependsOn` now does something.** It was declared on `RawSqlFragment`, documented as the way to opt a raw read into change-driven recomputation, threaded through all four dialect stores as an unread `_opts` — and read by no code at all. For a COMPUTED query (a handler that returns a value rather than a descriptor, which is the only shape whose handler is genuinely re-run) the declared tables now JOIN the query's own `source:` set, so a write to the table your raw read touches recomputes it. Union, never replacement: the query's declared source keeps firing.
1601
+
1602
+ Two limits worth knowing. A raw read inside a handler that returns a DESCRIPTOR is warned about but cannot be repaired by declaring tables — a change re-runs `store.query(descriptor)`, not your handler, so the raw result is not refreshed; return a computed value if it must be. And the record is best-effort: the tables are what you declared, never validated against the SQL.
1603
+
1604
+ New tunable `VOLTRO_RAW_READ_TRACKING_LIMIT` (default 32) bounds how many raw reads one request records for this diagnostic.
1605
+ - **@voltro/plugin-moderation, @voltro/plugin-ratelimit, @voltro/plugin-billing, @voltro/plugin-audit** — **A `RegExp` with the `g` or `y` flag matched every OTHER call.** Four plugins accept `match: string | RegExp` (or `include`/`exclude`) against an rpc tag and tested it with `re.test(tag)`. `.test()` on a global or sticky RegExp ADVANCES `lastIndex` and resumes from there on the next call, so the same tag matched, then missed, then matched. `/^orders\./` was fine; `/^orders\./g` — the spelling people copy out of a `replace` — was a control running at half strength:
1606
+
1607
+ - **`plugin-moderation`** — every second violating write sailed past a `block` rule and committed. A content-safety control, failing open, alternately. - **`plugin-ratelimit`** — every second request escaped its limit. - **`plugin-billing`** — every second billable call went unmetered. Silent revenue loss with a config that reads correct. - **`plugin-audit`** — with `include`, every second matching mutation went unrecorded: an audit trail with alternating holes, which is worse than no trail because it reads as complete.
1608
+
1609
+ All four now test against a **stateless clone** (`g`/`y` stripped) rather than the caller's object — compiled once at plugin construction where the rules are fixed (moderation, audit), cached per RegExp where the match set is walked per call (ratelimit, billing). The caller's RegExp is never mutated, so an app sharing one between a matcher and its own `replace` sees no change.
1610
+
1611
+ Nothing else moved: a non-global RegExp is used as-is, and string / array matching is untouched.
1612
+
1613
+ The shape worth remembering is that **a stateful matcher fails INTERMITTENTLY and only under repetition**, so every one of these looked correct in a test that fired one request, and none of them had a test that fired two. Each package now has one that fires the same tag four to six times.
1614
+ - **@voltro/cli** — **Read-replica pools ignored every connection setting, TLS included.** The primary was built from the full environment — `DB_MAX_CONNECTIONS`, `PG_SSL`, `DB_SCHEMA`, `DB_STATEMENT_TIMEOUT_MS`, the pool-acquire bounds — and each replica from a bare `dialect.makeSqlLayer({ url })`. Two pools in one process, disagreeing about all five, in exactly the deployments large enough to have replicas.
1615
+
1616
+ The TLS half is the one that makes this more than a tuning miss. `PG_SSL` exists so a transport decision is made once and loudly (`sslFromEnv` THROWS on an unrecognised value rather than downgrading) — and then the replica pool never asked. TLS survived only if the replica URL itself carried `?sslmode=require`, so the same process could encrypt its writes and read in the clear, with no knob in the system saying so.
1617
+
1618
+ Replicas are built through `replicaConnectionConfig(primary, url)` now: the primary's settings, the replica's URL. The URL wins over the primary's discrete `host`/`port`/… because every dialect's `connectionFromConfig` branches on `url` first — asserted against the real postgres parser, because a replica pool that silently connected to the PRIMARY would look perfectly healthy and double the primary's read load.
1619
+
1620
+ **Side effect worth planning for:** carrying `schema` / `statementTimeoutMs` moves postgres replicas off `PgClient.layerConfig` and onto the hand-built `pg.Pool` branch. That is the bounded, better path — it is the only one that can express `search_path` and `statement_timeout` at all — but it is a different branch from the one replicas used to exercise.
1621
+
1622
+ **And the boot pool line was doing arithmetic about one of `1 + replicas` pools.** `DB_REPLICA_URLS` with two entries opens three pools in the process, each sized by the same `DB_MAX_CONNECTIONS`. `formatDbPoolLine` counts them, from what `buildStore` actually BUILT rather than from `DB_REPLICA_URLS.length` — the env var is a request, and the builder declines it on a single-process dialect or one with no replication adapter, so printing the request would overstate the budget in the deployments that read this line most carefully. A deployment with no replicas reads exactly the line it read before.
1623
+ - **@voltro/database, @voltro/data-transfer, @voltro/cli** — `voltro data restore` no longer refuses because *some* voltro is running on the machine — it compares databases.
1624
+
1625
+ ```
1626
+ DB_URL=mysql://root:root@127.0.0.1:3399/agile_work_buddy voltro data restore ./backup .
1627
+ ✗ refusing to write into a target with a LIVE instance
1628
+ (AgileWorkBuddyApi (http://localhost:4000)).
1629
+ ```
1630
+
1631
+ That api serves port 3307. The target was 3399, a container created four seconds earlier. The target database never entered the decision, and the message then answered a question it had not asked — *"rows race with live writes"* — about two processes that share no database.
1632
+
1633
+ **The false positive is not the cost; the habit is.** The only way through is `--allow-live`, whose meaning is "yes, I know I am writing into a live target". Every developer with a dev server running learns to pass it for restores that are not live at all, and then it is in the script on the day the target really is production. The reporter's rehearsal harness now passes it permanently, with a comment saying the guard does not apply — the exact erosion the flag exists to prevent.
1634
+
1635
+ Both sides are identified now through `connectionIdentity` — a digest of (host, port, database), published on `/_voltro/inspect/app` as `meta.databaseId`. A **digest** rather than the details because that endpoint deliberately reports `isSet` without values, and publishing a host and a database name there would walk that back; the digest answers the only question being asked and nothing else. Set by BOTH boot paths — a manifest field only one of them fills is a guard that works in dev and not in production, and this one guards a write.
1636
+
1637
+ The direction of the errors shapes the rest: a wrong "different" steps aside from a target that IS live, a wrong "same" refuses a restore that was safe. So `'unknown'` — an older instance that reports no `databaseId`, an unparseable connection string — keeps refusing, and the normalisation stays small enough to be obviously correct (loopback spellings collapse, the dialect's default port fills in, the credential never enters it).
1638
+
1639
+ The refusal also names both sides now. One that named only the instance is why `--allow-live` became a reflex.
1640
+ - **@voltro/database, @voltro/runtime, @voltro/cli** — Two retention registrations for one table no longer resolve silently — and an app's now outranks a framework default.
1641
+
1642
+ A consumer bounded `_voltro_schedule_claims` to 1 hour from a startup. One second later the framework registered its own default for the same table, and won:
1643
+
1644
+ ```
1645
+ 18:41:23 startup: schedule-claims-retention: _voltro_schedule_claims bounded to 1h
1646
+ 18:41:24 retention: · _voltro_schedule_claims: every row older than 30d by claimedAt
1647
+ ```
1648
+
1649
+ Their startup went on printing `bounded to 1h` at every boot while the table kept everything younger than OUR TTL. They found it by counting rows. Nothing in either line said the first had been overruled, and the two read as a sequence of events rather than a contradiction.
1650
+
1651
+ `registry.set(table, spec)` did that, and the comment above it — *"so a double-registration (e.g. plugin re-init) is idempotent"* — describes a case that really exists and silently covered a different one.
1652
+
1653
+ **Precedence decides now:** app > plugin > framework, ties keep the later one as before, and `source` defaults to `'app'` so an application wins without knowing the field exists. A real conflict is reported at boot, on the line beside the policy that survived:
1654
+
1655
+ ```
1656
+ ! _voltro_schedule_claims: two registrations — kept the app's 1h,
1657
+ dropped the framework's 30d. An app registration wins over a plugin's,
1658
+ and a plugin's over a framework default.
1659
+ ```
1660
+
1661
+ **Every framework-shipped registration now says what it is** — the 13 across `@voltro/ai` and the plugins declare `source: 'plugin'`, because the default that makes an APP win is the same default that would make a plugin outrank the application whose data it stores. A test derives the call sites from the tree rather than listing them, so the fourteenth plugin is caught by existing rather than by being remembered.
1662
+
1663
+ The direction was not obvious and is worth stating: the loser is chosen by WHO registered, not by which TTL is narrower. "Narrower wins" sounds safer and is not — it would let a framework default we tighten in a later release silently start deleting an app's data faster than the app asked for. Whoever owns the data decides; we are the fallback.
1664
+ - **@voltro/runtime** — **The `POST /rpc` body cap is enforced as bytes arrive, not from the declared length (SEC-18).** The guard read `if (contentLength !== undefined && Number(contentLength) > maxRpcBodyBytes)`, so a request with `Transfer-Encoding: chunked` and no `Content-Length` skipped it entirely and `@effect/rpc` then buffered the body without bound — a cap on honest clients only, and a one-header memory DoS for everyone else.
1665
+
1666
+ The rpc branch now runs under `HttpServerRequest.withMaxBodySize`, the platform's own streaming ceiling: the body readers count bytes as chunks arrive, fail the moment the running total crosses the cap, and destroy the stream. The declared `Content-Length` is still checked FIRST, because an honest client should get its 413 without uploading anything — but it is no longer what enforces the limit. `@effect/rpc` reads the body with `Effect.orDie`, so the platform's failure arrives as a defect; that one defect is translated back into a 413 rather than being allowed to surface as an opaque 500.
1667
+
1668
+ Unchanged: uploads (separate plugin routes with their own `limits.maxBytes`) and WS frames (the `ws` library's 100 MiB default). `VOLTRO_MAX_RPC_BODY_BYTES` and `RpcServerOptions.maxRpcBodyBytes` still tune it; the default is 8 MiB.
1669
+ - **@voltro/plugin-sso-saml** — **`/saml/slo` acted on an UNSIGNED logout message, so ending a victim's session needed no credential at all.** node-saml verifies a redirect-binding signature only when the message CARRIES one — `hasValidSignatureForRedirect` returns `true` for a query with no `Signature` parameter — and the route handed `validateRedirectAsync` whatever arrived. It is a GET, it is deliberately `originGuard: 'exempt'` (the IdP is cross-site by construction), and it has no CSRF token, so `<img src="https://app.example.com/saml/slo?SAMLRequest=…">` on any page logged the visitor out. Measured, not inferred: an unsigned `LogoutRequest` came back `302` with both cookies cleared and a signed `LogoutResponse` for the IdP.
1670
+
1671
+ The route now requires the `Signature` parameter and answers `401` without it, BEFORE node-saml is consulted — a message nobody signed is not a message from the IdP, and the SAML redirect binding puts the signature on the query for exactly this reason. This is a precondition rather than a stricter verification: a signature that is present and wrong was already rejected.
1672
+
1673
+ **What an operator may have to change:** an IdP configured NOT to sign its SLO messages will now get a `401` at `/saml/slo`. Turn on logout-message signing in the IdP (Okta: *Sign SAML logout requests/responses*; Entra: signed by default). Nothing changes for SP-initiated `/saml/logout`, which clears the local session on its own leg — a user whose IdP is misconfigured is still logged out locally, they just do not complete the round trip.
1674
+
1675
+ Found while giving the assertion path real coverage: `samlSignature.test.ts` drives the ACS and SLO endpoints through the REAL `@node-saml/node-saml` against RSA-signed fixtures, so unsigned / wrongly-keyed / wrapped / tampered / HMAC-forged documents, the `Conditions` and clock-skew boundaries, the audience restriction and one-shot `InResponseTo` consumption are now assertions rather than assumptions.
1676
+ - **@voltro/plugin-scim** — **A de-provisioning from Entra answered `200 OK` and left the account ACTIVE.** Three independent ways an `active:false` was dropped on the floor, all with the same shape: the request succeeded, the response echoed `"active": true`, and the IdP recorded the offboarding as done.
1677
+
1678
+ - **`Boolean(op.value)` was the wrong primitive.** Entra sends `{"op":"Replace","path":"active","value":"False"}` with the value as a STRING, and every non-empty string is truthy — `Boolean("False")`, `Boolean("false")` and `Boolean("0")` are all `true`. A SCIM boolean is read properly now (`true`/`false`/`1`/`0`, as booleans, numbers or strings). - **The `path` was matched case-sensitively and bare only.** SCIM attribute names are case-INSENSITIVE (RFC 7643 §2.1) and IdPs send the target both bare (`active`) and schema-qualified (`urn:ietf:params:scim:schemas:core:2.0:User:active`). Anything but the exact spelling `active` was skipped by the loop. - **`add` was skipped entirely.** An `add` targeting a single-valued attribute IS a replace (RFC 7644 §3.5.2.1), and IdPs do send it that way.
1679
+
1680
+ The same coercion ran on create and replace, so `POST`/`PUT` with `"active":"False"` provisioned an ACTIVE user.
1681
+
1682
+ **A value that cannot be read now falls to `false`, not `true`.** That direction is deliberate: wrongly-inactive is an annoyance the next IdP sync undoes, wrongly-active is an ex-employee with a working login. An absent `active` on create still defaults to active — the SCIM default, untouched.
1683
+
1684
+ **What changes for a running deployment:** users an IdP believed it had deactivated may still be active in `_voltro_scim_users`. The next sync from the IdP will land correctly, but do not wait for it — reconcile the table against the directory when you upgrade.
1685
+
1686
+ Two things worth knowing that this does NOT change, both now covered by `scimSecurity.test.ts`: `userName` uniqueness is compared case-SENSITIVELY (so `ada@x` and `Ada@x` are two accounts on postgres, and one on a case-insensitively-collated MySQL — a real per-dialect divergence), and a `PUT` that omits `active` re-activates a deactivated user, which is what RFC 7644 §3.5.1 says a replace does but is still a sharp edge for a client that PUTs partial profiles.
1687
+ - **@voltro/database, @voltro/cli** — **`lifecycle: 'onTenantCreate'` seeds now actually run when a tenant namespace is provisioned.** They never did. The lifecycle was validated by `defineSeed`, recorded in `_voltro_seeds`, and listed by the dashboard — while the runner filtered to `'boot'` and `provisionTenantNamespace` fired nothing. A user declaring an `onTenantCreate` seed got a tenant with its tables, none of its data, no error and no log line. It read as wired at every site that mentioned it. `onSchemaChange` and `cron` were dead in exactly the same way.
1688
+
1689
+ **Why wiring rather than rejecting the declaration.** Both are honest answers to a declared-but-dead API, and for two of the three the seam was reachable, so finishing it beats deleting it — tenant-create seeding is table stakes for the multi-tenant story the namespace isolation already ships.
1690
+
1691
+ - **`onTenantCreate` — wired, end to end.** `provisionTenantNamespace` fires the seeds after the namespace DDL lands and before it resolves, so a caller that awaits provisioning gets a tenant whose tables AND reference data exist, or an error. Steps run against `store.withNamespace(namespace)`; a store that cannot scope is REFUSED rather than silently falling back to the shared tables, which would put one tenant's fixture in everybody's data. There is no fingerprint skip — a new namespace has none of the data whatever another tenant's run recorded — and the ledger row is keyed `<seedId>@<namespace>`, so N tenants produce N rows instead of one that reads "applied" for all of them. A failure THROWS, unlike a boot seed: a half-seeded tenant that reports success is the silent-failure class this fix exists to remove, and provisioning is idempotent so the caller's retry is safe. - **`onSchemaChange` — the runner and the seam are wired; ONE call site remains.** `fireSchemaChangeSeeds({ changedTables })` (from `@voltro/database`) runs every seed whose `watchedTables` intersect the change. The migration applier does not call it yet — until it does, run those seeds with `voltro db seed --id <name>`. - **`cron` — projected onto the real scheduler; ONE call site remains.** `seedCronSchedules()` turns each cron seed into a `ScheduleDefinition`, so it rides the coordinated cron scheduler (`_voltro_schedule_claims`, one firing fleet-wide, with the overlap/backfill/watchdog policies) instead of a per-replica timer. No boot path merges that list into `startScheduler` yet, so boot now WARNS by name for every discovered cron seed rather than accepting it in silence.
1692
+
1693
+ Also: `defineSeed` takes an optional `timezone` for `lifecycle: 'cron'`, defaulting to an explicit `'UTC'` — never the container's clock. And the non-boot hooks are installed by `runBootLifecycle`, the one builder both `voltro dev` and `voltro serve` already call, so the two boot paths cannot drift. This is not in tension with "a serving process does not auto-seed": that decision is about N replicas seeding at startup, whereas a tenant is provisioned by exactly one replica and a tenant without its data is broken wherever it happens.
1694
+
1695
+ No framework-table change and no codemod: the additions are new exports and a new optional field.
1696
+ - **@voltro/cli** — `voltro serve` now honours `port` from `app.config.ts`. It computed the port one line BEFORE it loaded the config, so `PORT ?? --port ?? 4000` was the whole precedence and the declared field was unreachable — an app declaring `port: 4130` ran on 4130 under `voltro dev` and on 4000 under `voltro serve`. Usually masked in production, because a platform that assigns a port sets `PORT`; a self-hosted `voltro serve` bound a port the app never declared, and a web app in the same project proxied to the declared one, so nothing answered.
1697
+
1698
+ Every command that binds an app listener now resolves it through ONE function (`resolveAppPort`), with one precedence: `VOLTRO_DASHBOARD_PORT` (only when the process IS the dashboard) → `PORT` → `--port` → `app.config.ts` `port` → 4000 (api) / 5173 (web). `PORT` outranks `--port` deliberately — the deployment platform assigns through `PORT`, and a `--port` baked into a container start command must not outrank it.
1699
+
1700
+ Three more divergences went with it. `voltro dormancy` ignored the declared port for the PUBLIC port it fronts the app with. `voltro dev` on a **web** app ignored `PORT` while `voltro start` honoured it. And an unusable value (`PORT=`, `PORT=8080x`) reached `Number()` at five of the six sites, where `NaN`/`0` makes node bind a random free port and report itself ready — it is now ignored with a warning naming the variable, and the next source in the precedence wins.
1701
+ - **@voltro/protocol** — **`sessionExpiryFromHeaders` no longer goes quiet when it cannot verify (SEC-16).** When a `voltro:session` cookie was present but no `VOLTRO_SESSION_SECRET` was configured, it returned `undefined` — which both callers (`ConnectionInfoMiddleware` in `dev.ts` and `serveApi.ts`) read as "no expiry bound", so a realtime subscription on that connection was never cut off and nothing said so. Silent absence of a security bound is the failure mode this repo refuses elsewhere.
1702
+
1703
+ It now logs once per process, at error level, naming the consequence rather than just the missing variable. Deliberately not a throw: the function runs in per-request middleware, and a `voltro:session` cookie left over from another app on the same host is a normal thing for a browser to carry — throwing would turn a stale cookie into a denial of service, in exchange for bounding a subscription a request-scoped middleware cannot bound anyway. The three outcomes are now distinct in the code and in the docstring: no cookie (honest `undefined`), no secret (loud), cookie that does not verify (`undefined`, correctly — it authenticates nobody, so there is no credential lifetime to inherit).
1704
+
1705
+ Audited every other caller of `resolveOptionalSessionSecrets` for the same pattern (SEC-19). `resolveSessionSecrets` falls through to the throwing resolver; `@voltro/plugin-auth`'s `sessionSecretsOf` was already handed a secret and consults the environment only for the `kid` and any rotation key, so it never degrades to unverified. The one remaining silent-but-fail-CLOSED case is `defaultPasswordStrategy` in `cli/src/dev.ts`, which returns `skip` (→ anonymous, never unverified-as-authenticated) when no secret is configured; it authenticates nobody, so it is not this defect, but it is equally quiet about a broken configuration.
1706
+
1707
+ `@voltro/protocol` gains a direct `@voltro/logger` dependency (already present transitively via `@voltro/database`) so the diagnostic goes through the same sink fan-out and redaction as everything else.
1708
+ - **@voltro/ai** — **The resumable-stream log grew without bound.** The sweep that found `_voltro_ai_inferences` was not finished: `_voltro_stream_events` — one row per streamed token, the largest per-call payload table this package has — and its `_voltro_stream_state` sibling had no `registerRetention` either. The seventh table of this class in recent audits, and the second in `@voltro/ai`.
1709
+
1710
+ **`gcResumableStreams` looked like the bound and is not one, in two separate ways.** Nothing in the framework calls it — the doc comment said "run it periodically", which makes the bound a thing an app has to remember — and it collects only streams whose state row says `done: true`. A producer that crashed mid-stream never sets that flag, so its log was immortal under the one rule that existed, and a crashed producer is exactly the case that leaves rows behind.
1711
+
1712
+ Both tables are registered now from `dataStoreResumableStreamStore` — building that store is the moment an app opts into DB-backed streams AND the moment the tables start filling, so the bound and the growth begin together; the memory and Redis backends declare no tables and announce nothing. 7-day default, `VOLTRO_STREAM_LOG_TTL_HOURS`, `framework` precedence so an app's own window wins. `gcResumableStreams` stays for a tighter, done-aware purge on an app's own schedule.
1713
+
1714
+ Swept on plain `createdAt`, both tables, one cutoff. A week is measured against a thing whose useful life is minutes — a resumable stream exists so a browser that lost its socket can reconnect — so the width is a bound on the disk rather than a decision about the feature. The events table has no `done` column and could not carry the collector's predicate anyway; sweeping the state row alone would orphan its events forever, which is strictly worse than sweeping neither.
1715
+
1716
+ **Also fixed here: `_voltro_prompts` was never migrated.** The prompt registry shipped declared, indexed and registered for retention — and absent from `frameworkTableAssembly.ts`, the one set `voltro dev`'s auto-migrate and `voltro db plan/apply` both build from. So the differ never created it, and because `recordPromptVersion` runs inside `aiStep`'s best-effort `catchAllCause`, provenance was recorded NOWHERE and nothing said so. It is created for an app with workflows (where `aiStep` stamps it) or with agents — the second gate is structural, not a guess: that gate is what creates `_voltro_ai_usage`, whose `promptDigest` column resolves against this table, and shipping the pointer without its target would be a dangling reference by construction.
1717
+ - **@voltro/client** — `useSubscription` no longer fetches the OLD procedure after a component switches its `rpcTag`.
1718
+
1719
+ The thunk that fills a cache entry was a `useRef` initialised once, so its closure pinned the FIRST render's `rpcTag` — while `queryKey` tracked the current one. A mounted component whose tag prop moved therefore re-keyed the cache, then filled the new entry by invoking the previous rpc: the data landed under the wrong key, and auto-optimistic source-routing patched the wrong entries from then on. `clientRef` and `inputRef` were already refreshed every render; the tag was asymmetric by accident, left behind by an earlier fix.
1720
+
1721
+ The same staleness had a second reach that a `tagRef` would not have closed. `SubscriptionCache.refreshAll()` (the soft re-auth path) re-forks every entry through the thunk it SAVED at create time — and with one shared mutable thunk per component, an entry keyed by the OLD input re-subscribed with the CURRENT one. Same wrong-key bug, one step removed.
1722
+
1723
+ So the key and the thunk that fills it are now minted TOGETHER, from a single `useMemo` over `[rpcTag, inputSerialized]`, and both `rpcTag` and `input` are captured per key. They can no longer describe different procedures. `client` deliberately stays late-resolved through a ref: it is not part of the key, and capturing it would re-introduce the loading-stub bug the ref exists for.
1724
+
1725
+ `useSubscriptionRebind.test.tsx` pins both directions against a real cache and a real runtime. It needed a DOM harness to exist at all — the neighbouring SSR-shaped tests render each case once, which re-initialises every ref and hides exactly this class of bug.
1726
+ - **@voltro/cli** — `_voltro_undo_log`, `_voltro_workflow_admissions` and `_voltro_workflow_start_contexts` are bounded — three more tables nothing ever deleted from.
1727
+
1728
+ Found by a question rather than a measurement: *"we have 42 plugins — do 13 registrations really cover them?"* Cross-referencing every `_voltro_*` table against the retention registry turned up 27 without one. Most are config, working sets, or covered by an `ON DELETE CASCADE`. Three were real:
1729
+
1730
+ - **`_voltro_undo_log`** — one row per undoable MUTATION, written inside that mutation's own transaction, and nothing in the framework deleted from it. Of everything this release has bounded it is the only table that grows with **user traffic** rather than with a timer, and each row carries the full `ChangeSet` the undo engine inverts. 30 days, `VOLTRO_UNDO_LOG_TTL_HOURS` — it genuinely is a history (it backs the per-subject "what can I undo" feed), so a user seeing their list truncated is a visible loss and the boot line names it. - **`_voltro_workflow_admissions`** — a pruner that EXISTED and could not run. `pruneAdmissions` was written, given a 30-day constant, wrapped as `gate.prune()`, and never called: the wiring's returned handle did not even expose it, so neither boot path could have invoked it if it had tried. The same shape as `debounce` never running, and as this very sweep being postgres-only. It is a registration now rather than a second timer, keeping the pruner's safety exactly — `releasedAt IS NOT NULL`, because collecting a lease still HELD frees a concurrency slot the app is currently using, which is a declared limit silently exceeded rather than a row lost. The unreachable `gate.prune()` is deleted.
1731
+
1732
+ - **`_voltro_workflow_start_contexts`** — one row per workflow START, also never deleted. This was first written up as *found, deliberately not fixed*, with a reason that was true and incomplete: the row is read by `executionId` whenever a runner rebuilds a workflow's AppContext, which can be at ANY point in that run's life including after a sleep measured in months, so a plain time TTL is a landmine.
1733
+
1734
+ That argues against a TTL **alone**, not against bounding it. The rule is liveness: collectable only when no run that is `running` or `suspended` still claims that execution. A sleeping run keeps its context however old the row is — asserted against a real store with a year-old suspended run, not merely argued in a comment.
1735
+
1736
+ It covers two cases a delete-at-the-terminal-transition could not. A terminal run past the TTL ages out **together with** its run row, since `_voltro_workflow_runs` carries the same 30 days — neither outlives the other. And a context whose run row has already been swept has no transition left to hook, so nothing keyed on the run could ever have collected it.
1737
+
1738
+ The `where` deliberately costs the postgres fast path (the raw `DELETE … RETURNING` branch takes no predicate) and sweeps through the portable read-then-delete instead. That is the right trade for a correctness predicate, and the reason that fast path is a branch rather than a gate.
1739
+ - **@voltro/cli** — **`voltro e2e` is documented as what it is — a tsx script runner, not Playwright (TEST-2).** `e2eCmd.ts` boots the api + web siblings and then spawns `node --import tsx <file>` per file matching `e2e/**/*.spec.ts`. There is no `playwright` dependency in any package, no template ships an `e2e/*.spec.*`, and no browser is installed. The docs taught `import { test, expect } from '@playwright/test'`, "Playwright runs `*.e2e.ts` files", and per-test isolation / fixtures / reporters / sharding "configured in your Playwright config" — three things wrong at once (file pattern, runner, and every named feature), and `voltro --help` said "run Playwright tests" as well. Meanwhile `docs/en/testing/overview.md` already described the tsx contract honestly, so the docs contradicted each other page to page.
1740
+
1741
+ **Documented, not implemented, and the reason is the trade:** wiring `playwright test` in would put a ~400MB browser download plus a config file in front of every user for a choice that is theirs, and would reduce the command to an alias for `playwright test` — which anyone can already run. What `voltro e2e` genuinely contributes is the lifecycle (boot both siblings, wait for both ports, tear down, aggregate exit codes), and the lifecycle is identical whichever driver an app brings: Playwright, Puppeteer, a chromium script, or plain `fetch`. The docs now show both a `fetch` + `node:assert` spec and a bring-your-own `playwright-core` one.
1742
+
1743
+ One real gap closed while correcting it: a spec now receives **`API_URL`** as well as `WEB_URL`. A spec that drives the api directly — auth flows, REST routes, webhook receipts, the cheap majority — had to guess the api port, and a guessed port that happens to be free fails as a connection error rather than as a test.
1744
+ - **@voltro/cli** — **Every web template's tests needed `jsdom` and no template declared it.** 54 `*.test.tsx` files across `voltro-templates/apps/*` (and 15 more in the starter's web app) carry a `// @vitest-environment jsdom` docblock, and 0 of the 19 frontend templates listed `jsdom` in `devDependencies`. It resolved anyway — `@voltro/web` and `@voltro/local-first` declare it, and the meta-workspace hoists — so every run inside this monorepo was green while a SCAFFOLDED project's first `voltro test` died on a missing environment.
1745
+
1746
+ That is the specific shape worth naming: `jsdom` is an *optional peer* of vitest, so the failure is invisible in the repo that develops the templates and certain in the repo that receives them. The dependency the tests actually need is now declared where the tests live.
1747
+
1748
+ All 19 frontend templates (`changelog`, `frontend-admin`, `frontend-app`, `frontend-auth`, `frontend-blank`, `frontend-cms`, `frontend-collab`, `frontend-contact`, `frontend-dashboard`, `frontend-docs`, `frontend-i18n`, `frontend-landing`, `frontend-portal`, `frontend-saas`, `frontend-spa`, `frontend-ssr`, `frontend-ssr-api`, `frontend-static-blog`, `frontend-status`) gain `jsdom`, and the starter's web app — which had 15 such files and declared neither `jsdom` nor `vitest` — gains both. `react-dom`, the other half of the DOM harness, was already a runtime `dependency` of all 19 and needed nothing.
1749
+ - **@voltro/cli, @voltro/client** — Stop emitting the removed `messages.queries` surface from the codegen and the client type.
1750
+
1751
+ `messages.queries` was deleted from `@voltro/workflow` as a declared API with no send path, but the codegen kept projecting it into the generated rpcGroup and `WorkflowClientMessages` kept declaring it. Neither broke a build in this repo, so it read as finished — but in a consumer app the generated file references a field the descriptor no longer has, and **`tsc` fails for any app with a workflow**. Found while writing the 0.34 upgrade guide, by typechecking the docs app.
1752
+
1753
+ No user action: `queries` had no send path, so nothing could have called it.
1754
+
1755
+ ### Internal (no consumer-facing effect)
1756
+
1757
+ - **@voltro/cli** — `bootPathParity.test.ts` gains four rules, and — more usefully — a written account of what it still cannot see.
1758
+
1759
+ It derived three sets: modules containing `.onChange(`, symbols called inside an inline `onChange` body, and modules exporting `export const (wire|attach)[A-Z]`. A dev/serve inventory found four production-only defects, and every one of them sat outside all three. What is new:
1760
+
1761
+ - **teardown parity.** Every receiver dev tears down inside an `onProcessShutdown` body must be torn down by the serve path, filtered to names serve declares, aliased where serve tears the same handle down under another name (the alias names the serve-side expression and it is asserted, so an alias cannot become a mute button), and otherwise listed with a reason. The skipped set is asserted to EQUAL the written list, so a silent skip is a failing diff rather than a clean-looking pass. - **shared-builder ARGUMENT parity, two rules.** (a) every option dev passes to a builder both paths call must be passed somewhere on the serve path; (b) neither path may SPLIT a builder the other calls once. (b) exists because (a) unions serve's two files and therefore cannot see an option present at one serve call site and absent at another — which is exactly how the REST projection lost its idempotency binding. - **shared constants are not re-spelled as literals** — one curated entry, and the file says it is curated.
1762
+
1763
+ Two corrections to what was already there:
1764
+
1765
+ - `reachedByServe` was a one-hop literal import check that could not tell an import from a call. It now requires at least one imported binding to be REFERENCED outside the import statements, which removes false reaches and adds none. - **transitive reach was measured and rejected**, with the evidence recorded: following local imports through third modules reports `inspectCdc.ts` as reached by serve — it is not; serve pulls in a module that imports it — which would delete a correct `DEV_ONLY` entry and turn a real asymmetry into a pass. Under-reporting the reach set is the safe direction; over-reporting silences the rule.
1766
+
1767
+ The limits are in the file header rather than implied. In particular: a teardown that moves INTO its constructor (which is what the outbox fix did) leaves dev's shutdown body and therefore leaves the derived set — a stronger guarantee, but it means that rule catches the NEXT instance of the class, not the one that motivated it. And no rule here can see whether reached code RUNS.
1768
+
1769
+ Every rule was red-verified by reconstructing the pre-fix source: rule (a) names `startRpcServer.pluginHttpInterceptor`, rule (b) names `restRoutesToHttpRoutes (dev 1, serve 2)`, the teardown rule names `outboxRunner`, and the constants rule names `serveApi.ts`.
1770
+ - **@voltro/cli** — `voltro serve` reads `DORMANCY_WAKEUP_TENANT` instead of writing `'default'`.
1771
+
1772
+ No behaviour changes today, which is the entire finding: the two are equal, so nothing could distinguish them and no test could fail. What was wrong was the comment. dev's fallback carried the words "Shared with serve on purpose — this used to be the dev request fallback here and `'default'` there, so a wakeup written in one was invisible under the other's key", beside a serve path that did not participate in the sharing at all. Changing the constant would have moved dev's wakeup key and left serve writing under the old one, and a wakeup an external waker cannot see does not fail — it never fires.
1773
+
1774
+ `bootPathParity.test.ts` grew a short, deliberately CURATED list for this class: a constant both boot paths must agree on, exported by the runtime, whose wrong value is silent. There is no way to derive "this literal is a re-spelling of that constant" from source, so the list is honest about being curated and asserts it is non-empty rather than quietly emptying out.
1775
+ - **@voltro/cli** — Give the five undecided procedures in `e2e-fixtures/{memory,postgres,sqlite}-api` an `openAccess:` decision, so the fixtures boot under `voltro serve` again after the default-deny gate landed.
1776
+
1777
+ Each reason states what the handler actually reads or writes and why exposure is safe: all three fixtures configure no auth strategy at all, so a scope guard there would name authority no caller could ever hold — the unsatisfiable-guard shape. `people.add` is the one worth reading: its own comment explains it must stay callable under serve, because serve is the only place column masking is on and seeds do not run there.
1778
+
1779
+ Found because `memory-api` could not complete a `voltro serve` boot at all. Nothing caught it: `serveBundle.test.ts` builds and imports that fixture but never boots it.
1780
+ - **@voltro/testing** — `makeVoltroTestClient`'s fake runtime is exported as `FAKE_RUNTIME`.
1781
+
1782
+ Not a feature — it is exported so `fakeRuntimeParity.test.ts` can check it. That guard scans `@voltro/client` for every `runtime.<method>(` the hooks call and asserts each exists on the fake, because the provider takes `runtimes as never` (unavoidable: `AnyRuntime` is `ManagedRuntime<never, never>` and a double cannot satisfy it) and that cast is what let the fake go stale when the write path moved to `runPromiseExit`.
1783
+ - **@voltro/database, @voltro/runtime** — Two scale questions answered with measurements rather than code, plus the two scripts that re-derive them.
1784
+
1785
+ **How many subscribers fit on one node?** There is a number and it is not a constant — it depends on a property of the app's QUERIES, not its scale. `node packages/runtime/scripts/fanout-ceiling.mjs` measures the marginal CPU of one change event per matched subscriber and divides an event-loop budget by it. At 10 matched writes/s against 100 ms/s of event loop, three runs: SHARED descriptors (N clients on one query) cost 0.5–0.9 µs each — the per-dispatch read memo collapses the read and the diff — for ≈ 11 000–20 000 per node; DISTINCT descriptors (`where userId = me`, i.e. any per-user dashboard) cost 22–29 µs with no sharing available, for ≈ 350–450. Quote the pessimistic one.
1786
+
1787
+ Ranges rather than points, and the reason is a correction the harness now enforces: the first run measured 250/500/1000 subscribers and put the shared shape at `0.95 µs / ≈10 000`; a re-run put the same fit at MINUS 0.66 µs, because that cost is under the noise floor at those sizes — and a negative slope divides into a ceiling of infinity. It measures 500/2000/4000 now and refuses to print a ceiling for a non-positive slope instead of printing `∞`.
1788
+
1789
+ The measurement's third result is the one that surprises: **200 of 200 subscribers whose predicate matched nothing were still woken by one write on their table.** Every subscription is a dependent of its own table and `handleChange` unions the matcher's hits with that set, so the matcher narrows nothing for a root-table subscription. The ceiling counts subscribers ON THE TABLE, and an app cannot buy headroom with a more selective `where`.
1790
+
1791
+ `CHANGE_LISTENER_CEILING = 512` is NOT that limit and now says so in its own header: it bounds `onChange` LISTENERS, one per declared artefact, and every subscriber in a process shares the dispatcher's single listener.
1792
+
1793
+ **A compiled-SQL-shape cache is not worth building.** Measured with `node packages/sql-postgres/scripts/db-path-cost.mjs` against postgres on loopback — the fastest denominator that exists, so these are upper bounds: `compileSelect` on a realistic 4-leaf predicate is 5.4–5.9 µs, `compileEagerJson` 5.9–7.0 µs, and the CACHE KEY such a lookup needs first is 1.3 µs, against a round trip of 300–2600 µs. So the cache nets ~4.6 µs on a query costing at least 300 — 0.2%–1.5% — and every call site compiles exactly once per round trip, so there is no hidden multiplier. Against that: params are captured per call, so the cache must store the shape and re-bind, and a bug there leaks one caller's values into another caller's query. Recorded in `sqlCompiler.ts` so the next audit re-measures instead of re-proposing.
1794
+
1795
+ Both scripts carry a `--selftest` that runs first and fails in both directions, for the reason the bundle-budget gate does: a harness that has quietly stopped measuring still prints a table, and a table gets quoted. Each records the methods that were WRONG — including the one that cost two minutes and a load average of 143: the measurement body imported its statistics helpers from the runner, the runner ends in `await main()`, and `main()` spawns the body.
1796
+ - Seven gate-breadth gaps closed (plans/optimizations/07, GATE-1..7). No package source changed: two test files, six scripts, `ci.yml`, `.gitleaks.toml`, `.github/dependabot.yml`, root `package.json` scripts, and a one-word comment fix in `pnpm-workspace.yaml`.
1797
+
1798
+ - **The migration differ is property-tested.** It was the strongest subsystem in the tree and example-based only — every case a schema somebody thought of, while the bugs that got out were combinations nobody did. `plannerProperties.test.ts` generates schema pairs and asserts convergence ("re-plan after apply is EMPTY", the oracle `applyPlan` already refuses to record a fingerprint without), plus determinism, ordering, additive-never- blocks, refusal-carries-a-fix and summary-matches-ops, across all six dialects. `sql-sqlite/__tests__/plannerConvergence.property.test.ts` closes the same loop against a REAL catalog: real CREATE TABLE DDL into an in-process sqlite, real introspection, re-plan empty. fast-check needed no new dependency — it ships inside `effect` as `effect/FastCheck`. 200 cases by default (under a second); `VOLTRO_PROPERTY_RUNS=5000` in the nightly. Both carry a cases-checked floor and run the properties against deliberately broken planners, because `fc.assert` over a corpus that generates nothing passes at full speed. - **`publint` + `@arethetypeswrong/cli` over the staged tarballs** — 77 packages, most with four or five subpath entries, and nothing had ever checked whether the published exports map resolves. It runs in the Package job against `.publish/`, never against `packages/*` (whose exports point at `./src/*.ts` — a manifest no user receives). attw's JSON is read rather than its exit code: the obvious config for an ESM-only repo, `--profile esm-only`, passes a package that ships no types at all. - **Coverage is collected on the pure surface, reported, and gates no number.** `@vitest/coverage-v8` had been a root devDep that nothing invoked. 61 packages measured, 17 declared integration-heavy WITH what covers them instead; the failures are structural (a package that measured nothing; a package that crossed the IO threshold undeclared). - **Secret scanning, in two halves.** `check-secrets.mjs` asks "did WE invent a secret value and ship it" — the thing that has happened twice — scoped to shipped files, with the private-key fixtures pinned by count. gitleaks answers "is there a credential to somebody else's system here"; `.gitleaks.toml` records the measurement behind every exclusion. - **The full matrix runs nightly.** Every heavy job carried `if: github.event_name != 'push'` on the assumption that a PR gates first — but work lands by pushing to main, so the matrix effectively ran only inside a release. That is how two stale aggregate goldens survived three releases. A `schedule:` trigger needs no other change (`schedule` is not `push`); the cost is written down beside it. - **Supply-chain residuals are assertions.** Every uncapped override floor must name the advisory it closes, the two capped ones must keep their caps, every cooldown exemption must be backed by an override, and dependabot's cooldown may not be shorter than pnpm's `minimumReleaseAge`. The check found the drift the audit named: a comment reading "these four" over a list of three. - **The five real-browser checks run in CI.** They were unwired for a mechanical reason — each resolved playwright from `<repo>/../e2e`, a sibling checkout `actions/checkout` cannot create — now a `VOLTRO_PLAYWRIGHT_DIR` seam. The runner judges exit status, `FAIL` lines AND a `PASS` floor, because a fixture that fails to boot quietly exits 0 having asserted nothing.
1799
+
1800
+ Every gate was red-verified by planting its defect and watching it fail. Two of them found real problems on their first run, reported rather than fixed here: two shipped secret VALUES in the generated agent-docs template (source: the bilingual docs site), and — from a semgrep measurement that is reported, not wired — 7 `bypass-tls-verification` and 2 `gcm-no-tag-length` findings.
1801
+ - Five quality gates that could pass without checking anything are closed (plans/optimizations/07, GATE-8..12). No package source changed; 40 `package.json` `test` scripts lost `--passWithNoTests`.
1802
+
1803
+ - **Doc samples could not see a phantom third-party teach.** Only `@voltro/*` imports could miss; every other bare specifier resolved to an untyped stub, so a docs page importing `@playwright/test` — a runner the CLI does not have — typechecked clean by design. Bare specifiers no workspace package depends on are now TS2307 unless listed in an auditable `THIRD_PARTY_ALLOWLIST` (unused entries fail the run). - **The docs SITE is now checked in CI.** `check-doc-samples` / `check-docs-code-parity` / `check-docs-structure` locate the site via `VOLTRO_DOCS_DIR` → `<repo>/.voltro-dev` → `../voltro-dev`, and CI checks `SinPP/voltro-dev` out into the workspace. Absent, they skip with a `::warning::` annotation + job-summary line; with the deploy key configured (`VOLTRO_DOCS_REQUIRED`) the absence is a failure. Measured while fixing it: the README-only mode CI had been running typechecked **zero** samples, because generated package READMEs carry `sh` fences only. - **Sample-count floor + selftest-first** for `check-doc-samples` (1200; the corpus is ~1465). - **`check-claimed-wirings` gained a `--selftest`, a claims-scanned count and a floor.** It reported the FILE count (1462), which does not move when the CLAIM regex rots; it verifies 3 anchored claims of 14 matched lines. - **`--passWithNoTests` is gone from all 40 packages that carried it** — every one of them has tests, so the flag was blanket forgiveness for a suite that stops being found. New `scripts/check-test-scripts.mjs` (with `--selftest`) enforces both directions: a package with tests may not carry the flag, and a package with none must be declared in `NO_TESTS` (currently empty).
1804
+ - **@voltro/database** — **`residency.ts`'s header described wiring that does not exist, and now a test holds it to the truth.** It read *"the serve pipeline binds the per-region store per request; the cloud control-plane manages the home mapping + provisions per-region infra"* — present tense, both halves untrue. `setResidencyConfig` / `bindResidentStore` / `provisionResidentTenant` have zero call sites outside their own tests, in every repo, and `@voltro/runtime`'s `localityAwareSelector` is in the same state: exported, tested, public, unwired.
1805
+
1806
+ That is a defensible position and the header now argues it rather than misstating it. `servableRegions` collapses to one element until a deployment actually holds stores in more than one region, and with one element every path in this module is equivalent to the single-store path the framework already takes — so wiring it today buys a map lookup and a new way to fail closed on a correct request. The primitive earns its keep when a second region exists.
1807
+
1808
+ Two limits are stated too, because the shape of the API invites assuming otherwise: residency here is **per tenant, not per table** (a per-table `region:` would mean one request touching two stores, which un-expresses cross-region joins, transactions and foreign keys, and makes the differ plan against N live schemas), and it is **orthogonal to the three column-exposure axes** — `.sensitive()`, `.encrypted()` and `.serverOnly()` each answer a different question and none of them is a placement signal.
1809
+
1810
+ The correction is a TEST, not a paragraph. `residency.test.ts` asserts the three entry points have no callers across the workspace and fails with an instruction to update the header when one appears — because this is the third time a present-tense claim has outlived the code it described, and a prose fix rots exactly the way the original did. It walks with `withFileTypes` and skips dot-directories (the `readdirSync`+`statSync` gap that ENOENTs when a codegen suite removes a scratch dir mid-walk), and asserts a non-empty file set first so a broken walk cannot pass vacuously. Red-verified against a planted call site.
1811
+ - **runtime, plugin-ai-flows** — Correct in-code status comments that described shipped features as unbuilt (the reactive diff-share memo; ai-flows human/media/agentic steps). No behavior change — the comments had misled a whole-framework audit into filing built features as missing.
1812
+ - **@voltro/cli** — **A comment claiming work is PENDING cannot outlive the work — gated now, for the decidable half, and the undecidable half is written down rather than faked.**
1813
+
1814
+ `plugin-ai-flows/engine.ts` carried "not yet wired (task #35)" beside a feature that had shipped, plus `#31`/`#34` beside two more. A whole-framework audit read those comments, believed them, and filed HITL as unbuilt. A stale comment is a false statement in the place a reader trusts most, and it survives every test in the repository.
1815
+
1816
+ `scripts/check-stale-task-comments.mjs` (CI + `pnpm gate`, `--selftest` first) checks the half a machine can decide — a comment citing an EXTERNAL RECORD:
1817
+
1818
+ - **PLAN-REF** — a `plans/**.md` path cited in a comment must exist. - **TASK-ID** — a `task #NN` / `TODO(#NN)` must have its number appear somewhere under `plans/`.
1819
+
1820
+ **It found 19 dead references in this repo on its first run** (23 more across the sibling repos), all fixed here. `plans/architecture/` and `plans/awb/` were deleted wholesale in one 243-file reconcile commit and every comment citing them has been dangling ever since, in files that are otherwise correct.
1821
+
1822
+ **What is NOT implemented, and why that is a finding rather than an omission.** The obvious phrase list is worse than nothing. Measured over this monorepo's own comment lines: `lands in` 81 hits, ~0 of them temporal ("the row lands in the table"); `TODO` 1288 hits, ~6 real (a CDC fixture declares a table called `todos`); `not (yet) wired` 12 hits, ~4 real — and those four are unfixable by rule, being either permanent DECISIONS with reasons or descriptions of a RUNTIME state. One of the twelve is the comment recording the fix for this very defect class. A rule that is two-thirds false positives gets switched off, and a switched-off rule is indistinguishable from a green one.
1823
+
1824
+ **The floor is on FILES WALKED, not on matches**, and that choice is load-bearing: this check's corpus is supposed to go to ZERO, so a floor on matches would go red on success and the pressure would be to lower it. The extractor half is guarded by `--selftest` against a fixture with known-dead citations instead — including a false-positive control (the same path in a string literal must be ignored). The selftest earned its place immediately: it caught a cheap pre-filter in the first draft that silently disabled the whole TASK-ID rule while the check still printed green.
1825
+
1826
+ `plans/` lives in the meta repo, so a `voltro`-only checkout genuinely cannot run this — it SKIPS LOUDLY (`::warning::`) rather than passing, and a wrong `VOLTRO_PLANS_DIR` is a hard failure.
1827
+ - The request path, cold boot and resident memory now have numbers, produced by named commands and gated nightly (plans/optimizations/03, PERF-1/2/3). No package source changed; two e2e fixtures gained a `notes.add` mutation.
1828
+
1829
+ The gap this closes is one shape: **the framework measured the part nobody doubts and did not measure the part everyone attacks.** Five `*.perf.test.ts` pin the reactive engine's costs to counted operations, while HTTP → `@effect/rpc` dispatch → JSON decode → txn wrap → handler → encode — the path a TechEmpower-style comparison actually benchmarks, over `RpcSerialization.layerJson` with no binary option — had no req/s and no p99 anywhere in the repo.
1830
+
1831
+ - **`scripts/rpc-bench.mjs`** boots a real `voltro serve` (from the precompiled serve bundle — the production path) on `e2e-fixtures/memory-api` and `postgres-api`, drives query / mutation / subscription-open over real sockets with a closed-loop `node:http` generator, and reports p50/p95/p99 + req/s. - **`scripts/boot-budget.mjs`** pins cold `voltro serve`. `VOLTRO_BOOT_TIMING=1` has printed a per-phase breakdown for a while and nothing asserted anything about it. - **Resident memory + schema decode** ride the same suite: `scripts/lib/memory-probe.mjs` (forced GC, then `heapUsed`) and `packages/protocol/scripts/schema-decode-bench.mjs` (the real fixture descriptors, against `JSON.parse`/`stringify` in the same process).
1832
+
1833
+ **What is gated is not the milliseconds**, and that is the design rather than a concession. Wall-clock on a shared runner cannot be held to a bound that is tight enough to be worth having, so what goes red is: any failed request (an `@effect/rpc` failure answers HTTP 200, so a status-code check would benchmark the error path), too few samples, non-monotone percentiles, the framework's latency as a MULTIPLE of a bare `node:http` floor measured in the same run on the same box, the per-subscription heap after a forced GC, and the number of MODULES a cold boot loads. The last two are absolutes because they are properties of the code, not of the clock. The wall-clock boot ceiling is pinned per machine label and SKIPS loudly anywhere else.
1834
+
1835
+ Both benches ship a `--selftest` that runs on every PR in Static checks while the benchmarks themselves run nightly — a benchmark whose percentile maths or success predicate has rotted still prints a beautiful table, and a table is what gets quoted.
1836
+
1837
+ Two measurements that were wrong before they were right, recorded in-source so they are not re-derived: reading per-subscription memory with `ps -o rss=` reported the process getting SMALLER after 200 subscribers each took a delivery (a GC between two samples), and timing a one-field schema decode without batching reported it beating `JSON.parse` (inside the clock's resolution).
1838
+
1839
+ `packages/runtime/src/dispatcherDelivery.perf.test.ts` also gained an exact assertion for the audit's own correction: after one delivery, fifty subscribers of one descriptor hold literally the same `lastDelivered` array, so resident cost there is O(distinct descriptors). Object identity answers that; an RSS reading cannot.
1840
+
1841
+ ---
1842
+
1843
+ ## [0.33.0] — 2026-08-11
1844
+
1845
+ ### ⚠ BREAKING
1846
+
1847
+ - **@voltro/protocol, @voltro/runtime, @voltro/voltro** — `CoordinatedScheduleHandle` gained `wake()`, `currentIntervalMs()` and `isArmed()`.
1848
+
1849
+ The type change that carries the poller work in this release (see *A coordinated tick is a FLOOR*). Two of the three shapes it touches are NOT breaking and are listed here so the classification is checkable rather than asserted:
1850
+
1851
+ - the effect parameter was **widened** — it may now return a tick outcome, and an existing `() => Promise<void>` still satisfies it; - `Coordinator.tryClaim` gained an **optional** third parameter (the caller's bucket width), so an existing implementation still conforms.
1852
+
1853
+ (The plugin-facing `scheduleCoordinated` also gained an OPTIONAL fourth argument, `{ disarmWhenIdle }` — additive, and how a plugin opts its own task out of polling entirely.)
1854
+
1855
+ What breaks is code that **constructs** a handle rather than receiving one: a hand-written test double of `PluginBindContext`, which is the ordinary way to unit-test a plugin's `bindDataStore`. Four of the framework's own suites carried one, and three of those compiled only because the stub was cast — which is also why the two new members must be REQUIRED rather than optional. An optional `wake()` would let a caller subscribe a change channel to a handle that silently has none, and a poller that never wakes is the failure this release exists to remove, arriving quietly.
1856
+
1857
+ The codemod is `manual`: the object literal needing the two fields carries no importable symbol and usually sits behind an `as never`, so no transform can tell it apart from an unrelated literal in the same test file. It is gated on the app mentioning `scheduleCoordinated` at all.
1858
+ - **@voltro/runtime** — `@effect/opentelemetry` is now an **optional peer** of `@voltro/runtime` instead of a dependency. **If you export traces or metrics, install it:**
1859
+
1860
+ ```sh
1861
+ pnpm add @effect/opentelemetry
1862
+ ```
1863
+
1864
+ If you do not (no `FRAMEWORK_TRACING`, no `FRAMEWORK_METRICS`, no `OTEL_EXPORTER_OTLP_*`), nothing changes and your install gets 24 lines quieter.
1865
+
1866
+ It is reached from one dynamic `import()`, only when tracing is on, and it declares seven non-optional OpenTelemetry peers of which we supply five. So every `pnpm install` of every consumer ended with an unmet-peer block describing a condition that broke nothing. Declaring the two missing peers as real dependencies was the wrong direction — one of them is `@opentelemetry/sdk-trace-web`, the BROWSER tracer — and 0.64.0 is the current stable, so there is no upstream release marking them optional to wait for.
1867
+
1868
+ The reporting consumer's argument is what decided it: *"a check that is loud on every upgrade teaches people to skip the output, and the next warning in that block is the one that matters. We read past this one for four releases."*
1869
+
1870
+ A boot with tracing enabled and the package absent fails with a message naming this install line — a startup failure, not a silent loss of telemetry.
1871
+
1872
+ **`voltro update` carries you across this** — codemod `0.33.0/01_opentelemetry-optional-peer`.
1873
+
1874
+ ### Added
1875
+
1876
+ - **@voltro/cli** — `voltro agents-md` now reports which `@voltro/cli` it seeded from, and warns when that is not the one the project installs.
1877
+
1878
+ ```
1879
+ seeded from @voltro/cli 0.31.0 (project has 0.32.0; whats-new describes 0.31.0;
1880
+ modules COPIED into ./agent-docs)
1881
+ WARN the `voltro` binary that ran is 0.31.0, but this project installs 0.32.0 —
1882
+ everything just written describes the OLDER version.
1883
+ ```
1884
+
1885
+ A consumer reported a freshly-seeded `agent-docs/whats-new.md` one release behind their installed version, twice. The published packages are correct (verified with `npm pack`), so the content came from a different `@voltro/cli` than the one they installed — the command reads its templates relative to the RUNNING binary, and a globally-installed `voltro`, a stale `dist`, or a parent workspace's copy all produce exactly that, with output that looked identical either way.
1886
+
1887
+ It does not refuse and does not pick a cli for you: running the workspace binary against a checkout is legitimate and common.
1888
+ - **@voltro/cli** — `voltro db encrypt-column <table>.<column>` — the data migration `.encrypted()` always needed.
1889
+
1890
+ `.encrypted()` encrypts on WRITE, so adding it to a populated column converts nothing that is already there, and there was no supported way to convert it. A consumer carried three plaintext credential columns for months with no next step: *"`.encrypted()` braucht einen Cipher UND eine Datenmigration der bestehenden Zeilen; gemeldet, nicht behoben."*
1891
+
1892
+ ```sh
1893
+ voltro db encrypt-column integrations.webhookSecret --dry-run
1894
+ voltro db encrypt-column integrations.webhookSecret employees.meilisearchKey --yes
1895
+ ```
1896
+
1897
+ Five guards, each for a way a naive version succeeds and destroys data:
1898
+
1899
+ - **Idempotent** — an already-ciphertext value is skipped, so an interrupted run is resumed by running it again. Double encryption is unrecoverable without the key history. - **Round-trip verified before the write** — every value is decrypted back in-process first, so a broken cipher fails with nothing written. - **Key checked against what the column already holds** — a *different* key round-trips fine, so the check above cannot see it. Resuming with the wrong key would leave a column readable with neither key alone. - **Width pre-flight** — ciphertext is `49 + 4×ceil(bytes/3)` characters, so a 64-char key needs 137 and a `varchar(100)` fails partway. Refuses with both numbers and the `.maxLength()` to set. Measured in BYTES: `'ä'.repeat(10)` is 10 characters and 20 bytes. - **`--yes` required**, `--dry-run` shows the counts, and no value — plaintext or ciphertext — is ever printed.
1900
+
1901
+ Verified against a real postgres: the conversion, the re-run no-op, both refusals writing nothing, and a decrypt back to the original including multi-byte content.
1902
+
1903
+ ### Changed
1904
+
1905
+ - **@voltro/runtime** — `_voltro_schedule_claims` swaps its `(scheduleName, bucket)` index for `(scheduleName, claimedAt)`.
1906
+
1907
+ A consumer read `pg_stat_user_indexes` on their live table and measured, over its whole lifetime:
1908
+
1909
+ ```
1910
+ _voltro_schedule_claims_pkey 348 978 scans
1911
+ _voltro_schedule_claims_claimedAt_idx 3 949
1912
+ _voltro_schedule_claims_scheduleName_bucket_idx 4
1913
+ ```
1914
+
1915
+ Four. It was declared "for the case where you would rather ask by field", and nothing ever asks by field — every read of this table goes through the primary key, which *is* `<scheduleName>@<bucket>`. An index nothing uses is not free: it is written on every INSERT, into a table written once per tick per schedule.
1916
+
1917
+ `(scheduleName, claimedAt)` is the shape of a query that now exists — the per-schedule prune a winning claim runs (`WHERE scheduleName = ? AND claimedAt < ?`). The `claimedAt` index stays: the retention sweep's cutoff spans every schedule and needs it leading, which the composite cannot provide.
1918
+
1919
+ No codemod: a `_voltro_*` change rides the declarative differ on `voltro db apply` and on a `voltro dev` boot, on every dialect.
1920
+
1921
+ The same measurement corrected something the reporter had said in an earlier round and we had repeated back to them — that both indexes went unused. The primary key is used constantly. That makes the finding sharper rather than weaker: the ability to answer this question in one lookup is not merely available, it is demonstrably in use on the same table, and the one read path that needed it was the one not taking it.
1922
+
1923
+ ### Fixed
1924
+
1925
+ - **@voltro/database** — A column ADDED with a `reference()` now gets its foreign key in the same plan.
1926
+
1927
+ `ADD COLUMN` emits no `REFERENCES` clause on any dialect, and the planner's FK branch lived only in the path for a column present on both sides — so adding a `reference()` column to an existing table planned an `add-column` and nothing else. The constraint appeared on the SECOND `voltro db apply`, when the column was live and the diff finally saw a live column with no FK.
1928
+
1929
+ Two applies converged, so the state was reachable, which is why this survived as a low-priority note for a long time. It is worse under `voltro dev`: the boot diff refuses to record a fingerprint while the re-plan is non-empty, so an app whose only pending change was such a column re-planned on every boot and never converged.
1930
+
1931
+ Both callers share one `addForeignKeyOps` builder now, and the existing dependency tiering already orders `add-column` before `add-foreign-key`.
1932
+ - **@voltro/runtime, @voltro/protocol, @voltro/workflow, @voltro/cli** — A coordinated tick is a FLOOR now, and a claim no longer outlives its bucket.
1933
+
1934
+ A consumer's `_voltro_schedule_claims` reached **86 214 rows / 33 MB** on two days of uptime and took their deployment down: ten of a fifteen-slot pooler pinned on the claim read, an SSR render measured at **300 490 ms** behind them, every page in three frontends unusable, and a `rollout restart` that could not complete because the surge pod could not get a connection. Two hours of their own measurement produced the diagnosis, and both halves of it were right.
1935
+
1936
+ **Where the rows came from.** They declare one workflow, have never started it, use no flow control and no offloaded inference. Over one hour, with two replicas:
1937
+
1938
+ ```
1939
+ voltro.ai.inference 1 259 rows/h (250 ms ticks) framework
1940
+ voltro.workflow.admission 1 247 rows/h (1 s ticks) framework
1941
+ their own eight schedules 18 rows/h
1942
+ ```
1943
+
1944
+ 99.3 % of the ledger was the framework polling two structurally empty queues. A fixed interval has no way to learn that, so:
1945
+
1946
+ - **`scheduleCoordinated`'s effect may now REPORT its tick.** Return `{ idle: true }` and the runner backs off toward a ceiling; return `{ idle: true, nextDueInMs }` and it arms for that instant instead — which is what keeps a `debounce` window from being slept through. Returning nothing keeps the fixed interval, so every existing plugin task ticks exactly as before. - **Where an arrival is guaranteed to wake it, an idle task STOPS ENTIRELY** (`{ disarmWhenIdle: true }`). Both framework tasks do, on any deployment where a peer replica's write is visible locally — Postgres LISTEN/NOTIFY, or a broadcast broker. Measured against a real Postgres on a deployment that uses neither queue: **2 claim rows in five minutes**, one per task, both at boot. Where that guarantee does not hold, the ceiling (`VOLTRO_POLL_CEILING_MS`, default 30 s) is the correct behaviour and is what they get. - **`handle.wake()` runs a tick now.** Both framework queues are tables with the framework's own CDC triggers on them, so an enqueue already produces a change event on every replica; both dispatchers subscribe to it. The idle case gets ~120× cheaper and the busy case gets FASTER — work starts on the INSERT rather than up to a tick later. - The claim bucket stays floored by the BASE interval. Replicas do not share a backoff state, and two replicas computing different keys for one moment would both win.
1947
+
1948
+ **The cadence is declarable.** `scheduling: { admissionDrainMs, inferenceTickMs, cancelSweepMs, pollCeilingMs }` in `app.config.ts`, each with a matching `VOLTRO_*` env var that overrides it — the same ordering as `VOLTRO_TENANT_ISOLATION` over `tenancy.isolation`. They were internal constants, and a number the framework picks on a user's behalf belongs somewhere they can read it without reading our source. One resolver, called by both boot paths, so there is no second default to drift.
1949
+
1950
+ **Why the rows never left.** A claim answers one question about one bucket and was already answered the moment the bucket passed. A winning claim now deletes that schedule's own predecessors, so the table's size is a small multiple of the number of schedules rather than a function of uptime. How far back it prunes scales with the caller's bucket width — a cron keeps ~68 minutes of them (its firings carry their own instant, so a stalled one can re-present an old bucket), a 250 ms task ~1 minute (it recomputes its bucket at tick time, so an old one is unreachable). Deleting too early is a double fire; that grace is the whole safety argument. The 24-hour retention sweep stays as the backstop for a schedule that was renamed or deleted, which the per-schedule prune can never revisit.
1951
+
1952
+ Where reactivity is absent — a non-Postgres dialect with no broadcast broker — a remote replica's enqueue produces no local event and the ceiling is the whole latency budget. `VOLTRO_POLL_CEILING_MS` is there for that case and documented as such.
1953
+ - **@voltro/logger** — The pretty log format now prints a nested `Error`'s `message`. It did not, and the JSON format did.
1954
+
1955
+ `Error.prototype.message` is non-enumerable, so `JSON.stringify(err)` emits the metadata and drops the message. `expandCauseForJson` has existed for a long time to solve exactly that — and it was wired into `jsonFormat` only. The section heading above it said "(JSON path)", which was literally accurate.
1956
+
1957
+ `voltro dev` prints the pretty format. What a consumer saw when their boot died on a saturated pooler:
1958
+
1959
+ ```
1960
+ auto-migrate failed — aborting boot
1961
+ err={"failure":{"cause":{"length":117,…,"code":"XX000"},"message":"PgClient: Failed to connect"}}
1962
+ ```
1963
+
1964
+ `length: 117` is the length of a message that is not there. Recovered by hand, it was `(EMAXCONNSESSION) max clients reached in session mode - max clients are limited to pool_size: 15` — the whole diagnosis in one sentence, naming the fix.
1965
+
1966
+ Both the field tail and the plain-object cause branch expand now. The fix is in the formatter, not at the reporting call site: every `log.error('…', { err })` anywhere had the same hole.
1967
+ - **@voltro/cli** — Every `@voltro/*` package now exports its own `package.json`, so `require('@voltro/cli/package.json').version` works.
1968
+
1969
+ It threw. Node has enforced this since 12: a package with an `exports` field exposes only what that field lists, and none of the 77 packages listed `"./package.json"`.
1970
+
1971
+ Reported by a consumer for whom it was the instruction WE gave for settling whether a security command had been running on stale code — so the verification step for a security question could not run at all. Both the workspace `exports` and the shipped `publishConfig.exports` are fixed, and a guard sweeps every package so a new one cannot ship without it.
1972
+ - **@voltro/cli, @voltro/sql-postgres** — The `db pool:` boot line now counts the connections this process holds OUTSIDE the pool, and names them.
1973
+
1974
+ It reported `max × replicas` and called that the connection count. A consumer sizing a per-pod budget against a pooler measured the gap:
1975
+
1976
+ > `LISTEN` läuft außerhalb von `dbMaxConnections` (eine pro Pod, gemessen sogar > 3). Der echte Bedarf ist `dbMaxConnections + 1`.
1977
+
1978
+ Their measurement was right and their conclusion was one short. The framework opens a standalone connection in three places, and a full deployment holds all three:
1979
+
1980
+ | Process | Connection | When | |---|---|---| | api `voltro serve` | CDC `LISTEN` consumer | `changeStrategy: 'cdc'` | | web `voltro start` | ISR invalidator `LISTEN` | a page declares `cacheInvalidatesOn` | | web `voltro start` | postgres ISR cache client | `SSR_CACHE=postgres` |
1981
+
1982
+ The third is not a `LISTEN`, which is why counting `LISTEN` rows in `pg_stat_activity` undercounts, and why `+1` could not have been documented as a constant: the count is per PROCESS and only the process knows what it armed.
1983
+
1984
+ The line says `No connections outside the pool in this process` when there are none — silence about it is what made "counted, zero" indistinguishable from "not counted". The `maxConnections` docstring, which promised `+1` as if it were the deployment's number, is corrected. Production-hardening docs (both languages) gain the table plus the `maxSurge` arithmetic a rolling update needs.
1985
+ - **@voltro/cli** — The retention sweep is registered on every dialect — it was postgres-only, and silently.
1986
+
1987
+ `wireRetentionSweep` opened with `if (dialect !== 'postgres') return`, so on mariadb, mysql, mssql and sqlite **none of its seven policies was registered, nothing was ever deleted, and the boot printed no armed-policies line** — so there was nothing to notice either. A consumer on MariaDB 11.8.8 measured it by reading the published bundle rather than their logs:
1988
+
1989
+ ```
1990
+ _voltro_schedule_claims 109 520 rows 32.8 MB over 20 days
1991
+ _voltro_schedule_runs 17 507 rows 7.4 MB
1992
+ ```
1993
+
1994
+ Their seven `VOLTRO_*_TTL_HOURS` variables were inert — read only inside the branch that never ran — and two of them were already set in their Helm chart.
1995
+
1996
+ **The gate was aimed at the right thing and applied to the wrong scope.** What is postgres-specific is the fast DELETE (`"camelCase"` quoting, `DELETE … RETURNING`), which is one branch of one function that has always had a portable fallback beside it. Gating the REGISTRATION on it turned a performance choice into a feature that does not exist. The dialect check now sits on the branch it describes.
1997
+
1998
+ Two things came out with it:
1999
+
2000
+ - **The fallback deleted row by row.** Acceptable while the path was unreachable; against the reporter's backlog it is 20 000 round trips per sweep pass. It reads a bounded batch of ids and issues ONE set-based delete for them — still bounded, so the DELETE never grows to lock the whole backlog. - **Two tests asserted the defect as intended behaviour**, with reasoning that was internally consistent and rested on the premise that was itself the bug (*"the sweep is postgres-only, and announcing a delete that will not happen is the mirror image of the defect"*). Both are inverted now and run across all five dialects.
2001
+
2002
+ This is the third turn of the same screw, and the reporter's framing is the one to keep: we fixed *a standing delete that never introduces itself*, then shipped *one that introduces itself and does not run* — and beside both of those sat one that silently did not exist.
2003
+ - **@voltro/cli** — `voltro db scan-credentials` no longer reports the framework's own redaction markers as credentials, and no longer claims a match was a *key*.
2004
+
2005
+ A consumer with correctly-redacting plugins got:
2006
+
2007
+ ```
2008
+ ✗ _voltro_row_history.data — 69 of 149 row(s) match a credential-shaped key
2009
+ matched (rows per needle, may overlap): token (69)
2010
+ ```
2011
+
2012
+ All 69 rows were `"_omitted": ["token"]` — `@voltro/plugin-versioning`'s record that a `.serverOnly().sensitive('secret')` column was deliberately left OUT of the snapshot. The scan matched the proof that nothing is stored there, called it a credential, and printed *purge them AND rotate the credentials* underneath.
2013
+
2014
+ Two changes. The headline says what the predicate does — it is a substring match over the whole serialized column, so it finds a credential-shaped **name** anywhere in the value, which it always did. And each hit is now EXPLAINED: a bounded second pass (500 matched rows per target) reads them back in-process and separates a JSON **key** from a **redaction marker** (`_omitted`, `__redacted`). Values are never printed and never logged.
2015
+
2016
+ A target whose every matched row is a marker reports as explained and exits `0`. The bar is deliberately high — every matched row examined, every one a marker and nothing else. A capped read-back, one real key, or one row that will not parse as JSON keeps the target a finding and still exits `1`.
2017
+
2018
+ The shape mattered more than the one key name: the more columns an app classifies correctly, the more markers it writes, and the redder the scan turned.
2019
+ - **@voltro/runtime, @voltro/cli** — `advisoryLock` scheduling no longer reads the whole `_voltro_schedule_claims` table to answer whether one claim row exists, and that table is now swept on the scale it fills.
2020
+
2021
+ The existence check ran `SELECT "id" FROM "_voltro_schedule_claims"` with no `WHERE` and no `LIMIT`, then filtered in JavaScript — twice per claim attempt (the fast path, and the re-read that separates "lost the race" from "the claims table is broken"), on every replica, for every schedule firing. A consumer measured ten concurrent copies of that scan holding every connection of a 15-slot pooler, with an SSR render behind them at **300 490 ms**. It is a primary-key lookup bounded to one row now (`id` *is* the claim key).
2022
+
2023
+ The pool-acquire bound added in 0.32.0 turns that from a hang into an error; it does not stop the scan from filling the pool. Both are needed.
2024
+
2025
+ `VOLTRO_SCHEDULE_CLAIMS_TTL_HOURS` also defaults to **24 hours** instead of 30 days. The 30-day default was copied from the framework's history tables (`_voltro_schedule_runs` and friends), and a claim row is a lock ledger — it answers a question about one firing instant and nothing reads yesterday's. At the 1 557 rows/hour that consumer measured, a 30-day window reaches ~1.1 million rows before the first one ages out. Raise it deliberately if you need to; the number to reason about is the longest a replica may be paused and still be trusted not to re-fire a bucket it already lost.
2026
+
2027
+ The boot announcement can now express an age under a day (`older than 1h`); it previously rounded every TTL to whole days, so an operator setting one hour read their own policy back as `older than 0d`.
2028
+ - **@voltro/runtime** — A coordinated periodic task armed below one second now runs at the interval it was given.
2029
+
2030
+ `scheduleCoordinated` floors the wall clock to its `intervalMs` and races on that instant; the claim key truncated it to second precision. A task at 250 ms therefore produced four bucket instants per second that collapsed to one key — the first tick won and the other three were dropped as "lost the claim". Measured: 1 of 4.
2031
+
2032
+ `voltro.ai.inference` is armed at 250 ms and was dispatching once per second, on every multi-replica deployment, with nothing above `warn` to say so.
2033
+
2034
+ This is the defect the coordinator's own comment describes at minute precision (6-field crons firing once a minute), one decimal place down; that comment was written before `scheduleCoordinated` existed, and `scheduleCoordinated` is the caller that goes below a second.
2035
+
2036
+ Milliseconds join the claim key only when non-zero, so every cron key is byte-identical to before — load-bearing during a rolling deploy, where old and new replicas computing different keys for one firing would both win and double-fire.
2037
+
2038
+ Note the consequence for table size: a sub-second task now writes claim rows at its true rate. Bounded by `VOLTRO_SCHEDULE_CLAIMS_TTL_HOURS` (24 h), and `ai.tickIntervalMs` raises the interval if you want fewer.
2039
+
2040
+ ### Internal (no consumer-facing effect)
2041
+
2042
+ - **@voltro/cli** — No separate consumer-facing note on purpose: this refines the per-needle breakdown described in the UNRELEASED 0.32.0 section, and that section — which is what a reader will actually see — carries the correction. Documenting it twice would describe one change as two.
2043
+
2044
+ The refinement: the per-needle counts OVERLAP and do not sum to the hit count (a row holding both a token and a secret is counted by both). The output line says so now, because two numbers printed under a total invite being added up, and a reader who adds them and gets more than the total loses confidence in the whole report.
2045
+
2046
+ ---
2047
+
42
2048
  ## [0.32.0] — 2026-08-10
43
2049
 
44
2050
  ### ⚠ BREAKING