# Changelog

All notable changes to the `@voltro/*` packages are recorded here. The format
follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

## Stability contract — read this before you pin a version

Voltro is **`0.x` — pre-1.0, and deliberately so.** Under SemVer, `0.y.z` means
"anything MAY change." We hold to exactly that, stated out loud:

- **Pin exact versions.** Depend on `@voltro/runtime@0.4.2`, never `^0.4.2` or
  `~0.4.2`. There is no compatible-range promise below 1.0.
- **Every MINOR may break.** Breaking changes land on a minor bump (`0.4.x →
  0.5.0`); patches (`0.4.1 → 0.4.2`) are additive or fixes only. This is the
  standard `0.x` reading of SemVer — the minor slot carries breaking under `0.x`.
- **Read this changelog before upgrading.** The `⚠ BREAKING` section of each
  release lists every incompatible change with its migration. It is the only
  migration path we provide — there is no deprecation cycle, no compat shim.
- **All packages release together (lockstep).** One coordinated version across
  the whole framework; the git tag is the source of truth.

This is not a placeholder disclaimer — it is the contract. The framework is
refactored aggressively while it has no external stability obligations, and that
velocity is the point. A stable-core tier and per-package `1.0` graduation are
planned, but not yet; until then, treat the whole surface as movable and pin
exact.

The **public API** of each package is its `publishConfig.exports` entry points,
minus anything marked `@internal` (those are cross-package internals and are
stripped from the published `.d.ts`). Importing a deep path that isn't an
exported entry point is unsupported.

---

## [Unreleased]

_Changes staged for the next release accumulate here (rolled up from
`.changes/*.md` at tag time — see `.changes/README.md`)._

---

## [0.50.1] — 2026-08-24

### Added

- **@voltro/cli** — A `0.51.0` upgrade note for the case 0.45.0's typed-`source:` note left open: a source that is COMPUTED but whose names are all known — a generic reader that takes its table from `input` or a registry. Both remedies the original offered are wrong there. There is no misspelled name to fix, and widening to `ReactivitySourceValue` (or casting) compiles instantly while taking that entire set of tables permanently out of the check the narrowing exists to provide.

  The note carries the recipe that keeps the names — `as const satisfies`, with the key set recovered separately — and the trap under it: an ANNOTATION widens the keys back even when the literal carries `as const`, because it is checked against the literal and then replaces its type. Filed under an unreached version because codemod selection is `from < version <= to`: 0.45.0's note can never fire again for anyone already past it. Documented on the subscriptions page in both languages.

### Changed

- **@voltro/data-transfer** — The `mariadb-dump`-not-on-PATH hint now also warns that a MariaDB 12.x client requires TLS by default, so running it BY HAND against a server without TLS fails with `TLS/SSL error: SSL is required` (2026) and needs `--skip-ssl`. Our own invocation is unaffected. It is said here because this hint is what sends people to install that client package, and the next thing many of them do is run the tool by hand — where the failure reads like a broken install of the thing we just told them to install.

### Fixed

- **@voltro/cli, @voltro/mcp** — `voltro check`'s `rbac/unguarded-mutation` no longer fires on `internal: true` procedures. Those are in no rpc group and on no route, so the finding's own sentence — "any caller who can reach the rpc surface can invoke it" — named a surface that does not exist, and neither remedy it offered was available: a guard protects nothing there, and `openAccess` is refused outright on an internal descriptor. An app with 990 internal mutations got 990 warnings and a `FAILED` exit from the same tree on which `voltro doctor` reported zero procedures without an access decision, which made `check` unusable as the CI gate it exists to be.

  The capability manifest now carries `internal: true` per procedure (present only when true), and the rule reads it through `isWireReachable` — the same predicate the boot access gate and all three rpc-group assemblies use, rather than a fourth spelling of the same flag. A manifest that does not carry the field is read as REACHABLE, so the rule stays loud on an api older than the field rather than going quiet on it.
- **@voltro/data-transfer, @voltro/cli** — `--assets` aborted the whole capture at the first blob reference the storage provider could not resolve. A dangling reference is a fact about the source data — a row pointing at an object that was deleted, or that never arrived because an earlier import ran without the flag — and no backup can put back bytes that are not there. Aborting made the flag unusable for exactly the deployment that needed it: 178 references, one resolvable, and the run stopped at the second.

  The phase now records the key, steps over it, and the run reports how many were skipped. Only a genuine not-found (`status === 404`) is treated this way; a 403 from a rotated credential or a 5xx from a backend outage still fails the capture, because calling those "the object is gone" turns a recoverable outage into a backup that quietly contains nothing.

  The same abort also cost the artefact its provenance: `voltro-backup-stamp.json` was written after the asset phase, so a backup taken WITH `--assets` had no stamp at all, and `restore` then greeted an artefact this tool had written minutes earlier as "an older/handmade backup. Cannot verify dialect or schema version." The stamp is now written on every path — it describes the dump, and the dump is on disk and correct by the time the assets run.
- **@voltro/cli** — `voltro doctor`'s eager-loaded-relation rule resolves nested relations against their PARENT's target table instead of against the query's roots, so every level is reported in one pass. It flattened the `.with({ … })` tree before resolving, which meant a level-2 relation stayed invisible until level 1's table had been added to `source:` — each fix revealed the next level, and a clean run after the first fix meant nothing. One app ran `doctor → fix → doctor` twice before the output stopped producing new findings.

  The one case that is still genuinely under-reported is now stated instead of being silent: when the executor's base table is neither declared nor textually readable, nothing resolves, and doctor prints which relation names it could not check rather than reporting clean.
- **@voltro/data-transfer** — A native restore whose client exited before consuming its input took the whole CLI down with an unhandled `EPIPE`. `createReadStream(dump).pipe(child.stdin)` had no `error` listener, so Node threw on a write to a pipe with no reader:

  Error: write EPIPE Emitted 'error' event on Socket instance at: at Socket.onerror (node:internal/streams/readable:1045:14)

  What that cost is the point. The crash landed before `close`, so the step never resumed: the client's stderr — the one place the reason was written down — was discarded, the transfer row was never closed, the in-progress marker was never re-asserted, and the operator got a raw Node stack trace over a database that was now half replaced. A deployment met it on a 64 MB restore and could not diagnose it at all, because the only diagnosis had been thrown away.

  Every stream in the step now carries a handler. `EPIPE` on the child's stdin is deliberately swallowed — a child closes the pipe by dying, and its exit code and stderr are the actual failure — while any other stream error is carried and reported. The failure line now always includes the tool's stderr (and says so explicitly when the tool printed none), plus how many bytes of the artefact had been fed before it stopped. A source error also ends the child's stdin, which turns a restore that used to hang forever on a vanished dump into one that fails, and a clean exit over a broken input stream is reported as a failure rather than as a truncated success.
- **@voltro/cli** — `voltro check`'s observed-vs-declared findings advised an op that does not exist. The recorder reports what the store did, and `store.upsert` is one call; a `target:` is `InsertTarget | UpdateTarget | DeleteTarget` and has no `upsert` member. So `observed/undeclared-write` said to add `{ table: '…', op: 'upsert' }` — a `TS2322`, from a line whose whole purpose is to be pasted. It now advises both halves, and the observed → declarable mapping is typed `Record<ObservedWriteOp, ReadonlyArray<TargetSpec['op']>>` so a new recorder op cannot ship without a declarable answer.

  The remedy no longer produces a finding of its own either: declared targets are now collected per table as a SET. `new Map(targets.map(t => [t.table, t.op]))` kept only the last op, so the correct two-entry declaration read as "declares update" and the insert half came back as a `wrong-op`.
- **@voltro/data-transfer, @voltro/cli** — The in-progress restore marker did not survive a native restore, on the exact dialects that were documented as safe. The claim — postgres and the mysql family drop only the objects the dump names, so the marker row survives — had a correct argument and a wrong premise: a native dump names the WHOLE database, `_voltro_replace_in_progress` included, and a mysql-family restore writes `DROP TABLE IF EXISTS` in front of each table. The table sorts early, so the guard was removed near the START of the window it exists to cover. Measured downstream as one row before and zero after, twice, once by hand and once through the command — and then a restore died and left a database with a schema, some of its tables, no users and no blobs, with nothing to stop the next boot.

  Two changes, covering different dumps. `voltro data backup` now excludes the marker table (`--exclude-table` / `--ignore-table`), so an artefact we produce cannot carry the thing that erases the guard on the way back in. And `restore` re-asserts the marker after the tool exits, on the failing path as well as the succeeding one, which covers dumps taken before this change and dumps made by hand: rewritten is a warning, and unwritable is an error, because the guard is then off for that run.
- **@voltro/plugin-storage, @voltro/cli** — `--assets` could not be refused when no storage was configured, and resolved the wrong backend when it was.

  `resolveStorageProvider({})` is total: its final `default:` arm returns an in-process `memoryProvider`. So the three `if (!provider)` refusals in `voltro data backup` / `export` / `restore` — whose entire job is to refuse a flag the app cannot honour — were checking a condition that cannot hold. What they let through is worse than an unchecked flag: with no storage configured, `backup --assets` captured from a fresh memory provider, found nothing in it, and wrote an artefact stamped `assets: { count: 0 }` — a rollback story that says the blobs are in there.

  It also ignored the provider the app REGISTERED. An app on `storagePlugin({ provider: s3(…) })` had its export/import/backup read and write the env-derived default instead, which surfaces later as "no object at key" — the exact drift `appStorageProvider()` exists to prevent, in call sites that never adopted it.

  `configuredStorageProvider()` is the honest predicate: the app's registered provider, else the env-named one, else `undefined`. A memory provider nobody asked for is not a decision. The CLI now loads the app config BEFORE resolving — `storagePlugin(...)` registers at construction, so asking first answered with the default no matter what the app had configured.

### Internal (no consumer-facing effect)

- **@voltro/cli** — `voltro data backup` / `restore` / `clear-replace-marker` are now driven end to end, one case per declared option and one per refusal, as real CLI subprocesses against a real database.

  Every defect this command has shipped lived in the SEQUENCE rather than in a function — an unhandled `EPIPE` that discarded the client's stderr before `close` fired, a marker erased by the restore's own artefact, an asset abort that skipped the provenance stamp, a refusal that could not fire — so none of them was reachable by testing a part. `nativeBackupRestore.e2e.test.ts` covers the whole surface against sqlite (a file copy: no vendor binary, runs anywhere) and `nativeBackupRestoreDialects.integration.test.ts` runs the vendor-tool half against postgres, mariadb and mysql, skipping BY NAME when the server or the client binary is absent — including when the client is present but too old for the server, which a "is it on PATH" check reports as ready.

  The suite checks itself against `DATA_FLAGS`, the command's own declared option list, so an option cannot be added without a case that drives it.

---

## [0.50.0] — 2026-08-23

### ⚠ BREAKING

- **@voltro/cli** — The read-only run-history endpoint moved:

  GET /_voltro/admin/imports → GET /_voltro/admin/transfers

  Same secret, same query parameters, same response shape. Only the path changed.

  It answers for four directions now — import, export, native backup, native restore — and it named one of them. The subcommand and the table were renamed for exactly that reason in 0.49.0 and this path was left behind, which is worse than renaming none of them: a reader who follows a rename tries the matching path, gets a 404, and concludes the instance is too old.

  `voltro data transfers --target api` uses the new path for you. What needs a hand is anything calling it directly — a monitoring check, an uptime probe, a curl in a runbook, a dashboard datasource. The codemod is `manual` and declares `reach: 'beyond-source'`, so it prints whether or not the path is found in your repository.
- **@voltro/database, @voltro/voltro** — `InterruptedReplace.tables` is `number | null`, and the interface gains `kind?: 'replace' | 'restore'`.

  `_voltro_replace_in_progress` records two destructive operations now — an import's `--mode replace` and a native `voltro data restore`. A restore replaces the whole database from an artefact, so "how many tables was this going to empty and refill" has no answer: not known up front, not meaningful after.

  Writing `0` would have preserved the type and been worse. That number is rendered into the boot refusal, which is read under pressure, and it would have said `began emptying 0 table(s)` — a measurement that was never taken. The framework's own text says "this database" instead; `Number(null)` is `0`, so coercing it reintroduces exactly the sentence being avoided.

  `DataTransferRun.direction` also widens to name `'backup'` and `'restore'` explicitly. That one is documentary — the union already ended in `| string`, so no assignability changes.

  **`voltro update` carries you across this** — codemod `0.50.0/03_interrupted-replace-tables-nullable`.

### Added

- **@voltro/cli, @voltro/data-transfer, @voltro/database** — `voltro data backup --assets` and `voltro data restore --assets` now move the stored blobs alongside the vendor dump, through the same content-addressed phase the logical `export` / `import` path uses — streamed, deduped by sha256, verified on the way back, resumable per key.

  `--assets` was accepted by `backup` and silently ignored, with the only signal a field in the closing JSON reading `NOT included`; `restore` did not accept it at all. A rows-only backup restores a database whose rows reference objects nothing puts back, and the reference and the object are checked at different times, so that state is discovered by a user rather than by the restore.

  Three refusals, each for a belief that is otherwise acted on silently: `backup --assets` with no storage provider is refused rather than swallowed; `restore --assets` on a rows-only artefact is refused; a restore WITHOUT `--assets` over an artefact that has them warns and proceeds, because restoring rows without blobs is legitimate and refusing it would push people at `--force`.

  The dump itself still has no resume — a vendor artefact is one opaque file with no offset to restart from, and the logical path is what exists for that.
- **@voltro/cli, @voltro/database, @voltro/data-transfer** — A native `backup` / `restore` now writes to the same `_voltro_data_transfers` record `import` and `export` use, so `voltro data transfers` answers "did last night's backup finish" from the instance that ran it. A native run reports blobs rather than rows — a vendor tool reports no row count we can trust, and printing `0 row(s)` over a dump that worked would be a wrong measurement.

  `restore` also writes the `_voltro_replace_in_progress` marker before its first destructive statement and clears it after the last write, blobs included, so a killed restore refuses the next boot instead of serving a half-loaded database. `--allow-live` guards from the wrong side — it asks you not to — and this guards from the right one. The marker carries a `kind` so the refusal can say something different for a `replace` (re-import the capture) and a `restore` (finish the restore), and `tables` is nullable because a native restore has no table count to claim.

  The sqlite / turso restore is atomic now (temp file + rename): its marker lives in the very file being replaced, so a plain in-place copy would leave a truncated database with nothing left to catch it.

  A target with no `_voltro_data_transfers` table still gets its backup; the closing line says it was not recorded rather than implying it was.
- **@voltro/cli** — A re-issued migration note for the `voltro data imports` → `voltro data transfers` rename that shipped in 0.49.0.

  Codemods are selected by `from < version <= to`, so the 0.49.0 note fires once, on the jump that crosses 0.49.0, and cannot be corrected for anyone already past it. Its gate searched `.ts` / `.tsx` while the command it is about lives in shell scripts, CI job definitions and runbooks — so a project whose only occurrence sat in `.gitlab-ci.yml` crossed 0.49.0 and was told there was nothing to apply.

  The re-issue is filed under 0.50.0, prints unconditionally, and says what to grep for. Redundant for anyone already fixed; the alternative is firing for nobody.

### Fixed

- **@voltro/cli** — A `manual` codemod's `appliesTo` can now search every text file the project owns — `.sh`, `.yml`, `.json`, `.md`, `Makefile`, the `.js` scripts — through a new `ctx.text` on the predicate context, and a codemod may declare `reach: 'beyond-source'` to print its note even when nothing matched.

  Both halves close the same gap, and it was in the codemod most in need of a gate. A manual codemod exists BECAUSE its subject could not be transformed, which usually means it is not source at all — and `appliesTo` was reading the ts-morph project, which holds `.ts` / `.tsx` and nothing else. Measured with one identical CLI invocation in four files: the `.ts` one printed the note, the `.sh`, `.yml` and `.md` ones printed nothing, and the run reported `codemods: nothing to apply for this jump`. That is an acquittal from a check that never looked at the file, and an acquittal gets acted on.

  `reach: 'beyond-source'` covers what no scan of one repository can reach — an inline script in a CI runner's own UI, a CronJob spec in another repo, a wiki runbook. The note prints either way; only its framing changes, and an uncertain one says plainly that we could not look there.
- **@voltro/data-transfer** — When `mariadb-dump` is absent and the mysql-family fallback runs Oracle's `mysqldump` against a MariaDB server, the failure now names the way out.

  The stderr it prints is the child's own words and is the right first thing to show — and on its own it is a dead end: `Unknown table 'COLUMN_STATISTICS' in information_schema (1109)` names a table nobody asked for, in a schema nobody wrote, about a feature nobody enabled. Everything needed to act on it was known where the fallback was DECIDED: which binary we wanted, which one we took, and why the difference matters. That travels with the step now and prints under the stderr, including the counter-move a reader reaches for on their own (`--column-statistics=0` does not exist on `mariadb-dump`, so it fixes the wrong client and breaks the right one).

---

## [0.49.0] — 2026-08-23

### ⚠ BREAKING

- **@voltro/cli, @voltro/data-transfer, @voltro/database, @voltro/sql-sqlite, @voltro/voltro** — A data transfer is a series of short requests now — no single one may outlive a caller's budget.

  **The rule:** *a request that carries bytes never runs a transfer; a request that starts a transfer never carries bytes.* It was broken in the worst available place. The upload was already chunked and resumable — many short requests, each abandonable — and then the FINAL chunk fell through and ran the whole import. So the longest request of the flow arrived AFTER the entire upload had succeeded, and a caller under a policy that caps a single request (a job runner that kills a client at ten minutes; a 30 s ingress ceiling) lost the most expensive thing they had already paid for. A single-request upload had the same shape without the excuse, and the export had no protocol at all: one request that read the whole database and streamed it back.

  **Import.** `POST /_voltro/admin/import` accumulates and answers `202`. `POST /_voltro/admin/import/start` begins the run and answers `202 { runId }` as soon as the run's first row exists. `start` is idempotent per upload — it is a short request and therefore a retryable one, and without a claim a retry would begin a second destructive run from the same bytes. A claim whose run has ENDED is taken over rather than honoured forever, so a process that dies holding one cannot poison a bundle.

  **Export.** `POST /_voltro/admin/export` answers `202 { runId }` and produces in the background; the bytes come back from `GET /_voltro/admin/export/download?runId=&offset=&length=` in ranges, resumable, with the total in a header. Object storage (`--bundle-key`) remains for a bundle you want to KEEP — it is no longer the only way to get one out, because requiring it would leave an instance without storage unable to export at all.

  **The client.** `--max-request-seconds` (or `VOLTRO_MAX_REQUEST_SECONDS`) declares the budget — declared rather than probed, because the thing that kills a request is a policy on the caller's side and only they know it. `--detach` returns once the run has started and says the outcome is NOT known. Attached, the CLI polls the record and prints per-table progress; Ctrl-C then loses the watching and never the run.

  **The trap, stated because it is the one way to get this wrong:** a failure used to arrive in the response (409 on drift, 409 on a refused mode, 500 otherwise). After the split the response is a `202`, so **a client deriving its exit code from the status line reports a failed import as a success.** The exit code comes from the polled record, in one shared function, and the drift check moved into `start` where it can still be a refusal that leaves no history.

  **Two knobs that were constants.** `VOLTRO_IMPORT_UPLOAD_DIR` and `VOLTRO_EXPORT_ARTIFACT_DIR` move the staging areas off the default temp filesystem — which on a container is frequently a small tmpfs, so an instance simply could not accept a bundle the size a grown database produces, and the failure arrived as a write error halfway through an upload somebody had been waiting on.

  **Renamed:** `voltro data imports` → `voltro data transfers`, and `_voltro_data_imports` → `_voltro_data_transfers` with a `direction` column. The command showed one direction and now shows both; the table rename carries its rows via the declarative differ on every dialect and needs nothing from you. The CLI rename ships a `manual` codemod — the command lives in scripts, CI jobs and runbooks, which `voltro update` cannot see or rewrite. The four PUBLIC exports that named the same record were renamed with it (`ImportRun`, `describeImportRun`, `IMPORT_RUNS_TABLE`, `_voltroImportRunsTable`); those are application source, they ship their own `transform` codemod, and they have their own entry.
- **@voltro/database, @voltro/voltro** — The run-history exports say `transfer`, not `import`.

  `@voltro/database` (and `voltro/database`) renamed four public exports along with the table behind them:

  | was | is | |---|---| | `ImportRun` | `DataTransferRun` | | `describeImportRun` | `describeTransferRun` | | `IMPORT_RUNS_TABLE` | `DATA_TRANSFERS_TABLE` | | `_voltroImportRunsTable` | `_voltroDataTransfersTable` |

  An EXPORT writes to this record now — the row carries a `direction` — so every one of those names described half of what it holds.

  A `transform` codemod rewrites all four, alias-aware, from either module spelling. It also rewrites a hand-spelled `_voltro_data_imports` in a string, template or raw-SQL fragment, and that is the half worth stating: the four identifiers announce themselves as compile errors, while a query that addresses the table by name has nothing to fail on. The table itself moves with its rows via the declarative differ on every dialect.

  This is filed apart from the transfer-protocol entry beside it deliberately. That one's codemod is `manual` and is about a CLI invocation living in scripts and CI jobs; this one is application source and is rewritten for you. Reading the first as covering both is what would leave a build broken with a note saying nothing in your source was affected.

### Added

- **@voltro/database, @voltro/data-transfer, @voltro/cli** — An import records WHETHER it staged, and a soft-dropped column says so in a drift refusal.

  **`_voltro_data_imports.staged`.** The pre-upload preflight says what the instance WILL do; the run's own line says what it did — and that line is printed inside the instance, which is exactly where an operator using `--target api` cannot read it. So "we were told it would stage" and "it staged" were two claims with no way to close the gap between them from outside. The column is `null` for a mode where the question does not arise; `voltro data imports` and `GET /_voltro/admin/imports` both carry it.

  **A soft-dropped column is named as one.** `<original>__dropped_<stamp>` is what the differ leaves behind when an app stops declaring a column — only the database that did the drop has it, so it drifts against every target. The refusal reported it as an ordinary missing column ("the value has nowhere to go"), which points at the TARGET's schema: the one place the fix does not lie. It now says where the column lives and how to reclaim it.

### Fixed

- **@voltro/data-transfer, @voltro/sql-sqlite, @voltro/database** — A successful `--mode replace --no-atomic` left a marker that refused the next boot.

  Two defects, one visible symptom, and both were silent by construction.

  **The clear was gated on the WRITE's condition.** `!(useLedger && ledger.truncated)` means "an earlier attempt already recorded this destructive run, do not write a second row" — correct on the write. Copied down to the clear its meaning inverts: `ledger.truncated` is set by the emptying step of THIS run, so on `--no-atomic` (the only mode where `useLedger` is true) the clear was skipped by the very run that wrote the marker.

  **And a `Date` in a predicate is not bindable on sqlite.** `better-sqlite3` binds numbers, strings, bigints, buffers and null; the row path has coerced Dates since that store was written, and the eager-join compiler carried its own private copy of the fix, but `query` / `updateMany` / `deleteMany` bound the raw value. So every comparison of a timestamp column against a `Date` failed with `Failed to execute statement` — including the marker's clear, which swallows its errors ON PURPOSE (a store with no marker table must not fail an import over a bookkeeping row) and therefore said nothing. The retention sweep compares `lt(column, cutoff)` the same way.

  Together: a fully successful replace left the marker standing, and the next boot REFUSED with "a destructive import did not finish" over a database that was completely fine. The one recovery is a command the operator has no reason to think they need. It stayed invisible because `atomic` defaults to true for `replace`, and because a staged replace writes no marker at all — two defaults hiding the one mode that exists for large, interruptible loads.

  The coercion is shared now and applied at all three predicate sites, with a guard that fails on a fourth that forgets. A dry run also closes its trace row: a preview that finished instantly used to leave the record open, so a polling caller waited out its whole budget over a run that was long done.
- **@voltro/cli** — `voltro codegen` declared twenty framework tables fewer than a boot.

  The app half of this was fixed last release and looked like the whole thing. It was not: `codegen` derived the FEATURE MIX from the file list it had just walked, and that list is the entity/relations set — which contains no `*.workflow.tsx`, no `*.agent.ts`, no `*.cron.tsx`. So every feature flag came back false, and the dialect was never passed at all, taking `_voltro_cdc_offsets` with it. Measured on an app with two workflow files: 18 framework tables where the shared assembly produces 33.

  It calls `assembleFrameworkTables({ root })` now — the same entry `voltro db plan/apply` uses, which detects the mix from the root rather than being handed one.

  The parity guard moved with it. Comparing the two WALKS could not see this: both walks were right about files, and the divergence was introduced one layer past them. It compares the assembled SETS now, and runs codegen's own table function on a tree whose only feature signals are a workflow file and an agent file — a source assertion that the right function is CALLED cannot see a wrong argument handed to it, and a wrong argument is what this was.
- **@voltro/cli** — `voltro data --help` advertised four subcommands out of nine.

  It printed `<export|import|backup|restore>` while the command dispatched those four plus `imports`, `inspect`, `unpack`, `clear-replace-marker` and `clear-staging`. A quoted enumeration is read as exhaustive — the same failure `subcommandNames.ts` was written for after a `db` list cost two wrong conclusions — and the missing entry here is the one that answers "what is my import doing right now" for somebody who cannot reach the pod.

  `subcommandHelpParity.test.ts` had listed `data` among the commands it could not check, loudly and correctly: there was no name list to check against. There is one now (`DATA_SUBCOMMANDS`), the dispatch is a keyed `Record`, so a subcommand with no name and a name with no handler are both compile errors, and the usage line is generated from the same array. `data` is out of the unchecked list.

---

## [0.48.0] — 2026-08-22

### Added

- **@voltro/cli, @voltro/data-transfer** — An import over `--target api` can be asked what it will do, and watched while it does it.

  The transport's two correct properties combined into one blind spot: the run happens INSIDE the instance, so every line it produces goes to a pod log; and it is deliberately decoupled from the caller, so killing the client does not stop it (which is what keeps a dead client from leaving a half-emptied target). Someone reaching for `--target api` cannot reach the database directly and usually cannot read that log either, so "watch for the staging line" was advice they could not follow.

  **A preflight, before a byte is uploaded.** A `replace` over the api now asks the instance whether it will stage, and prints the answer — including the reason when it will not. It answers from `decideStaging`, the same function the run itself calls, so the two cannot drift; a second copy of that decision would answer confidently and diverge on the next change. With `--bundle-key`, where the client never holds the bundle, it sends the key and the instance reads the table list out of the archive's own manifest — the caller about to have an instance empty its own database is the last one who should be told to check a log they cannot read.

  **`voltro data imports`** (and `GET /_voltro/admin/imports`, behind the same data-transfer secret) reads the history and the run in flight. `_voltro_data_imports` was write-only; it is now opened before the first table, **advanced every couple of seconds as tables land**, and closed with the outcome — so polling it is the progress feed. Not a streamed response on the upload connection: a chunked body has to survive every proxy in between, and a buffering reverse proxy turns a progress feed into exactly the silence it was meant to replace.

  **And a `replace` that does not stage now says why.** The staging set was an early `return []` three conditions deep, and the empty array met a `length > 0` further down and read as "do not stage". A bundle table the target does not have — the ordinary case for a development source against a production target — turned the non-destructive path off in silence. Every reason routes through one message now; the cycle case additionally used to be gated on `atomic`, so the mode where an interrupted run leaves the worst outcome was also the one that said the least.

### Changed

- **@voltro/cli** — The declared framework schema no longer depends on `NODE_ENV`.

  `_voltro_traces` and `_voltro_undo_log` defaulted to on outside production. That was allowed with an explicit justification — one decider makes every command in a deployment agree — and the justification was about the schema FINGERPRINT, which only ever compares processes inside ONE deployment.

  The declared set has a second reader that spans two, and it was never considered: `voltro data`. A bundle exported from a development database carries the tables that database has, and a staged (non-destructive) `replace` needs every bundle table to exist in the target. So one source tree produced a bundle a production target could not stage, and the run fell back to truncating it — with nothing red anywhere, on the one path where that difference is the entire point.

  Both tables are declared in **every environment** now. The trade is the one `_voltro_cdc_offsets` already makes: an unused declared table costs one empty table and buys agreement. `VOLTRO_UNDO` / `VOLTRO_TRACING_PERSIST` still decide what a process WRITES; they never decided the schema and still do not. Set `schema: { traces: false, undo: false }` in `app.config.ts` to keep one out — in every environment, or the divergence is back by hand.

  **Upgrade:** a production app that never declared them gains two empty tables. `voltro db apply` (or a `voltro dev` boot) plans and applies them like any other framework table, on every dialect. Run it before the pods roll, as with any schema change — a migrate job and a fleet that disagree is exactly what this removes.

  `voltro doctor` now prints the three decided tables and what decided each, on a HEALTHY run. That text existed and was reachable only from the `prod-mismatch` refusal — a message that appears exclusively after a fleet is down is an explanation, not a warning.

### Fixed

- **@voltro/cli, @voltro/data-transfer** — `voltro data export --exclude a,b` made the bundle BIGGER, and made it unusable for a `replace`.

  It expanded into `{ kind: 'tables', tables: <everything else> }`, on the reasoning that a manifest should record what was exported rather than claim "everything". Right goal, wrong mechanism, and it cost two things at once. `all` is the only scope that filters out the tables which describe a deployment, so excluding two names silently added nine others back — the migration ledger among them, whose foreign row takes an environment down at the next boot. And `replace` refuses a named scope, so the honest way to leave a table out was also the way to make the bundle unusable for the mode it was being prepared for.

  The exclusion is a FIELD of the `all` scope now: the filter still runs, the manifest still says "everything except these" (a different and truer claim than "these"), and `replace` accepts it while naming the tables it will therefore not touch. `--exclude` also works over `--target api` now — it no longer expands against a table list only the instance has, so the instance resolves it where that list already is.

  Every framework table is classified as portable or environment-local, with the reason, and a new one fails the build until somebody decides. That guard existed and did not help: it was satisfied by a second, hand-kept list inside its own test, and the two disagreed about `_voltro_traces` and `_voltro_undo_log` — nothing was unclassified, something was classified twice, once wrongly. There is one list now, read by both guards. Traces, undo, the outbox and its attempts, idempotency keys, storage grants, spend and usage accounting, delivery attempts and schedule-firing history joined the environment-local side: a row from elsewhere would make the target act, or claim history it did not live.
- **@voltro/sql-turso** — Opening a second pooled turso connection could fail instantly with `database is locked` — from inside the constructor, before a single query ran.

  `makeConnection` set its pragmas in the order `journal_mode` → `foreign_keys` → `busy_timeout`. The engine defaults `busy_timeout` to **0**, so a statement that meets a held lock fails on the spot instead of waiting — and `journal_mode= experimental_mvcc` needs the file exclusively. Since `makeConnection` runs once per POOLED connection (default 4), opening connection two while connection one held the file hit that exclusive pragma with no lock-wait configured yet:

  SqlError: Failed to enable Turso MVCC (journal_mode=experimental_mvcc): database is locked

  `busy_timeout` is set FIRST now. Nothing else changed — same value, same pragmas, same connection.

  **Why it hid for so long.** The file already carried a long, correct note about `busy_timeout` being mandatory with a pool, and a separate fix had closed the DDL half (`retryFilter` + bounded retries in `applySchema`). Both are about the same lock class, so the constructor read as covered — but a setting cannot protect the two pragmas that run before it.

  It surfaces as an unrelated flaky test, because the failure lands wherever the second connection happens to be opened: a `CREATE TABLE` in one run, an MVCC pragma in the next. It cost three release gates — twice locally, once on a CI runner — and was twice diagnosed as machine contention and closed. It is contention-DEPENDENT, which is not the same as being the machine's fault.

  Verified: 8 serial runs and 6 concurrent suites at load 12 — 0 failures, 0 occurrences of the message. The failure was intermittent before, so this is evidence rather than proof; the mechanism, however, is not in doubt.
- **@voltro/runtime, @voltro/data-transfer, @voltro/cli** — The `source:` recorder broke every WRITE on the in-memory store, and actions are recorded now.

  The recording wrapper is a `Proxy`, and it handed methods back unbound — so `this` was the PROXY, and a class with `#private` fields answers that with `TypeError: Receiver must be an instance of class InMemoryDataStore`. Under `voltro dev` on the memory store, where the recorder is installed by default, that is every write in the app.

  Every test passed throughout, and the reason is worth more than the fix: `query` is the one method the wrapper invokes with an explicit receiver, so everything that only READ through it worked. The wrapper's whole purpose is reading, so nothing in its own suite ever wrote. It was found by a test about something else entirely — asking what `crud.create` reads — which needed a write to answer.

  **Actions are recorded too now**, and an action's `source:` means something different from a query's. A query's is a reactive trigger set; an action's declares what it TOUCHES, and the field's own documentation records what an undeclared read costs: `voltro check` reported a table five action paths read and wrote as an orphan, and advised removing it. That is not a quiet subscription, it is advice to delete a live table.

  **Measured rather than reasoned about:** `crud.create`, `crud.update` and `crud.remove` issue NO read at all, so a read recorder has nothing to say about them — but `crud.getById` does read, and with `include:` it eager-loads, so it is covered like `crud.list`.

  **And a kill test for the one moment nobody had reproduced.** The existing test kills mid-LOAD, which staging turned into the harmless part; the destructive second went untested precisely because it became short. The new case kills as the swap begins and asserts the property rather than the race: the target is one state or the other, never a mix, and never empty. Two defects in the harness came out of writing it — a worker that died SILENTLY (its failure now goes into the marker the parent already reads, instead of looking like a slow start), and an `exit` listener attached only after the SIGKILL, which hung to the full timeout whenever the child finished first.

  **And the import trace was never written on the path most likely to be used.** A deployment ran a successful `voltro data import` against a database that HAD the table, and got no row and no message at all. The write was gated on this PROCESS's table registry, and `voltro data`'s own boot builds a store and introspects the live schema — it never registers the framework set, so the gate was `undefined` exactly there. The authority for "does the target have this table" is the target's SCHEMA, which that boot already introspects; the table is also registered before the write, because a CLI run has not done it and the write needs the column metadata.

  The sharper half is the silence, and it was self-inflicted: the skip was written three lines under a comment about how an absent table cannot be detected by the write failing. The message now lives in `voltro data import`, which is the layer that INTROSPECTED — a first attempt put it in the importer, where a `targetSnapshot` may legitimately be narrow rather than complete, so it fired at callers whose snapshot simply did not mention a framework table.

### Internal (no consumer-facing effect)

- **@voltro/data-transfer** — `SAVEPOINT_BATCH_SIZE` carries its documentation again.

  A new `TRACE_ADVANCE_MS` was declared BETWEEN the constant's doc block and the constant, so TypeScript attached the block to whatever now followed it and the exported symbol was left bare — the api golden recorded it as `// @public (undocumented)` and the published report shipped it that way.

  Third time this exact shape has appeared (`sourceKeys`, `recordsTable`, now this one), always the same mechanism: an insertion above a documented declaration silently re-homes the comment. Nothing warns, because both the code and the doc block are individually valid — only the golden's `(undocumented)` marker notices, and it reads as noise unless someone diffs it against the last TAG.

  Documentation only; no behaviour, no signature change.

  **`apiSurface: compatible`, and the reason is the whole point of the change.** The golden line that moved is `// @public (undocumented)` → `// @public`: an api-extractor MARKER describing whether a doc comment is present. No type, no signature, no name. `SAVEPOINT_BATCH_SIZE` is still `= 200`, still exported, still the same literal type — nothing that compiled can stop compiling.

---

## [0.47.0] — 2026-08-22

### Changed

- **@voltro/database, @voltro/data-transfer, @voltro/cli, @voltro/plugin-storage** — A `--mode replace` no longer writes per-row history, and three things that were reported alongside it.

  **Write recorders are suspended for a `replace`.** A replace SETS a state; it does not change rows, so a per-row history entry describes something that did not happen. A deployment measured what that costs: they run `versioningPlugin({ timing: 'in-transaction' })` on 70 of 80 tables, so one import wrote 242 950 history rows and doubled the write load of the most expensive run they make — and those rows were the source of refused writes they spent three rounds diagnosing.

  The deciding argument is not the cost. **The import path had already decided this, and the recorder was the one layer that did not hear.** These routes write through the RAW store on purpose — no tenant scoping, no row filter, no `audit()` stamping. A recorder fired anyway because it hangs one level below the wrapper. Suspending it makes the layers agree.

  Suspended for `replace` only: an `upsert` or an `append` CHANGES existing state, which is exactly what a recorder is for. Scoped per execution context rather than by a switch, because an import runs while the app serves requests and a process-global flag would silently drop recording for everything concurrent with it. And every such run SAYS what it suspended — dropping history quietly would be the same defect in a nicer costume.

  This also removes the reason a staged `replace` used to fall back. A recorder is keyed by table name, so a staged write found none and the recorder never ran; rather than record inconsistently, the run took the slower path. For the deployment above that meant the staged path could never activate — permanently, on every environment.

  **`voltro codegen` wrote a truncated table declaration.** It discovered tables with the walk that collects the rpc GROUP's inputs — descriptors, workflows, events, and deliberately no `*.entity.ts`. So the generated `voltro-tables.generated.d.ts` listed the framework's tables plus `actors`, which the framework injects. Measured downstream: 37 names where a `voltro dev` boot writes 117, and 299 `TS2322` errors from every `source:` naming one of their own tables. The way in was our own message — `voltro test` refuses a stale rpc group and tells you to run `voltro codegen`. Table discovery has its own walk now, and a test compares the two walks' RESULTS on a real tree rather than trusting they mean the same thing.

  **The eager-relation doctor rule missed the state that is most wrong.** It resolved relations only against the tables a query already DECLARED, on the reasoning that the base is virtually always in `source:`. Measured: a query reading `projects` while declaring only `projectTeams` produced no finding at all, while the same query with the base added produced two. The base is a fact about the executor, so it is read from the executor now.

  **A rollback capture says when its path shares the root filesystem.** The 409 answered "is storage configured", which is not the question — a `filesystem` provider pointed at a container directory with no volume behind it passes it and dies with the pod. What a process CAN observe is that a mounted volume is a different filesystem: `pathDurability` compares device ids, and a capture landing on the same device as `/` says so. Three-valued on purpose, and explicitly not an alarm on a development machine, where everything is one device and nothing is a pod.

  **And the import records ITSELF, once.** Dropping per-row history left an operator asking "was this data imported, and when" with nothing to read, and trading too much for none is not obviously the better trade. `_voltro_data_imports` carries one row per RUN: the mode, the transport, the bundle, the SOURCE deployment's schema fingerprint, the counts, and — for the run an operator is actually looking for — the failure. It is written for a failed import as well as a finished one; a trail that only records successes goes quiet exactly when it is needed.

  Best effort, unlike the interrupted-replace marker, and the difference is deliberate: the marker is a safety interlock and a run that cannot write it must not proceed, while history is valuable and not load-bearing. A target that has not been migrated yet still imports, and says the trace could not be written.

  Two things it learned from being wired. The table is `.nonReactive()` — nobody subscribes to "an import happened", and a reactive bookkeeping write showed up in every suite that counts the LOAD's writes. And it is written only when the target actually DECLARES the table: the in-memory store accepts a write to any name, so an absent table cannot be detected by the write failing, and inventing the row would put a framework write into every embedder's counts.

  **Measured at the reported magnitude.** 114 tables, 243 048 rows, real MariaDB, staged replace: the whole run takes 56.5s and the DESTRUCTIVE TRANSACTION takes **1.13s**. The window in which a dead process can leave a half-replaced target is the second number; before this it was the first.

---

## [0.46.0] — 2026-08-22

### ⚠ BREAKING

- **@voltro/runtime, @voltro/cli** — An aggregate's `incremental.source` is a table name, and is now typed and audited like one.

  0.45.0 narrowed a query's and a stream's `source:` for a specific failure: a name matching no table does not error, it produces a subscription that serves once and goes quiet. An aggregate's CDC source misses the same way and is quieter still — the runner subscribes to a table nothing writes, no delta arrives, and the aggregate stops tracking its input while every read of it succeeds and returns a number.

  It stayed `string` in that change, and not because anyone weighed it. It reads differently — `incremental.source`, a single name on a definition, rather than a list on a descriptor — so it did not fit the loop, and a shape that does not fit reads as a type mismatch instead of a gap.

  Both halves close now. The field is `TableName`, so a name no table carries is a compile error from the next `voltro dev`. And the boot audit takes aggregates alongside queries and streams, so a stale one is named in the same warning — the runtime half matters because a source can be correct at the type level and still be a table the deployment does not have.

  The audit's aggregate half runs on both boot paths and needed its OWN call on each, since aggregates are discovered several hundred lines after the existing one — which is precisely the shape that ends up wired on one path only, so it is asserted by name over both files.

  A recompute-only aggregate declares no `incremental` at all and is not audited: reporting an absence as an unresolved source would report a choice as a defect.

  **`voltro update` carries you across this** — codemod `0.46.0/01_typed-aggregate-source`.

### Added

- **@voltro/data-transfer, @voltro/cli** — A foreign-key cycle is caught BEFORE a staged `replace` loads, and leftover staging tables have a command.

  Two things the staged swap left open, closed.

  **The cycle.** The swap inserts parents first, so two tables referencing each other cannot both be satisfied by a bulk copy on postgres, sqlite or SQL Server — and `SET CONSTRAINTS ALL DEFERRED` does not rescue it, because postgres only defers a constraint declared `DEFERRABLE` and the framework declares none. Until now that surfaced as a FAILED SWAP after the whole bundle had loaded: minutes of work, then a refusal. `topo.ts` already orders parents-first and breaks a cycle at its closing edge, so a cycle is exactly a reference pointing FORWARD in that order — cheap to see before anything is loaded. Such a run says so and takes the row-by-row path, whose deferred-FK pass exists for that shape. A table referencing ITSELF is deliberately not a cycle: one statement carries the whole table, measured on all five engines, and treating it as one would cost every app with an `audit()` mixin the staged path.

  **The leftovers.** A staged run drops-then-creates, so it collects its own; what survives is staging for a table set a later run does not touch. `voltro data clear-staging --yes` lists them and drops them.

  It is a COMMAND and not a boot sweep, which is the decision worth recording: a booting process cannot tell a leftover from a staging table another replica is loading into right now, and with several replicas that is not a rare race — one booting pod would delete an import in flight. The refusal without `--yes` says so, and names the tables so the operator can check before answering.

  **And `--no-atomic` stages too now, which is where the change is largest.** The flag exists for resumability on a large bundle, and it used to be the mode with the WORST failure: the target emptied and partially refilled, in neither state — the kill test measured 889 rows of 8 000. Staged, the ledger keeps its exact meaning (a recorded table is one fully loaded; it just lands in staging) while the target stays untouched until the swap. Resumable AND all-or-nothing, which the two flags could not be at once before.

  Three things came out of wiring it, and none was visible from the design. Dropping staging in the `finally` destroyed exactly what a resume needs, so a second run read from tables that no longer existed — found by the resume test, not by reasoning; staging is dropped only after a successful swap now, and a run that kept its rows says so. The SUCCESS case needed its own guard: after a completed run the ledger still says every table is done while staging is gone, so a re-run would have copied nothing over the target — `ledger.truncated` has always meant "the destructive step already happened" and the old path is guarded by exactly that flag, so it guards this one too. And the test lever was wrong at first: withholding the snapshot disables staging but also fail-closes the rollback capture, so the run refuses before loading — the unstaged case is now driven by the real reason, a registered write recorder.

  Two existing assertions inverted, and BOTH truths are kept rather than one replaced: `--no-atomic` now keeps the target when it can stage, and still costs exactly what it always did when it cannot. A killed staged run leaves the target's rows intact and writes no interrupted-replace marker — that state cannot arise on the staged path, so there is nothing for a boot to refuse over.
- **@voltro/runtime, @voltro/cli** — `voltro dev` says when a query reads a table it did not declare in `source:`.

  The other half of the `source:` problem, and the one no static tool can see. A stale name is a compile error now, and the boot warns about one that resolves to nothing. A name that is simply ABSENT has never had an observer: the write lands, the row is in the database, a reload shows it, and the open panel does not move. The type is satisfied, the audit is satisfied, the write path is correct and its tests are green.

  So while `voltro dev` runs, every read is attributed to the query that made it and compared against that query's own `source:`. The finding names the table and what will not happen, once per query per boot.

  **The design question was never the recording, it was compose-vs-restrict.** Only a read that CONTRIBUTES rows belongs in `source:` — a restricting read re-running on every unrelated write puts every list back on the wire. An app that scanned its own source for this needed two hand-written exceptions to get from a thousand findings to thirty, and a rule needing an exception list on a correct codebase has already spent its attention.

  Neither exception is a list here. A table reached only through a predicate subquery is narrowing BY CONSTRUCTION — it returns no column to anybody — so it classifies itself off the descriptor, on every app, with nothing to maintain. And the framework's own restricting reads are ours: they are issued below the wrapper, or, where the framework runs APP code to decide access (a row filter's loader), marked at the call site we control instead of at the ones we do not.

  Deliberately narrow, and each edge is a decision rather than a limitation: it reports what it SAW and never claims a declaration is otherwise complete; a query with no `source:` at all is left alone, because the finding is about an incomplete list and not a missing one; and it says which tables it did not count, so the classification can be checked rather than trusted.

  Dev only. `voltro serve` installs no sink, which makes every part of it inert — no wrapper, no async-local write, no comparison. `VOLTRO_SOURCE_RECORDER=off` turns it off in dev.

  `restrictingReads` (`@voltro/runtime`) is the escape for an app helper that resolves access somewhere the framework does not call it. It is a no-op outside a recording session, so it can be left in place.

  Measured against a running `voltro dev`, not only in tests: a query declaring one table while reading two is reported once across eleven requests, and the correct queries beside it produce nothing.

  **Eager-loaded relations count, and they were the hole.** `.with({ subTasks: true })` issues no second read — `compileEagerJson` folds the whole spec into ONE round trip — so the loaded table is never a read's own table and never a join. It is a relation NAME on the descriptor, and the recorder resolves it through the relation registry: the target, and for a many-to-many the JUNCTION as well, since a write there changes membership, which is precisely the change a user makes. Nested `with:` recurses against the target's relations, the same walk the compiler does; an unresolvable name yields nothing rather than an invented table, and a throwing target thunk cannot take the request down with it.

  This was the reported failure's own shape, so the first version of the recorder could not see the case it was built for.

  Measured against a running `voltro dev` and real `POST /rpc` calls: twelve requests across four queries produced exactly two findings — the two deliberately-incomplete ones. The SAME eager load declared correctly beside them is silent, and so is a query with no `source:` at all. A check that only ever fires is not evidence that it fires for a reason.
- **@voltro/cli** — `--rollback-key <key>` — a `replace` over `--target api` has the INSTANCE store the target's current rows in its own object storage before deleting them.

  The direct path already captured beside the bundle. That is useless on this transport, and the reason is the whole point: the process that would roll a transaction back IS the instance, so a capture in the pod's filesystem goes away with exactly the failure it exists for — a deployment lost 240 172 rows to a run whose api pod disappeared nine minutes in. Object storage is durable, is already configured wherever the storage-push export works, and is reachable afterwards from anywhere.

  Pushed BEFORE the first delete, through the same archive sink the export uses — one sink, not two, because two is how an instance comes to push a bundle and fail to keep one. A failure to store it stops the import with the target untouched.

  Fail-closed in both directions, deliberately:

  - asked for and impossible (no storage configured) → **409**, naming the fix. The request is the operator saying "I cannot afford to lose this", and serving them anyway is the one answer that removes their precaution while looking like agreement. - not asked for → the run proceeds and says what it did not keep. A `replace` into a scratch environment is legitimate, and refusing it would push people to the flag that turns the safety off everywhere.

  The decision is made from the headers alone, before a byte of the bundle is read: a run that cannot take the capture it was asked for must not cost an upload first.
- **@voltro/database, @voltro/data-transfer, @voltro/cli** — An interrupted `replace` cannot be silent any more.

  The capture only helps if somebody knows to reach for it, and a half-replaced database is indistinguishable from an empty one FROM THE INSIDE — every table exists, every constraint holds, every query returns nothing without erroring. A deployment served over one for ninety minutes and only found out through an unrelated fingerprint mismatch.

  So a `replace` writes one row (`_voltro_replace_in_progress`) before the first delete and removes it after the last insert, and finding it at boot is a REFUSAL — on both boot paths, out of one function. The message names how many tables, how long ago, over which transport, and the capture to restore from, with the command spelled out.

  The row lives in the SAME transaction as the emptying, so it is present exactly when the emptying is: a run that rolls back cleanly takes the marker with it, and a boot over a database nothing happened to is not refused. A completed `replace` clears its own marker and every older one, so the recovery import restores the data and silences the alarm in one command.

  Nothing expires — a half-replaced database does not become whole with time, so `voltro data clear-replace-marker --yes` is a decision somebody makes.

  Measured against a live mariadb by killing a run mid-load: the marker is there, the refusal names the capture, and a completing run clears it.
- **@voltro/data-transfer** — The staged-swap primitive for `replace` — load somewhere else, then swap the content in one short transaction.

  `--mode replace` empties the target and loads into it inside ONE transaction, held open for the whole network-bound load. A deployment measured nine minutes for 242 950 rows, and the promise that the target is left as it was found rests entirely on a live process being there to roll it back. A promise that rests on the process surviving is a promise about the weather.

  **The design the plan carried was wrong, and measurably so.** Shadow tables plus a final `RENAME` moves every inbound foreign key WITH the renamed table — postgres 17 by OID, MariaDB 11 and MySQL 8.4 by tracking the rename, including inside MySQL's atomic multi-pair `RENAME TABLE`. After the swap every key points at the table the design then DROPS. The atomicity of the rename, which that design reasoned about carefully, was never the hard part.

  Keeping the table OBJECTS and swapping the CONTENT has none of that: every constraint stays pointed at the same object, and the long client-driven load moves OUT of the destructive transaction, which then holds only server-side bulk SQL.

  **Four of five engines need no integrity switch, which inverts what the design assumed.** Three self-referencing rows — `actors.createdBy → actors`, the framework's own pattern — inserted by one `INSERT … SELECT`: postgres, SQLite and SQL Server take all three (they check at STATEMENT end); MariaDB and MySQL answer `ERROR 1452` (they check per ROW) and need the switch their own `emptyTables` already uses. Measured on each, and measured again after: a genuine violation attempted following the swap is still refused on all five, so the suspension does not leak past it.

  The statement builders refuse a table name outside the framework's identifier class. This module concatenates SQL and its names arrive from a bundle MANIFEST — a file an operator can edit — so the rule applied at declaration is re-asserted where the concatenation happens rather than assumed to have survived the round trip. Staging tables are `_voltro_staging_<t>`, so the boot differ's framework-table asymmetry treats them as ours instead of planning them as user tables somebody forgot to declare.

  **The importer does not use it yet**, and attempting that integration is what surfaced two blockers worth stating: a staged write would fire a change event (waking reactivity, CDC and the analytics mirror for tables nobody declared), and it would miss the target's column metadata (`encodeRowForSchema` looks the table up by NAME, so a `json()` column would be written unencoded — a wrong value, not an error). Both are tractable; neither is a line-level change. Until then the two protections already shipped — the capture written before the first delete, and the marker that refuses the next boot after an interrupted run — remain what covers the reported outcome.
- **@voltro/data-transfer, @voltro/cli** — `--mode replace` writes down what it is about to destroy.

  Before the first delete it exports the target's CURRENT rows — exactly the tables it will empty — as an ordinary bundle beside yours, and says where:

  rollback capture: 240172 row(s) across 75 table(s) → ./out.rollback-2026-… If this run does not finish, restore with: voltro data import ./out.rollback-… --mode replace

  It is on disk BEFORE anything is destroyed, so it depends on no transaction and on no process being alive to roll one back. That is the whole point: a deployment lost 240 172 rows to a `replace` whose api pod disappeared nine minutes in, and recovered from an export they had taken twenty minutes earlier out of HABIT. This is that habit as behaviour. It is NOT the fix for the class — the emptying must not become visible until the load stands, which is a rebuild — it is the small half that covers the reported outcome today.

  **Fail-closed.** A capture that cannot be taken stops the import before it starts, target untouched. A net you believe in and do not have is worse than none: the belief is what stops you taking your own export.

  The capture is a COMPLETE bundle over a snapshot narrowed to the emptied tables, not a `tables`-scoped one over the whole schema. Same files, different manifest — and the manifest decides whether it can be restored at all, since `replace` refuses a partial bundle for a reason that is exactly false here.

  `--no-rollback` opts out, `--rollback-dir <path>` relocates it. Only `replace` takes one: `upsert` and `append` destroy nothing.

  **Not on `--target api`, and it says so.** The capture would live inside the instance — the thing that can go away, which is the failure it exists for. A replace over that transport warns and names the export to take first.
- **@voltro/database, @voltro/data-transfer** — Staging clones — the load-side half of the staged swap for `replace`.

  The swap primitive shipped without the importer using it, and two things stood in the way. Both are solved by ONE answer.

  A typed write resolves its table by NAME: `encodeRowForSchema` and `stampGeneratedId` both look it up in the registry. Writing to `_voltro_staging_notes` therefore found nothing — and the failure mode is not an error, it is a `json()` column written UNENCODED. Separately, every dialect store's insert ends in `routeEvent`, whose reactive guard reads `isTableReactive`, which is `isReactive !== false` — so an UNREGISTERED name counts as reactive, and loading a large bundle into staging would emit an event per row for tables nobody declared.

  A staging table registered as a CLONE of its target, marked `isReactive: false`, answers both: the columns resolve, and the guard the framework already has returns before the emit. `.nonReactive()` is the documented way to say exactly that, so nothing at the store needed a special case — and it is the clone rather than the absence that makes the load quiet, which is the part worth remembering if this is ever simplified.

  `registerStagingClones` returns a REQUIRED undo instead of trusting a caller to remember one. `allRegisteredTables()` feeds the declared set, the boot differ and `voltro doctor`, so a clone left in the registry reads as a table the app declares and nobody created. A failure part-way through registers nothing at all.

  `unregisterTable` is new in `@voltro/database` for this: narrow on purpose. `clearTableRegistry` wipes everything and exists for tests; this removes ONE name a bounded operation owns for its duration.

  **The precision is recovered, and it is strictly better than what it replaces.** Staging carries no foreign keys — required, since a staged row whose parent has not been staged yet must not be refused — so a violation moves from load time to swap time, where the database answers with one message naming a constraint. `stagedReferences` reads the edges INSIDE the replaced set off the snapshot the importer already holds (deliberately not `incomingForeignKeys`, which answers the already-answered question of keys pointing in from OUTSIDE), and `danglingProbeSql` asks STAGING the question the database was asking — against the STAGED parent set, because the swap inserts parents from staging and what matters is whether the reference resolves AFTER it.

  Measured against a live postgres with four staged children — one good, one NULL, two dangling — driven through a real failing swap: the database named `ghost`; the probe named `ghost` AND `phantom`, skipping the NULL and the good row. The row-by-row path stops at the FIRST failure, so a bundle with four bad references costs four round trips; this reports all of them in one pass. The target was verified unchanged afterwards, which is the first thing the message says.

  **The importer uses it now.** `--mode replace` stages when it can: create a staging table per table, load into those OUTSIDE any transaction, then swap the content across in one short transaction of server-side SQL. A process that dies during the load leaves the target exactly as it was, because nothing has been deleted yet — the destructive window shrinks from the length of the load to the length of a copy.

  Wiring it surfaced two more things, and neither was visible from the design.

  `INSERT … SELECT *` fails the moment the target has a STORED generated column — `CREATE TABLE … (LIKE t)` copies such a column as a PLAIN one (measured: `is_generated: NEVER`), so the select hands the target a value for a column it computes itself: `cannot insert a non-DEFAULT value into column "slug"`. The swap names its columns now, minus the generated ones, and `swapStatements` REFUSES an empty column list rather than falling back to `SELECT *`, so the trap cannot return by omission.

  And a WRITE RECORDER on any table in the set rules staging out. A recorder is keyed by table NAME, so a staged write looks up `_voltro_staging_notes`, finds none, and never runs — `versioningPlugin({ timing: 'in-transaction' })` promises "if the change committed, the entry is there", and the swap's bulk SQL has no per-row hook to keep that with. Measured, not guessed: the recorder went from more than one call to zero. Such a run keeps the path that honours it and SAYS why, as does a `--no-atomic` run or a store the framework cannot send DDL to.

  Every staged run says it staged. The two paths are indistinguishable from outside — both end with the target holding the bundle — and an operator deciding whether they can afford to interrupt needs to know which one is running.

  Verified through the real importer, and falsified before being kept: with staging off, the assertion that the target still holds its own rows WHILE the bundle loads goes red. On real postgres the staged path runs the existing foreign-key replace suite unchanged.

### Fixed

- **@voltro/protocol, @voltro/voltro** — A `_voltro_*` name in `source:` is no longer narrowed against the generated table declaration.

  Which framework tables an app declares is DEPLOYMENT-dependent. The measurement is already in the maintainer notes: at one `NODE_ENV`, on one dialect, flipping a single flag adds or removes `_voltro_traces`, `_voltro_undo_log` or `_voltro_cdc_offsets` from the declared set. The generated `voltro-tables.generated.d.ts` is written by ONE `voltro dev` run, on one machine, with one set of those inputs.

  Narrowing framework names against it therefore made `source: '_voltro_traces'` compile for whoever generated the file and fail for a colleague — a type error decided by an environment variable, which is the exact class the declared-schema rule forbids one layer up. An app's own tables are unaffected: `_voltro_` is a reserved prefix, so every name a user writes for their own data narrows exactly as before.

  **How it surfaced is the part worth recording.** Until now `keyof VoltroTableNames` was always `never` inside this repo, so `TableName` was always `string`, so the narrow and wide types were the same type and every rule about them held vacuously. The first time an augmentation was ever present — a fixture that boots a real server writing the declaration beside its generated rpc group — four framework source files stopped compiling. The split had shipped without once being exercised in the direction that matters.

  Two things now stop that from going quiet again. A type-test program compiles the framework's own sources under an augmentation that deliberately declares NONE of its tables, and it lives in its OWN tsconfig: `declare module` merging is program-global, so a sibling type test's augmentation had silently rescued the very assertion this one exists to make. And `scripts/check-type-tests.mjs` (CI + `pnpm gate`) DISCOVERS type-test programs and runs them — because nothing did. The existing narrowing assertions had never been compiled once: excluded from their package's tsconfig for a good reason, and picked up by nothing else. It refuses a zero-program run, and a program that compiles zero `*.test-d.ts` files, for the same reason every other check here carries a floor.

  **Why `apiSurface: compatible`, and how to check it rather than take it.** The gate flagged the golden line as CHANGED and asked the right question — can this turn code that compiled into code that does not? Here it cannot, because the edit WIDENS a union, and every public position the type appears in is an INPUT: `source?:` on a descriptor, and `normalizeSource`'s parameter. Nothing in the published surface RETURNS `TableName` or `ReactivitySource`, which is the only direction in which widening breaks a consumer — an assignment FROM the type into something narrower. `@voltro/voltro` is listed beside `@voltro/protocol` because it re-exports the type, so its golden moved too; the gate matches per package, and one package's classification must not vouch for another's.
- **@voltro/sql-mssql** — A `json()` column could not take a value on mssql. At all.

  store.insert(t, { payload: { a: 1 } }) -> TypeError: Invalid string. [EPARAM] store.insert(t, { payload: null }) -> OK

  So the column worked only while it held nothing. Reached first through `@voltro/plugin-versioning`, whose history row carries a full-row snapshot in exactly such a column: the versioning recorder could not write on that dialect, and therefore neither could any write to a table it covers.

  The store handed the caller's row straight to `sql.insert(row)`. The mysql store runs `encodeRowForSchema` first — the schema-driven step that turns a json value into text a driver can bind — and mssql had no equivalent anywhere on its write path, so tedious received a JS object for an NVARCHAR parameter and refused it.

  **Encoding alone would have been worse than the bug.** A value written as text and handed back as text means a WRITE returns a string where a READ of the same row returns an object, and nothing errors — the caller gets a different type depending on how it got there. So every `OUTPUT INSERTED.*` path decodes too (insert, insertMany, update, updateMany, patchJson, the delete old-image, and the MERGE upsert), and the test asserts the ROUND TRIP rather than the absence of an error, against a live SQL Server.

  With it, the mssql case is back in the versioning key-length suite — the bound on `id()` exists on that dialect too, and it was absent for one release only because nothing could write there.
- **@voltro/data-transfer, @voltro/cli** — A `scope: all` bundle carried the exporting deployment's own bookkeeping, and `replace` wrote it into the target. The target's next boot refused to start:

  auto-migrate: SCHEMA FINGERPRINT MISMATCH — declared=6e2c61081a9ed80c live=28af9a54414f22f1

  The refusal was correct and the row was the defect. A migration-ledger row is not DATA — it states which schema THIS deployment applied — and the fingerprint is computed over the declared table set, which legitimately differs per environment (`NODE_ENV=production` declares `_voltro_traces` and `_voltro_undo_log`; a `development` run does not). So the imported row was not stale, it was FOREIGN. The environment was down for ninety minutes, and the ledger row also HID the incident it travelled with: the api would not start, and the reason looked like the failed import rather than a row in a bookkeeping table.

  Ten framework tables are now classified as environment-local — the migration ledger, the file-migration and seed records, CDC offsets, schedule claims, wakeups, workflow watermarks / pending starts / admissions / pauses. They are dropped from an export's `all` scope, skipped on import, and never emptied by a `replace`, each with the reason a foreign row would be wrong stated beside it. Two of them would have made the target ACT: a pending start runs a workflow somebody queued elsewhere, a pause silently stops one here.

  `all` is the only scope filtered. A caller who NAMES one of these tables gets it — an explicit name is an expectation, and this module already refuses to drop those silently.

  The classification refuses to be incomplete: a guard scans every framework table and fails until a new one is decided either way. A hand-list rots by omission, and the omission cost ninety minutes.

  Also fixed, found while reading that path: the admin import endpoint spread `atomic` only when truthy, so an explicit `atomic: false` was dropped and the importer applied its own default — which for `replace` is `true`. The one value a caller can only express by asking for it was the one the wire discarded.

---

## [0.45.0] — 2026-08-21

### ⚠ BREAKING

- **@voltro/protocol, @voltro/cli, @voltro/voltro** — `source:` on a query is now typed against the app's own tables, so a typo or a missed rename is a compile error instead of a subscription that goes quiet.

  A `source:` is matched by NAME against change events, so a name matching nothing does not break the query — it makes it permanently silent: it compiles, boots, serves its first snapshot and never updates. From the outside that reads as a feature that does nothing, with a correct write path and green tests behind it. The boot has warned about this since 0.26.0, on both paths; a warning is read once, and a rename lands in a diff where nobody is checking strings.

  `voltro dev` writes `voltro-tables.generated.d.ts` beside the generated rpc group, augmenting `VoltroTableNames` with the FULL live set — app entities, plugin `extendSchema.tables` and the framework's own — from the same binding the boot audit resolves against, so the type and the warning cannot disagree about which tables exist. `source:` narrows to those names.

  Nothing changes at runtime: these are still string literals, so a descriptor carrying them is as browser-loadable as before. That is what ruled out accepting the table VALUE — a descriptor is loaded value-level by the web client, and a table value drags `@voltro/database` across that boundary.

  **Breaking, and filed that way after being written up as additive.** The test is not whether a symbol disappeared, it is whether code that compiled can stop: `['tasks', 'agent_messages']` was assignable and is not, which is the whole point where the name is stale and an obstacle where the source is genuinely computed. `normalizeSource`'s parameter narrowed with it. The wide shape stays public as `ReactivitySourceValue` for the computed case.

  The break does NOT land at upgrade time, which is why the codemod is a written note rather than a transform: right after `voltro update` the generated file does not exist, `keyof VoltroTableNames` is `never`, `TableName` falls back to `string`, and everything compiles as before. The narrowing switches on at the next `voltro dev` — a different command, by which point the change that caused it is no longer what the reader is looking at. A transform could not have found the sites either, since the type that rejects them has not been generated yet. And the two things `tsc` flags — a stale name versus a runtime-computed one — want opposite fixes, so the mechanical one (widen the annotation) would convert every defect this surfaces back into the quiet subscription it exists to expose.

  Delete the generated file and `source:` widens back to `string`.

  One deliberate asymmetry, stated because it is one: runtime READERS of a descriptor's source stay wide (`ReactivitySourceValue`). Narrow where an author writes, stay wide where the framework reads — a reader that refused an unknown name would be asserting a fact it cannot check, and the first thing it would reject is the stale name it exists to report.

### Added

- **@voltro/cli** — `voltro doctor` reports a query that eager-loads a relation and does not declare its table in `source:` — the failure that looks like a broken feature and is not.

  The write lands, a reload shows it, every test of the write path is green, the name in `source:` is spelled right and the table exists. So neither the typed `source:` nor the boot audit has anything to say, and the only observer is a user watching a panel that does not move.

  ```
  ✗  1 query loads a relation it does not declare:
     tasks.getById: eager-loads `subTasks` from 'tasks' but does not declare
     'task_sub_tasks' in `source:` — the view will not update when 'task_sub_tasks' changes.
  ```

  No exception list, deliberately. An eager-loaded relation is composition by definition — its rows are IN the result — and its table comes from the relation registry, so the missing name is a fact rather than an inference. A many-to-many is reported twice when needed: adding or removing a link writes only the JUNCTION row, so declaring the target alone leaves the list stale on exactly the operation a user performs to change it. A computed `.with()` key yields nothing rather than a guess.

  The general question — every table an executor reads — is NOT answered, on purpose: it needs a compose-versus-restrict judgement a scan can only infer from syntax, and a rule that guesses on a correct codebase teaches its reader to ignore it. Design in `plans/open/framework/source-completeness.md`.

  Two things around it:

  - **The stale-`source:` audit covered queries only, on both boot paths.** A stream carries a `source:` too, and a stale one there is the same permanently quiet subscription with a longer-lived connection behind it. Both paths now take the set from one `auditableSources`. - **`voltro codegen` writes the typed-`source:` declaration too**, from the same shared `declaredTableNames` merge the boots use. Letting it lag was the bad direction: a table added since the last `voltro dev` would make a CORRECT `source:` a type error. Both commands now say what they wrote — the narrowing has a silent no-op if the app's tsconfig does not pick the file up, so the write has to be loud enough that a reader can check.

### Fixed

- **@voltro/database, @voltro/sql-mysql** — A binding failure names the TYPE of every value, so the culprit is read rather than guessed.

  `ER_WRONG_ARGUMENTS` / 1210 reads like a count problem and often is not: a statement with twelve columns and twelve placeholders is internally consistent, and the driver is refusing one VALUE it cannot bind. Measured against a live mariadb 11.8 and mysql 8.4 (mysql2 3.22), binding to a PREPARED statement:

  | value | mariadb | mysql | |---|---|---| | plain object | **1210** | accepted | | array | **1210** | accepted | | bigint | accepted | accepted | | Invalid Date | accepted | 1292 |

  So the same row binds on one engine of the family and not the other — which is how a suite comes to fail on mariadb and pass on mysql in the SAME run, and why the type of each binding is the diagnosis rather than a detail.

  `describeDriverError` now reports `bindings: id:string data:Object changedAt:Date …` beside the placeholder count and the statement. Types only; a value there would be row data in a log line, the same reason the statement is carried only in its placeholder form.

  The types travel as a FIELD, not in a message. That is load-bearing: a failing write recorder rethrows with its own sentence, so anything said only in text is dropped exactly where it is needed. `extractDbCause` collects it like any driver field, so it survives every wrapper between the failing statement and the log.
- **@voltro/database, @voltro/testing** — A driver error now carries the two numbers a binding failure is made of.

  `ER_WRONG_ARGUMENTS` / errno 1210 means the parameter count did not match the placeholder count — reproduced against mariadb 11.8 by sending one parameter for two `?` — and the message says only `Incorrect arguments to mysqld_stmt_execute`. Neither number was reachable from the error, so an investigation into one of these starts by eliminating hypotheses instead of subtracting.

  `describeDriverError` reports `placeholders=N` and the statement, and the statement is carried ONLY in its placeholder form. That restriction is measured, not cautious: against mysql2 3.22 the prepared path (`execute`) leaves `?` in `err.sql` because the server did the binding, while the text path (`query`) interpolates and the same field then holds row DATA. The placeholder is the discriminator, and the form that keeps it is exactly the form 1210 arises in.

  Alongside it, `reportEngineVersion` (`@voltro/testing`): a dialect suite prints the engine BUILD it ran against. A suite that is green on a developer machine and red in CI is only comparable if both name their software, and the test compose file uses moving tags — so "the same tag" is not the same build, and checking the tag locally observes what it points at today rather than what the runner resolved.
- **@voltro/database, @voltro/plugin-versioning, @voltro/plugin-flags** — A versioned table whose NAME was long enough could not be written to at all.

  `id()` is `VARCHAR(64)` on mysql / mariadb and `NVARCHAR(64)` on mssql, and unbounded `TEXT` on postgres and sqlite. The versioning recorder built its history key by concatenation — `rowver_<tableName>_<rowId>_<version>`, which is `42 + len(tableName)` characters for a 32-character row id — so a 22-character table name fit and a 23-character one produced `ERROR 1406 (22001): Data too long for column 'id' at row 1`. A recorder runs on EVERY write, so this was not a refused import: it was a table nobody could write to, on three of five dialects, at a boundary no one can see when naming a table.

  `derivedRowId(prefix, …parts)` (`@voltro/database`) derives a deterministic key of CONSTANT width — `rowver_<32 hex>`, 39 characters whatever goes in — joined over a `\u0000` separator so the parts stay injective (a `_`-joined key cannot tell `('a_b','c')` from `('a','b_c')`). Widening the column was the alternative and moves the wall rather than removing it; `id()` is also every user table's PK type. Nothing legible is lost: every table deriving a key this way already stores the parts in their own columns.

  The same construction was in `plugin-flags` (`flag_<key>`, over an unbounded user-chosen flag key) and is fixed with it. A guard scans framework sources for an `id:` composed by interpolation and requires the helper, with an allowlist whose entries each name why their parts cannot grow — and which fails if an entry stops matching.

  Also fixed: the versioning suite's live coverage was postgres-only, and postgres is one of the two dialects where that column is unbounded, so it was structurally incapable of seeing this. `@voltro/sql-mysql` is a test devDep of `@voltro/plugin-versioning` now, with a mysql+mariadb case driving an ordinary insert and update against a 33-character table name.
- **@voltro/database, @voltro/cli** — Two gaps on the `--target api` path, both about a failure that is present and unreadable.

  **The driver was unreachable behind a WRAPPED rejection.** `extractDbCause` unwrapped a `FiberFailure` at the root only, so one reached through a `.cause` link stopped the walk — it carries `stack`, `message` and `name` and nothing else, which is indistinguishable from "no driver under this". That is exactly the shape a failing write recorder produces: it rethrows `new Error(<what it was doing>, { cause: err })` where `err` is the rejection its own insert made. So the same database refusal classified where no recorder runs and degraded to the bare runtime rendering where one does — which is the difference between the direct importer and an import through a running app with versioning or audit on. The walk now unwraps at every link.

  **And the refusal report was never printed on that transport.** A refusal that crossed HTTP arrives as a 500 whose message embeds the `RowsRefusedError` as JSON; the CLI printed that body raw. So the operator on the transport that exists for "the database is somewhere you cannot open a shell" got the one output that has to be triaged by hand — and tallying the capped row list is how a per-table distribution gets reported that is not the real one. `--target api` now prints the same report as the direct path, `byTable` line and cap notice included.
- **@voltro/data-transfer, @voltro/cli** — Two reporting defects that made a refused import unreadable, both of the shape "the payload is present and property access is not the way to it".

  **A refusal lost its tag on the mode that raises it most.** `--mode replace` runs in one transaction by default, and rolling that back needs a rejection — which the atomic wrapper obtained by throwing `new Error(Cause.pretty(cause))`, a rendering rather than the failure. From there the typed error could not come back: it was re-wrapped as a `BundleError` carrying itself as text. So `Effect.catchTag('RowsRefusedError', …)` matched nothing on the default path, `ImportError`'s union was a claim that path could not honour, and the CLI's refusal report — which branches on the tag — printed nothing at all. The typed error is thrown and passed through now; `asImportError` is exported for callers who catch the rejection rather than the effect.

  **And the report read the tag off a `FiberFailure`.** What `Effect.runPromise` rejects with does not expose `_tag` by property access, so the renderer took its "not my error" branch on every direct-path run while being wired, tested and correct — the test drove the renderer with the error object, which is not the shape the call site produces. A reported refusal now also ENDS the command instead of being rethrown into `fatal unhandled cli error`: a refusal is a condition with a named cause, not a framework defect.

  **An api host is no longer reported as an unreachable database.** A connect failure carries an address, a port and an errno — the same shape a database driver's carries — and one global handler renders that shape, so `--target api --api-url https://…` against a stopped instance printed `the database is not reachable at <api-host>:443 … Configured by: DB_URL` with `DB_URL` not in play. The transport names its own failure now (`InstanceUnreachable`), and the database explainer declines an endpoint whose PORT cannot be a database — judged by port because a driver reports the resolved address, so a host comparison would silence the real message for anyone naming their database by hostname.

### Internal (no consumer-facing effect)

- **@voltro/sql-postgres** — A test teardown terminated connections its own pool was still closing, and the resulting error failed the RUN rather than any test.

  `clusterColdStart` drops a per-run database, and the runners it spawned are killed with SIGKILL, so their backends never close — hence the deliberate `pg_terminate_backend` before the `DROP`. But `pool.end()` resolves once it has ASKED the pool to close, not once every socket is down, so the terminate could also land on a connection belonging to the test itself. `pg` reports that as an `error` event on the idle client, and an unhandled one takes down the process.

  The shape it took on a release gate is the reason this is written down: **36 of 36 test files green, and the suite exiting 1.** Nothing points at the teardown — the failure is attributed to whichever suite happened to run last, which is a different one each time. A connection error while we are tearing the database down carries no signal, so it is handled where it arises.

  Test-only; no product code changed.
- **@voltro/plugin-auth** — The TOTP skew-window test uses a fixed secret. Test-only; no product code changed, and the assertion is unchanged.

  It failed once on a release gate — `expected true to be false`, meaning a code two steps outside the ±1 window verified. That is the shape of a security defect, so it was treated as one until measured:

  - `TOTP_SKEW` is 1 and the verify loop checks exactly three counters, compared with `timingSafeEqual`; - `T0` is a constant and the clock is injected, so the only varying input was `generateTotpSecret()`; - over **50 000 fresh secrets**: zero collisions between the ±2 codes and the ±1 window (pure chance predicts ~0.3), zero degenerate secrets, uniform length; - **60 consecutive runs** of the file: green.

  So the implementation is sound and that red was two 6-digit codes coinciding — about six in a million per run. Worth stating plainly: that makes the observed failure a one-in-167 000 event, which fits every measurement and is still remarkable. It was not reproduced.

  The fix is to remove the coin flip rather than to re-run until green. A random secret buys this test nothing — the property under test is the WIDTH of the window, which does not depend on which secret is used. It only buys a rare red that costs a diagnosis cycle and teaches the reader to re-run. Pinned, so the next failure there means the window moved.

---

## [0.44.1] — 2026-08-19

### Fixed

- **@voltro/database, @voltro/data-transfer, @voltro/cli** — A refused import row reported `(FiberFailure) SqlError: Failed to execute statement` — our runtime's rendering of a rejection, marker and stack frames and all — instead of the constraint that fired. It names neither a rule, nor a code, nor even which layer refused, and every row refused for the same cause carries it identically.

  Two defects, and fixing either alone still leaves a reader stuck.

  **`Cause.squash` elects a branch, and first is a position, not a ranking.** A `Cause` is a tree, and a transaction routinely produces one with more than one leaf: the statement that failed, and whatever the rollback or a finalizer did on the way out. When the first leaf was the bare wrapper, the driver error sitting in the sibling branch was never looked at. Measured: `sequential(bareSqlError, sqlErrorWithDriver)` classified as nothing while the same two branches in the opposite order classified as `unique constraint PRIMARY … [ER_DUP_ENTRY/1062]`. `extractDbCause` flattens failures AND defects now, in Cause order, expanding a nested `FiberFailure` leaf, and elects the branch that names a driver — the others' chains are appended rather than dropped.

  **A row's reason may never read like a stack trace.** The runtime rendering is stripped before any tier looks at the text, so the failure mode cannot return invisibly. And when no driver detail is reachable at all, the reason now names the CHAIN of wrappers the failure passed through — the difference between "the database refused this row" and "the connection died mid-import" — while the run logs the full rendering of the first few such failures. Never returned over the wire: it carries frames, and on some engines a driver's sentence carries row data.

  `RowsRefusedError` also gained `byTable`: **complete** per-table counts. `rows` is capped at 20, so per-table counts tallied off the printed list sum to the cap rather than to the failure — and nothing else in the payload offered any.

---

## [0.44.0] — 2026-08-19

### ⚠ BREAKING

- **@voltro/data-transfer, @voltro/cli** — `runImport` returns an `ImportOutcome` instead of the bundle's `Manifest`, and `--mode replace` runs as ONE transaction by default.

  **Why the return type changed.** The summary line counted the rows the BUNDLE carries, not the rows the run wrote. Those differ most exactly where it matters: a bundle directory carries its own resume ledger, so a copied directory imports nothing — correctly, with a warning naming the file to delete — and the run then printed `import complete … rows: 242950` over a target it had not touched. The warning was one line above, which is one line too far for anyone piping the output through `tail -1`. The outcome carries `rowsWritten`, `rowsSkipped` and `fullyResumed`, the CLI reports written-vs-carried, and a fully-skipped run says so on its LAST line.

  Migration: `runImport(...)` now resolves to `{ manifest, tablesWritten, rowsWritten, tablesSkipped, rowsSkipped, fullyResumed }`. Read `.manifest` where you read the manifest before.

  **Why replace is atomic now.** The all-or-nothing guarantee was written for the emptying step, and read — reasonably — as covering the run. A replace that died partway through the LOAD left the target emptied of its old rows and holding part of the new ones, measured on a live instance over sixteen minutes. There is no useful state for a replace to stop in, so it is a default rather than a flag you have to know about. It also closes the window that produced that failure: with the tables emptied and the load uncommitted, a concurrent writer in the application waits instead of inserting a row the bundle is about to insert too.

  `--no-atomic` (CLI) / `atomic: false` (API) opts out. The trade is stated where it bites: every write to those tables waits for the load, and on postgres the bulk `COPY` loader cannot join a transaction it does not own — an atomic run now says that once rather than being quietly slower.

  Two smaller things from the same report: `--mode replace` against a running instance warns about the empty-target window when it is NOT atomic, and the `--target api` upload reports progress per chunk plus a line explaining that the final request stays open for the whole import — sixteen minutes of silence is indistinguishable from a hang, and one operator killed a run that had finished.

  **`voltro update` carries you across this** — codemod `0.44.0/01_import-outcome`.

### Fixed

- **@voltro/sql-mysql, @voltro/sql-postgres, @voltro/sql-sqlite, @voltro/sql-mssql, @voltro/data-transfer** — A write and its write-recorders now succeed or fail together on every SQL dialect, and the data importer's retry is idempotent.

  A recorder (a versioning trail, an audit log) runs on the caller's connection and is ALLOWED to fail — a recorder that throws must take the write down with it, that is its contract. Outside a caller transaction the two were not one unit: the row's statement committed on its own, and the recorder's INSERT ran afterwards as a second autocommit statement. So a recorder that threw left a COMMITTED row behind a write that reported failure. Measured directly: `insert` throws, the row is in the table, and a second attempt at the same row is `ER_DUP_ENTRY` on PRIMARY.

  Anything that retries a failed write then meets its own row. The data importer retries by design — it holds a row whose write failed and tries again once the remaining tables have streamed — so a `--mode replace` that had just emptied a table failed on a duplicate key IN that table. From outside, that is the impossible-looking thing: an import that emptied a table and then failed because something was already in it. Reproduced verbatim, including the table name and `[ER_DUP_ENTRY/1062]`, by putting the old retry back.

  Both ends are closed, and they are independent on purpose:

  - **The cause.** A table that HAS recorders writes inside a transaction now, so the row and the trail commit together or not at all — on mysql, mariadb, postgres, sqlite and mssql, verified by one suite that asks all five the same question. A table with no recorders — the default — takes the direct path unchanged; `recordsTable` is a Map-size check first, so it costs one comparison. - **The defence.** The importer's retry upserts instead of inserting in `replace` mode. That covers every OTHER way a write can land while reporting failure: a driver timeout on a write the server applied, a connection lost after the commit, a concurrent writer inserting the same key. It is sound precisely because the table was emptied by this same run — there is nothing in it that is not ours.

  The test that pinned the divergence went red when the fix landed, exactly as its own note said it would, and is inverted with that note kept.

---

## [0.43.2] — 2026-08-18

### Fixed

- **@voltro/sql-mysql, @voltro/database, @voltro/cli** — The mysql-family binlog reader no longer keeps a table excluded after the very migration that fixed it.

  A UNIQUE on an unbounded text column is a MariaDB hash long-unique, whose hidden `DB_ROW_HASH_n` column the reader can never account for — so the table is held out of binlog capture and the exclusion is reported. `voltro dev` builds its store BEFORE it migrates (kv, cross-replica broadcast and the analytics mirror all need one), so on a boot whose own auto-migration bounds the column, that finding was drawn from a schema that stopped existing about a second later. The exclusion then outlived its cause for the life of the process, and the message — correct when written, and typically the only error line in the boot log — went on describing the pre-migration database.

  Three changes:

  - `DataStore.refreshChangeCaptureExclusions()` (optional; implemented by the mysql-family store) re-runs the probe and re-points the LIVE reader, in both directions — schema work that CREATES the condition now excludes the table immediately instead of after three failed writes. `voltro dev` calls it once, after all its schema work. `voltro serve` needs no equivalent: it builds its store after every schema step and never applies DDL itself. - The definitive message now states its own durability — that it is the schema as read at reader attach, and that applying the remedy does not by itself lift the exclusion. - On a boot that will re-check, the finding is reported as a provisional note rather than as a verdict, so a boot that fixes the condition leaves no error line about it. The note escalates to the full verdict on its own if the re-check never runs. - Two reader fixes the re-check depended on: applying a new exclusion set now WAITS for a reconnect already in flight (it is what applies the set, so resolving before it landed meant the caller's next write hit the old filter), and the reconnect loop no longer keeps resuming from an offset it has just jumped away from — that turned one purged offset into a reconnect every watchdog interval, forever, delivering nothing.
- **@voltro/data-transfer, @voltro/database, @voltro/sql-mysql, @voltro/sql-postgres, @voltro/sql-sqlite, @voltro/sql-mssql, @voltro/cli** — `voltro data import --mode replace` no longer leaves a target in neither state, and now works against schemas that have foreign keys.

  The delete step ran table by table and stopped at the first refusal, so a run that could not finish left dozens of tables emptied and nothing loaded — and a second attempt destroyed more than the first, because it got further before hitting the same wall. The wall itself was not exotic: MySQL, MariaDB and SQL Server check a foreign key as each ROW is deleted, so a table that references ITSELF cannot be emptied by any ordering of the tables. `createdBy → actors` on the `actors` table is exactly that shape, and it is what an audit mixin on an actor table produces.

  - `DataStore.emptyTables()` (per dialect) empties the whole set as one unit, in one transaction, with referential integrity suspended for the duration — `FOREIGN_KEY_CHECKS` on the mysql family, a multi-table `TRUNCATE` on postgres, `defer_foreign_keys` on sqlite, per-table `NOCHECK`/`WITH CHECK CHECK` on mssql. All-or-nothing on every engine, including under `--atomic`, where it runs on the transaction the import already holds. - A **pre-flight refusal**: if a table OUTSIDE the bundle holds rows referencing one inside it, the import refuses before deleting anything and names the tables, the columns and the row counts. Those rows cannot be restored from the bundle, so forcing it is not an option. An EMPTY outside table blocks nothing. - Table-level failures carry the driver's own reason and code, the way row-level failures already did. `truncate <table> failed: Failed to execute statement` fits every plausible cause equally; the classification that produced `foreign key <name>: … [ER_NO_REFERENCED_ROW_2/1452]` one level down now applies one level up. The word "truncate" is gone from the message too — the step issues DELETE, and naming a statement it does not run sends whoever reads it to reproduce the wrong thing. - A typed refusal reaching the `--target api` transport keeps its text: the admin import endpoint answers `409` with the reason instead of flattening it to `import failed`, on the one transport where the operator has no other way to see it.

  The bulk empty emits no change events, where the per-table loop emitted one per row. An import through `--target api` now asks every live subscription to re-read once it lands — the coarse refresh the framework already uses after a broadcast gap — so neither the missing deletes nor a table the bundle carries EMPTY leaves a subscriber holding rows that are gone. Wired where the route is mounted, which is the one place both boot paths share.

  On postgres the emptying is a `DELETE` per table, not a `TRUNCATE`, and the difference is not performance: postgres refuses `TRUNCATE` on a table with ANY incoming foreign key, rows or not, while the mysql family refuses a DELETE only when rows actually reference the doomed ones. A `TRUNCATE` version made an EMPTY table outside the bundle block a replace on postgres and not on mariadb — one import, refused on one engine and accepted on the other, over a table holding nothing.

  `--target api` also no longer times out on a full bundle. Both api-target calls went through `fetch`, whose undici default gives up after 300 s — a bound on the caller's database size, on a call whose response arrives only when the import does. They wait as long as the instance needs now, stream the body instead of buffering the whole bundle, and take `--timeout <seconds>` when a deadline is wanted. If one is hit, the message says the instance is probably still importing — and gives different advice for `replace` than for the idempotent modes, since re-running the first while it is mid-flight would empty the target under it.

  Two more, found by measuring rather than by reading:

  - **`--atomic` on postgres could not import a bundle that needed the deferred-FK repair at all.** A failed statement aborts the transaction there, and that repair depends on a row whose parent has not loaded yet failing, being held, and being retried — so the first such row poisoned every write after it. Every tolerated write now runs inside a savepoint. Per-row savepoints measured 2.40x the time of none on 5 000 rows, so they are amortised: one savepoint per batch of 200, and a batch that fails rolls back whole and replays row by row. The mysql family and sqlite leave a transaction usable after a failed statement and pay nothing for any of this. - **The replace pre-flight asked the caller's snapshot.** Over `--target api` that is the app's DECLARED schema, which cannot show a table the app stopped declaring but the database still has — and rows in a table nobody declares are exactly the rows nobody is watching. `DataStore.incomingForeignKeys()` reads the live catalog per dialect; the snapshot remains the fallback for stores without one.

  A bundle bigger than one chunk is now uploaded as a series of short requests, so a proxy body cap or an ingress read timeout has nothing large to choke on, and the switch is automatic — the packer's stream is buffered one chunk ahead, so a small bundle is sent exactly as before and nobody has to know in advance which table is the big one. The import still runs ONCE, at the end, over the whole bundle. Resume is byte-exact (`packBundle` is deterministic over a directory, which this package now asserts), guarded by a bundle key so a different bundle under the same upload id is refused rather than spliced into the partial one, and by a contiguity check so a mis-ordered append cannot produce an archive that only fails later during decode. `--chunk-size <mb>` overrides the 16 MiB default.

---

## [0.43.1] — 2026-08-18

### Fixed

- **@voltro/database** — A write recorder that fails inside someone's transaction now says which recorder, on which write, and what the database actually said.

  `@effect/sql` renders every driver failure as `SqlError: Failed to execute statement` — one sentence that fits a missing column, a dangling foreign key, an over-long value and a duplicate key equally. The driver's own words hang off a SYMBOL on a `FiberFailure`, so a caller who reaches for `.cause` gets `undefined` and concludes there is nothing there.

  That is expensive precisely where recorders run: the caller's write was ordinary, and what failed was framework machinery one table over. The message now reads

  ```
  write recorder '_voltro_row_history' failed while recording an update on 'users':
  Duplicate entry 'rowver_…' for key 'PRIMARY' [code=ER_DUP_ENTRY errno=1062 …]
  ```

  and the original error is kept as `cause` for anyone who does walk the chain. The failure still takes the transaction down — that is the guarantee and it is unchanged.

  `describeDriverError` (`@voltro/database`) is the shared summariser, built on the existing cause extractor rather than a second walker. It returns nothing when the chain carries nothing driver-shaped, so an ordinary programming error from a recorder arrives as itself instead of wrapped in prose about a database.
- **@voltro/database** — On MariaDB, a `varchar` column could introspect as `json` because a DIFFERENT table had a json column with the same column name.

  MariaDB names a column-level CHECK after the COLUMN, and those names are unique per table, not per schema. `information_schema.check_constraints` on MySQL has no `TABLE_NAME`, so the introspector recovered it by joining `table_constraints` on `(schema, constraint_name)` — which on MariaDB cross-products every same-named check across every table. Measured on 11.8:

  ```
  a.payload  LONGTEXT      CHECK (json_valid(`payload`))
  b.payload  VARCHAR(255)  CHECK (`payload` in ('x','y'))
  
  join result:  a → json_valid, a → in(…), b → json_valid, b → in(…)
  ```

  So `b.payload` reads as `json`, and `a.payload` picks up an enum it does not have. Downstream that is not a cosmetic label: the planner emits a blocked `alter-column-type` with `from: 'json'` that no `.narrowedFrom()` can honestly acknowledge, because the premise is false — and since the data-transfer manifest records introspected types, the same misreading travels into the bundle and reappears as schema drift on import.

  MariaDB's own `check_constraints` HAS `TABLE_NAME`. The introspector asks for it directly now and keeps the join as the MySQL path, where check-constraint names are schema-unique and the join is sound. That asymmetry is why a single-engine test could not see this: the wrong query passes on MySQL.
- **@voltro/database** — `voltro db apply` could not drop a CHECK constraint on the mysql family at all, and each of the three reasons hid the next.

  **1. `DROP CHECK` is MySQL-8 syntax.** MariaDB has never had it — measured on 11.8, `ALTER TABLE t DROP CHECK c` is `ERROR 1064`, while `DROP CONSTRAINT c` works on both engines. A plan containing a `drop-check` therefore died on the first one, on a family whose migrations are NOT atomic: the run stopped with the earlier statements committed and no rollback.

  **2. The name was assumed, not read.** The applier dropped `<table>_<column>_check` — which is only what its own `add-check` would have named it. A CHECK created at table bring-up is INLINE and UNNAMED, so the server names it (`CONSTRAINT_1` / `<table>_chk_1` / the column name). Dropping a name that does not exist reports "does not exist", which is indistinguishable from the "already gone" a resume legitimately produces — so the statement succeeded, the constraint stayed, and the plan re-proposed the identical `drop-check` forever. The name comes from the catalog now.

  **3. A column-level CHECK cannot be dropped by name on MariaDB at all.** Measured: the catalog lists it under the column's name, `DROP CONSTRAINT` on that name answers 1091, and only redefining the column removes it. The applier now drops by catalog name, ASKS whether the constraint survived, and redefines the column when it did — so a rebuild happens only in the case that needs one.

  **And the same investigation closed the MySQL `.oneOf()` round-trip gap.** `parseEnumCheck` was documented as handling MySQL's rendering and did not: MySQL backslash-escapes the string DELIMITERS (`_utf8mb4\'draft\'`), and the parser rewrote those to the SQL doubling `''` — which is how a quote INSIDE a value is written. Every delimiter became escaped content, every value came back empty, and the filter dropped them. `.oneOf()` now round-trips on MySQL, and the suite that asserted the gap as a known one asserts the round-trip instead.

  Plus, on a failed apply: the error now states how many operations were already applied and whether this dialect rolls back. The ledger held that number; it never reached the operator, who had to re-plan and diff the counts to learn how far the run got.
- **@voltro/plugin-versioning** — `versioningPlugin({ timing: 'in-transaction' })` built its history row's primary key from `(rowId, version)` while the version counter three lines above was scoped to `(tableName, rowId)`. Two versioned tables carrying the same row id therefore collided — permanently.

  The shape is not exotic: `actors.id === users.id` is what the framework's own audit trail asks for, an `actors` row whose id is the user's so an audited write satisfies `createdBy → actors`. In an app that follows it, every user row has a twin.

  The collision does not heal, and that is what turns a duplicate into an outage. The second table's insert fails, so its history row is never written, so `maxOf` for that table stays `null`, so the next attempt computes the same version and the same id. Every write to that row is dead from then on — surfacing as `ER_DUP_ENTRY` on an ordinary `store.update`, naming a row id in a table the caller never wrote to.

  The key is `(table, rowId, version)` now — the same shape the post-commit path always built. It stays deterministic (no clock, no process-local counter), which is what lets it survive a replica restart; it just carries every part of the key it claims to be unique over.

  **No cleanup is needed for rows already written.** They keep their old ids and belong to whichever table wrote them; the new keys cannot collide with them, and `byRow` is not unique. An app blocked by this is unblocked by the upgrade alone.

  Covered twice: the recorder against a port that refuses duplicates (the mechanism, including that a repeat does not settle), and two versioned tables sharing an id against live postgres (the real primary key, inside the caller's transaction). The suite that existed exercised ONE table, which cannot produce a collision at all — and read exactly like a suite that covered this.

### Internal (no consumer-facing effect)

- **@voltro/sql-mysql** — Two test-only defects in `sql-mysql`, both found by a release gate, both of the same family: a check that could not fail, and a failure reported in the wrong place. No product code changed.

  **An assertion that could not fail.** `dropCheckSyntax.integration.test.ts` fell back to a HAND-BUILT plan when the planner produced no operations — and the fabricated operation was a `drop-check`, which is exactly what the next line asserts the plan contains. So an engine whose planner stopped emitting it would have been handed one and reported green. The fallback is deleted; both engines produce the operation now, which is what this release fixed, and the assertion is load-bearing again (4/4 on mysql AND mariadb without it).

  It surfaced as a TYPE error rather than a false pass, because the fallback's object widened `plan` into a union `applyPlan` does not accept. Worth noting which check caught it: `vitest` transpiles without type-checking, so the suite was green and only `tsc` objected — the gate's `typecheck` and `lint` steps are what went red.

  **A wait that gave up in silence.** `waitFor` in both binlog CDC suites looped to a deadline and then RETURNED, so a "prove the reader is live" wait that expired let the test carry on, kill the binlog dump thread, and fail twenty lines later on `expect(ids).toContain('todo_wd_before')` — an assertion about a different claim, in a different place. It throws now, naming the wait and the window, and all twelve call sites carry a label.

  The window is named too: `FIRST_ATTACH_MS = 30_000`, up from 12 s. The reasoning is the 40 s window already in the same file, whose comment says a re-attach plus binlog catch-up takes longer under a loaded full-suite run — a FIRST attach does both and only skips the backoff, so 12 s beside 40 s was an asymmetry the file's own reasoning did not support. That is an argument from the neighbouring comment, not a measurement; if it expires again, `waitFor` now says which wait and for how long, and that number is the one to argue with rather than raising this one twice.

---

## [0.43.0] — 2026-08-18

### ⚠ BREAKING

- **@voltro/data-transfer, @voltro/cli** — `DanglingReferenceError` is now `RowsRefusedError`, and it separates the rows that failed from the rows that failed because those rows did.

  **The name.** The import raised it for EVERY row still refused after deferred-FK resolution, whatever the reason — a NOT NULL violation, a duplicate key, a value the database computes for itself. The tag named ONE possible cause and put it where a reader looks first, so any other refusal arrived mislabelled. The new name states the outcome; each row's `reason` states the cause, which is where a cause can honestly be claimed.

  **The split.** A row is `derived` when one of its reference columns holds the primary key of another row that also failed in this run: it could not have landed whatever it contained, so its reason describes the parent's problem. The relation is transitive, decided from the DATA (the failed ids against the reference-typed values), so no schema knowledge is needed. `primaryCount` is the number an operator acts on, `rows` lists primary failures FIRST so the cap never spends its budget on consequences, and the CLI leads with both numbers:

  ```
  import refused 300 row(s), of which 1 are the actual failures — the rest could
  not land because a row they reference did not.
    teams t1  foreign key teams_ibfk_1: the referenced row does not exist [1452]
    … and 299 row(s) behind them. Fix the 1 above and re-run; they resolve with
    their parents.
  ```

  In an FK-dense bundle one refused parent takes its whole subtree with it, so the length of a flat list says how connected the data is, not how many problems there are — and the single row that explains all of them sits somewhere in the middle of it.

  The codemod rewrites the import and every use of the symbol. It does NOT rewrite a tag STRING (`Effect.catchTag('DanglingReferenceError', …)`, `err._tag === '…'`) — change those to `'RowsRefusedError'` — and the payload gained `primaryCount` plus a `derived` flag per row.

### Fixed

- **@voltro/cli** — `voltro doctor`'s `subject-write-no-guard` rule reads the DESCRIPTOR before it reports. It looked only at the executor, and the access decision is not declared there.

  Every form of access decision the framework has — `internal: true`, `guards: [...]`, `openAccess:` — is declared on the DESCRIPTOR. So an app that declares them properly got a finding for each one, and on a codebase whose procedures are mostly `internal: true` the rule fires on essentially all of them and is wrong essentially every time.

  That is worse than a rule that finds nothing: it is the longest line in the report and it reads like a security finding, so it teaches the reader to skim the place a real finding would have appeared.

  The premise was structurally impossible for most of them, and the framework says so itself: `security.defaultDeny` refuses at boot any wire-exposed procedure declaring neither `guards:` nor `openAccess:`. So "an anonymous caller reaches the write" can only be true of an `openAccess` procedure. That is what the rule looks for now — plus the handler check it always honoured, plus an ownership comparison against `subject.id`, which is the refusal the rule says is missing. Where no descriptor can be read, it says nothing: the claim is about a declaration, and a finding whose evidence was never opened is the failure mode this fixes.

  The `use:` line changed with it. It recommended `.guard(requireScope('…'))`; the shipped guide teaches declarative `guards:` and calls a hand-written per-executor scope check the thing `guards:` exists to delete. A rule may not recommend the shape the guide argues against.
- **@voltro/database, @voltro/data-transfer** — A `voltro data` transfer no longer carries GENERATED column values, and no longer loses every row that has one.

  The export wrote the computed values into the bundle and the import sent them back in the `INSERT` column list. MariaDB refuses that for any value except NULL (`1906: The value specified for generated column 'x' in table 't' has been ignored`); postgres refuses a non-DEFAULT value outright. So the transfer failed per ROW, not per table, and only for the rows whose generated value was non-NULL.

  That selectivity is the dangerous part. `.uniqueActive()` lowers to exactly such a column on mysql/mariadb — a STORED generated column holding the key while the row is live and NULL once it is soft-deleted — so the refused rows are the LIVE ones and the accepted ones are the tombstones. A table can come out looking like "a few rows failed" or empty, depending only on how many of its rows are deleted, and any table with a foreign key into it fails behind it.

  Both ends are fixed from one source of truth: **introspection now reports generated columns on every dialect** (`generatedAs`, from `information_schema.generation_expression` on mysql/mariadb, `is_generated` on postgres, `PRAGMA table_xinfo`'s hidden flag on sqlite, `sys.computed_columns` on mssql). It was declared-side only before — invisible to the planner, which does not compare it, and load-bearing for anything that writes rows back.

  The exporter omits those columns, values and all. The importer strips them from every incoming row using the TARGET's snapshot, because a bundle already written still carries them and a file on disk is data, read where it is.

  **`voltro serve`'s admin export/import needed a second fix, and without it this one reached only the direct transport.** The running instance hands those handlers `declaredSnapshot(tables)` — no dialect — and `.uniqueActive()` lowers to a generated column only when the snapshot knows the engine. So over `--target api` the snapshot described a schema with no generated columns while the database had several, and the export wrote their values back into the bundle exactly as before. It passes the dialect now (the variable was already on the next line).

  `generatedAs` is also kept OUT of the schema fingerprint. Introspection can read the fact back but not a comparable value — the engine returns its own normalisation of the expression, never the declared spelling — so hashing it would make declared and live disagree permanently: a schema nobody touched reporting a changed declaration on every boot, and a transfer target that matches its bundle exactly refused as drifted.

  Covered by a live mariadb→mariadb and mysql→mysql round trip over a `.uniqueActive()` table with live and soft-deleted rows, asserting the target recomputed the value rather than that the row merely arrived.
- **@voltro/sql-mysql** — On mysql/mariadb, a write REJECTED by the database through `insertIgnore` was reported as a conflict when the caller was not inside a transaction — and never reached the caller as a typed `ConstraintViolation` at all.

  Two causes, both measured against live MySQL 8.4 and MariaDB 11.

  **The connection.** `INSERT IGNORE` demotes every error to a warning, so the store reads `SHOW WARNINGS` to tell a rejection from a conflict. That describes the last statement on a CONNECTION, and outside a transaction every statement acquires its own from the pool — so the read was unattributable and came back empty. The rejected write then surfaced as "the insert was skipped as a conflict, but no existing row matches conflictColumns […] the constraint that fired is unknown", whose enumeration lists only conflict causes. A rejection described as a conflict is the exact sentence this path was fixed once already for producing; it survived on the path that had no transaction to read on. The store now pins one connection for the whole decision — the same pinning `insertRecoverAutoId` does for `LAST_INSERT_ID()`.

  **The classification.** The store swallowed the driver's error and raised a prose one of its own, so `classifyConstraintViolation` had nothing to read: this was the one write path where the typed error could not fire, while every other one produced it. The warning IS the driver's payload — `INSERT IGNORE` only changed how it was delivered — so it is handed on in the shape the driver would have thrown. A rejection now arrives as the same `ConstraintViolation { kind: 'foreignKey', … }` a plain `insert` produces. Nothing new crosses the wire: the classifier extracts the constraint NAME as a delimited group, never the sentence.

  This is the default `voltro data import` path (`--mode append`, `--on-conflict skip`, without `--atomic`), so a row rejected by a foreign key was reported to the operator as a conflict with an unknown cause.

  `constraintViolation.integration.test.ts` now asserts the `insertIgnore` seam per dialect against live postgres 17, MySQL 8.4, MariaDB 11 and SQL Server 2022. It was the missing assertion behind a claim derived from the wiring — the classification does sit on all ten write paths, which is not the same as a classifiable error arriving on all ten.
- **@voltro/protocol, @voltro/runtime** — The framework's store errors — `ConstraintViolation`, `TenantScopeViolation`, `TenantRowNotFound`, `ServerOnlyColumnWrite`, `TableValidationFailed`, `StoreOperationFailed` — are exported from `@voltro/protocol` and can therefore be declared in a descriptor's `error:` union. They could not be.

  They lived in `@voltro/runtime`, which reaches `node:child_process`, `node:http` and `node:crypto`. A descriptor is loaded VALUE-LEVEL by the web client (the `RpcClient` needs every procedure's Schema), so a descriptor importing from there is refused at boot by the browser-safety guard — correctly. The typed half of these errors was therefore unreachable: the docs told you to declare them, and the boot said no.

  What that leaves is the untyped half only. The error still arrives as an `InternalError` carrying a readable sentence, so telling `foreignKey` ("the row you picked is gone") from `foreignKeyInUse` ("this row is still referenced") — two different messages for the user — means parsing that sentence. Over a set of generated delete mutations that is a string comparison per procedure, which is the thing the typed error exists to delete.

  `@voltro/runtime` re-exports all six, so server code is unchanged. The classes have no server dependency of any kind — the file imports `Schema` from `effect` and nothing else, and `browserSafetyGuard.test.ts` now pins a descriptor that declares one, so moving them back reads as a boot failure rather than as a passing rename.
- **@voltro/database, @voltro/sql-mysql** — A blocked `alter-column-type` told every dialect to write a postgres cast.

  The refusal is correct — a bare type change may not be value-preserving, so it is refused until acknowledged. Its `fix:` line was not:

  ```
  acknowledge it on the column: `.narrowedFrom('json', { using: 'meta::text' })`
  ```

  `USING <expr>` is postgres syntax and a postgres capability. The mysql arm of the applier emits `MODIFY COLUMN`, the mssql arm `ALTER COLUMN`, and sqlite rebuilds the table — none of them can carry a cast expression and none of them reads `using`. So an operator on any other engine was handed a line to paste into their schema containing syntax their database has never seen, inside an argument that is discarded. A refusal is read as an instruction, and the more carefully it is read the more thoroughly a wrong one is followed.

  The fix line is dialect-aware now: postgres keeps the `using` half, everything else gets `.narrowedFrom('<type>')` plus the fact that the engine converts in place — because a refusal that offers no expressible fix reads as "the framework cannot do this at all".

  Covered on live MySQL and MariaDB by both halves at once: what the refusal SAYS, and that following it applies AND converges with the row intact. A message test alone would keep passing over a broken apply; a convergence test alone is what let the wrong message survive this long.

  Also on the same path: `upsert` with a PARTIAL row (one omitting a NOT NULL column that has no default) failed on MariaDB and succeeded on MySQL. The native `INSERT … ON DUPLICATE KEY UPDATE` validates its insert half even when only the update half runs, so the row was rejected although the target existed and only needed patching. A partial row takes the lookup path on both engines now; the single-statement form still covers the complete-row case, which is what a data transfer and every generated CRUD write send.
- **@voltro/sql-mysql** — On MariaDB, `upsert` could write a DIFFERENT row than the one it was given and report success.

  `INSERT … ON DUPLICATE KEY UPDATE` fires on ANY unique key, not on the one named in `conflictColumns`. So an incoming row whose (say) `email` already belonged to a different primary key updated THAT row instead — and since `id` is excluded from the SET list, the row the caller handed over was never written. Measured on a live server: the call returned a row, the target kept the old id, the new row was absent, and the existing row had silently taken the incoming values. A bulk transfer on top of that prints `import complete` over missing data, which is the worst failure shape available: there is nothing to investigate.

  The two engines of the family disagreed here, which is part of why it survived. The non-RETURNING path (MySQL) looks the row up by `conflictColumns` first, does not find it, and lets the INSERT fail with a duplicate-key error. Loud was always right.

  Both refuse now, with a message naming both ids and the fact that the collision was on a constraint other than the one named. The check runs inside a transaction — a short one of the store's own when the caller is not already in one — so the wrong row is rolled back rather than reported after the fact: detecting this afterwards still leaves someone else's row overwritten.

---

## [0.42.0] — 2026-08-17

### ⚠ BREAKING

- **@voltro/cli, @voltro/data-transfer** — **Two security defects on the data-transfer surface, both found by using the feature rather than by reading it.**

  **A `{ profile }` in an admin-export request could name a PATH.** `loadProfile` resolved the client's string with `resolve(cwd, x)` — which returns an absolute path unchanged and lets `../` traverse — and the resolved file is `await import()`ed, which RUNS it. So a holder of the data-transfer secret could make the api process execute any file on the pod: an escalation from "can export prod data" to "can run code", and chainable on an instance whose object storage is a filesystem the same caller can write to.

  `POST /_voltro/admin/export` now accepts a NAME only — `[A-Za-z0-9_-]{1,64}`, resolved under `data-profiles/` and checked to be contained there. **Migration: if you passed a path over `--target api`, move the file to `data-profiles/<name>.ts` and pass `<name>`.** `voltro data export --profile` on a DIRECT target still accepts a path: it runs on the operator's own machine, where a path is not an escalation. No user-authored code changes, so `codemod: none`.

  **An unrecognised masking action copied the value through.** `applyAction` ended in `return input.value`, so a profile with a typo in the action shape (`{ action: 'fake', kind: 'email' }` instead of `{ fake: 'email' }`) exported every row of a `.sensitive()` column verbatim — with a 200 and an audit line counting the column as masked. Measured against a live instance: a masked export of two users came back carrying both real addresses.

  The applier now throws, and `planMasking` refuses the policy BEFORE a row is read: `MaskingError` gained `invalidActions`, reported separately from `unclassified` because the fixes differ — one needs a classification, the other needs the policy corrected.

### Added

- **@voltro/runtime** — A write the database refuses on an integrity rule now raises a typed `ConstraintViolation` instead of an opaque `SqlError`. It carries `{ kind, table, operation, constraint?, column? }`, where `kind` is one of `foreignKey` · `foreignKeyInUse` · `unique` · `notNull` · `check`. Declare it in a procedure's `error:` to pattern-match it; undeclared it still reaches the client as an `InternalError` carrying its own sentence rather than `Failed to execute statement`.

  It carries NAMES and never the driver's message, which on most engines contains row data — postgres attaches the complete failing row to a not-null and a check violation, mysql and mssql echo the duplicate value. Classification is measured against live postgres 17, MySQL 8.4, MariaDB 11, SQL Server 2022 and sqlite.

  Raised from one guard covering every write op (insert · insertMany · upsert · insertIgnore · update · updateMany · delete · deleteMany · hardDelete · patchJson); the tenant-FK case still resolves to `TenantScopeViolation` first.

### Fixed

- **@voltro/cli, @voltro/data-transfer** — `voltro data import|export` — four defects on the `--target api` path, all found by a consumer seeding a fresh cluster from a bundle.

  **A flag this command does not read is now an ERROR.** `--dry-run` and `--tables` were accepted on the import path and dropped in silence: a preview against a production-shaped cluster ran the import instead (2905 rows, then a 500), and a run narrowed to a one-row table wrote all 10 593. Both are one defect — an argument parser that ignores what it does not understand — so every `voltro data` subcommand now declares the flags it reads per target and refuses the rest, naming the flag and what to use instead.

  **`--dry-run` and `--tables` now work on the import, on BOTH targets.** A dry run reaches every verdict a real run reaches (schema fit, cross-dialect portability, mode legality, the table selection) and stops before the first write; the api path carries them as `x-import-dry-run` / `x-import-tables` and echoes `{ dryRun: true, wrote: false }`. A `--tables` name the bundle does not carry is refused, listing what it does. `--dry-run` on an api EXPORT is refused rather than ignored — previewing a read protects nothing.

  **The schema-drift pre-flight compares the INTERSECTION, not whole schemas.** A bundle's fingerprint covers its source schema regardless of export scope, and two environments never have identical whole schemas, so the check refused every cross-environment seed with a diff whose every line said the difference changes nothing — making `--force` the routine way to import and removing the protection it guards. It now reports only what would break the load: a carried table or column the target lacks, a type mismatch, or a column the target REQUIRES that the bundle carries no value for.

  **A failed row says why.** `reason` was `Failed to execute statement` for every one of 2905 rows. It now names the constraint and the rule (`foreign key tasks_laneId_fkey: the referenced row does not exist [23503]`), or the driver's own message with its code, or — where there is no driver under the failure — the error from the layer that refused.

  Also: `voltro data inspect` accepts a directory bundle instead of dying inside the archive reader with a JSON parse error (`--target api` always unpacks into a directory, even when the path ends in `.vbundle`).

  **Three more, found by running the whole thing against live MariaDB and MySQL** rather than against sqlite:

  - A re-run of a COMPLETED import wrote nothing and reported the bundle's full row count — the resume ledger lives in the bundle directory, so truncating a target and re-importing printed `import complete … 10593 rows` over an empty database. Resume is right; being quiet about it was not. It now warns, names the skipped tables, and says which ledger file to delete. - The deferred-FK recovery pass OVERWROTE the diagnosis. When a held row cannot be written, the resolver retries it with every `reference` column nulled to break a cycle — and that attempt's failure replaced the original reason, so a row whose real problem was one column reported a not-null violation on a column the framework itself had nulled. The recovery attempt no longer records a reason. - MySQL/MariaDB errno **1364** (a statement that OMITS a column which is NOT NULL with no default) is classified as a not-null violation. postgres reports 23502 for that situation and mssql 515, so the mysql family was the only one where "you did not supply a required column" came back unclassified.
- **@voltro/database** — CHECK constraints were invisible to introspection on **MySQL** — and with them every `.oneOf()` column and every `json_valid` marker.

  `information_schema.check_constraints` differs between the two engines of the family: MariaDB carries `TABLE_NAME`, MySQL does not have that column at all. The introspector selected it, the query errored, and an `Effect.orElseSucceed` turned that into an empty list. A swallowed error and an empty result read identically, which is why this needed a two-engine test to surface. The query JOINs `information_schema.table_constraints` for the name now, which both engines answer.

  `parseEnumCheck` also learned MySQL's rendering. The same clause is stored differently:

  mariadb 11 `status` in ('draft','live','done') mysql 8.4 (`status` in (_latin1'draft',_latin1'live',_latin1'done'))

  MySQL puts a charset introducer before each literal, which the pattern — written against MariaDB's form — did not read. Both are pinned in `enumCheckParity.test.ts`.

  Neither fix completes the round trip on MySQL: `.oneOf()` still comes back unclassified there. That is asserted as a known gap in `oneOfCheck.mariadb.integration.test.ts` (which fails the moment it starts working) and written up in `plans/open/framework/mysql-oneof-roundtrip.md`.
- **@voltro/database** — `voltro db apply` works on MySQL. It could not create a table with an index, and could not drop a column, on that engine at all.

  `IF [NOT] EXISTS` outside `CREATE`/`DROP TABLE` is a MariaDB extension — MySQL rejects it with ER_PARSE_ERROR (measured on 8.4 for `CREATE INDEX IF NOT EXISTS`, `ALTER TABLE … DROP COLUMN IF EXISTS`, and `ADD COLUMN IF NOT EXISTS`). The applier emitted the first two, because `@effect/sql-mysql2` reports the dialect `mysql` for both engines and the shared branch had only ever run against MariaDB. A `reference()` column gets an index by default, so in practice most tables were affected.

  The applier now asks the SERVER which engine it is (`SELECT VERSION()`; MariaDB stamps itself into the string) and emits the plain form on MySQL. The idempotency `IF [NOT] EXISTS` provided moves into the statement runner, which tolerates exactly the errnos meaning "already in the requested state" — 1061 for a duplicate index name, 1091 for dropping something absent. The engine is read from the connection rather than from `DB_DIALECT` or `variant`, because the DDL has to be legal for the server that receives it and those are what an operator typed.

  Verified against live MySQL 8.4 and MariaDB 11: a schema evolution — create, add column, add index, drop column — applied through `applyPlan` on both, converging at every step, plus a replayed plan (what a resume does) that must not error on the statements it repeats.

  **sqlite had the same defect, found by the new cross-dialect scenario on its first run.** `ALTER TABLE … DROP COLUMN IF EXISTS` is accepted by postgres, mssql and MariaDB and rejected by sqlite — and the generic emitter, shaped for postgres, is what sqlite used. So `voltro db apply` could not drop a column on sqlite either. The conditional form is now emitted only where it is legal, and the "already dropped" case is tolerated per dialect (`idempotentDdl.ts`).

  `runDialectParity` gained a schema-evolution scenario — create, add column, add index, drop column, applied for real with a convergence check after each step — so the migration APPLIER is now covered on all five dialects. It previously had one scenario covering one op kind, while twenty-two covered the store; that split is why four emitter defects survived.

  **And a fifth, found by making one MariaDB-only suite two-sided.** `text().unique()` on an unbounded text column created a table on MariaDB and failed the CREATE outright on MySQL: `BLOB/TEXT column 'x' used in key specification without a key length`. The bring-up emitter (`migrate.ts`) wrote an inline `UNIQUE`; the declarative applier had always written a separate PREFIXED unique index. The two emitters disagreeing on one statement is the failure shape this package's own notes describe, and only one engine said so.

  `migrate.ts` emits the prefixed index now, through the same `indexStmt` that already owns the per-dialect `IF NOT EXISTS` rule, and names it `<table>_<column>_key` to match the applier's — so the two paths produce the same object.

  **Note the behaviour change on MariaDB.** It accepted the inline form by backing it with a HASH long-unique index, whose hidden `DB_ROW_HASH_n` column breaks the binlog CDC reader (documented in `packages/database/CLAUDE.md`). Uniqueness on such a column is now enforced on the first 191 characters rather than the whole value — which is what `voltro db apply` already did, and what MySQL can express at all. Bound the column with `text().maxLength(n)` if you need full-value uniqueness.
- **@voltro/sql-mysql** — `insertIgnore` on MySQL was a different feature from `insertIgnore` on MariaDB — and the difference could turn a conflict into an error.

  The whole diagnostic apparatus — the refusal to report a REJECTED write as a conflict, and the message naming the constraint that actually fired — sat behind a `variant === 'mariadb'` branch. MySQL took an `else` that used no `INSERT IGNORE` at all: look for a row matching the conflict columns, insert if there is none. That cannot hold the one property the method exists for. A caller that looks before anyone else writes sees nothing, so the write it then makes is the one that raises the duplicate-key error `insertIgnore` promises never to raise — reproduced deterministically against both engines with an uncommitted holder (the lookup cannot see the holder's row; the insert cannot proceed until it commits).

  Underneath sat the reason a straight port would still have produced nothing: **MySQL answers a PREPARED `SHOW WARNINGS` with 1295 ER_UNSUPPORTED_PS**, and the warning read is deliberately failure-tolerant (a diagnostic must never replace the caller's real problem), so it returned an empty list — indistinguishable from a statement that raised nothing. MariaDB accepts both protocols. The read goes through the text protocol now, the same spelling the binlog path already used for `SHOW MASTER STATUS`.

  Both engines now run one `INSERT IGNORE` and reach one decision function. What differs is only the probe for "did it land": MariaDB has `INSERT IGNORE … RETURNING *`; MySQL has no RETURNING, so the row's own key answers instead. `SELECT ROW_COUNT()` — the obvious alternative — cannot be used: measured on both engines, it reports 1/0 correctly but CLEARS the warning list on MySQL, and run the other way round returns `-1` because `SHOW WARNINGS` is then the last statement. The count and the diagnosis cannot both be had; the diagnosis is the one worth having.

  Found by converting the suite that covers this to run on both engines, which is also where every MySQL assertion in it had been reporting the driver's generic `Failed to execute statement`.
- **@voltro/database** — A `reference()` column now creates a real foreign key on **MySQL**. It did not before: MySQL/InnoDB parses a column-inline `REFERENCES` clause and discards it — no constraint, no warning, the `CREATE TABLE` succeeds — while MariaDB honours the identical clause. Both engines reach the same emitter (the driver reports the dialect `mysql` for either), and every mysql-family integration suite in the repo runs against MariaDB, so referential integrity that postgres, MariaDB, mssql and sqlite all enforced was silently absent on MySQL.

  Both emitters now write a table-level `CONSTRAINT <table>_<column>_fkey FOREIGN KEY …` inside the `CREATE TABLE`, which both engines honour and which `CREATE TABLE IF NOT EXISTS` keeps idempotent. Existing MariaDB schemas are unaffected — the introspected snapshot carries no constraint name, so nothing re-plans.

  Verified against live MySQL 8.4: the constraint is in the catalog, the server refuses an orphan row, introspection reads it back, and the re-plan is empty.
- **@voltro/sql-turso, @voltro/testing** — A migration on turso applied correctly and then reported itself as failed: `voltro db apply` ran an `add-column`, re-planned to prove convergence, saw the column still missing, proposed the same operation again, and the second execution died with `duplicate column name`. No fingerprint was recorded, so every subsequent boot re-proposed the same work — and the error named the migration applier, which had done nothing wrong.

  The client caches one prepared statement per connection per SQL text, and a prepared statement carries the schema it was prepared against. So a cached `PRAGMA table_info(t)` keeps answering with the old columns after a DDL — it is never re-prepared, so sqlite's schema-cookie re-preparation never runs. The invalidation for this existed, on the unprepared path (`sql.unsafe`) only, and the migration path sends its DDL through the PREPARED one. The statement that changed the schema and the cache that had to be dropped were on the same connection, one function apart, with nothing connecting them.

  Any schema-changing statement now drops that connection's cached statements, whichever path it arrived on.

  Three hypotheses were measured and disproven before this one — an applier retry (each operation is issued once), an MVCC snapshot (two raw libsql clients both see the DDL), and a pool-wide cache problem (four connections held open together, the PRAGMA prepared on each, a DDL on one: the other three answer correctly, because SQLite bumps the schema cookie and the driver re-prepares on the connections that did not make the change).

  The two `runDialectParity` scenarios that drive the migration applier were skipped for turso on the strength of that misreading. They run now, and the per-fixture opt-out that carried the skip is deleted: it was holding a defect open while reading like a documented limitation.

  **`apiSurface: compatible`, and the reason is a date rather than an argument.** Removing `DialectFixture.skipApplierScenarios` moves a line in `@voltro/testing`'s golden, so the changelog's narrowing detector flags it — and it is right to, because that detector's baseline is `origin/main`. But the field never reached a RELEASE: it was added after `v0.41.0` and deleted before this one, both inside the same unreleased range. `git show v0.41.0:packages/testing/etc/testing-dialect.api.md` does not contain it. No published version ever offered it, so no consumer can have set it, and there is nothing to migrate.

  Worth writing down because the first reading of this was wrong in the safe direction: it was filed `BREAKING` with a codemod on the strength of "an optional field disappeared from a published package's surface", which is the right instinct and the wrong conclusion here. **"Removed relative to main" is not "removed relative to what users have"** — a symbol that lives and dies between two tags trips the detector while breaking nobody, and the difference is only visible by asking the last TAG rather than the last commit.

---

## [0.41.0] — 2026-08-17

### ⚠ BREAKING

- **@voltro/web, @voltro/cli** — `middleware.ts` exports `defineMiddleware(...)` (from `@voltro/web/middleware`) instead of a bare function, and each export carries its own `match`. Several middlewares per file are allowed; **at most one may match a given route**.

  Migration: the codemod wraps the existing default export. That is behaviour-preserving — no `match` means every server-rendered route, which is what an unwrapped middleware did — and its note explains how to replace a hand-written path gate with a `match`.

  **Why it was worth a break.** A hand-written `if (!req.pathname.startsWith('/app')) return` is invisible: nothing can tell you a middleware runs nowhere, or that two of them claim one route. `match` puts it where both the boot and `voltro doctor` can read it.

  **The matcher speaks ROUTES, not URL patterns** — `under` / `routes` / `except`, validated against the app's own route patterns. A path matching no route refuses the boot instead of silently never firing. This is the deliberate difference from the `'/((?!api|_next/static|…).*)'` shape: our hook runs after route matching, so an app has never needed to know its own asset layout, and non-page requests are reachable only by asking (`assets: true`) — where, note, there is no render, so only `setCookies` takes effect.

  An overlap refuses the boot and names both middlewares plus the route. Declaration order is not a semantic, "most specific wins" silently drops the broader hook, and merging needs a per-field rule nobody remembers — so two hooks writing one `authorization` header is a refusal, not a resolution.

  **The web bundle budget moved UP, and the split is worth stating** because only one half is a cost the framework imposes:

  | measured | before | after | | --- | --- | --- | | first load | 184 955 B | 185 309 B (**+354**) | | lazy route chunks | 3 502 B | 4 415 B (+913) |

  The **+354 B of first load is the real price** — one `serverContext` chunk, 0.2 KB gz, which every app now carries whether or not it declares a middleware. That is the number to argue with, and it leaves 6.7 KB of headroom under the ceiling.

  The +913 B is NOT a per-route regression: the fixture gained four routes (`exact`, `exact/[id]`, `mw`, `mw/skip`) to exercise the feature end to end, at 0.1–0.2 KB gz each, which accounts for the growth without remainder. Re-pinned with `--update` rather than by hand, so the `slackFloor` keeps ratcheting — a ceiling nobody lowers again silently permits re-inflating to the old number.

### Fixed

- **@voltro/data-transfer, @voltro/cli** — `voltro data export` could not export a table whose primary key is not named `id`, and one of its two failure modes reported success.

  The keyset column was `columns.find(c => c.type === 'id')?.name ?? 'id'`, and `type: 'id'` is tagged only on a column that is BOTH the single-column primary key AND literally named `id` — identically in all four dialect introspectors. So any introspected table with another PK name was ordered by a column that does not exist. It now comes from the real primary key (the synthesised `<table>_pkey` index), with the declared `id()` column still winning where there is one.

  **A composite or absent primary key is now REFUSED**, not silently ordered by the first column: keyset pagination on a non-unique order splits equal values across page boundaries, so rows are dropped or duplicated into a bundle that reports success. Bounded exports are recoverable; a quietly short backup is discovered at the restore.

  **A requested table missing from the schema is refused too.** `scope: { kind: 'tables' }` used to drop unknown names, so a run that explicitly named a table wrote `"tables": []` and printed `export complete` with exit 0. `kind: 'all'` over an empty database is still a legal empty export — the asymmetry is deliberate: a named table is an expectation.

  **Failure reasons survive.** `String(e?.message ?? e)` produced `"write table failed: "` with nothing after the colon — `??` falls back on null/undefined, and an Effect `TaggedError` carries an empty-string `message`. Every catch site in the exporter now reports tag, message or cause.

  **New: `voltro data export --exclude a,b`** — everything except these, resolved against the live table list. It is the escape hatch the refusals above require; without it a single unkeyable table would block a whole-database export. An unknown name is refused for the same reason. Direct target only (the expansion needs the live table list), and it expands to an explicit `tables` scope, so the manifest records what was actually exported.

  Reported with a reduced repro, a four-way variation over PK TYPES that ruled type out, and two disproved hypotheses. The affected tables include `@effect/cluster`'s own (`cluster_locks`, `cluster_migrations`), so no app running workflows could take a whole-database export.
- **@voltro/database, @voltro/runtime, @voltro/cli** — `.encrypted()` had three writers and two encodings. The store wrote `encrypt(JSON.stringify(v))`; `encryptField` — the documented raw-SQL escape hatch — and `voltro db encrypt-column` wrote `encrypt(v)`. All three produce the same `enc:v1:` envelope and nothing distinguished them, so a value written by one and read by another either threw with the wrong diagnosis or came back subtly wrong (`decryptField` handed back the JSON encoding verbatim, quotes and all, raising nothing).

  There is one encoding for every WRITE now, and every READ resolves BOTH forms — so **no data has to be rewritten and nothing is blocked**. That second half is the point: the old form is already on staging and production disks, and a fix that needs the rows rewritten before the app works is an outage with a migration attached.

  Reading two forms is deterministic, not a heuristic. After decrypting, a parse failure is the raw form; a parse to a STRING is the JSON form; a parse to a non-string depends on the column's declared type (a text column cannot hold a number, so `12345` is a raw string that parsed by accident). The one case nothing can separate — a raw secret whose literal text is `"abc"`, quotes included — is stated in the code rather than hidden.

  **`voltro db encrypt-column` verified itself against the wrong decoder.** It wrote the raw form and checked it with `cipher.decrypt` — a decoder nothing reads these columns with — so it reported success over columns the app could not read. It round-trips through `decodeFieldValue` now, the same function the store calls. A self-check against a decoder the runtime does not use is not a weaker check; it is a second opinion from the same mistake.

  The command also NORMALISES rows in the old encoding as it goes (reported separately from the ones it encrypts), so an operator does not write a script per column. It skips anything ambiguous and anything it cannot decrypt.

  **The width pre-flight measured the wrong thing after the encoding changed.** It sized the ciphertext from the PLAINTEXT's byte length while the cipher is handed the JSON encoding — two characters more at minimum, and more for every escape. Measured on a real MariaDB: a 63-byte value in a `varchar(135)` passed the check and the UPDATE answered `ER_DATA_TOO_LONG`, which is the failure that check exists to prevent, mid-column with the rest already converted. It measures the encoded length now, and the refusal says "encodes to" rather than "is" so an operator measuring their own column finds the number it names.

  **Two dialect defects, both found by running the command against real servers.** SQL Server reports `-1` for `NVARCHAR(MAX)` — its spelling of unbounded — and the pre-flight read it as a one-character column, so it refused the widest column the dialect has and printed `declared as -1` at the operator. And SQLITE has no `information_schema` at all: the shared catalog query died there with `Failed to prepare statement` and no statement attached, on a dialect the command claims to support. It uses `pragma_table_info` now, reporting no length because sqlite enforces none.

  Measured end to end on postgres, mysql, mariadb, mssql and sqlite: a table holding plaintext, the old encoding and the current encoding side by side converts, every row decodes back to its original value, a re-run writes nothing, and a wrong key refuses with exit 1.

  **Backups and restores were never affected and now say so.** `voltro data export` reads through the raw dialect store, so ciphertext travels verbatim in either encoding — pinned by a test, because a future change that wrapped that store would put plaintext credentials in a bundle.
- **@voltro/protocol, @voltro/cli** — Three findings from one consumer round, all of the same shape: something the framework knows and does not say.

  **A decode failure on a GUARDED procedure now says the guard did not run.** The payload decodes before the handler, so a guard on a procedure with a malformed payload never gets the chance to refuse. A consumer auditing a guard called one with an incomplete payload, got a decode error instead of a `ScopeError`, and concluded the guard was not applied — the wrong conclusion in the dangerous direction. The title now carries `(guarded — the guard did NOT run: the payload failed to decode first, so this says nothing about access)`. It discloses nothing new: that a procedure is guarded is already visible to anyone who sends a VALID payload. An `openAccess:` declaration is not an enforced guard and gets no such sentence — `hasEnforcedGuard` is the one predicate, read by both the label and the wire error union, because two copies of that rule would disagree invisibly.

  **`middleware.ts`'s `httpOnly` default is documented at the field, and warned about.** It defaults to `HttpOnly`, which is wrong for a session cookie a browser SDK reads back: Supabase's `createBrowserClient` reads `document.cookie`, so a forgotten `httpOnly: false` gives the browser a session it cannot see — the SSR render is perfect and the user is signed out at the first client-side call. The consumer only avoided shipping it because their probes already set the flag. `voltro dev` warns once per cookie when a session-shaped name is written with no `httpOnly` decision; an explicit decision either way silences it, because warning on a decision is how a diagnostic becomes noise.

  **`voltro dev` restarts when `middleware.ts` changes.** It is loaded once per boot, that is documented, and a consumer read it and still lost an afternoon: they sabotaged the middleware, saw no change, and concluded it was not wired — in an environment where everything else hot-reloads. It now restarts through the same respawn a hard-restart field in `app.config.ts` uses, extracted so there is one copy of the `execArgv` inheritance and the signal forwarding.
- **@voltro/cli** — `middleware.ts` now produces ONE view of the request that every downstream reader takes. Previously only `buildLoaderQuery` saw the hook's result, while the loader context (`ctx.headers`), the SSR request snapshot (`useServerRequest()`) and the locale resolver kept reading the raw request — four readers, two answers, within eighty lines of one function.

  The consequence was worse than an inconsistency: a hook that renews purely via `setCookies` — no `headers` at all, which is the normal shape for a cookie-session IdP and the reason the response half exists — moved nothing for the render that ran it. The rpc call still sent the old `Cookie` header, because a renewed cookie only reached the browser.

  `setCookies` is applied to the cookie jar before the render, the `Cookie` header is rebuilt from that jar (an explicit `cookie` in the hook's own `headers` still wins), and `maxAge <= 0` deletes, so a hook that signs someone out renders them signed out. Both SSR boot paths shadow the raw headers out of scope after the hook runs, so a new reader added below is correct without knowing any of this.
- **@voltro/cli** — `voltro start` dropped `middleware.ts`'s `Set-Cookie` on **streamed** responses — which is the arm a plain `renderMode: 'ssr'` page takes, so it was the common case. The hook renewed the session server-side, the render used the fresh value, and the browser kept the consumed one. Against an IdP that rotates refresh tokens and detects reuse, that is worse than not renewing at all.

  The cause is worth stating because it read as handled: a streamed response hands the socket to `stream(res)` and the caller never looks at the returned `headers`, so the `withCookies(...)` wrapper on that arm was dead code — sitting under a comment promising the cookies were written on every arm. The cookies now travel with the headers `streamSsrResponse` itself writes, and the dead wrapper is gone.

  Found by booting real `voltro dev` and `voltro start` servers against a fixture and reading the response. Every unit test was green throughout, and the render's own HTML was correct — only the wire was wrong.

---

## [0.40.0] — 2026-08-16

### ⚠ BREAKING

- **@voltro/client, @voltro/web** — A subscription whose COLD START failed is its own state — `failed: true`, `loading: false` — so "it is loading" is a true statement again.

  The old shape left `loading: true` for a subscription where nothing was in flight and nothing more was coming. The type's own comment predicted the consequence:

  > A cold-start failure leaves `loading` TRUE … so a component that branches on > `loading` alone renders a skeleton forever. Check `error` to break out of it.

  A consumer quoted that back with the right conclusion: **a comment that predicts the misbehaviour of its own field is an API resting on discipline.** `loading` means "something is coming" everywhere else; here it meant "something is coming OR never again", and the escape hatch was a second field that the natural shape of a wrapper — pass `{ data, loading }` through — silently drops. They had three such wrappers. The framework had six, in `useWorkflow.ts`, and the compiler named all six the moment the state existed.

  `failed` is a positive discriminant, so the check reads as one:

  ```tsx
  if (s.loading) return <Skeleton/>
  if (s.failed)  return <RetryPanel error={s.error}/>
  return <Table rows={s.data}/>
  ```

  **BREAKING**: `!loading` no longer proves `data` is present, so every call site that reads `data` after a bare `loading` check is a type error naming file and line. Nothing fails silently. The codemod is `manual` on purpose — a transform could add `|| s.failed` everywhere and would be wrong about half of them, since an infinite skeleton is precisely what this removes.

  **Measured blast radius**, because the estimate was worse than the reality: 20 errors inside `@voltro/client` (mostly its own `useWorkflow` wrapper and the type-tests) and **zero** in `@voltro/web`, `devtools-ui`, the devtools app, the cloud app, and all 45 templates. Most consumers were already using a `fallback` or a wrapper, which is what made the original defect so quiet.

  Unchanged: a failure AFTER the first snapshot still leaves good data on screen with `error` set — only the cold start is `failed`. A `fallback` subscription still has `data` always present, so `failed` reports there rather than gating. `idle` stays opt-in by overload; `failed` is not opt-in, because any subscription's cold start can fail. And the state is terminal for one TRANSPORT: a reconnect discards the error and re-subscribes.
- **@voltro/protocol, @voltro/runtime, @voltro/voltro** — A guard refusing a caller whose credential was REJECTED now answers `Unauthenticated`, not `ScopeError`.

  Measured by a consumer: a user's tab outlived their IdP's token lifetime. The strategy logged it plainly —

  ```
  WARN auth strategy "supabase" rejected request: supabase jwt expired
  WARN mutation.tasks.update failed: missing required scope 'task:u:o'
  ```

  — and the wire said the caller lacked a scope. Technically true (an anonymous caller holds none) and it sends everyone who reads it into the permissions system while the problem is an expired session. They did that round; the subject was their administrator, and the same call with a fresh token worked.

  **The obvious fix would have broken more than it fixed**, which is why this shape and not that one. Failing hard when a strategy rejects would break every `openAccess` procedure for anyone holding a stale cookie — a public page that needs no session at all would start refusing. So the FACT travels instead: a rejected credential stamps `credentialRejected` on the anonymous subject it falls back to, and only a guard that actually refuses spends it. `openAccess` never reaches that point and is untouched.

  Three details worth knowing if you touch it:

  - **The stamp happens AFTER the app's `fallback`.** That callback builds its own anonymous subject and knows nothing about the rejection; stamping before it would be silently discarded — the shape of every "wired on one path" defect in this codebase. - **ONE conversion point** (`asRefusal`), at the exit rather than at each `return`. Every branch builds the `ScopeError` it always built; one place decides what it MEANS. - **`Unauthenticated` is merged into the wire union** for guarded procedures AND guarded events, beside `ScopeError`. Without that it would be a tagged error the descriptor cannot represent, and the server would collapse it to `InternalError` — the defect another consumer reported the same week.

  A caller who presented NO credential still gets `ScopeError`. Collapsing both would send a genuinely under-privileged user to the login page.

  **Why this is BREAKING although nothing was removed.** Three published results gained a union member:

  ```ts
  checkGuards(…)        // ScopeError | Unauthenticated | null   (was ScopeError | null)
  checkGuardsEffect(…)  // Effect<ScopeError | Unauthenticated | null>
  bindEvent(…)          // Stream<…, ScopeError | Unauthenticated, …>
  ```

  The test for breaking is not "did a symbol disappear", it is whether code that COMPILED can stop compiling — and a widened return does that wherever the old union is named (`const refusal: ScopeError | null = checkGuards(…)`), or narrowed exhaustively. It was filed as `Added` first; the golden diff is what showed otherwise, and the rule is worth more than the classification that felt right. `anonymousSubject`'s new second parameter is OPTIONAL and breaks nothing.

  `voltro update` carries you across it — codemod `0.40.0/02_rejected-credential-widens-guard-results`, a written note. A transform would widen each annotation, which compiles and keeps the reported behaviour: the decision at each site is what an expired session should do that a missing permission should not.

### Added

- **@voltro/client, @voltro/web** — The client re-resolves `authHeaders` when the server says the credential was rejected — and `client.refreshAuth()` for an app that knows earlier.

  `authHeaders` is resolved once per CONNECTION and attached per frame, so a tab open longer than the IdP's token lifetime keeps presenting a dead token until something reconnects. A consumer measured it: dragging a card on a board failed, their ADMINISTRATOR's token had simply expired while the page stood, and nothing in `@voltro/client` could force the re-resolve.

  The trigger is the `Unauthenticated` error — which only became distinguishable from `ScopeError` in this same release. Before that the client could not have told "your session died" from "you lack a permission", and reconnecting on the second would have been wrong.

  `refreshAuth()` is a SAME-SUBJECT rebuild, and the difference from `reconnect()` is one flag and a security boundary: `reconnect()` exists for a login / logout / tenant switch, where the next subject may be entitled to strictly LESS, so the cache must not be seeded from the old one. A token refresh is the same person with a fresh credential, so seeding is correct and the screen keeps its rows instead of blanking for the round trip. Getting that backwards is silent in both directions — seed on a subject change and you paint one user's rows into another's; refuse to seed on a refresh and every open screen blinks on every rotation.

  The policy lives in ONE function (`wireAuthRefresh`) that every host wires, because "when do we reconnect on an auth error" is exactly the kind of decision this repo has watched drift when it was written twice. Two guards, answering different questions: a **rate** ceiling (one rebuild per window — a rejected mutation arrives alongside every rejected subscription on the page) and a **total** ceiling (stop after N refreshes with no successful call between, because at that point the credential is not stale, it is refused, and the answer is a sign-in screen rather than another socket).
- **@voltro/cli** — `middleware.ts` — a web app's one server-only hook, for renewing a credential before the SSR render uses it.

  Reported: a consumer's SSR detail pages arrived empty on the first request of every day. Their cookie token had outlived the IdP's lifetime, the api resolved the caller to anonymous, and every `preload` on the page failed. They could not fix it in the app, and the reason is structural: `ctx.query` and every `preload` entry are bound from ONE cookie string **before any loader runs**, so a layout loader that renews the session cannot reach them — and a `type: 'web'` app has no auth middleware.

  ```ts
  // middleware.ts — web app root, server-only
  export default async (req) => {
    const fresh = await refreshSession(req.cookies['sb-session'])
    if (!fresh) return
    return {
      headers:    { authorization: `Bearer ${fresh.accessToken}` },
      setCookies: [{ name: 'sb-session', value: fresh.cookie, maxAge: 3600 }],
    }
  }
  ```

  **Why not `app.config.ts`.** That file is imported into the CLIENT bundle, verbatim, the moment any api declares `authHeaders` — the thunk is a function, so it cannot be serialised. A hook that renews a session reaches for an IdP SDK by definition, so putting it there drags the server graph into the browser. The consumer proposed exactly that shape (`serverAuthHeaders` beside `authHeaders`) and it is the one place it cannot go.

  **Why `setCookies` is not optional.** We asked whether writing cookies back was in scope, expecting the answer to be about a round trip. It was about correctness: Supabase ROTATES refresh tokens and detects reuse, so a hook that renews server-side and does not write the result back leaves the browser holding a consumed token. Without it the hook is not "slower but correct" — it can destroy the session.

  **Deliberately not a general middleware.** It can replace credentials and set cookies. It cannot redirect, return a response, or rewrite a route — because authorization belongs on the API, which is the only thing that sees the data, and a web-side hook that can refuse a request becomes a second authorization layer beside the real one. A hook that cannot refuse also cannot be mistaken for a guard. For a login redirect, a loader already throws `RedirectError`.

  Details worth knowing: only auth-shaped headers (`authorization`, `x-tenant`, `x-voltro-*`) are forwarded to the api, so a returned `host` or `content-length` cannot produce a failure that looks like anything but a header copy; cookies default to `HttpOnly` + `Path=/` + `SameSite=lax`; multiple cookies are written as separate header lines, never comma-joined (a cookie's `Expires` contains a comma); the file is loaded ONCE per boot; a failure to IMPORT is fatal rather than degrading to "no middleware", and a middleware that THROWS fails the request — the render must not proceed on the credential it was told to replace.

  Wired on BOTH SSR boot paths (`voltro dev` and `voltro start`), with the cookies written on every response arm — streamed, buffered, redirect and 404. A partial application would renew a rotating token and drop it.
- **@voltro/testing** — `makeTestContext` supplies `ctx.events`, so an executor that publishes can be unit-tested at all.

  `ctx.events` is a field PRODUCTION puts on every `AppContext`, and the test harness did not — so any handler containing `ctx.events.publish(...)` died on `Cannot read properties of undefined (reading 'publish')` the moment it ran under test.

  **The shipped `api-durable` template demonstrates exactly that pattern** (publish inside the mutation's transaction, so it fires on COMMIT and not on rollback), and its own test passed anyway — because it called the executor with `await` instead of running it. An executor written in the Effect style RETURNS an Effect, and awaiting a non-thenable hands the object straight back, unrun. The assertion then failed on `row.status` being `undefined` and pointed at the assertion rather than at the call. Two defects propping each other up: the harness could not have run that handler, and the test never asked it to.

  It is the REAL `makeEventPublisher` over a real `EventBus`, not a stub. A fake would re-implement the payload validation and the tenant stamping and would be wrong the first time either gains a case — the lesson `plugin-broadcast` paid for twice. `ctx.eventBus` is the read side:

  ```ts
  const seen = ctx.eventBus.subscribe(orderPlaced, { orderId })
  await invoke(placeOrder, executor, input, ctx)
  expect(seen.received).toHaveLength(1)
  ```

  One bus for the whole harness so a `withSubject` / `withTenant` re-scope still publishes where the test is listening; the PUBLISHER is per-subject, because the tenant it stamps is the caller's.

### Fixed

- **@voltro/cli** — `voltro agents-md --force` no longer exits 0 when it wrote nothing.

  Reported: a consumer's `agent-docs/` is owned by the pod (root). They ran the command as `admin`, and it **overwrote nothing, said nothing, and exited 0**. They read the unchanged file as "the framework has not fixed this yet" — it had — and lost a full round to it.

  The cause was three `orElseSucceed`s in the agent-docs copy. `makeDirectory`, `readDirectory` and every `copyFile` degraded to success, so a destination the process could not write produced an empty run that reported itself as done. An unreadable source directory came back as `[]` and did the same.

  Failures are collected and reported now, and the command **exits 1** when the seed is incomplete:

  ```
  agents-md: 12 file(s) could NOT be written — the seed is INCOMPLETE.
  The commonest cause is ownership: a container wrote these as root and you are
  running as someone else.
  ```

  `--force` is an explicit instruction to overwrite, so silently not overwriting is the one outcome that must never be reported as done. `stat` is the single remaining silent degrade, deliberately: a source entry that vanished mid-walk is not a write failure and must not fail the run.

  Pinned in both directions — a real unwritable directory yields failures, a clean copy yields none, and a source guard asserts that `stat` is the ONLY call on that path allowed to swallow. Red-verified by restoring the original `orElseSucceed`.
- **@voltro/cli** — Every server-side loader context is now checked by the compiler, and the static prerender stopped handing loaders a context missing `search` and `headers`.

  `ctx.isServer` shipped with a source-reading guard, and that guard had the defect it exists to prevent: it matched context literals by shape (`loader({` / `ctx: {`) and therefore found ONE of the two in `build.ts`, missing the one built as a typed arrow return. Its tripwire — "at least 5 sites" — passed, because a floor cannot tell 5-of-8 from 5-of-5. The gap was found by a parallel report, not by the guard.

  The invariant moved from "the literal mentions `isServer`" to "the literal is CHECKED BY THE COMPILER": every server loader context is now `satisfies SegmentLoaderContext`, whose `isServer` is required. A site that forgets it is a type error naming the file and line — strictly stronger than any regex over shapes, and verified by removing one.

  **It caught a second defect immediately.** The static prerender built its page loader context with only `params`, `pathname`, `signal` — no `search`, no `headers`, no `query` — while `LoaderContext.search` is declared `string`. A static page's loader reading `ctx.search` got `undefined` where the type promised a value. The segment context twenty lines above it in the same file already passed `search: ''` with a comment explaining why.

  Two smaller things worth knowing if you touch the guard: the closing brace is part of its pattern because the bare phrase also appears in the comment explaining the rule (the first version counted its own documentation), and the CLI's `SegmentLoaderContext` mirror keeping `isServer` non-optional is what the whole enforcement rests on — `isServer?:` would make every `satisfies` pass while a forgetful site reports itself as the browser.
- **@voltro/protocol, @voltro/cli** — A guard's `ScopeError` reaches the client as `ScopeError` on a mutation, not as `InternalError`.

  Measured by a consumer over the wire, same session, same foreign team:

  | kind | declared `error:` | denial arrived as | |---|---|---| | query `webhooks.list` | `AccessDeniedError` | `_tag: 'ScopeError'` ✓ | | mutation `…updateReferenceLabels` | `AccessDeniedError` | `InternalError` ✗ | | the same mutation, after adding `ScopeError` to its union | | `_tag: 'ScopeError'` ✓ |

  Their client maps `ScopeError` to *forbidden* and `InternalError` to *something went wrong*, so a permissions refusal looked like a crash — on every relationship-guarded write in the app.

  **Their observation was exact; the mechanism was not, and the difference is where the fix goes.** They diagnosed it as "the merge only happens on the streaming path". `withGuardError` is called by every lifter, so the wire union carries `ScopeError` on both. What differs is a SECOND reader: the server refuses to ship a tagged error the descriptor cannot represent, collapsing it to `InternalError` rather than emitting a raw defect tree — and it was handed `descriptor.error`, the RAW declaration, while the union it protects is the WIDENED one. It judged against a narrower set than it had advertised. A query never reaches that check (it is delivered through `wireErrorFromCause`, which preserves the tag), which is exactly why the split fell along query/mutation.

  `wireErrorUnion(descriptor, kind)` is now the single owner of "what can this procedure put on the wire", used by the lifters AND by both bind sites.

  **Two more error classes were collapsed the same way, and neither was reported:**

  - **`BusinessRuleViolation`** — unconditional for mutations. `withRuleError`'s own comment says it MUST be in the union "or the violation crosses the wire as an untyped defect". It was in the union, and collapsed before it got there. - **The `requiresApproval` refusals** — `ApprovalRequired` / `ApprovalExpired` / `ApprovalUnavailable`, so "parked for approval" was indistinguishable from "the server broke".

  `openAccess:` still merges nothing: a procedure advertising a denial it cannot produce is what makes an error union stop meaning anything.

  If you worked around this by declaring `ScopeError` yourself, the declaration is now redundant rather than wrong — the union is the same either way, and you can delete it whenever you like.

---

## [0.39.1] — 2026-08-16

### Fixed

- **@voltro/runtime, @voltro/cli** — An app-registered tuple source is no longer replaced by the framework default.

  Both boot paths carried this comment, verbatim:

  > An app whose relationships live in its own tables overrides with > `setTupleSource`.

  and then called `setTupleSource(default)` **unconditionally**. That call runs AFTER the app's startups — `runBootLifecycle` at `dev.ts:3910` and `serveCommand.ts:869`, against the registration at `dev.ts:4065` and `serveApi.ts:937` — and `setTupleSource` is last-write-wins. So the framework won every time: an app registering its own source in a `*.startup.tsx`, which is what the docs tell it to do, had it silently replaced.

  The consequence is not subtle. Every relationship guard would then be answered from `_voltro_rebac_tuples` — empty, for exactly the app that keeps its relations in its own tables — so every guard DENIES, fail-closed, with a `no tuple source` warning that never fires because a source *is* registered: the wrong one.

  The comment was not wrong about the design; it described the design while the code removed it. Same shape as the `DORMANCY_WAKEUP_TENANT` scar — equal by value at both call sites, so nothing could fail, and the comment was the only place the intent survived.

  `registerDefaultTupleSource` fills the gap only when nobody else did, and returns whether it acted so the boot can say which source is live instead of leaving an operator to guess. `setTupleSource` itself is unchanged: an app calling it twice still gets the second one, because narrowing that would trade one silent surprise for another.

  Found while re-checking a consumer's claim that a different item was still open. It was not — but it sits beside this, and this is the one that would have bitten them. Pinned in both directions: a unit test for the registrar, and a source guard asserting neither boot path installs `loadResourceTuples` through `setTupleSource` again. Red-verified by restoring the original line.
- **@voltro/cli** — `voltro doctor` no longer reports "scope vocabulary: none found" when it could not read the config at all.

  A consumer HAD configured `doctor.scopeVocabulary` and still read `none found`. The cause was not their vocabulary: `app.config.ts` could not be imported outside their pod — an auth strategy demands its secret while the config is evaluated — so the vocabulary was never read. Inside the pod the message disappears and the rule runs.

  "You have no vocabulary" and "I could not look" are different states, and only the first is a statement about their app. This section already made exactly that distinction one member earlier — its own comment reads **DORMANT AND CLEAN MUST NOT PRINT THE SAME** — and then collapsed the third.

  The signal existed and was not connected. `offlineManifest` deliberately survives an unimportable config (a doctor run must not die on one), so the empty vocabulary arrived looking like a real absence, while the same failure was reported a hundred lines earlier under plugin tables. The consumer read the section about scopes and searched at the wrong end — which is the correct way to read a report.

  The rule now probes importability before claiming an absence, and says so:

  ```
  •  scope vocabulary: NOT EVALUATED — `app.config.ts` could not be imported.
     This is not a statement about your scopes: the rule never got to read them.
     … The same import failure is reported above for plugin tables; both sections
     have this one cause.
  ```

  The dormant case keeps its own wording, so a quiet run on an app that genuinely publishes no vocabulary is not relabelled as a broken config.

### Internal (no consumer-facing effect)

- **@voltro/cache, @voltro/kv** — The RESP TTL tests in `@voltro/cache` and `@voltro/kv` stopped measuring the machine. Test-only; no product code changed.

  Both wrote a key with `ttlMs: 150`, asserted it was still there, then slept 300 ms and asserted it was gone. The second half is fine — waiting LONGER only strengthens "it expired". The first half was a race: the write and the read are two round-trips, so on a loaded runner the key legitimately expired before the liveness assertion, and the test reported a defect that was not there. It failed exactly that way on a release gate, in the keydb engine, while passing locally with 24/24 green.

  Now: a 2 s window, so liveness has real headroom rather than 150 ms of it, and the expiry is awaited as a CONDITION (`awaitGone` polls) rather than as a duration. Idle machines finish in about the TTL; loaded ones take as long as they need; a key that never expires still fails, because the ceiling is a failure mode and not a timing assumption.

  Fixed in BOTH packages in one change. The two files carried the identical construction, and this repo's standing lesson is that a fix landing in one copy of a duplicated shape leaves the other one broken — `@voltro/kv` had not failed yet, which is a statement about luck rather than about the test.

  `@voltro/kv`'s header also pointed at `../cache/test/docker-compose.yml` for bringing the engines up. That path does not exist; there is one compose file, at the repo root.

---

## [0.39.0] — 2026-08-16

### Added

- **@voltro/client, @voltro/web, @voltro/cli** — A preload that fails server-side now says so in the hydration payload, so a page can tell "still loading" from "actually empty".

  Reported by a consumer whose session cookie had outlived the IdP's token lifetime — for them, practically every first page view of the day. Every `preload` on the page failed at once, the api having resolved the caller to anonymous:

  WARN [voltro:dev:web] preload seed failed tag=projects.getById … ScopeError — missing required scope 'project:r:o'

  The page rendered a skeleton title over an empty table, and that WARN was the only record anywhere. **The client saw exactly what it sees for a page that declares no preload at all** — both arrive as the absence of a seed — so no app could distinguish the two without inventing a convention of its own. The reporting consumer did exactly that, page by page.

  `usePreloadedSubscription` now returns `preloadFailed: true` in that case:

  ```tsx
  const projects = usePreloadedSubscription<Project[]>('api', 'projects.list')
  
  if (projects.loading) return <Skeleton/>
  if (projects.preloadFailed) return <Spinner label="Loading…"/>   // not empty — unasked
  return <Table rows={projects.data}/>
  ```

  It says nothing about WHY, deliberately: the server's failure text is a refused call's error message and belongs in the server log, which is the one place a browser cannot read. A boolean is the whole contract. It also says nothing about the LIVE subscription, which usually recovers on its own — the browser reconnects with a credential the SSR request did not have. So the honest reading is "the first paint has no server data, and that was not for lack of asking", which is exactly enough to choose a spinner over an empty state.

  `seedPreloadedSubscriptionFailure` is exported from `@voltro/web/ssr` beside `seedPreloadedSubscription` for a loader that runs its own preloads.

  Two shape notes, both deliberate:

  - **The field is widened on THIS hook, not on `SubscriptionState`**, so no existing `useSubscription` call site is un-narrowed by it — the same scoping rule the `skip`/`idle` overload follows. - **`seedFailure` is REQUIRED on the internal preload runner, not optional.** This is the hook whose omission WAS the defect, and an optional hook is an omissible one — the same mistake with a nicer name that cost us `startOutboxRunner`'s teardown. Making it required is what listed all three render paths (dev, start, static prerender) at the compiler rather than at review.
- **@voltro/web, @voltro/cli** — `LoaderContext.isServer`, and the type now says a loader runs TWICE.

  Reported after two days of debugging: a loader carried a server-only call, and nothing in `LoaderContext` said the same function runs again in the browser on every in-app navigation. The consumer read "runs once per request" into the gap, which is the reading the wording invited.

  **The docs were worse than a gap — they contradicted themselves.** The loaders page opened with "the page's server-side data hook" and its first code sample carried the comment `// Server-side fetch — runs on the Node side, never in the browser`, while 230 lines further down the same page said "on a client-side navigation … the loader runs in the browser". A reader who hits the false line first stops looking. Both are corrected, in both languages, and the precondition is now the first thing the page states.

  **Why it survived two days is the part worth repeating:** a client-only failure is invisible to every probe that does not NAVIGATE. A fresh page load, a `curl`, any SSR check all take the server path and pass. Only clicking a link inside the running app reaches the other one.

  `ctx.isServer` is the supported discriminator, because the two things that look like they answer the same question do not:

  - `query` is absent in the browser, so `if (ctx.query)` appears to work — but it branches on the ABSENCE OF A FUNCTION, which says nothing about why it is absent and breaks the moment anything else becomes conditional; - `headers` is `{}` in the browser, **not** `undefined`, so `if (ctx.headers)` is TRUE on both paths. The reporter checked exactly that, and it silently did nothing.

  It is REQUIRED rather than optional: an optional boolean is omissible, and a server path that forgot it would read as `undefined` — falsy — and claim to be the browser, which is the precise failure the field exists to prevent. The compiler names every client construction site; the three SERVER render paths build their context as untyped literals, so `loaderIsServer.test.ts` derives those by shape and fails on one that omits it (red-verified against a removed line, which named the file and offset).
- **@voltro/react-native, @voltro/client, @voltro/web, @voltro/cli** — React Native gets the whole client, not half of it. `startMobileApis()` connects and re-dials over the **same** supervisor `@voltro/web` uses — the supervisor moved into `@voltro/client` rather than being copied, because a second copy of its stale-seed gate (the rule that stops one subject's rows appearing in the next subject's screens) is a second thing to keep correct. Web's two browser-specific behaviours are injected options now: the devtools status entry and the dev-only wedge reload.

  `voltro codegen` in a mobile app writes `.framework/mobileApis.generated.ts` from `voltro.mobile.ts` — which apis the app talks to, and where each one's rpc group and descriptors come from. Only the binding is generated; the procedure types ride the import of the api's own `rpcGroup`, so a schema change needs no regeneration. The ws URL is a runtime parameter and deliberately not baked in: `localhost` on a phone is the phone, and `resolveDevWsUrl()` takes the LAN host Expo already knows.

  `createAsyncStoragePersistence()` makes `defineStore({ persist })` work on a device. A store reads during render and a render cannot await, so it hydrates into memory once — awaited before the first screen — then serves reads synchronously and writes through, coalescing per tick. A storage with no `getAllKeys()` and no declared `keys` refuses rather than hydrating empty: an empty cache is indistinguishable from a first run.

  `useMobileConnectionStatus()` takes an optional `onlineSource`. Its default reads `navigator.onLine`, which React Native does not have — so on a device it answered "online" forever, airplane mode included. Pass `netInfoOnlineSource(NetInfo)`. `isInternetReachable` is believed only when it is a boolean, because NetInfo reports `null` while its probe is out and reading that as offline flashes a banner on every cold start.

  Two type declarations were WRONG, and running the mobile template through the scaffold harness is what said so — nothing else in the repo typechecks a generated entry, on web either.

  `ClientDescriptorMap` was a structural copy of `@voltro/protocol`'s `ClientDescriptor` that narrowed `source` to one string while the real descriptors carry several. It is now that type, not a copy of it. And an api's `group` is the erased `RpcGroup.Any`: `RpcGroup` is declared `in out` in @effect/rpc, so the CONCRETE group codegen emits was never assignable to `RpcGroup<Rpc.Any>` — the boundary rejected the only value anyone passes it. Erasing at the boundary and restoring at the call site is what `ApiHandle.client` already does; the one cast is where the rpc client is built.

  `dispatchDeepLink()` is new for the same class of defect. `matchFirstDeepLink` returns a descriptor from a heterogeneous array, so its handler declares `Record<string, never>` params while the match hands back `Record<string, string>` — the result could not be invoked by anyone.

  `apiSurface: compatible` — every changed declaration either widens what a producer may pass or replaces a type that misdescribed its own values. Code written against the narrow `source` was already wrong at runtime, and no call that compiled before stops compiling. `useMobileConnectionStatus` gained an optional parameter.

  Not verified here, and not implied: that the loop runs on a device. Everything above is unit-tested without a simulator, and only a simulator can prove Metro. That is an Expo/EAS CI step.
- **@voltro/runtime** — A `TupleSource` receives the whole `subject`, not just `subjectId`.

  Reported precisely, from a guard review rather than an outage. `subjectId` cannot express a CREDENTIAL that is narrower than the person holding it, and an API key is exactly that: the key's binding lives in the subject's `metadata` (`keyType`, `teamId`), while `subject.id` is the OWNING USER. A tuple source could therefore only resolve the owner's memberships and was blind to which team the key was minted for — so an owner who belongs to two teams passed the guard for both. The comment beside their hand-written check says what that costs:

  > Without the binding check below a key minted for team A worked on every team > in the org.

  Nothing was ever wrong in their app, because they kept the executor-side check. The defect is that a DECLARED guard could not replace it:

  ```ts
  setTupleSource(async (req) => {
    const boundTeam = req.subject.metadata?.teamId
    if (boundTeam !== undefined && boundTeam !== req.resourceId) return []
    return loadResourceTuples(store, req.subjectId, req.resourceType, req.resourceId)
  })
  ```

  A guard that must always run paired with a hand-written check is not a declaration — it is a comment with a type signature. And the pairing is exactly the thing nobody re-derives when they delete the "redundant" half a year later; the reporter had already written the reason into their parity test to stop that happening.

  `subject` is typed as the full union deliberately: its system and anonymous members carry no `scopes` or `metadata`, so the narrowing is the caller's to do and is visible where it happens.
- **@voltro/cli** — A declared relationship guard whose `resourceType` is not registered now refuses the boot instead of denying every caller forever.

  The report described the shape exactly: an app arms its authorization from a startup (`defineResourcePolicy` + `setTupleSource`). If that registration does not happen, the app boots **clean**, takes traffic, and every procedure with a declared guard refuses from then on. Fail-closed, so nothing leaks — and a total outage of the guarded surface whose only signal is a log line nobody reads, because the boot was green. At their size: 39 procedures — team settings, hours import, role administration.

  Since 0.38.0 a `*.startup.tsx` that THROWS already refuses the boot, so the sequence they described is closed. This gate exists because that fix covers only the throwing case, while the same silent outage arrives by three doors no startup-error handling can see:

  - the startup registers some types and not the one a guard names — a typo in a `resourceType` is not a compile error, it is a string on one side and a string on the other; - the file was renamed out of the discovery pattern, so it never ran and never threw; - the registration was conditional on something false at boot.

  All three end in the same place, and the framework can rule out all three by asking one question it already holds both halves of: which `resourceType`s does the discovered surface NAME, and which are REGISTERED?

  The refusal names the type, the count and the procedures that demanded it — grouped by type, because the fix is one `defineResourcePolicy` per type while the damage is per procedure. It also names the spelling trap, since nothing else in the system compares those two strings.

  **Why refuse rather than warn**, because "fail-closed already, so it is safe" is the plausible objection: safe is not working. The procedures are DOWN, and down-with-a-warning is precisely the state being reported. A refusal is recoverable in seconds and impossible to miss.

  It runs after the startups have settled, on both boot paths — not beside `assertProcedureAccessDecisions`, which reads declarations and can run at discovery. This one reads a registry the app fills during its startups, and placing it earlier would have failed every app that registers from one, which is all of them. A check that fails on everything gets deleted rather than fixed.

### Fixed

- **@voltro/client** — A timer no longer discards the optimistic preview of a write the server confirmed. **A rollback happens if and only if the write FAILED.**

  Measured by a consumer on a Gantt bar: drag it, the server writes, the mutation reports success — and five seconds later the bar jumps back. They suspected the server first and proved it was holding the data correctly, reasons included, before finding that the client was throwing the confirmed patch away itself.

  The cause was one line: `confirmByMutation` armed a `setTimeout` calling `revertByMutation` — **the same function the FAILURE path calls**. Success and failure ended in the same discard, one immediately and the other five seconds later.

  What makes it unambiguous is WHEN that timer could fire at all. Any server event already retires confirmed patches through the seamless hand-off, so the window only ever expired while `base` was still STALE. The revert therefore replaced a value that reflects the committed write with one the client knows does not:

  | | keep the patch | revert (before) | |---|---|---| | delta arrives later | invisible hand-off | 5s of stale, then a jump back and forth | | delta never arrives | matches what is saved | **contradicts what is saved, permanently** |

  The bottom-right cell is the damage: the user watches their saved change disappear and either redoes it or plans on a state they believe was not stored. A "leaked" patch is gone on the next subscription; a silent revert is healed by nothing.

  Expiry is now a **resync**: the client re-issues that subscription, keeps the patch, and says so loudly on the error bus (`voltro logs`) — the silence was the second half of the report, because in the UI this is indistinguishable from "the server did not save it", which is the false trail they followed first.

  **Two more seams of the same defect, both found while fixing it:**

  - **An `error` event retired confirmed patches.** An error does not advance `base` — it sets `baseError` and leaves the rows where they were — so dropping there discarded a committed write's preview for nothing. - **A resync whose snapshot comes back UNCHANGED must not retire them either.** The first version of this fix had exactly that hole: it re-asked the server, the answer was byte-identical, and the patch was dropped anyway — the reported bug reached by a longer route. The hand-off rule is now explicit (`supersedesConfirmedPatches`): a delta always supersedes, an error never does, and a snapshot only when it actually MOVED the base.

  The rule is pinned against the source, not only behaviourally: no `setTimeout` in the cache may name a revert. The defect was never inside a function — it was which function a timer pointed at, and a behavioural test only sees that if somebody thought to write the case. Nobody had: `CONFIRMED_PATCH_TTL_MS` was named nowhere in the test file, which the reporter also pointed out. It is now, red-verified by restoring the original line.
- **@voltro/ui-shadcn** — `AnimatedNumber` rendered `0` into static markup. It initialised its state to zero and counted up on hydration, so every statically rendered page SHIPPED the zero — the landing site's own stats row went out as "0 … 0 … 0% … 0", which is what a crawler, an answer engine and any reader with JavaScript off saw. A number that only exists after hydration is not a number on the page.

  It now renders the target value (so the server's markup and the first client render agree — no hydration mismatch) and drops to zero inside the observer callback, at the one moment the animation is actually about to run. The count-up is decoration layered on a correct page rather than the only way to see the value.

  The test that covered this asserted `toBe('0')` before scrolling — it was pinning the defect. It asserts the final value now, with the reason written next to it, plus a second case for the count-up itself.

  `CodeCompare`'s corner tags take an `eyebrow` prop instead of hardcoding "Before" / "With Voltro". This kit renders a bilingual site, and an English label over German copy is the same defect as any other hardcoded string. The English default keeps every existing call working.
- **@voltro/cli** — `voltro dev` reports the db-pool budget too, and the out-of-pool count is right on every dialect.

  The line was `voltro serve`-only, and the module said so with its reasoning: a dev machine has one process and no replicas, so `max × replicas` is noise. It even asked a future reader not to "fix the parity gap" by moving it.

  The reasoning was fine and its PREMISE was false. `voltro dev` is not always a dev machine — at least one consumer runs it as their deployment, two API pods against a shared pooler, and `retentionSweep.ts` already reasons about that same consumer in its own header. Two modules cannot both be right about what `voltro dev` is. The cost was not theoretical: that consumer REPORTED the out-of-pool `LISTEN` connection, we answered it in this line, and they could not see the answer, because the one boot path they run does not print it.

  So the exception is conditional rather than per-command now. `voltro serve` always reports; `voltro dev` reports when the environment shows the process is not a laptop — `REPLICA_COUNT` (a process cannot know how many of itself are running, so a platform set it), `DB_MAX_CONNECTIONS` / `PG_MAX_CONNECTIONS` (somebody is already reasoning about this number), or `DB_REPLICA_URLS` (which multiplies the pools inside ONE process — the case most likely to be mis-budgeted, because it does not look like a fleet). A bare `voltro dev` with nothing set stays silent, which is the half of the original decision that was right.

  **And the out-of-pool arithmetic was wrong for two dialects.** It was derived inline as `dialect === 'postgres' && CDC !== '0'`. The mysql/mariadb ROW-binlog reader speaks the REPLICATION protocol, which is a separate connection from the SQL pool by construction, so a mariadb deployment read a line that under-reported its own process by one. It is counted now. mssql Change Tracking is NOT counted, and that zero is verified rather than omitted — it reads through the store's own `SqlClient` and its module says "the CT reader needs no second pool".

  Both boot paths go through one `reportDbPoolLine`, and the CDC derivation takes the ARMED state as a parameter instead of re-reading the env: a binlog reader stands down when every app table is `.nonReactive()`, and billing a connection nobody opened is the same class of error as missing one.
- **@voltro/cli** — The 0.37.0 strict-input note under-sold the one case that breaks hardest, and the correction is RE-ISSUED rather than edited.

  0.37.0 made an undeclared input field reject the call. Its note described the general case correctly and then, under WHAT DOES NOT CHANGE, said "an empty input to a procedure that declares none is still fine". True about sending `{}`, and it reads as reassurance to the owner of an `input: Schema.Struct({})` — whose procedure is the one shape that did NOT move from "silently drops the extra field" to "rejects it". It moved from accepting EVERYTHING to accepting nothing, because an empty `TypeLiteral` has no expected keys for excess-property checking to compare against. The strongest-looking declaration was the only one enforcing nothing.

  A consumer measured the upgrade across 2 705 procedures and found three real breaks. The most expensive was exactly this: a `getMy` declaring `Schema.Struct({})` while a shared table hook always sent `{ limit }`. Before, the limit was discarded and the call worked; after, the live subscription AND the SSR seed of that page both die.

  **Why a new codemod and not an edit.** `selectCodemods` filters `from < version <= to`, so anyone who has already crossed 0.37.0 — including the consumer who reported this — will never see that note again, whatever it says. A correction filed there reaches only users who have not upgraded yet, i.e. not the ones holding the broken app. `0.39.0/01_empty-input-schema-rejects-every-field` carries it to the people who need it. (The 0.37.0 note is corrected too, for users still short of it. That edit is necessary and not sufficient, and the difference between those two words is why there are two files.)

  Its `appliesTo` is deliberately BROADER than the original's. 0.37.0's fires on a spread into a procedure input or the untyped string form of `ctx.query` — the constructs that carry a field the author never typed, which is the right gate for the general case and the wrong one here. The payload does not have to be invisible for this to break: the reporting consumer reached it through an untyped wrapper hook passing an explicit `{ limit }`. So this one gates on the DECLARATION — an empty struct anywhere in the app — which is the population that actually changed behaviour.
- **@voltro/cli** — All three ways a mutating inspect request can be refused now name the variable, the header, AND where the value comes from.

  A consumer verifying a row filter hit this one:

  401 {"error":"unauthorized","reason":"inspect: POST needs the write credential — send it as the `x-voltro-inspect-write` header alongside the bearer. The read token authorises reads only."}

  Their words for it: the message is good, and the missing half is **where the value comes from**. They knew what to send and not what to send AS. They gave up on our tooling and hand-signed a session token instead.

  That half is exactly the part a user cannot guess, because in dev nobody ever typed it: `voltro dev` MINTS `VOLTRO_INSPECT_WRITE_TOKEN` into the project's gitignored `.env.local`. Every arm says so now.

  **It is a function over the set, not a fix to the reported member** — this is the third message on this surface to be fixed one at a time. The three refusals were three hand-written strings of decreasing usefulness:

  - `unset` — named the variable. Fine. - `absent` — named the header and not the variable. The reported one. - `mismatch` — `inspect: write-credential mismatch`, which named neither, and is the case where knowing WHICH of the two values to look at is the entire remedy. Nobody had reported it, which is not evidence that it was fine.

  They come from one `inspectWriteHint(refusal, method)` beside the existing read- token hint, so the next arm cannot be added without the vocabulary. The method is folded in because the surface answers for `/erase` and for `/routes` in the same words, and a caller who did not know their call was a mutation is the caller most likely to be reading it.
- **@voltro/cli** — `ctx.isServer` reaches a LAYOUT loader too, on every server path. It was threaded into the page loader and left off the segment chain, so a layout loader read `undefined` — falsy, i.e. it concluded it was in the browser while server-rendering.

  Three of the five constructions never passed it: the prerender context in `build.ts`, the dev SSR renderer's layout call, and the test fixture pinning the shape. The two that did are the ones a `tsc` run named, because `SegmentLoaderContext` — the CLI's mirror of `LoaderContext` — had not grown the field at all.

  Making it REQUIRED rather than optional is what found the other three, and it is the same reasoning the flag itself ships with: an optional `isServer` cannot distinguish "the server forgot to pass it" from "this is the browser", and both spellings of that mistake claim the browser. A field whose whole job is to answer one question must not have a third answer.

  `webDevSegmentChain.test.ts` asserts the loader argument with an exact `toEqual`, so a field added to one loader's context and not the other fails there. That assertion is the reason this is one release and not two: `tsc` was already green on the test file while the run would have gone red.
- **@voltro/cli** — The pre-bundled api client is fingerprinted from the DESCRIPTOR SOURCES, so a schema change reaches the browser.

  Reported: a consumer changed an input schema on a descriptor and `voltro dev` kept serving the old client. The failure is quiet in the worst way — the CLIENT rejects the call, so the api logs nothing, because no request ever arrives.

  Vite pre-bundles the workspace api client and its optimize-cache hash keys on the lockfile and package.json, never on a pre-bundled dep's source content. So the framework fingerprints the client itself and flips `optimizeDeps.force`. That machinery was right and it was watching the wrong file:

  **`rpcGroup.generated.ts` imports each descriptor by export name and lifts it. It contains no schemas.** Codegen's own header says so. The generated file is therefore byte-identical across any schema edit, and BOTH mechanisms were structurally blind to it — the across-boot check hashed it, and the in-session watcher watched it.

  The in-session watcher's own comment already described the symptom ("the browser kept decoding responses against the stale schema"): the cache-busting half was fixed when that was hit, and the DETECTION half kept asking the file that cannot answer. So it never fired. The consumer's third reason — "runs only at boot" — is not quite right, and it does not matter: the in-session mechanism exists and was blind for the same reason. One fingerprint feeds both now.

  It hashes every `*.query.ts` / `*.mutation.ts` / `*.action.ts` / `*.stream.ts` / `*.event.ts` and every `*.workflow.tsx` descriptor, plus the generated group itself (which is what moves when a procedure is added or removed without a descriptor file changing content). Paths are hashed with contents, so a rename cannot come out as a no-op. `*.server.ts` executors are deliberately excluded — they never reach the browser, and including them would force a re-optimize on every handler edit, which is the whole working day and is how a forced re-optimize gets turned off.

  **The `proxyTarget` skip is gone from both.** It stood in for "is this api external", and a workspace api served on its own origin has no `proxyTarget` while still being edited locally. `findWorkspaceApiDir` returning a directory is the question that was meant. It survives in exactly one place — the ws proxy, where without a target there is nothing to proxy to — and the test asserts that count rather than its absence.
- **@voltro/cli** — `voltro probe access` now names the one thing that makes its red meaningless.

  The command asks "does a declared guard refuse a caller presenting nothing". It turns out that under `voltro dev` a caller presenting nothing is not anonymous: dev resolves a login-less request to a FALLBACK TENANT (`$TENANT ?? 'acme'`). An app whose scopes derive from the tenant therefore admits, and the probe reported `ANSWERED an unauthenticated call` against a guard that is perfectly fine.

  That is not an occasional false positive, it is a structural one: the local registry only ever holds `voltro dev` / `voltro start` processes — `voltro serve` does not register — so every target the command picks up WITHOUT `--url` is in exactly that state.

  The failure block now says so, and says what to do instead:

  voltro probe access --url http://<host>:<port>

  against a `voltro serve` process, where an anonymous request carries `tenantId: null` — the case a guard actually has to refuse.

  Found by running the command as a FINDER for the first time rather than as a test: it flagged a shipped template, and the flag was wrong in dev and right about production, for two different reasons. A tool whose red needs a paragraph of context should carry the paragraph.
- **@voltro/protocol** — A rejected rpc payload no longer answers with the procedure's whole input type.

  Measured by a consumer against 0.38.0, anonymously, with no session at all:

  POST /rpc {"tag":"workAreas.create","payload":{"name":"x"}} → { readonly name: string; readonly storeId?: string | null | undefined; readonly type: "department" | "location" | "zone" | "station"; readonly parentId?: string | null | undefined; … } └─ ["type"] └─ is missing

  That procedure declares `guards: [{ scope: 'workArea:c:o' }]`. The refusal never happened — the payload decode runs first and failed first — so the caller got a field-by-field description of a write they are not allowed to make. On an app with ~700 write procedures that is a free enumeration of the entire write surface for anyone who can reach the port: no session, nothing that looks like rate-limit abuse, and no log line.

  The rendered TITLE is now the procedure name, and the issue PATH is untouched:

  workAreas.create input └─ ["type"] └─ is missing

  So the half that made the 0.37.0 strict-input change cheap to adopt — WHICH key, and whether it is missing or unexpected — survives intact, while the types and the enum members do not. The excess-property case still lists the accepted key NAMES, deliberately: the caller already sent the key, that list is what makes the fix a one-line read, and names without types were not what was reported.

  **What this does NOT do, stated because the report asked for it.** It does not run `guards:` before the decode. In `@effect/rpc`, a `Request` is decoded against the payload schema and answered on failure without ever reaching `server.write` — so it never reaches the handler and never reaches `applyMiddleware`. Auth middleware runs strictly after the decode, and there is no point in that path holding both a resolved subject and an undecoded payload. Evaluating guards first means replacing the protocol layer, not annotating a schema, and `voltro probe access` therefore still reports a guarded procedure whose input it cannot guess as `inconclusive` rather than `refused`.

### Internal (no consumer-facing effect)

- **@voltro/datetime, @voltro/local-first, @voltro/react-native** — `@voltro/datetime`, `@voltro/local-first` and `@voltro/react-native` shipped with no api-extractor golden, so the public-surface drift tripwire did not cover them — and the docs-audit finding that motivated this landed in exactly that gap (the docs promised a `useTimezone()` hook that `@voltro/datetime` never exported, and no gate could see it). All three are wired now, root + subpath entry (`./context`, `./react`, `./schema`): six goldens, `api:check` green on each, and no existing golden changed (the new `paths` entries every sibling map gained are purely additive).

  Root cause fixed in the generator rather than by hand: `gen-api-extractor.mjs` now creates the package's `etc/` directory with the wiring. api-extractor refuses to create its own report folder, so a package wired without one failed at its first `api:report` instead of at generation — which is how these three went live uncovered. Internal: no consumer-facing behaviour changes.
- **@voltro/cli** — `gen-api-extractor.mjs --check` verifies the api-surface wiring instead of writing it, and runs in CI (and therefore in `pnpm gate`, which derives its steps from `ci.yml`). It fails when a published entry point has no api-extractor config, no golden, an EMPTY golden, a stale config/golden for a dropped export, or no `api:check` script.

  It is derived from `publishConfig.exports` inside the generator's own loop — not a curated list and not a second copy of the derivation — so a package that joins the workspace is covered without anyone remembering to add it. It carries a floor (60 packages) for the reason every check in `scripts/` has one: the failure mode of a wiring check is a green line over a walk that found nothing.

  Verified by injecting each defect and watching it go red (missing golden, empty golden), confirming exit code 1, and confirming `--check` mutates no file. Internal: tooling only.
- **@voltro/cli** — `rpcSurfaceFingerprint.ts` wrote its composite-key separator as a literal NUL byte instead of the `\u0000` escape. Same runtime value, no behaviour change — the file's own 16 tests pass identically before and after.

  It matters because of what the byte does to the FILE rather than to the hash: a source file containing a NUL is binary to every text tool, so `grep` skips it and prints nothing, which is indistinguishable from a clean file. This repo has been bitten by exactly that — a 1020-line module that every grep-based audit had silently skipped, including one searching for a string that file declares.

  The guard (`noLiteralNulInSources.test.ts`) caught it on the release gate, in a file added earlier in this same release. The rule was already written down; what enforced it was the test.

---

## [0.38.0] — 2026-08-14

### ⚠ BREAKING

- **@voltro/plugin-audit, @voltro/protocol, @voltro/plugin-auth** — An audit row now says when an action was taken through an IMPERSONATED session, on the default settings, and no redactor can take that away.

  `AuditEvent` gained `impersonation`, and the datastore sink a nullable json column of the same name. The mark is lifted out of `subject.metadata` BEFORE the redaction chain runs, so `redactSubject` — including a custom function that erases the subject wholesale — never gets a say.

  **The defect it closes.** `@voltro/plugin-auth` mints the mark into `subject.metadata`, and `auditPlugin`'s default `redactSubject: 'metadata'` replaces that whole bag. That default is right: the bag is where a per-user provider credential lands, and an audit table is the last place a live PAT should be. The consequence was that on defaults an impersonated action was recorded indistinguishably from the user's own — the one distinction an audit trail exists to make. The documented mitigation (`redactSubject: impersonationAuditRedactor()`) worked and was opt-in, and an audit property that depends on somebody wiring it is not a property.

  The alternative fix — a keep-these-keys option on `redactSubject` — was rejected for the same reason: it leaves the default wrong, and "who really did this" is not the app's metadata to configure away. It is a property of the event, so it is now a field of the event.

  **BREAKING: `IMPERSONATION_METADATA_KEY` moved from `@voltro/plugin-auth` to `@voltro/protocol`.** It names the one reserved key in `Subject.metadata`, and `Subject` is protocol's type. Two packages need it — plugin-auth writes the mark, plugin-audit reads it — and a plugin must not depend on another plugin, so spelling the string in both would have made it a second definition no guard is watching. Everything else stays: plugin-auth still exports `impersonationOf`, `isImpersonated`, `ImpersonationMark` and `impersonationAuditRedactor`. The codemod repoints the import, preserving an alias and the type-only form.

  The redactor keeps working and is no longer load-bearing. Set `redactSubject: 'none'` or a custom function for your own reasons; the impersonation mark is recorded either way.

  No migration is needed for the new column — a `_voltro_*` change rides the declarative differ on `voltro db apply` and on a `voltro dev` boot, on every dialect.
- **@voltro/cli** — A `*.startup.ts` that fails now refuses the boot. It used to warn and let the server come up.

  Measured, on a real `voltro dev` against postgres, while building the row-filter integration test in this same release. A startup reached for `ctx.store.select(...)` — a builder that lives on the request-scoped `MutationStore`, not on the `DataStore` a startup receives — and threw on its first line. The boot printed:

  ```
  warn   startup: function rejected
  info   startup: registered      <- next line, same file
  ```

  and then served every request with no row filter registered. Two lines contradicting each other, the second asserting exactly the thing that had just failed, and an app that looked healthy while its access control was absent.

  **The rule this overturns was right when it was written.** The header of `startupRunner.ts` read "Errors are logged but never fatal — a failing startup MUST NOT block the rest of the app", and for the startups it was written for — an SSE bridge, a sync loop, a metric aggregator — that is the correct call. It stopped being right when a startup became the documented seam for REGISTRATION: `setRowFilter` is installed from one.

  The runner cannot tell a registration from a background loop, and the two failures are not symmetric. "The app refuses to boot" is fixed in seconds and is visible to everyone; "the app serves without its access control" is visible to nobody. So the default is the recoverable one, and an app that genuinely wants best-effort writes the `try`/`catch` inside its own startup — one line, at the site where somebody decided the failure was acceptable, where a reviewer can see it. Deliberately not a flag: a flag moves that decision away from the startup it applies to and makes it one setting for all of them.

  **Two sibling silences went with it**, because fixing only the rejection would have left two more ways to reach the identical state — the per-seam shape this repo keeps paying for. A startup file that cannot be imported, and one with no default-exported function, used to warn and skip. Both refuse now. The second matters more than it sounds: the convention is a DEFAULT-exported function, a named export is discovered and never runs, and that is indistinguishable from a startup that ran and did nothing.

  **And the 2-second race is gone, which is the half that made the rest reliable.** The old code did not wait for a startup to settle — it raced it against 2000 ms and let the boot win. A startup slower than that was reported as fine, so a rejection arriving afterwards had nothing left to refuse. The runner now waits for the startup to SETTLE, so every failure is catchable however slow it is.

  That race existed to protect one shape: a startup that never returns because it holds a fiber until shutdown. Counted before changing it, that shape appears in ZERO of the four startups this framework ships — `warm.startup.tsx` (twice), `searchBackfill.startup.tsx` and the memory fixture's all return. So the race protected the shape we discourage and penalised the shape we teach, and the penalised one includes `searchBackfill`, our own example, which awaits a full-table query plus an index backfill and is the likeliest thing in the box to exceed two seconds. Slow AND failing put a consumer back in exactly the silent state this release removes.

  **So a startup that never returns now refuses the boot too**, after `VOLTRO_STARTUP_TIMEOUT_MS` (60s default), with a message naming the file and showing the `onShutdown` shape to use instead. That makes a previously-documented capability illegal — "a fiber that resolves only on shutdown" — and it is the deliberate half of this change rather than a side effect. A startup that is merely SLOW is unaffected: it is waited for and registers when it finishes.

  `startup: registered` is written only when the function actually returned. The old code printed it for a failed startup and for one still running.

  Verified against a real process in every direction, not only in units: the reintroduced defect exits 1 and never listens; a never-returning startup exits 1 and never listens; a slow-but-successful one still registers; and the healthy fixture boots and serves all eight row-filter assertions. `startupRunner.test.ts` is new — there were no tests on this runner at all, which is part of why the contradiction survived.

  **`voltro update` carries you across this** — codemod `0.38.0/02_startup-failure-refuses-boot`.

### Fixed

- **@voltro/cli** — A table declared through `databaseHandle({ … })` but not exported as a top-level table is now DISCOVERED — so its mixins apply.

  Discovery kept whatever `isTable()` accepted out of a schema module's `Object.values`, which only ever sees tables the file exports DIRECTLY. A schema that builds its tables programmatically exports the builder's result:

  ```ts
  export const blogEntities = contentTypeToEntities(blogPost)   // { draft, published }
  export const database = databaseHandle({ …, blogPostDrafts: blogEntities.draft })
  ```

  `isTable({ draft, published })` is false, so those tables never entered the schema registry — while `databaseHandle` had registered them for by-name lookup perfectly well.

  **Two registries with different populations, read by different things.** `getTable()` found the table, so insert validation knew `tenantId` was NOT NULL. The schema registry is what drives the mixins, so nothing stamped it. Measured against a real `voltro dev` boot of the scaffolded `api-cms` template, over real RPC:

  ```
  TableValidationFailed: { "table": "blogPost_drafts", "summary":
    "missing required column 'tenantId' — NOT NULL with no default and not auto-stamped" }
  ```

  Every `content.saveDraft` in that template, since it shipped. Its own unit test could not see it, because the test builds its schema registry by hand — the one step the running app does not perform.

  **The write failing was the lucky half.** Reads do not announce themselves: `makeQueryFinalizer` AND-merges `tenantId` from this same registry, so a table missing from it is a table nobody scopes. The visible symptom was a broken mutation; the invisible one was tenant isolation quietly not applied to those tables.

  The fix takes the by-name registry's DELTA across each schema module's import, so a handle-declared table is attributed to the file that declared it — rather than reading the whole global registry, which by then also holds framework tables that this set deliberately excludes. It lives in `loadDiscovered`, which `voltro dev` and `voltro serve` both call, so the two cannot disagree about it.

  No app change is needed; the idiomatic `databaseHandle` declaration now works as its documentation always said.
- **@voltro/database, @voltro/cli** — A migration that fails because `DB_SCHEMA` names a schema that does not exist now says so.

  Postgres answers `3F000 no schema has been selected to create in`, and the only thing that puts a non-default schema on the connection's `search_path` here is `DB_SCHEMA`. The error mentions neither the variable nor the schema, so the FIRST `CREATE TABLE` of the boot fails with a message about SQL and the reader goes looking at the DDL — for a typo in an environment variable.

  Found while building the row-filter integration test below: the boot aborted, the log named `_voltro_migrations` and a postgres routine, and nothing in it pointed at the one line of configuration that caused it. Same shape as a 403 that says nothing about `VOLTRO_INSPECT_TOKEN`, fixed in the same release.

  The remedy also states what the framework will NOT do: Voltro creates tables, never the schema itself. That is a namespace decision, and inventing one from a typo puts a migration somewhere nobody is looking.

  `remedyFor` is deliberately a one-entry map and the test asserts the empty case as well as the full one. A remedy is worth printing only where the mapping from driver code to cause is exact; a list of maybes is how a reader learns to skip the section.

  **Alongside it: the row filter is now driven under `voltro dev` against a real postgres.** Both defects that reached consumers lived between layers that were each individually covered — a module-local `let` that split per instance, then a query producer that built its context from an unscoped request — and three consumer documents carried "we have not run this against a real database" as an honest caveat. `rowFilterDevPostgres.integration.test.ts` boots the fixture twice: handlers with no predicate of their own, a filter registered from a `*.startup.tsx`, and a NEGATIVE CONTROL run with the registration skipped that asserts the same queries return BOTH owners. Without the control, "the caller saw one owner" is equally consistent with a database that only ever held one.
- **@voltro/cli** — A shard that cannot be released no longer spins a fiber at full speed. Upstream `@effect/cluster` releases shards through `Effect.eventually` — retry until success, zero delay, logged at DEBUG — so a shard whose storage was unreachable retried as fast as the event loop allowed, in the one place nobody watches.

  Our patch replaces it with `Effect.retry(Schedule.exponential(100) ∪ Schedule.spaced(5000))`. `union` takes the MINIMUM of the two delays, so the 5s spacing caps the backoff instead of compounding with it, and both schedules recur forever — attempts stay unbounded, which is what the original intended. The persistence was always correct; only the missing delay was the defect.

  This entry exists because the change would otherwise have shipped undocumented. The patch lives in `packages/cli/templates/patches/`, and the changelog gate requires an entry for `packages/*/src` — so nothing would have demanded one, even though the file is installed into every user project that runs cluster. A rule that cannot see a path is not the same as a path with nothing on it.

---

## [0.37.0] — 2026-08-13

### ⚠ BREAKING

- **@voltro/protocol, @voltro/runtime, @voltro/cli** — A field a procedure's input schema does not declare now REJECTS the call. It used to be discarded and the call ran with what was left.

  The measurement, from a consumer's root layout:

  ```ts
  query?.('userSettings.list', { employeeId })
  ```

  That procedure declares `userId` / `userIdIn`. Effect's default `onExcessProperty: 'ignore'` decoded the payload to `{}` — not reasoned, measured:

  ```ts
  decodeUnknownSync(Struct({ userId: optional(String) }))({ employeeId: 'e' })  // → {}
  ```

  An empty input to a LIST query is not a narrower filter, it is the ABSENCE of one. Their admin, signed in as `2d0add2c…`, was served the settings row of `4410c2f8…` — another user's language and theme in the first paint, with nothing in any log to say so.

  **Why refuse rather than warn.** The decoder cannot tell a projection field from a FILTER field, and that asymmetry is the whole risk: dropping an unknown `include` costs a caller some data, dropping an unknown `tenantId` hands them somebody else's. Nothing at decode time distinguishes the two, so the safe direction is the only one available — the same fail-closed reasoning as the row filter's refusal, one layer up. A warning would have to be read by someone, in a log, after the wrong rows were already served.

  The typed loader query that shipped in 0.36.0 closes the same hole for callers we compile. This closes it for the ones we do not: a plain `fetch`, a curl, a still-cached bundle after a field rename, and every untyped caller.

  Three things measured rather than assumed, because none follows from the annotation's name: it propagates into NESTED structs, through every member of a UNION, and leaves a non-struct payload (`Schema.Void`, a scalar) alone.

  **`Schema.Struct({})` needed a filter, and only a real process showed it.** The fixture's `notes.list` declares an empty input; `POST /rpc` with `{ employeeId }` came back `200` with a snapshot, which for twenty minutes read as the whole change having failed. An empty `TypeLiteral` has no property signatures, so Effect has no expected key set for a key to be excess OF — self-consistent, and the wrong answer here, because `input: Schema.Struct({})` is the STRONGEST declaration a procedure can make and it was the one shape that accepted everything. It gets an explicit predicate now; `Schema.Record` keeps its open key set, because there the openness is declared.

  **Verified against a running `voltro serve`, not only in units.** A declared input succeeds and inserts its row; an undeclared field is refused naming the key and the accepted set. The refusal arrives on the channel a payload decode failure ALREADY used — a missing required field produces the same `Die` with a `ParseError` message — so this adds no new error shape for a client to handle, it moves one case onto the channel the sibling case was always on.

  `strictInput` lives in one module and every `Rpc.make` payload in `@voltro/protocol` goes through it — query, mutation, action, stream, event, plus the workflow start on both the server lifter and the browser-loaded rpc group. `strictInput.test.ts` asserts that SET by scanning the source, not the five lifters somebody remembered: a rule applied at the sites you can list is the shape that let `bootStoreCodec` be fixed twice and break a third time.

  **`voltro update` carries you across this** — codemod `0.37.0/01_procedure-input-rejects-undeclared-fields`, a written note. A transform would have to guess which declared field a stray one meant, which is the same guess that produced the defect.

### Added

- **@voltro/plugin-audit** — `redactInput` / `redactOutcome` gained `'shape'`, and `redactSubject` gained `'metadata-shape'`: the payload's STRUCTURE survives, no value from it.

  ```json
  { "__redacted": { "jiraToken": "string(113)", "attempts": "number" } }
  ```

  Requested by the consumer who had asked for the redaction one round earlier, and both requests were right. They spent a day on a bug their own audit trail could have ended in seconds — a value arrived as 113 characters where 44 were due, and the row that would have said so read `{"__redacted":"all"}`. `'all'` remains the default on every field; this is opt-in.

  The rules, and the two that are decisions rather than details:

  - A string reports its LENGTH. Never a prefix, never a hash — `enc:v1:` is a prefix and so is the first byte of a private key, so there is no prefix length that is safe for every credential format. - A number, boolean or date reports its TYPE only. A number can BE the secret. - **A key can be the value.** An object keyed by user data puts a datum where a schema name belongs, so a key is reproduced only when it looks like a declared field — a short plain identifier. The first version truncated long keys and documented the weakness instead; this module's own test caught 62 characters of a secret surviving on the first run. A leak with a footnote is still a leak. - **A string's length is a real disclosure, and a small one.** Stated in the docs rather than buried: for a fixed-format credential it carries nothing, for a human-chosen password it is a weak hint. `'all'` stays the default for anyone that matters to.

  The `'shape'` outcome describes the payload it REPLACES — the value on success, the error on failure — rather than the event. Describing the event would report `{ kind, value, durationMs }` and hide the field, which is the failure the option exists to end. An error's `_tag` still survives, as it does under `'all'`.

### Fixed

- **@voltro/cli** — `voltro db --help` listed fifteen of eighteen subcommands. `adopt`, `scan-credentials` and `encrypt-column` shipped and never joined the hand-written string.

  A consumer wrote both halves of that gap into a requirements document, as separate items, neither of them about help text:

  - **`voltro db encrypt-column` "does not exist"** — filed as a feature request, quoting the fifteen names they saw as evidence. It shipped in 0.33.0, and enabling `.encrypted()` on a populated column by hand is exactly the migration they were about to write themselves. - **`scan-credentials` "no longer exists"** — filed as CLOSED, a credential scanner struck off their list as removed. It had not moved.

  A quoted enumeration is read as exhaustive, and the more careful the reader the more thoroughly they act on the missing entry. Same lesson a boot refusal in `procedureAccessGate` had already taught us, in a place nobody thought of as a message.

  The usage line is now GENERATED from the dispatch table's key type (`Record<DbSubcommand, Handler>` in `dbCommand.ts`, names in `subcommandNames.ts`), so a handler with no name or a name with no handler fails to compile. `voltro privacy` is keyed the same way. The prose summary beside it cannot be generated — it carries per-command annotations — so a test asserts it mentions every name, because it carried the identical three omissions and it is what `voltro --help` prints first.

  `subcommandHelpParity.test.ts` also NAMES the six commands whose subcommand menus have no dispatch table behind them (`webhooks`, `evolve`, `new`, `data`, `storage`, `add`). They dispatch through a switch and are unchecked; a silently-unchecked command reads exactly like a checked one.
- **@voltro/cli** — A 401 or 403 from the inspect surface now names `VOLTRO_INSPECT_TOKEN` and says which side is missing.

  `voltro db plan --against <url>` printed `remote returned 403` and stopped. A consumer read that as a DATABASE permission problem — the natural reading of a 403 from a command whose entire subject is a database — and went looking at grants. The cause is one unset environment variable, which the command reads four lines above the message.

  `voltro probe access` had half of it: it named the variable on 401 and not on 403, while classifying both as `refused`. So the two commands somebody needs during an access migration were the two that would not say what was wrong, and one of them said something misleading instead.

  `inspectGateHint` is shared by both call sites and distinguishes the two statuses, because they call for different actions: a 401 means no credential was sent (set the variable), a 403 means the one sent was not accepted (the two values differ). Both halves of the sentence name the server AND the calling shell — naming one side produces a second failed attempt.
- **@voltro/cli** — `VOLTRO_TEMPLATES_DIR` is authoritative when set. It used to be a HINT: if the path it named held no `apps/` (or no `baselines/`), both resolvers fell through to the sibling-checkout walk-up and quietly used a different tree — or none.

  A pointer that silently isn't followed is worse than a wrong one. A CI job aimed at the wrong path scaffolded from whatever it happened to find, and a job whose checkout had failed reported an empty template catalogue with nothing connecting that emptiness to the variable it was given. `scripts/lib/docsSite.mjs` states the same rule for `VOLTRO_DOCS_DIR`, and arrived at it the same way: you said where it is; it is not there.

  Behaviourally this only changes the misconfigured case — a correct `VOLTRO_TEMPLATES_DIR` resolved to the same place before and after. What changes is that a wrong one now shows up as "not found, here is the path I was told" at the first thing that reads it, instead of as a different tree three steps later.

  The unbundled resolution order is otherwise untouched: sibling `voltro-templates` → `.voltro-templates` → the bundled `templates/` a published CLI ships.

### Internal (no consumer-facing effect)

- **@voltro/plugin-ai-flows** — Two comments in the flow engine cited task records from a plans tracker that has since been deleted. Comment-only; no behavior, no API, nothing a consumer can observe.

  Worth writing down because of HOW it surfaced. The tracker was retired in the META repo, and the gate that went red was in THIS one — `check-stale-task-comments.mjs` resolves a comment's `task #NN` against `../plans`, so deleting a plan document in one repo can only be half a change, and the other half is in a repo the deleting commit never touched.

  Neither comment was WRONG, which is the part that makes the rule earn its keep. The first claims `@voltro/ai` has first-class media generation — true: `generateImage`, `generateSpeech`, `generateVideo` all ship in `packages/ai/src/media.ts`. It now names those three instead of a record number, which is checkable without the deleted document. The second only quoted the retired id inside its own account of a defect (a `"not yet wired (task #35)"` message that outlived the shipped HITL park and misled an audit into filing it as unbuilt); the quote lost the number and kept the whole lesson.

  The check's own failure text is the reasoning: a plan is retired for exactly two reasons — the work shipped, or it was dropped without shipping — and a comment still citing it asserts the second while usually meaning the first.

---

## [0.36.0] — 2026-08-13

### ⚠ BREAKING

- **@voltro/integration-http, @voltro/plugin-atlassian** — A 401 from an upstream now produces `code: 'unauthorized'`, not `code: 'session_expired'`. The connection vault's own failure — where we DO know the credential is unusable — becomes `code: 'credential_unusable'`.

  `session_expired` asserted a cause the status cannot support. A 401 says the credential was not accepted and says nothing about why: expired, revoked, insufficient scope and MALFORMED all produce it. A consumer's plugin sent ciphertext as a bearer token (a separate defect, fixed in the same release), the upstream answered 401, this name called it an expired session, and their health check acted on the name and deleted a valid session. Login loop, with every symptom pointing at a revoked credential.

  Names get acted on, which is the whole reason to split them:

  - `'unauthorized'` — the upstream refused. Non-transient, so still never retried; `status` rides along so a caller that knows more about its own upstream can decide for itself. Deciding for them is what this gives up. - `'credential_unusable'` — the connection vault could not produce a credential (no grant, revoked grant, refresh failed). Here the claim is ours to make, because the failure is ours rather than the far end's.

  The 401 message stopped saying "session expired" too. It now says the credential was refused and that the reason is not in the response — which is the honest sentence and the one that would have saved the day this cost.

  Its test asserts the CLAIM rather than banning the word: the first version forbade `/expired/i` and went red against the corrected message, which lists expiry as one of several things a 401 can mean. That distinction is the point of the change, so the assertion had to be about `session expired` specifically.

  **`voltro update` carries you across this** — codemod `0.35.1/01_unauthorized-replaces-session-expired`.

### Added

- **@voltro/web** — **`apiSurface: compatible` — why the three altered golden lines cannot break a caller.** `LoaderContext` and `LoaderFn` each gained a type parameter WITH a default, so an unparameterised reference still resolves. The one that needed proving is `query?`, which went from a written-out signature to `LoaderQuery<Procedures>` — and `LoaderQuery` is a conditional whose false branch is character-for-character the previous signature. `unknown` does not extend `ProcedureTypeMap`, so the defaulted instantiation takes that branch.

  Proved with `tsc` rather than by reading it: a probe asserting mutual assignability between `LoaderQuery<unknown>` and the old signature compiles, and inverting the probe fails — with tsc printing the resolved type as `<T = unknown>(tag: string, input?: Record<string, unknown> | undefined) => Promise<T>`, which is the old signature verbatim.

  `LoaderContext` takes the app's procedure map, so a loader's `query` infers its input and output from the descriptor instead of returning `unknown`.

  ```ts
  import type { AppProcedures } from '<your-api>/rpcGroup'
  
  export const loader = async ({ query }: LoaderContext<AppProcedures>) => {
    const rows = await query?.('bookmarks.list', { limit: 100 })
    //    ^ inferred; an unknown tag or a wrong input shape is a compile error
  }
  ```

  `AppProcedures` is generated already and has been for a while — it was wired to `createHooks` on the CLIENT and to nothing on the server, so every loader call site spelled its own output type by hand and a typo in a tag compiled. A consumer reported it twice.

  The extraction reuses `ProcedureInput` / `ProcedureOutput` from `@voltro/client` rather than re-deriving them: a second answer to "what does this tag return" drifts the first time a descriptor field is renamed, and both answers look right in isolation.

  Opt-in, and non-breaking: with no map named, the signature is the previous `<T = unknown>(tag: string, …)`. The framework cannot import an app's generated file, which is the same reason `createHooks<AppProcedures>` takes it explicitly.

  Covered by a `.test-d.ts`, because the failure mode is "it compiles when it should not" and no runtime assertion can observe that. Two of its cases exist because the first version was vacuous: an `interface` fixture does not satisfy the map constraint (no implicit index signature — the codegen emits an alias for exactly this reason), so the typed branch fell back silently and every `@ts-expect-error` came back unused.

### Fixed

- **@voltro/cli** — The store a plugin receives through `bindDataStore` now carries the storage codec, so an `.encrypted()` column read through it decrypts.

  A consumer measured both stores inside one request: `ctx.store` gave a 44-character plaintext PAT, and the store their plugin's `credentialsResolver` received gave 113 characters of `enc:v1:…`. Ciphertext is a syntactically valid bearer token, so nothing threw. Jira answered 401, `@voltro/integration-http` named that `session_expired`, their PAT health check did the reasonable thing with that name and deleted the session, and the user got login → dashboard → login forever. A configuration error in the costume of an authentication refusal, where every symptom pointed at the one explanation that was wrong.

  The part worth recording is that `bootStoreCodec.ts` was written for exactly this, after it happened at two other seams, and its header predicts this consumer's symptom verbatim: "a route reading an `.encrypted()` column got the literal string `enc:v1:…` back … the failure reads as 'wrong credential'". The fix was applied per-seam. `bindDataStore` was not one of the seams anybody listed, so it happened a third time — and a per-seam test stayed green throughout, because it covered the two seams somebody remembered.

  `bootStoreHandouts.test.ts` asserts the rule instead: no boot path hands a plugin the raw driver, on either boot path, with the wrapper applied before the handout. The codec needs no Subject — it is how a column is spelled on disk versus in JS — so there was never anything a boot-level store could not carry.

  Also relevant to anyone who followed the 0.28.0 codemod: that codemod told apps to stop carrying a credential on the Subject and look it up in the resolver instead. Doing exactly that is what put an app on this seam, so the instruction and `.encrypted()` were not simultaneously satisfiable through it.
- **@voltro/runtime, @voltro/cli** — The rpc/WebSocket query and stream arms now resolve row visibility before the executor sees a context. Fixes a 0.35.0 regression that made every read throw for an app with a registered row filter, and the older leak underneath it.

  0.35.0 shipped two things for the row filter: the registration moved to `globalThis` (so a duplicate `@voltro/runtime` instance cannot hide it), and a scoped store built without a resolved scope started throwing instead of silently serving unfiltered rows. The first was a real fix for a real hazard. The second was correct in principle and immediately fatal in practice, because the framework itself had a path that did exactly what it now refuses.

  The consumer who reported the original leak ran the two-line check we asked for and `getRowFilter()` was visible from their request path — so the instance split was NOT their cause, and our hypothesis was wrong. Their measurement is what found the real one: the refusal fired, meaning the registration was FOUND and `ctx.rowFilter` was still undefined at the store. Nothing was missing; a step was.

  Four arms reach a request context. `makeOneShotQueryRunner` (REST) and `makeQuerySubscriber` (SSE) both `await withRowFilter(...)` and say so in a comment. The rpc query handler and the stream handler — each hand-copied into both boot paths — handed the raw request straight through. So a user's executor received a context whose `ctx.store` applied no row filter, on the two arms that carry the most traffic. It survived because subscriptions are refiltered per DELIVERY, which made a descriptor-returning query look correct end to end while the executor's own reads were not.

  `withScopedRequest` is the seam that fixes it once: a request that already carries a scope passes through untouched (resolving twice would run the app's `load` twice per request), an app with NO filter stays fully synchronous, and an app with one gets an Effect — which every one of these call sites already accepts. A boot-path parity test pins both stream arms and the shared producer.

  The refusal also stopped firing for a SYSTEM subject. That is not a softening: `resolveRowFilterScopeFor` returns `NO_ROW_FILTER` for a system subject, so the only correct value was already determined, and several legitimate paths (schedules, resumed workflows, the webhook trigger context) build a context directly with no scope. Demanding a decision there is what took the api down.

---

## [0.35.0] — 2026-08-13

### ⚠ BREAKING

- **@voltro/runtime, @voltro/cli, @voltro/plugin-clickhouse, @voltro/plugin-duckdb, @voltro/plugin-analytics-postgres** — The analytics CDC-mirror's version is derived from the CHANGE under `changeScope: 'fleet'` (postgres CDC, mysql binlog) — warehouse baseline plus the change's per-key position in the totally-ordered fleet stream — instead of each replica's own clock. An N-replica deployment still issues N duplicate writes per change (every replica observes the whole stream; that is the transport), but they are now BYTE-IDENTICAL — same row image, same version — so the sinks' existing guards (ClickHouse `ReplacingMergeTree(version)`, DuckDB/postgres `excluded.version > version`) dedupe them for free, with no leader election and no clock anywhere. This closes the real defect behind the N× cost: under clock skew larger than the gap between two changes to one row, a peer's duplicate of the OLDER image could take the higher version and win in the warehouse permanently and silently. A replica joining mid-stream seeds each key's numbering from the warehouse's own high-water mark via the new REQUIRED `AnalyticsMirrorImpl.maxVersion` read (all shipped warehouse sinks implement it; tombstoned deletes keep their version so the read answers after a delete — a custom sink follows the codemod note). The once-per-boot `changeScope=fleet` warning that named this cost is REMOVED — the hazard it named is gone. Local-scope stores keep the hybrid-clock version unchanged. New tunable: `VOLTRO_ANALYTICS_MIRROR_VERSION_STATE_LIMIT` bounds the per-key version state (default 100000; least-recently-changed keys re-seed from the warehouse on their next change).
- **@voltro/cli** — The declared framework table set no longer reads a runtime flag. `CDC`, `VOLTRO_UNDO` and `VOLTRO_TRACING_PERSIST` each moved it before this release; `app.config.ts` gained `schema: { traces?, undo? }` to declare the two that still need a decision.

  A consumer measured two fingerprints from one source tree, one database and one `NODE_ENV`, differing only in `CDC`. Their Helm chart gives the pre-upgrade migrate Job its own `env:` list — `NODE_ENV`, `DB_*`, the obvious migration inputs — while `CDC: "0"` lives in the pods' block, because change data capture is obviously a runtime concern. Nothing about the name reads as schema-affecting, so it was in none of their three overlays' jobs. The declared set is what the schema fingerprint hashes, so that is a GREEN migrate job followed by every pod refusing to boot. Latent for months, and it would have fired on their next deploy.

  Counting the family after their report found three, not one — measured on a mariadb app at `NODE_ENV=production`, each flag flipped alone: `CDC=0` removed `_voltro_cdc_offsets`, `VOLTRO_UNDO=on` added `_voltro_undo_log`, `VOLTRO_TRACING_PERSIST=all` added `_voltro_traces`. All three are the kind of value an operator puts on the pods and not on the job, and only one of them had been noticed.

  `NODE_ENV` had already produced this exact failure in 0.34.0 and was fixed by making one decider resolve it for every command. That fix does not generalise here: a job legitimately does not carry an observability flag, so there is nothing to agree on. The rule is therefore stated rather than patched — **the declared set may depend only on inputs every process in one deployment computes identically** (the source tree, `app.config.ts`, the dialect, and `NODE_ENV`), and `declaredSchemaGates.test.ts` sweeps every `VOLTRO_*` / `CDC` / `DB_*` name the framework reads anywhere in `packages/*/src` and fails if any of them moves the set. Derived rather than listed, because a test naming the three known offenders only re-checks what someone already remembered — which is how the two unreported ones survived.

  The three got two different answers, deliberately. `CDC` left the derivation entirely: `_voltro_cdc_offsets` follows the DIALECT now, so a mariadb or mssql app declares it whether or not that process drives CDC. The cost is one empty offsets table and it is the same trade `impliesScheduleTables` already makes in writing. `VOLTRO_UNDO` and `VOLTRO_TRACING_PERSIST` could not simply be dropped — both can legitimately turn a table on in production, and a declared set that ignored them would leave capture writing to a table nobody created — so they keep their runtime meaning and lose their declaring power. Turning capture OFF still needs no declaration and never will; turning it ON without one is refused at boot, on both boot paths, with the config field named.

  The `prod-mismatch` refusal also stopped being two hashes and a command. It prints which of the three decided tables THIS process declared and from which input, because the ledger stores no table set to diff against and the command it used to recommend was the one the operator had just run successfully. That half matters beyond the framework's own tables: a plugin's `extendSchema.tables` is app code and can read anything, so the rule above cannot be enforced for it.

  **`voltro update` carries you across this** — codemod `0.35.0/05_declared-schema-drops-runtime-flags`.
- **@voltro/protocol, @voltro/runtime, @voltro/cli, @voltro/devtools-ui** — A declared event must decide who may listen — the boot gate now covers `defineEvent`, closing SEC-1's sibling. `defineEvent`'s `guards:` was optional and `bindEvent` skipped an empty list, so under `security.defaultDeny` an event with NO access declaration was silently subscribable by anyone who could open the socket, while the identical shape was already refused for every procedure.

  `defineEvent` now accepts `openAccess: '<reason>'` — mutually exclusive with `guards:`, reason string required — exactly as the four procedure definers do. The erased `{ open }` decision rides the same `guards` array every enforcement path reads; `bindEvent` treats it as "no check" (an open event pays what an unguarded one pays: nothing), and `eventToRpc` no longer unions `ScopeError` into the wire contract for an event that cannot produce a denial.

  **Breaking for `security.defaultDeny` apps (the default):** an app with a `*.event.ts` declaring neither `guards:` nor `openAccess:` now refuses to boot under `voltro dev` and `voltro serve`, naming every undecided event — the same message, from the same gate, procedures get. `voltro doctor` lists the same set. Migration: give each event a decision (`guards: [{ scope: '…' }]` or `openAccess: '<why anyone may listen>'`); an app that wants the old default-allow declares `security: { defaultDeny: false }` once, in `app.config.ts`. Plugin-declared events are not judged — the gate reads the app's own discovered files only.

  The events inspect snapshot (and the devtools Events panel) now counts only ENFORCEABLE guards and carries the `openAccess` reason, so a deliberately open event renders as "open access" instead of as "1 guard" over an event anyone may subscribe to.

  **`voltro update` carries you across this** — codemod `0.35.0/01_event-access-decision`.
- **@voltro/plugin-ai-flows** — **The breaking half, first:** `RunStepStatus` gained `'skipped'`. A sixth member means an exhaustive switch over it stops compiling and a status-keyed lookup has a hole — so a manual codemod fires on any app that names the type or its literals. Everything else here is additive (optional fields, new exports, one nullable column that rides the declarative differ).

  Flows can now BRANCH, FAN OUT, and no longer chain without a bound. Three additions, and the third is a defect fix wearing a feature's clothes.

  **`when:` — a conditional step.** A step runs only if its condition holds against the run context; a false condition SKIPS the step rather than failing it, so it produces no output and anything referencing it sees an absent value. The run timeline carries the rendered reason (`{{mode}} equals "full"`), because a step that silently vanished is indistinguishable from a step nobody declared.

  The condition is STRUCTURED data (`{ ref, op, value }`), not an expression string, and that is three decisions in one. A flow can be authored as a stored row a user edits in a browser — an expression there is an evaluator running user-authored source on the server. The visual editor can offer a dropdown over a structure and cannot over a string it would have to parse. And `validateFlow` already walks every reference, so a structured `ref` joins that check for free: a typo'd condition would otherwise evaluate absent, take the false branch, and skip its step on every run, forever, with nothing logged.

  Truthiness here deliberately differs from JavaScript's: `0` and `''` are TRUTHY. A step gated on a generated count or string means "did the producer run", not "is it non-zero" — the second is `{ op: 'neq', value: 0 }`, sayable when meant.

  **`group:` — concurrent steps.** Consecutive steps sharing a group name run at the same time, each keeping its own durable step, so a replay resolves every branch from the journal exactly as it would sequentially. Kept as a flat field rather than a nested `parallel([...])` because the durable step name, the run timeline and a `human` step's signal name are all INDEX-keyed — nesting would re-index every flow already running.

  Three rules, all enforced at registration: grouped steps cannot read each other's outputs (they have no order between them), a group must be contiguous (a name that stops and resumes would run as two sequential fan-outs), and a `human` review cannot join a group (it suspends the whole run). Context writes are applied after the whole segment in AUTHORED order — applying them as branches land would make the run context depend on scheduling, which a durable replay must never do.

  **A chain is bounded — this half is a fix.** `chainTo` carried exactly one guard, a flow could not chain to itself, so `A → B → A` and any deep chain were unbounded: each hop starts a child run with a fresh idempotency key, so nothing collapsed it and nothing was counting the hops. A run now carries the chain that led to it ON THE PAYLOAD — deliberately not reconstructed from the run rows, because a guard whose evidence comes from a query is a guard that permits the loop whenever the query fails. A chain is refused on a cycle, or at `maxChainDepth` (default 5; `aiFlowsPlugin({ maxChainDepth })` or `VOLTRO_AI_FLOW_MAX_CHAIN_DEPTH`), and the refusal lands on the run row's new `chainRefusal` column naming the path. The parent run still SUCCEEDS: a refused follow-up is a configuration problem, not a reason to destroy a completed result.

  All three are driven through the REAL durable executor in tests, not just their pure helpers — a primitive that is correct and reaches nothing is the defect class this package's own segmentation module exists to prevent.
- **@voltro/protocol, @voltro/runtime, @voltro/cli, @voltro/ai, @voltro/plugin-billing, @voltro/plugin-flags, @voltro/plugin-governance, @voltro/plugin-notifications, @voltro/plugin-presence, @voltro/plugin-storage** — Every first-party plugin rpc route now declares an access decision (`guards:` or `openAccess: '<reason>'`), and `security.defaultDeny` is enforced in the DISPATCH spine as defense in depth behind the boot gate: a descriptor that reaches the wire with no decision (a third-party plugin route, an embedder's hand-bound descriptor) is refused per-request with a typed `ScopeError` before the transaction / external I/O. Twelve previously-open routes now require a scope: `billing.startCheckout` / `portalUrl` / `previewChange` / `changePlan` / `changeSeats` / `invoices` → `billing:manage`; `billing.reportUsage` → `billing:report`; `governance.export` / `erase` → `admin:full` (already enforced in-handler, now declared); `storage.mintUploadUrl` / `ingestUrl` → `storage:manage`; `storage.listRefs` → `storage:browse`. Migration: grant the scope to the role/subjects that legitimately hold each capability (rbac role, `resolveScopes`, api-key scopes) — the codemod lists every route and the open-by-design surfaces that did NOT change. `PluginRpcRoute` gains `guards`/`openAccess` fields, carried through the route lift into the enforced descriptor; the synthesized agent/undo/connections built-ins declare `openAccess` so they keep serving under default-deny.

### Added

- **@voltro/workflow, @voltro/cli** — `awaitSignal` now logs a one-time hint (once per workflow, never per poll) when its declared `timeoutMs` exceeds a threshold, naming `awaitSignalSuspending` — the drop-in variant that SUSPENDS the run and frees the worker slot for human-approval-length waits (WF-11). Threshold: `workflows: { suspendSignalHintMs }` in `app.config.ts` (default 5 minutes), env override `VOLTRO_WORKFLOW_SUSPEND_HINT_MS`. A hint only — the framework never swaps the variant under a run, because the two journal differently and a silent swap mid-history is a replay trap.
- **@voltro/data-transfer, @voltro/sql-postgres, @voltro/cli** — The logical importer bulk-loads postgres targets via `COPY … FROM STDIN` (PERF-13). `voltro data import` engages it automatically wherever plain-INSERT semantics provably hold — `--mode replace`, or the default `upsert` into a table that is empty at import time (the fresh-target shape of every cross-dialect migration) — and never under `--atomic`. A refused COPY batch is atomic (nothing landed), so the importer replays exactly that batch through the per-row path with held-row / deferred-FK semantics intact. MEASURED on a 7-column table (text/int/bool/jsonb/timestamptz), 50 000 rows, local postgres: row-by-row 12.8 s (~3.9 k rows/s) vs COPY 0.59 s (~84.6 k rows/s) — **21.7×**. New seams: `ImportOptions.copyLoader` / `copyBatchSize` (default 5000) in `@voltro/data-transfer`, and `makePgCopySession` / `encodeCopyRow` in `@voltro/sql-postgres` (a submittable CopyIn query over the existing `pg` driver — no new dependency). Other dialects keep the per-row writes.
- **@voltro/cli** — `voltro probe access` asks a RUNNING app whether its declared access is actually enforced — the question none of the existing checks ask.

  `voltro check`, the boot access gate and `security.defaultDeny` all verify that a decision was DECLARED. None of them verifies that the declaration REFUSES anyone. That distinction is not hypothetical here: the dispatch spine and the boot gate were separate for several releases, a procedure filtered out of the rpc group while still bound in the handler map served silently on one path and crashed the other, and `check` itself counted a decided-open route as guarded. Every one was the declaration and the behaviour disagreeing, found by reading rather than by asking.

  It calls every guarded procedure with NO credentials and reports three verdicts: `refused` (enforcement works), `admitted` (the finding), and `inconclusive` — the call failed for a reason that is not an access refusal, usually payload validation running before the guard. `inconclusive` is never counted as a pass; `--strict` fails on it, which is what CI wants.

  It probes ANONYMOUSLY on purpose. That is strictly weaker than scope-by-scope differentiation and strictly safer: the alternative puts credential minting into a command that can be pointed at production. Procedures declared `openAccess:` are skipped — probing them would report every deliberately-public route as a finding and bury the real ones, which is the same signal-to-noise failure `kind: 'open'` was added to the wire to fix.

  `fetchJson` gained an explicit `anonymous` option for this one caller; it is an opt-out, never a default, and both directions are pinned by a test — a bearer attached here would make every result meaningless while still printing green.

  **Validated against a live app, and it took two corrections to get there.** The first version sent a readable request id, which the transport converts with `BigInt(id)` — so every probe came back as a Defect before any guard ran, and every app looked broken. The second read a top-level `_tag` off an object while `POST /rpc` answers an ARRAY of envelopes, so a correctly-refused call scored as `admitted`. Both versions had a green unit suite, because the fixtures asserted the shape the code assumed. The fixtures are now copied from a real transcript.
- **@voltro/cli, @voltro/runtime** — `app.config.ts` gained `reactive: { deliveryConcurrency, rawReadTrackingLimit }` — the delivery-loop tunables were env-only, which left a number the framework picks on the project's behalf undeclarable in the one file that carries every other tunable.

  Resolution stays inside the Dispatcher constructor (`resolveReactiveConfig`), so neither boot path can drift, and the env vars still win over the declared value: an operator acting on a running deployment outranks the project file. The threading itself is source-pinned across all three files (`dev.ts`, `serveCommand.ts`, `serveApi.ts`) because the serve side is a two-file relay and the union is where an option goes missing invisibly.
- **@voltro/cli, @voltro/database** — Data residency is DECLARABLE and wired. `tenancy.residency` in `app.config.ts` opens one store per servable region on both boot paths and routes every request to its tenant's home region — or refuses it.

  The primitives have existed for two rounds (`setResidencyConfig`, `residentPlacement`, `bindResidentStore`), exported and tested, with **zero callers**. A user could reach them, but nothing in the framework did: there was no way to declare residency and no request ever consulted it. That gap was pinned by a test walking every workspace source, which went red on this change and asked for the module header to be corrected — it now names its consumers instead of asserting it has none, so a SECOND unreviewed caller still fails.

  ```ts
  tenancy: {
    isolation: 'namespace',
    residency: {
      servableRegions: ['eu-west'],
      regionUrlEnv: { 'eu-west': 'DB_URL_EU', 'us-east': 'DB_URL_US' },
      homes: [{ tenantId: 'acme', region: 'eu-west' }],
    },
  }
  ```

  `regionUrlEnv` names an env VAR, not a URL — a connection string is a secret and `app.config.ts` is committed. Everything else about a region's store (pool bounds, TLS, `search_path`, timeouts) is inherited from the primary connection, so a region cannot silently run with different limits than its deployment.

  **Every failure is a refusal, never a fallback**, because a residency system that degrades to a default store violates residency at exactly the moment something is misconfigured. Unresolvable tenant, unmapped home, or a home region this deployment does not serve are all typed refusals; the last one names the region so a gateway can route it.

  Four declarations are refused at BOOT rather than warned about: residency without `isolation: 'namespace'` (the region keeps regions apart, the namespace keeps tenants apart — one without the other is not isolation), a servable region with no env-var name, one whose env var is unset, and a tenant mapped to two regions.

  Two boundaries worth knowing:

  - `ctx.storeForTenant(id)` resolves residency for THAT tenant, not the caller's, so a handler acting on another tenant reaches that tenant's region or is refused. Background work (schedules, workflows) runs with no tenant and must use it — `ctx.store` there is the primary store. - A transaction is never re-routed. A caller-supplied store is used as given; it already went through residency to exist, and moving writes off the connection holding the lock is a worse failure than the one residency prevents.

  Homes resolve once at boot (an array, or a function reading your own table), so adding a tenant home needs a restart — chosen over a cache with a staleness window on a decision whose whole value is that it is never wrong.
- **@voltro/runtime, @voltro/cli** — `voltro schedule backfill <name> --from <iso> --to <iso> [--yes] [--limit N]` and `POST /_voltro/inspect/schedules/:name/backfill` (WF-14) — fire every cron occurrence of a schedule over an explicit range, sequentially, each recorded against its own cron-derived `scheduledAt` with `trigger: 'manual'`. Fills the gap boot backfill (walks from the last recorded run only) and cluster-cron catch-up (capped at one day) leave open. Bounded and confirmable: above 25 occurrences it refuses without `--yes` (printing the count), above the per-request cap (default 1 000, `--limit` up to a hard ceiling of 10 000) it refuses outright, firing nothing — never a silent prefix. Wired on both boot paths through one shared hook.
- **@voltro/devtools-ui, @voltro/plugin-search** — The Search dashboard panel now RENDERS the drift surface REL-1 shipped server-side and no dashboard showed (the additive-JSON silent-drift shape the 4-layer rule exists for): per-index `dropped` / `pendingDrift` / `drifted` / last-drift badges, the repair queue itself (`GET /drift` — oldest first, with attempt counts and the engine's last error), and a **Resync now** action (`POST /resync`) gated on the new `canResyncSearch` capability (its own flag — a resync re-reads only the drifted rows; a reindex re-reads the whole table). Landed across all four layers in one change set: shared `SearchPage` + wire types + capability + EN/DE strings here; HTTP fetchers + page wiring in voltro-devtools; tenant-scoped `apps.inspectSearchDrift` / `apps.inspectSearchResync` proxies + hooks + page wiring in voltro-cloud (the indexes proxy schema carries the new fields as OPTIONAL, so a customer app from before the drift ledger still decodes). `search.query` also now carries an explicit access decision (`openAccess`, with the tenant-scoping rationale in source) instead of the undecided SEC-1 shape.
- **@voltro/cli** — Workflow wakes over the change stream (WF-8): on a fleet where remote changes reach the change spine (Postgres LISTEN/NOTIFY CDC — the common broker-less multi-replica deployment), a remote replica's `signal-sent` event, start context, or run transition now triggers an immediate, coalesced `pollStorage` on every replica, so cross-replica signal/step latency stops being bounded by the 10 s storage poll. Honest subset by design: the cluster engine has no per-run wake seam, so the change event wakes the poll early rather than replacing it — the poll tick stays the safety net. Local-origin changes never wake (a replica waking on its own recorder rows would be a poll storm). Wired by the same `makeWorkflowWake` builder on both boot paths.
- **@voltro/runtime, @voltro/workflow, @voltro/cli** — `ctx.workflows.start(name, payload, { at: Date })` — delayed one-off starts (WF-13). The start is parked as a durable `_voltro_workflow_pending` row (`mode: 'delayed'`) and fired by the coordinated drainer when `at` arrives, so it survives restarts and fires on whichever replica drains. At `at` it becomes an ordinary ARRIVAL: declared flow control (debounce, singleton, rateLimit, …) judges it as of that moment — `at` never bypasses a control. The handle reports `status: 'queued'` with `deferral: { mode: 'delayed', dueAt }`. An `at` in the past starts immediately; `{ at, wait: true }` is refused. `at` is an absolute instant by design (no `delay` spelling): a delay is ambiguous about its epoch and every queue system answers it differently, while an instant composes with the schedule/backfill surfaces.
- **@voltro/workflow, @voltro/cli** — `workflows: { recording: 'coarse' }` in `app.config.ts` (env override `VOLTRO_WORKFLOW_RECORDING`) — turns off the two fire-and-forget per-step writes to `_voltro_workflow_run_steps` (WF-10) for hot high-step workflows. Run rows, run events (signals/timers/cancels/stall reports) and the cluster engine's durable journal are unaffected — replay and redrive work exactly as before; the cost is an empty step timeline for runs recorded under coarse. Measured before it was built (`packages/cli/scripts/admission-throughput.mjs`): the recorder costs exactly 2 store writes per step, off the step's critical path — which is why the knob is a skip, not a batcher.
- **@voltro/workflow, @voltro/cli** — `workflow({ schedule })` — the workflow-side cron declaration (WF-12), with Temporal Schedules' overlap vocabulary about the RUN: `onOverlap: 'skip' | 'buffer' | 'cancelOther'`. Pure sugar over the shipped scheduler: at boot it lowers into a real schedule named `workflow:<name>` (same coordinated claims, run rows, Schedules panel, `voltro schedule` verbs). The synthesised firing awaits the workflow run to completion, which is what makes skip/buffer bind on the run's duration; `cancelOther` cancels only the still-running run this schedule itself started. The firing watchdog (`schedule.maxRuntime`) defaults to 24 h here. Cron and timezone are validated at definition time.

### Changed

- **@voltro/plugin-clickhouse** — `clickhouseAnalytics` now BATCHES `track()` inserts by default (PERF-11) — 20 events / 5 s, plugin-posthog's conservative numbers — instead of one HTTP insert (and one MergeTree part) per event. What changes observably for an app that never set `batch`: a successful `track()` now means "buffered", not "ClickHouse accepted the row"; events become readable up to 5 s after they were tracked; a flush failure drops that batch with a warning (a hard crash loses whatever is still buffered — graceful shutdown drains via `dispose`). Opt OUT with `batch: false` to restore one immediate, confirmed insert per event; `batch: { maxSize, flushIntervalMs }` tunes the window. No compile break — `batch` widened to `ClickhouseBatchOptions | false`, and the previous opt-in spelling keeps working (its defaults are now 20/5000 rather than 1000/5000).
- **@voltro/database, @voltro/cli, @voltro/workflow** — The migration advisory lock is now scoped to the configured schema (`DB_SCHEMA`) instead of one framework-wide constant. A postgres advisory lock is database-scoped and MySQL `GET_LOCK` is server-wide, so two apps sharing one database in different schemas used to serialize each other's migrations and defer each other's boot-time trigger repair — with a log line blaming "another instance". Now: postgres derives a stable 64-bit key from the schema name (FNV-1a 64 of `voltro_migration_lock:<schema>`, sign bit cleared; collisions across schemas are possible and only reintroduce serialization, never a race); mysql/mariadb/mssql suffix the lock NAME with the schema (hashed past MySQL's 64-char `GET_LOCK` cap). Every taker moved together in this change — the declarative applier, the file-based runner, the boot auto-migrate, the CLI's reactive-trigger boot repair, and the workflow cluster first-boot gate (its own distinct key, same derivation). On mysql/mariadb, where `GET_LOCK` is server-wide and `DB_SCHEMA` is not a connection pin, setting `DB_SCHEMA` to your database name is how two apps on one server un-share the lock.

  **Rolling-deploy story.** An app WITHOUT `DB_SCHEMA` (or with `DB_SCHEMA=public`) keeps the EXACT pre-change lock key and name — old and new replicas contend on the same lock throughout the rollout; nothing to do. An app WITH a non-default `DB_SCHEMA` changes its lock key when it lands this version: during that one rollout window, old-generation and new-generation replicas do not mutually exclude their DDL. The boot auto-migrate DDL is idempotent (`IF NOT EXISTS`-shaped), so the practical exposure is the known postgres `CREATE TABLE IF NOT EXISTS` catalog race — worst case one replica's boot fails and restarts. Avoid running `voltro db apply` concurrently with THAT rollout; after it, everything contends on the schema-scoped key.
- **@voltro/plugin-search** — `POST /reindex` now STREAMS the source table (keyset-paginated `streamTable`) and upserts one bounded page at a time instead of loading the whole table into memory — the old shape was an OOM on exactly the tables big enough to need a reindex (PERF-12). The page size is a new tunable, `searchPlugin({ sync: { reindexBatchSize } })` (default 1000), and the `/indexes` panel reports it as part of the policy in force. `backfillIndex` keeps its plain-array signature for small explicit seeds. Additive surface only: a new optional `SearchSyncOptions` knob + a new `SYNC_DEFAULTS` key — no existing call site changes meaning.
- **@voltro/plugin-search** — Sync-stat counters no longer pay a read+CAS against the OLTP primary on EVERY indexed-table write (PERF-14). Counts buffer in memory and flush per window — `searchPlugin({ sync: { statsFlushIntervalMs } })` (default 5000 ms; `0` restores the per-event durable write) with an early flush at `statsFlushMaxBuffered` (default 1000) pending counts. Mirrors the runtime's api-key usage buffer, SHUTDOWN included: plugin deactivate drains the tail on both boot paths, so a graceful deploy loses nothing; a hard crash loses at most the current window of counters (never a change — the drift ledger stays the durable record). `GET /indexes` drains the buffer before reading, so the panel stays truthful mid-window. `StatsStore` gained a delta-applying `add` (the flush target); both shipped impls carry it and nothing consumes user-provided `StatsStore` implementations.
- **@voltro/database, @voltro/plugin-webhooks, @voltro/testing, @voltro/voltro, @voltro/workflow** — Golden churn from this round's signature WIDENINGS, classified per package:

  - **@voltro/database** — every migration entry point (`applySchema`, `runMigrate`, `runFrameworkBootstrap`, `applyNamespacedSchema`, `provisionTenantNamespace`, the lock functions) gained a trailing OPTIONAL parameter (`SchemaApplyOptions` / `MigrationLockScope`) for the schema-scoped lock and the dialect retry predicate. Every existing call compiles unchanged; omitting the parameter is exactly the old behavior. - **@voltro/workflow / @voltro/testing / @voltro/voltro** — the same widenings re-exported through the aggregates, plus `PresenceWrite`-adjacent type surface already classified in this release's presence entry. - **@voltro/plugin-webhooks** — `deliverWebhookWorkflow`'s payload type inference had COLLAPSED to `AnyStructSchema | Struct<Fields>`, which made `execute`'s requirements `any` for every consumer: there was no type contract in force to break, only one that silently did not exist. It now infers the real payload struct. The export's only callers are the framework's own boot paths (it exists for cluster-runner registration); an app that passed a wrong-shaped payload under `any` now gets the compile error it should always have had — which is the fix, not collateral.

### Fixed

- **@voltro/cli, @voltro/protocol** — `voltro doctor`, the boot refusal, and `GuardSpec.resource`'s own doc comment now name the per-resource form of an access decision. All three listed two ways to decide and there are three.

  A consumer with 565 undecided procedures set `security: { defaultDeny: false }` across their app, and their reasoning was correct at every step from what they were shown. Their authority is per-team — a viewer in one team, an admin in another — so a subject-global `guards: [{ scope }]` would state a check they do not perform, and the boot refusal warns against exactly that ("reaching for a scope every caller already holds satisfies the gate, reads as protection, and enforces nothing"). `openAccess:` would be untrue. Both offered forms were rightly rejected, so they turned the gate off and kept enforcing in handlers.

  The form that fits them — `guards: [{ action, resourceType, resource }]`, backed by `defineResourcePolicy` and a tuple source registered over their own tables — has shipped for several releases, is wired on both boot paths, fails closed without a resolver, and is documented under Authentication → Authorization. They looked: they read `GuardSpec.resource`, whose doc comment described the resolver as "a future ReBAC / `accessPolicy()` resolver". That sentence was written before the ReBAC path shipped and never updated, and it is the only thing a reader of that type has. A doc comment that says "future" about something built is not a small inaccuracy — it argued a careful team out of a security gate.

  An enumeration inside a refusal is read as exhaustive, and the more careful the reader, the more thoroughly they act on it. `accessDecisionForms.test.ts` pins all three forms in all three places, including the `defaultDeny: false` branch — an app that has already given up is precisely the audience that needs to learn there was a third option.
- **@voltro/protocol, @voltro/database, @voltro/runtime, @voltro/plugin-broadcast, @voltro/sql-postgres, @voltro/sql-mysql, @voltro/sql-sqlite, @voltro/sql-mssql** — A reactivity-channel publish now says where it came from — `origin: 'inline'`, because it happened in THIS process — instead of borrowing the `'injected'` stamp its transport seam applies by default. Two defects came out of that one mislabel, both silent:

  - **A channel published synchronously from inside a change listener never left the replica.** `plugin-broadcast` suppresses re-publishes while it is injecting, and the bracket was coarse: it dropped EVERY emission made in that window, not just the event it had injected. So `onChange` → `publishReactivity` woke the local node and no peer ever heard it — no error, no log. The one plugin doing cross-replica fan-out (presence) escaped only because it re-publishes from its own transport callback. The guard now suppresses by provenance, so a local publish made inside the bracket travels like any other. - **A replica could not tell its own channel publish from a peer's.** Both arrived `'injected'`, so a listener fanning a channel onward had nothing to key on and needed a boolean per channel to avoid an echo. `origin` answers it now.

  Two supporting changes, each with its own failure mode:

  - `origin` no longer survives the wire. It describes how an event reached THIS process, so the receiving replica strips what the sender serialised and stamps its own. Without this the guard fails OPEN — measured, an arrival still claiming `'inline'` amplified one publish into 163 events and killed the test worker. - The transport-origin stamp has one definition (`externalChangeEvent`, `@voltro/database`) instead of five hand copies across the memory store and the four dialect stores. `injectOriginParity.test.ts` fails if any store grows its own again — a store that hand-stamps would override a caller's stated origin, and the visible result is a channel that stops crossing replicas on that dialect only.

  `DataStore.injectExternalChange` keeps its shape: an event that states no origin is still stamped `'injected'`. A store passed to `plugin-broadcast` that does not stamp at all (the interface is structural) is detected by identity and falls back to the old coarse suppression rather than amplifying.
- **@voltro/cli, @voltro/devtools-ui** — `voltro check` no longer mistakes a deliberate `openAccess:` mutation for an unguarded one — and no longer mistakes it for a guarded one either. The manifest serialises the decision as a `kind: 'open'` guard entry carrying the reason string; `toInput` now translates it into `openAccess` on the graph procedure with `hasGuards: false` (nothing IS checked), and the `rbac/unguarded-mutation` rule skips a procedure whose author already "confirmed it is intentionally public" — the rule's own fix text. Previously the open entry was counted as a guard, so the finding disappeared for the wrong reason: the open mutation read as protected.

  `@voltro/devtools-ui` gains the hand copy of the `SerialisedGuard` wire union (it is deliberately dependency-free, so it cannot import the CLI's), renders an access badge on the RPC page — guard count, or "open access" with the reason in the tooltip — and the copy is pinned from the owning side by `serialisedGuardParity.test.ts`, in the style of `migrationOpKindParity`.
- **@voltro/cli** — `ctx.query` in a loader rejects an `error` event instead of returning it as the query's rows.

  A consumer put a guard with an unheld scope on a query and called it over HTTP with a valid user JWT. The batch came back 200 with an `error` chunk carrying a `ScopeError` and an `Exit: Success` after it. Both are correct — the transport worked and the guard worked — but `buildLoaderQuery` unwrapped exactly ONE member of the three-member `subscriptionEvent` union and passed the other two through as data. The loader returned the error OBJECT, it went into the SSR seed, and the component called `.map()` on it: `TypeError: kept.map is not a function`, on 135 pages, for every user.

  The `.catch(() => null)` in our own documented loader pattern could not fire, because nothing was thrown. Neither could `seedPagePreloads`' `onError`, for the same reason. The same rejection over the live socket sets `error` and leaves data empty, so the two transports were saying different things about one event.

  The unwrap is exhaustive now: `snapshot` yields its rows, `error` rejects with the typed error attached (as `cause` and `voltroError`, so an app can still branch on `ScopeError` rather than parse a string), and a first-event `delta` — structurally impossible today — throws naming itself a framework bug rather than handing a loader an id-keyed patch. `buildLoaderQuery` has one implementation shared by `voltro dev` and `voltro start`, so the fix covers the production path the report did not measure.

  The HTTP 200 is unchanged, and the reporter is right that it should be: the batch transport did succeed.
- **@voltro/database, @voltro/cli** — The schema apply (`applySchema` / boot auto-migrate / framework bootstrap) now consults the dialect's own transient-failure predicate instead of dying on the first `SQLITE_BUSY`. The gap was located in `@voltro/sql-turso`: `busy_timeout` cannot retry the deferred-upgrade lock class, the store path already honoured the dialect's `retryFilter`, and the migration applier never consulted it — so a DDL statement that met the schema lock failed on the first attempt while every equivalent DML statement would have been retried. The CLI threads each loaded dialect's `retryFilter` through the new `SchemaApplyOptions`; retries are bounded (`VOLTRO_MIGRATION_DDL_RETRIES`, default 4, exponential backoff with jitter) and only ever re-run statements that are safe to re-run: per-statement on the per-operation dialects (sqlite/turso/mysql/mariadb, `IF NOT EXISTS`-shaped or covered by the duplicate-index tolerance), whole-transaction on postgres/mssql, whose retryable classes (deadlock victim, serialization failure) roll the transaction back cleanly. No `retryFilter` threaded means no retry — exactly the previous behavior. The declarative plan applier (`applyPlan`) is deliberately unchanged: its operations are not uniformly idempotent, and partial failure there is owned by the resume ledger.
- **@voltro/web, @voltro/client** — The web first load no longer ships `msgpackr` — 190.5 → 180.6 KB gz (−9.9 KB, 5.1% of the whole first load) for every app, measured on the zero-procedure fixture and re-pinned into `bundle-budget.json`.

  It shipped because `@effect/rpc`'s RpcSerialization module top-level-imports msgpackr while every Voltro path selects `layerJson`, and msgpackr declares no `sideEffects` flag — so no bundler was allowed to drop it. The fix is a dependency patch adding `sideEffects: false`, declared at BOTH workspace roots (the @effect/cluster patch rule: a meta-root `pnpm install` must apply it too, or the two installs fight over node_modules). An app that genuinely calls `makeMsgPack` keeps the library — the flag only permits dropping it when unused.

  The bundle-budget gate's slack floor is what keeps this fixed: the budget is re-pinned to the new number, so quietly re-inflating past it fails CI.
- **@voltro/cli, @voltro/database** — Under `tenantIsolation: 'namespace'`, a tenant's namespace is now PROVISIONED on its first use — schema DDL plus the `onTenantCreate` seed lifecycle, memoised per namespace per process.

  Neither ever happened: `provisionTenantNamespace` — documented as "the entry point the CLI / runtime use, eager at migrate time or lazily on first use" — had zero callers in the entire codebase, `withNamespace` returns a pure view, and so a fresh tenant's first request died on "relation does not exist" while the `onTenantCreate` seeds (recorded as wired) fired only from tests that called the function directly. Found by the claimed-wirings checker the moment it learned to read active-voice claims.

  Both boot paths build the provisioner from one builder; the namespace view's async methods await the memoised ensure (one resolved-promise await after the first settle), sync members stay sync, and the method classification is a DERIVED guard — a new `DataStore` method fails the test until someone decides whether it must await provisioning. A failed provision is surfaced and forgotten, never cached: one transient DDL failure must not become a permanently broken tenant on a replica. Verified end to end on live postgres, control included: the raw view still fails against a fresh namespace, the provisioned one creates the schema and fires the lifecycle exactly once.

  Eager provisioning stays the app's move (call `provisionTenantNamespace` from a seed or startup over your own tenant table) — the framework has no tenant registry to enumerate, and the docs now say so instead of implying otherwise.
- **@voltro/runtime, @voltro/cli, @voltro/testing** — `setRowFilter`'s registration moved from a module-local variable to a `globalThis` cell, and a scoped store that receives no filter while one is registered now throws instead of serving unfiltered rows.

  A consumer measured four read paths returning every row of the tenant to every employee — 19/19 contracts, 231/231 time-entry requests, 6/6 user settings, 4637/4637 shifts — on both HTTP and WebSocket, with `row filter registered` in the boot log and 27 green tests. One of those paths was the only thing keeping `userSettings.update` safe, so every user could edit every other user's settings.

  Their exclusion work was exhaustive and right at every step: the load succeeded, `predicateFor` returned predicates for the right tables, nothing was `unconstrained`, no handler bypassed the store, and a `setRowFilter` + `makeTestContext` pair reproduced CORRECT filtering. That left them concluding the remaining variable was the store — memory in the green reproduction, postgres in the red deployment.

  It is not the store, and the correction is the transferable part: their reproduction ran inside ONE module instance and production does not necessarily. `ROW_FILTER` was a module-local `let`, so an app's `*.startup.tsx` and the framework's serve pipeline write and read different variables whenever they hold different copies of `@voltro/runtime` — the serve bundle inlines the framework while app modules stay external, and strict pnpm resolves one version into two physical directories when two importers have different peer contexts. The pipeline reads `undefined`, correctly interprets it as "this app registered no filter", and serves everything.

  `@voltro/database`'s `coreTablesRegistry` carries this exact fix with a comment describing this exact failure, for a value whose worst case is a crash at boot. This one's worst case is silent data exposure and it did not have it.

  The second half answers the reporter's second ask directly: a filter that cannot be applied must fail loudly rather than pass quietly. `undefined` and "we could not tell" had collapsed into one value, and the doc comment on that option already asserted they must not. Every deliberately unfiltered path — system sweeps, `runAsSystem`, change-stream subscribers, the seeding store in a test — now passes `NO_ROW_FILTER` explicitly, because "this app has no filter" and "this path is unfiltered on purpose" are different claims and only the second is a decision somebody made.
- **@voltro/cli** — A bare `voltro serve` under docker compose now drains on SIGTERM — in-flight requests complete against a fully-alive app, the listener refuses new work, live WebSockets are ended cleanly, and the process exits on its own, well inside `VOLTRO_SHUTDOWN_GRACE_MS`. No preStop hook or endpoint removal required. Two real defects closed (both measured against a live server): a single connected WebSocket wedged `nodeServer.close()` — node's `closeAllConnections()`/`closeIdleConnections()` cannot end an upgraded socket while `close()` still waits on it — so EVERY shutdown with a connected web client ran to the 10s deadline cut and the steps queued behind the close (the store's connection-pool close included) silently never ran; and the shutdown hook deactivated plugins and drained the analytics mirror BEFORE the request drain, so a request finishing during shutdown hit dead services and its writes were never mirrored. The drain is bounded: in-flight requests get 60% of the shutdown grace (floor 500ms), stragglers are then destroyed, and idle keep-alive sockets are swept continuously so a finished response never delays exit. The stale `serveApi` comment claiming `NodeRuntime.runMain` owns SIGTERM (and pointing at a k8s preStop hook as the fix) is rewritten to describe the drain that actually runs. Verify against a real serve with `node scripts/serve-drain-check.mjs`.

---

## [0.34.0] — 2026-08-12

### ⚠ BREAKING

- **@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.**

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

  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.

  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.

  **What changed:**

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

  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.
- **@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.)

  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.

  **`voltro update` carries you across this** — codemod `0.34.0/11_ai-flows-per-step-human-signal`.
- **@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.

  The mirror now:

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

  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.

  Migration (`voltro update` prints it):

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

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

  **`voltro update` carries you across this** — codemod `0.34.0/05_analytics-mirror-versioned-writes`.
- **@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.

  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".

  **Two properties carry the whole design, and both exist because sending an email and locking a customer out are irreversible:**

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

  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.

  **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'`).

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

  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.

  Also fixed here, all the same class — a mirror that believed arrival order:

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

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

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

  **`voltro update` carries you across this** — codemod `0.34.0/13_billing-provider-dunning`.
- **@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).**

  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

  ```ts
  const override = getConnectionSubject(clientId)
  if (override) return override
  ```

  so for the **life of that connection** nothing after that line ran again. Three things stopped happening, none of them audible:

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

  This is the frozen session cookie that 0.34.0 just removed, one layer down, on a channel with no expiry at all.

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

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

  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.

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

  **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).

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

  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.
- **@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.

  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.

  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.

  **`voltro update` carries you across this** — codemod `0.34.0/02_keyed-writes-are-tenant-scoped`.
- **@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.

  ```ts
  governancePlugin({ deriveSubjectScopes: { subjectTable: 'users' } })
  ```

  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.

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

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

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

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

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

  **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)`.
- **@voltro/cli** — **`voltro create-project` now creates the workspace root when there is none, and `voltro init` means what its name says.**

  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.

  Five separate defects fed one broken first contact, and all five are fixed:

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

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

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

  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.

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

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

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

  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.
- **@voltro/cli** — **The web process's postgres ISR cache and CDC invalidator understood `PG_*` only, and every template teaches `DB_URL` (PROD-5).**

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

  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.

  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.

  **The fallbacks are LOUD now, and one of them is a refusal:**

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

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

  **`voltro update` carries you across this** — codemod `0.34.0/17_isr-postgres-refuses-without-a-database`.
- **@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.

  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.

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

  **`voltro update` carries you across this** — codemod `0.34.0/02_keyed-writes-are-tenant-scoped`.
- **@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.

  Every logger surface (`createLogger`, `makeLogger`, `LoggerLayer`) now installs `makeDefaultRedactor()`:

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

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

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

  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.

  **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:

  ```ts
  const ctx = makeTestContext({
    subject: user('u1'),
    plugins: [mailPlugin({ provider: 'memory', from: 'app@acme.com' })],
  })
  await invoke(sendWelcome, sendWelcomeHandler, { to: 'ada@acme.com' }, ctx)
  expect(readMailBuffer()[0].to).toEqual(['ada@acme.com'])
  ```

  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.

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

  `ProcedureExecutor` gains a fourth type parameter `R = never` (the services the handler resolves) — additive, existing three-argument uses are unchanged.

  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.
- **@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.**

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

  Both dialects now follow the posture postgres has had since F6:

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

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

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

  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.
- **@voltro/cli** — **One place decides what environment a `voltro` process is in, and the migration commands stopped guessing (PROD-2).**

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

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

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

  The polarity is argued per gate rather than flipped once:

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

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

  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.

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

  **`voltro update` carries you across this** — codemod `0.34.0/16_declare-node-env-for-migration-commands`.
- **@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.

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

  **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:

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

  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.

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

  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.

  **`voltro update` carries you across this** — codemod `0.34.0/06_origin-guard-and-trusted-proxies`.
- **@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.

  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.

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

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

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

  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.

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

  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.

  **`voltro update` carries you across this** — codemod `0.34.0/06_origin-guard-and-trusted-proxies`.
- **@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.

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

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

  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.

  **`voltro update` carries you across this** — codemod `0.34.0/06_origin-guard-and-trusted-proxies`.
- **@voltro/plugin-presence, @voltro/ui** — Presence has no table, and its roster now pushes only when it actually moves.

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

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

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

  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.

  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.

  **`voltro update` carries you across this** — codemod `0.34.0/25_presence-has-no-table`.
- **@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.

  Two halves shipped together, because either alone is worse than neither:

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

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

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

  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.

  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.
- **@voltro/mcp** — `routeHttp` (@voltro/mcp) returns a `Promise<HttpOutcome>`.

  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.

  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.

  **`voltro update` carries you across this** — codemod `0.34.0/26_route-http-is-async`.
- **@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.**

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

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

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

  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.

  **Two things fixed on the way, both consequences of the default moving:**

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

  **`voltro update` carries you across this** — codemod `0.34.0/23_saml-signature-and-replay-defaults`.
- **@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.**

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

  **The default is now `false`, and the option exists.** The reasoning, since the direction is a relaxation:

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

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

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

  **`voltro update` carries you across this** — codemod `0.34.0/23_saml-signature-and-replay-defaults`.
- **@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:

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

  Migration — pick the one that matches your deployment:

  ```ts
  // app.config.ts — a durable engine (the normal answer)
  searchPlugin({ backend: { engine: 'meilisearch', url: process.env.MEILI_URL!, apiKey: … }, indexes })
  
  // …or assert the ONLY shape in which memory is correct outside dev:
  // exactly one process, re-seeding every index at startup via backfillIndex().
  searchPlugin({ singleProcessMemoryIndex: true, indexes })
  ```

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

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

  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.

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

  **`voltro update` carries you across this** — codemod `0.34.0/14_search-memory-backend-refuses-production`.
- **@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.

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

  **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).

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

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

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

  **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.
- **@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.

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

  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.

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

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

  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.

  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.
- **@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:

  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)

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

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

  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.

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

  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.

  **`voltro update` carries you across this** — codemod `0.34.0/06_origin-guard-and-trusted-proxies`.
- **@voltro/cli** — **`voltro serve` refuses to boot with pending `*.migration.ts`, instead of saying nothing (PROD-7).**

  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.

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

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

  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.

  **`voltro update` carries you across this** — codemod `0.34.0/18_serve-refuses-pending-file-migrations`.
- **@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.

  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.

  Three changes, and none of them works alone:

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

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

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

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

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

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

  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.
- **@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.

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

  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.

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

  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.
- **@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.**

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

  **BREAKING — a scheme renders HEADERS, plural.**

  ```ts
  signPayload(scheme, body, secret)             → signRequest(scheme, { rawBody, secret, messageId })
    { headerName, headerValue }                     { headers }
  verifySignature(scheme, body, secret, value)  → verifyRequest(scheme, { rawBody, secret, headers })
  ```

  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.

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

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

  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.

  **Three delivery defects, found by reading the spec's delivery section against the code:**

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

  All three are asserted end-to-end against a real receiver, per dialect.

  `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"`.
- **@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).**

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

  **The guarantee:**

  > **IDENTITY IS PERSISTED. AUTHORITY IS RE-RESOLVED AT RESUME, OR ABSENT.**

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

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

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

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

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

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

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

  **`voltro update` carries you across this** — codemod `0.34.0/20_workflow-authority-resolves-at-resume`.
- **@voltro/workflow** — Two workflow APIs were declared and read by nothing. Both are resolved, in opposite directions.

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

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

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

  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.
- **@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.

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

  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.

  **`voltro update` carries you across this** — codemod `0.34.0/04_workos-oauth-state-pkce`.
- **@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.**

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

  catch (err) { if (err._tag === 'EntitlementExceeded') showPaywall() }

  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.

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

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

  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.

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

  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:

  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

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

  The same configuration now gates `x-forwarded-proto`, which decides whether a response is treated as https (and therefore whether HSTS is announced).

  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.

  **`voltro update` carries you across this** — codemod `0.34.0/06_origin-guard-and-trusted-proxies`.

### Added

- **@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.

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

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

  **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).

  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.
- **@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.

  ```tsx
  const row  = useSubscription('app', 'documents.byId', { id })
  const save = useMutation('app', 'documents.setBody')
  
  const body = useCrdtText({
    cell: { table: 'documents', id, column: 'body' },
    remote: row.data?.body ?? null,
    push: (w) => save.mutate({ id: w.id, body: w.update }),
  })
  
  <textarea value={body.text} onChange={(e) => body.setText(e.target.value)} />
  ```

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

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

  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.

  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.
- **@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.**

  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.

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

  So the composition is not "point a flag at an experiment":

  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.

  ```ts
  export const checkoutButton = defineFlag({
    key: 'checkout.button',
    value: Schema.Literal('blue', 'green'),
    default: 'blue',
    variants: [{ name: 'control', value: 'blue' }, { name: 'green', value: 'green' }],
    experiment: 'checkout-colour',
  })
  
  export default defineExperiment({
    name: 'checkout-colour',
    on: { table: 'orders' },
    variantFrom: 'checkoutArm',                       // the arm the flag served
    variants: [{ name: 'control' }, { name: 'green' }],
    metric: { kind: 'conversionRate', column: 'completed' },
  })
  ```

  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".

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

  ```ts
  export const jobQueue = reactivityChannel('job-queue')
  
  defineQuery({ name: 'jobs.depth', source: jobQueue, ... })
  publishReactivity(ctx.store, jobQueue)   // every subscriber re-runs its executor
  ```

  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.

  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.

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

  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.
- **@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.

  **`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.
- **@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".

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

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

  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.

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

  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).
- **@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.

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

  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.
- **@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.

  `voltro dev` and `voltro serve` now both serve two routes:

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

  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.

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

  **Five gates, every one default-closed:**

  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.

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

  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.

  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).
- **@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.

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

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

  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.
- **@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.

  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.

  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.
- **@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.

  ```
  voltro db branch --pr 128 [--seed empty|copy] [--keep] [--json]
  ```

  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.

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

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

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

  **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.
- **@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.

  Five series now, emitted by **every** dialect store, labelled `dialect` and `op`:

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

  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.

  Three decisions worth knowing, because each has a wrong answer that looks reasonable:

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

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

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

  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.
- **@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).

  **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:

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

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

  Three properties worth knowing:

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

  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.

  `emailVerifiedAt` rides the declarative differ on `voltro db apply` and on a `voltro dev` boot, on every dialect.
- **@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`).

  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.

  Security posture, since a half-built OAuth flow is a vulnerability rather than a feature:

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

  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.

  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.
- **@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).

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

  **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).

  `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).
- **@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.

  `@voltro/testing` gains the Rails `travel_to` / Laravel `Carbon::setTestNow()` ergonomic:

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

  Four decisions that are the substance of it:

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

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

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

  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.

  **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:

  | 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 |

  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.

  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.
- **@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.

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

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

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

  **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).
- **@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.

  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.

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

  - **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.
- **@voltro/testing** — **A request-level test harness, subject factories, and row factories (TEST-3, TEST-5).**

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

  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.

  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").

  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.

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

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

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

  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.

  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.

  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.
- **@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.

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

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

  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.
- **@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.

  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.

  ```ts
  // app.config.ts
  export default defineApiApp({
    workflows: {
      staleness: {
        stallAfterMs: 30 * 60_000,          // default; set it above your slowest STEP
        onStalled: async (run) => { await page(run.tag, run.runId, run.reason) },
      },
    },
    scheduling: { stalenessSweepMs: 5 * 60_000 },  // or VOLTRO_STALENESS_SWEEP_MS
  })
  ```

  **Two decisions worth knowing before you tune it:**

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

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

  **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:

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

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

  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.

  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.

  `invitations` is registered with the retention sweep at 90 days (`VOLTRO_INVITATIONS_TTL_HOURS`), armed only when the feature is configured.
- **@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.**

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

  ```ts
  export const pageSize = defineFlag({
    key: 'search.pageSize',
    value: Schema.Number,
    default: 20,
  // default: 'twenty',   ← Type 'string' is not assignable to type 'number'
  })
  
  const size: number = flagValue(ctx, pageSize)   // server
  const size = useFlagValue(pageSize)             // browser, same type
  ```

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

  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.

  ### Dead-flag detection — two axes, and only some of it is a proof

  `GET /_voltro/inspect/plugins/flags/list` now carries a lifecycle report, and the dashboard panel renders it.

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

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

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

  **What it cannot see ships in the payload**, not in a docs page next to a number somebody is about to act on:

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

  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.

  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.
- **@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).

  Bind it once per api, at module scope:

  ```ts
  // src/lib/api.ts
  import { createHooks } from '@voltro/client'
  import type { AppProcedures } from '@acme/api/rpcGroup'
  
  export const { useSubscription, useMutation, useAction } = createHooks<AppProcedures>('app')
  ```

  ```tsx
  const { data } = useSubscription('projects.list')   // rows inferred — no <T>
  const create = useMutation('projects.create')       // input + output inferred
  ```

  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.

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

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

  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.
- **@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.

  `POST /auth/impersonate/start` and `/stop` mount when you configure `impersonation`. What that config REQUIRES is the design:

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

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

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

  **Four escalation refusals**, each with a test that goes red without it:

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

  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.

  `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.
- **@voltro/cli** — **`voltro webhooks consumer` — generate the verification package your SUBSCRIBERS install, from your own event schema.**

  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.

  ```
  voltro webhooks consumer --out ../partner-sdk
  voltro webhooks events --json
  ```

  **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:

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

  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.

  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.

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

  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.

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

  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.

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

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

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

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

  **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.
- **@voltro/workflow** — Two workflow failure modes that previously had no detector at all.

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

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

  Also added: `resolveRunGuardTuning()` reads `VOLTRO_WORKFLOW_MAX_RECLAIMS` and `VOLTRO_WORKFLOW_REPLAY_SHAPE_LIMIT`, mirroring `resolveFailoverTuning`.

### Changed

- **@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".

  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.

  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[]`.

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

  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.
- **@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.

  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:

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

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

  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.
- **@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.

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

  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.
- **@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.

  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.

  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.

  What each driver can actually enforce differs, and is stated rather than smoothed over:

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

  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.

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

  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.

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

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

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

  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.
- **@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.

  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.
- **@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.

  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:

  | | median | min | max | |---|---|---|---| | before | 2 388 ms | 2 188 ms | 2 817 ms | | after | 560 ms | 482 ms | 675 ms |

  **4.3× by median, 4.5× by minimum.** The bundled `commands` chunk went from 567 kB to 35 kB.

  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.

  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.
- **@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.

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

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

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

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

  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.

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

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

  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"}`.

  Reproduce the numbers: `node packages/sql-postgres/scripts/db-path-cost.mjs`.
- **@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).**

  The delivery loop already shared the READ and the DIFF across subscribers of one descriptor. Three things it did not:

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

  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.

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

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

  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.

  **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.
- **@voltro/client, @voltro/web** — A reconnect degrades to last-known-good data instead of to skeletons.

  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.

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

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

  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.
- **@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.

  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:

  - **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).

  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.
- **@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).

  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.

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

  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.
- **@voltro/cli, @voltro/runtime** — **The undo wire surface no longer depends on `NODE_ENV`, because half of it is a build artefact (PROD-10).**

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

  The gate is split:

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

  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.

### Fixed

- **@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.

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

  **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:

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

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

  **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.
- **@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.

  That property held by ACCIDENT on both paths, and on one of them it had already broken:

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

  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.
- **@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.**

  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.

  Nothing said so. It does now: one `warn` at attach naming the cost, the consequence and the tracking id.

  **Both proposed fixes are wrong, and the reasoning is the deliverable:**

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

  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.
- **@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.

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

  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:

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

  `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).
- **@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.

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

  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.

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

  Two failures of one command, reported together because they compound: the first produces the artifact, the second is what makes the artifact dangerous.

  **`localhost` silently meant "unix socket", and the port was dropped.**

  ```
  DB_URL=mysql://app:app@localhost:3307/… voltro data backup ./out .
    ✗ mysqldump: Got error: 2002: Can't connect to local MySQL server
      through socket '/tmp/mysql.sock' (2)
  ```

  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.

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

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

  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.
- **@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.

  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.

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

  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.

  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.
- **@voltro/cli** — **Every CLI invocation opened with two lines of Node internals.**

  (node:12345) ExperimentalWarning: localStorage is not available because --localstorage-file was not provided. (Use `node --trace-warnings ...` to show where the warning was created)

  That was the literal first thing a new user saw from the tool, on `voltro version` as much as on `voltro dev`.

  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.

  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.
- **@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.

  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.

  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.

  Its minimal form is why a reviewer misses it: pnpm keys a patch by version but the file NAME is arbitrary, so

  '@effect/cluster@0.61.0': packages/cli/templates/patches/@effect__cluster@0.60.0.patch

  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.

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

  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.

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

  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.
- **@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:

  MISSING DEPENDENCY Cannot find dependency '@vitest/coverage-istanbul'

  — 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.

  Two halves, because either alone leaves someone stuck:

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

  Three details that are deliberate rather than incidental:

  - **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.
- **@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.

  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.

  **Three things about this were not obvious, and each was a way to ship it looking wired:**

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

  `onSchemaChange` seeds fire too (the applier gained the call); `onTenantCreate` already did.
- **@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:

  ```
  plan.from=cb2b03b8…  live=d8ebc8d5…   → refusing: "the live schema has drifted"
  ```

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

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

  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.

  Two user-visible consequences, both wanted:

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

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

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

  voltro: the database is not reachable at 127.0.0.1:5432 (ECONNREFUSED).

  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.

  Either start one: pnpm db:up or point at your own: DB_URL=postgres://user:pass@host:5432/dbname

  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.
- **@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.

  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.

  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.

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

  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.

  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.
- **@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.

  It is counted now: `voltro_db_eager_fallback_total{dialect, reason}`.

  The `reason` label is the part to get right, and conflating the two arms would have made the alertable case unfindable:

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

  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.
- **@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.

  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:

  ```
  1  voltro serve   ✗ SCHEMA FINGERPRINT MISMATCH declared=8aaa5c9b live=72c2305a
  2  db files → nothing pending · db plan → 0 operations · db apply --plan → "up to date"
     _voltro_migration_plans: unchanged
  3  voltro serve   ✗ the identical refusal
  4  introduce ANY real delta → apply has work → records
  5  voltro serve   ✓ boots
  ```

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

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

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

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

  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.

  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.

  **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.
- **@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.

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

  ```
  sql.execute                                     236 441 spans
    SELECT "id" FROM "_voltro_schedule_claims"     52 461 · avg 58.5 s · max 1 191 s
    INSERT INTO "_voltro_schedule_claims" …        12 434 · avg 52.6 s
    SELECT * FROM "_voltro_workflow_pauses" …       4 377 · avg  6.2 s
  their own queries, same window:
    todos.listWith                                     56 · avg 0.77 s
  ```

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

  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.

  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.
- **@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.

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

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

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

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

  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.
- **@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.

  `insertMany` chunks at the boundary now, on all four stores. Three things about how, because each was a way to get it wrong:

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

  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.

  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.
- **@voltro/cli** — `GET /_voltro/inspect/migrations` is served, on BOTH boot paths — `voltro db plan --against` had never worked anywhere.

  Three layers, and only one of them was the one the consumer could see:

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

  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.

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

  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.

  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.

  **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.
- **@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.

  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.

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

  Two properties held deliberately:

  - **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, …)`.

  **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)`.
- **@voltro/cli** — **`voltro migrate --create-only` bootstrapped a database with no plugin tables (PROD-8).**

  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".

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

  `schemaApplyParity.test.ts` asserts `frameworkTablesFor` has exactly one caller, which is the form of the claim the header was making all along.
- **@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.

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

  **What lands.** A per-operation resume ledger, `_voltro_migration_ops`, covering every operation that runs outside a transaction:

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

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

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

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

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

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

  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).
- **@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.

  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.

  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.

  Escaping is unchanged — a term is still a literal, so `a.b` does not match `axb`.
- **@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.

  ```
  CREATE INDEX `apiKeys_keyPrefix_idx` ON `apiKeys` (`keyPrefix`(191));
  ERROR 1089: the used length is longer than the key part
  ```

  `keyPrefix` is `text().maxLength(32)` → `VARCHAR(32)`. Two of these in one migration (`auditLogs.traceId` has the same shape).

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

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

  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.

  Both halves are fixed:

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

  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.
- **@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.

  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.

  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:

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

  Everything else — pool size, `DB_SCHEMA`, TLS, the acquire bounds — means the same thing in every command.

  **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.
- **@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).

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

  Three more per-dialect drifts closed in the same change, all in transaction handling:

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

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

  No API is removed or narrowed; the new bracket is additive to `@voltro/database`.
- **@voltro/cli, @voltro/database** — **`onSchemaChange` seeds were dead AND silent.**

  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.

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

  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.

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

  Found by someone reading a service's logs and noticing it kept saying it was looking for webhooks on a deployment that has none.

  The module's own header already made the argument, one paragraph long, and stopped a step short:

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

  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.

  The three reasons are now taken one at a time, and only the third needs a clock:

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

  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.

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

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

  Two changes, and the first is the one that generalises:

  - **`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()`.

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

  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.

  `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.
- **@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:

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

  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.

  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.

  `ChangeEvent.oversized` / `PluginChangeEvent.oversized` say what was recovered, and the guarantee is exactly this — no more:

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

  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.

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

  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.

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

  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.
- **@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.

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

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

  '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

  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.

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

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

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

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

  **Two latent defects came out of the same read:**

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

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

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

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

  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.

  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.

  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.

  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.
- **@voltro/cli** — **A plugin's `extendSchema.migrations` ran in development and in NO production path (PROD-6).**

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

  Latent, because no first-party plugin ships migrations today. It was loaded and waiting for the first one that did.

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

  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.
- **@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.

  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:

  - **"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.

  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.

  The cross-instance path itself was already proven: `presenceCrossInstance.integration.test.ts` runs two `attachPresenceBus` instances against a real Redis broker.
- **@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.

  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.

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

  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.

  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.

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

  Two things changed.

  **A warning at subscribe time**, naming the query and the SQL:

  ```
  [voltro] reports.summary: a raw SQL read in this live query declares no
  dependsOn, so no write can invalidate it — every subscriber keeps its first
  result until it reconnects. Declare the tables it reads:
  ctx.store.raw(fragment, { dependsOn: ['orders'] }) — or on the fragment itself.
  The read: SELECT sum(total) FROM orders WHERE tenant = ?
  ```

  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.

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

  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.

  New tunable `VOLTRO_RAW_READ_TRACKING_LIMIT` (default 32) bounds how many raw reads one request records for this diagnostic.
- **@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:

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

  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.

  Nothing else moved: a non-global RegExp is used as-is, and string / array matching is untouched.

  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.
- **@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.

  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.

  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.

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

  **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.
- **@voltro/database, @voltro/data-transfer, @voltro/cli** — `voltro data restore` no longer refuses because *some* voltro is running on the machine — it compares databases.

  ```
  DB_URL=mysql://root:root@127.0.0.1:3399/agile_work_buddy voltro data restore ./backup .
    ✗ refusing to write into a target with a LIVE instance
      (AgileWorkBuddyApi (http://localhost:4000)).
  ```

  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.

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

  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.

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

  The refusal also names both sides now. One that named only the instance is why `--allow-live` became a reflex.
- **@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.

  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:

  ```
  18:41:23  startup: schedule-claims-retention: _voltro_schedule_claims bounded to 1h
  18:41:24  retention: · _voltro_schedule_claims: every row older than 30d by claimedAt
  ```

  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.

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

  **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:

  ```
    ! _voltro_schedule_claims: two registrations — kept the app's 1h,
      dropped the framework's 30d. An app registration wins over a plugin's,
      and a plugin's over a framework default.
  ```

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

  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.
- **@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.

  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.

  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.
- **@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.

  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.

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

  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.
- **@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.

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

  The same coercion ran on create and replace, so `POST`/`PUT` with `"active":"False"` provisioned an ACTIVE user.

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

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

  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.
- **@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.

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

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

  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.

  No framework-table change and no codemod: the additions are new exports and a new optional field.
- **@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.

  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.

  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.
- **@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.

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

  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.

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

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

  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.

  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.

  **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.
- **@voltro/client** — `useSubscription` no longer fetches the OLD procedure after a component switches its `rpcTag`.

  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.

  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.

  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.

  `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.
- **@voltro/cli** — `_voltro_undo_log`, `_voltro_workflow_admissions` and `_voltro_workflow_start_contexts` are bounded — three more tables nothing ever deleted from.

  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:

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

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

  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.

  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.

  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.
- **@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.

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

  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.
- **@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.

  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.

  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.
- **@voltro/cli, @voltro/client** — Stop emitting the removed `messages.queries` surface from the codegen and the client type.

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

  No user action: `queries` had no send path, so nothing could have called it.

### Internal (no consumer-facing effect)

- **@voltro/cli** — `bootPathParity.test.ts` gains four rules, and — more usefully — a written account of what it still cannot see.

  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:

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

  Two corrections to what was already there:

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

  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.

  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`.
- **@voltro/cli** — `voltro serve` reads `DORMANCY_WAKEUP_TENANT` instead of writing `'default'`.

  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.

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

  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.

  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.
- **@voltro/testing** — `makeVoltroTestClient`'s fake runtime is exported as `FAKE_RUNTIME`.

  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`.
- **@voltro/database, @voltro/runtime** — Two scale questions answered with measurements rather than code, plus the two scripts that re-derive them.

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

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

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

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

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

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

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

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

  - **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).
- **@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.

  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.

  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.

  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.
- **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.
- **@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.**

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

  `scripts/check-stale-task-comments.mjs` (CI + `pnpm gate`, `--selftest` first) checks the half a machine can decide — a comment citing an EXTERNAL RECORD:

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

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

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

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

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

  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.

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

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

  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.

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

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

---

## [0.33.0] — 2026-08-11

### ⚠ BREAKING

- **@voltro/protocol, @voltro/runtime, @voltro/voltro** — `CoordinatedScheduleHandle` gained `wake()`, `currentIntervalMs()` and `isArmed()`.

  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:

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

  (The plugin-facing `scheduleCoordinated` also gained an OPTIONAL fourth argument, `{ disarmWhenIdle }` — additive, and how a plugin opts its own task out of polling entirely.)

  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.

  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.
- **@voltro/runtime** — `@effect/opentelemetry` is now an **optional peer** of `@voltro/runtime` instead of a dependency. **If you export traces or metrics, install it:**

  ```sh
  pnpm add @effect/opentelemetry
  ```

  If you do not (no `FRAMEWORK_TRACING`, no `FRAMEWORK_METRICS`, no `OTEL_EXPORTER_OTLP_*`), nothing changes and your install gets 24 lines quieter.

  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.

  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."*

  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.

  **`voltro update` carries you across this** — codemod `0.33.0/01_opentelemetry-optional-peer`.

### Added

- **@voltro/cli** — `voltro agents-md` now reports which `@voltro/cli` it seeded from, and warns when that is not the one the project installs.

  ```
  seeded from @voltro/cli 0.31.0 (project has 0.32.0; whats-new describes 0.31.0;
    modules COPIED into ./agent-docs)
  WARN the `voltro` binary that ran is 0.31.0, but this project installs 0.32.0 —
    everything just written describes the OLDER version.
  ```

  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.

  It does not refuse and does not pick a cli for you: running the workspace binary against a checkout is legitimate and common.
- **@voltro/cli** — `voltro db encrypt-column <table>.<column>` — the data migration `.encrypted()` always needed.

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

  ```sh
  voltro db encrypt-column integrations.webhookSecret --dry-run
  voltro db encrypt-column integrations.webhookSecret employees.meilisearchKey --yes
  ```

  Five guards, each for a way a naive version succeeds and destroys data:

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

  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.

### Changed

- **@voltro/runtime** — `_voltro_schedule_claims` swaps its `(scheduleName, bucket)` index for `(scheduleName, claimedAt)`.

  A consumer read `pg_stat_user_indexes` on their live table and measured, over its whole lifetime:

  ```
  _voltro_schedule_claims_pkey                   348 978 scans
  _voltro_schedule_claims_claimedAt_idx            3 949
  _voltro_schedule_claims_scheduleName_bucket_idx      4
  ```

  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.

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

  No codemod: a `_voltro_*` change rides the declarative differ on `voltro db apply` and on a `voltro dev` boot, on every dialect.

  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.

### Fixed

- **@voltro/database** — A column ADDED with a `reference()` now gets its foreign key in the same plan.

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

  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.

  Both callers share one `addForeignKeyOps` builder now, and the existing dependency tiering already orders `add-column` before `add-foreign-key`.
- **@voltro/runtime, @voltro/protocol, @voltro/workflow, @voltro/cli** — A coordinated tick is a FLOOR now, and a claim no longer outlives its bucket.

  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.

  **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:

  ```
  voltro.ai.inference          1 259 rows/h    (250 ms ticks)   framework
  voltro.workflow.admission    1 247 rows/h    (1 s ticks)      framework
  their own eight schedules       18 rows/h
  ```

  99.3 % of the ledger was the framework polling two structurally empty queues. A fixed interval has no way to learn that, so:

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

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

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

  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.
- **@voltro/logger** — The pretty log format now prints a nested `Error`'s `message`. It did not, and the JSON format did.

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

  `voltro dev` prints the pretty format. What a consumer saw when their boot died on a saturated pooler:

  ```
  auto-migrate failed — aborting boot
  err={"failure":{"cause":{"length":117,…,"code":"XX000"},"message":"PgClient: Failed to connect"}}
  ```

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

  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.
- **@voltro/cli** — Every `@voltro/*` package now exports its own `package.json`, so `require('@voltro/cli/package.json').version` works.

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

  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.
- **@voltro/cli, @voltro/sql-postgres** — The `db pool:` boot line now counts the connections this process holds OUTSIDE the pool, and names them.

  It reported `max × replicas` and called that the connection count. A consumer sizing a per-pod budget against a pooler measured the gap:

  > `LISTEN` läuft außerhalb von `dbMaxConnections` (eine pro Pod, gemessen sogar > 3). Der echte Bedarf ist `dbMaxConnections + 1`.

  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:

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

  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.

  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.
- **@voltro/cli** — The retention sweep is registered on every dialect — it was postgres-only, and silently.

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

  ```
  _voltro_schedule_claims   109 520 rows   32.8 MB   over 20 days
  _voltro_schedule_runs      17 507 rows    7.4 MB
  ```

  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.

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

  Two things came out with it:

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

  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.
- **@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*.

  A consumer with correctly-redacting plugins got:

  ```
  ✗  _voltro_row_history.data — 69 of 149 row(s) match a credential-shaped key
         matched (rows per needle, may overlap): token (69)
  ```

  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.

  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.

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

  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.
- **@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.

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

  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.

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

  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`.
- **@voltro/runtime** — A coordinated periodic task armed below one second now runs at the interval it was given.

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

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

  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.

  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.

  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.

### Internal (no consumer-facing effect)

- **@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.

  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.

---

## [0.32.0] — 2026-08-10

### ⚠ BREAKING

- **@voltro/plugin-audit** — **`auditPlugin` stored what a call RETURNED, verbatim, with no option to reach it. `redactOutcome` now exists and defaults to `'all'`.**

  Found on the first run of `voltro db scan-credentials` after we widened its columns, against a real database:

  ```
  ✗  _voltro_audit_log.outcome — 9 of 263 row(s) match a credential-shaped key
  ```

  Four were `webhooks.create` rows carrying a live 64-character `signingSecret` in full. The reporter had BOTH existing options on — `redactInput: 'all'`, `redactSubject: 'metadata'` — and there was no third to reach this field with.

  **The option that existed covers the field these calls leave empty.** `redactInput`'s own docstring names "an API key at issuance" as its motivating case, and for a credential-ISSUING call the secret is never in the input:

  ```ts
  apiKeys.createPersonalApiKey({ name, scopes })  // input: nothing sensitive
    → { keyValue: '<the plaintext key>' }          // outcome: the whole point
  webhooks.create({ url, subscribedEvents })      // input: nothing sensitive
    → { signingSecret: '<live secret>' }           // outcome: returned once, by design
  ```

  MIGRATION: `outcome.value` and `outcome.error` become `{ __redacted: 'all' }`. The error's `_tag` SURVIVES — a trail recording "something failed" without saying what is not a trail, and a tag is a schema-declared discriminant that structurally cannot be a secret. `kind` and `durationMs` are untouched, the caller still receives the real result, and a `record` predicate still sees the live outcome. Set `redactOutcome: 'none'` to keep the old behaviour.

  Also documented: a FUNCTION sink gets neither the `_voltro_audit_log` table nor the retention sweep — both are gated on `sink` being the literal `'datastore'`. A function that redacts and delegates to `dataStoreAuditSink` still writes rows on a database where the table exists, while creating it nowhere and arming the TTL nowhere. It works where you tested it and fails on the next fresh database. `redactOutcome` removes the reason to reach for that composition; the docstring now names the cliff for anyone who reaches for it anyway.

  **`voltro update` carries you across this** — codemod `0.32.0/01_audit-redacts-outcome`.

### Added

- **@voltro/cli** — **`voltro doctor` said nothing when the unknown-scope rule did not run, which read exactly like a clean result.**

  The rule needs a declared scope vocabulary — with `@voltro/plugin-rbac` that is the union of its `roles` map. An app doing RBAC without the plugin (roles as plain literal arrays in `lib/teamRoles.ts`) publishes none, so the rule stays quiet. A consumer confirmed it empirically: doctor reports **0** unknown-scope findings on the tree that contains the exact bug the rule was built for — `webhooks.test` guarded by `webhooks:test` while no role grants it. Silence read as coverage.

  Two things now:

  - **The dormant state SAYS it is dormant**, in the human report and in `--json` (`evaluated: false`, which used to be an empty findings array indistinguishable from a clean app). It ends with the sentence that matters: this section is not a clean bill of health. - **An app can point the rule at its own vocabulary**, which the consumer proposed and which doctor now reads:

    ```ts
    // app.config.ts
    doctor: { scopeVocabulary: './lib/teamRoles.ts#ALL_TEAM_SCOPES' }
    ```

  Deliberately a plain read of an exported string array. Anything cleverer (evaluating a roles map, following a builder) fails differently per app and lands back at "quiet for reasons you cannot see".

### Changed

- **@voltro/cli** — **`voltro db scan-credentials` now says its name set is a heuristic, so a clean run stops reading as an all-clear.**

  Reported with the case that proves it: on the same table, in the same column, the scan found `signingSecret` and missed `keyValue` — the plaintext API key an `apiKeys.*` mutation returns. `keyValue` matches none of the needles and never will; no name list covers every convention an app can invent.

  Not a defect, a heuristic being a heuristic — and the reporter's framing is the fix: *"one line in the output saying the name set IS a heuristic would stop a clean run reading as an all-clear."* The report ends with what a clean result actually means: no credential-SHAPED key found, which is not the same as no credential.
- **@voltro/cli** — **`voltro db scan-credentials` reported a hit count without saying which key matched, under advice that could not be carried out or argued with.**

  The output was `69 of 149 row(s) match a credential-shaped key` followed by `Purge them AND rotate the credentials`. Purge what? Rotate which credential? And a column mentioning the word `token` in prose reads identically to one holding a live one — the reporter had both kinds in the same column and no way to separate them from the output.

  Each hit line now names the needles and their row counts — which OVERLAP and
  do not sum to the hit count, because a row holding both a token and a secret is
  counted by both, and two numbers printed under a total otherwise invite being
  added up:

  ```
  ✗  _voltro_audit_log.outcome — 69 of 149 row(s) match a credential-shaped key
         matched (rows per needle, may overlap): token (61), secret (12)
  ```

  One extra COUNT per needle, taken only for a target that already matched — so the cost lands exactly where somebody is about to do work and nowhere else.

### Fixed

- **@voltro/cli** — **`voltro update --dry-run` could never preview a jump's codemods — the manifest it reads has never been published.** Checked against the registry rather than inferred: `@voltro/cli` at 0.25.0, 0.29.0, 0.30.2 and 0.31.0 all ship no `voltro` field at all, while the repo's own `package.json` carries 65 entries.

  `scripts/prepare-publish.mjs`'s `cleanManifest` builds a fresh publish manifest field by field rather than deleting from a copy — an allowlist by construction — so a new top-level key is dropped in silence and nothing downstream mentions it.

  **What this did NOT cost, stated because the alarming reading is the wrong one:** no user has ever missed a codemod that should have run. The manifest feeds the PREVIEW only; the actual run happens after the install, out of the target CLI's own registry, which is present by then. And the missing case was already handled loudly — the preview printed *"could not preview … This is NOT the same as 'no codemods'"* rather than an confident "none". An honest "I could not look" for four releases, where the feature was built to look.

  The fix carries `voltro` through, and the guard that goes with it is the part worth keeping: `codemodManifest.test.ts` asserts the REPO's package.json carries every codemod, and it was green on every one of those releases. A source-reading guard cannot see what the publish pipeline does downstream of it. So the assertion is made against the STAGED file, read back off disk after it is written, and it runs inside `prepare-publish` itself — which the gate's `Pack + verify` step already invokes, so it needs no separate wiring.

  Red-verified by removing the one-line fix and re-running: the staged manifest comes back with NO entries against the source's 65, and publishing aborts.
- **@voltro/cli** — **`voltro dev` read the COMPILED config, so editing `app.config.ts` had no effect in any app that had ever run `voltro build`.**

  `loadConfig` preferred `.framework/dist/server/appConfig.js` whenever it existed — a build output, i.e. whatever the config said the last time somebody built. The comment on that branch said the `.ts` source is the fallback "for dev", but the condition was `existsSync`, which cannot know which command is running.

  Measured on the fixture: `app.config.ts` set to `locales: ['de','en'], defaultLocale: 'de'`, `voltro dev` booted, SSR response `<html lang="en">` — the value from a build hours earlier. Neither the declared default nor the first declared locale reached the render.

  Found while reproducing a consumer's report that a declared `locales:` did not affect `<html lang>` in dev. An app that has never built is unaffected, which is why this survived.

  **And the second half of the same report: dev never negotiated `Accept-Language` in resolver-only mode.** `makeSsrI18nResolver` returns `() => undefined` for an empty wrap set — right for the PROVIDER, wrong for the locale — so the caller fell through to `cookie ?? defaultLocale`. `voltro build` had already grown the resolver-only runtime; dev had not. The same dev/build drift as the release before, one release later, in the opposite direction.

  Measured after the fix, all three signals: no headers → the declared default; `Accept-Language: en` → `en`; cookie beats `Accept-Language`. `ssrIntlDiagnostic` is gated on a REAL provider now, so resolver-only mode does not claim one was supplied.
- **@voltro/cli** — **The duplicate-version check read the pnpm store, so it fired for everyone on the release right after they upgraded.**

  Last release taught it to see copies a walk from the app root cannot reach — a second version pulled in by a SIBLING workspace package. It did that by reading the pnpm virtual store directly, and the store also holds every version pnpm ever unpacked. On a real tree, one release later:

  ```
  @voltro/cli — 0.29.0, 0.30.0, 0.30.1, 0.30.2, 0.31.0
  ```

  …while EVERY `@voltro/*` in that workspace was linked at exactly 0.31.0. The extras were residue: nothing links them, `pnpm store prune` removes them, they cannot be loaded. The reporter's summary is the right one — *"the rule moved from blind-to-the-real-case to noisy-on-every-upgrade, and both endings are the same: the reader stops looking."* This ending is the worse of the two, because it fires for everyone at exactly the moment they are reading the output.

  **A package manager's cache is not a statement about the program.** It counts what is LINKED now: one `package.json` per (workspace package × framework package), derived from `pnpm-workspace.yaml` or the conventional `packages/*` / `apps/*` layout. That is cheaper than the store walk it replaces, free of residue, and still finds the sibling case it was widened for.
- **@voltro/cli** — **The declared-event scan searched for the event NAME, so it missed every app following the typed path — 19 findings, 0 real.**

  `emit` is typed `(event: string | OutgoingEventDescriptor<P> | DeclaredEventLike, …)`, and the DESCRIPTOR overload is the type-safe one — the path the schema-decode behaviour rewards. An app on it writes `emitEvent(ctx, apiKeyCreated, …)` and never repeats the name string anywhere near the emit.

  So the rule found apps that emit by string literal and missed apps that emit by descriptor, which is backwards: the second group is the one doing it properly. The ADVISORY caveat we shipped was exact and useless — it said "an emit through a variable will read as missing here", and the variable IS the idiomatic call.

  It resolves the binding now. The exported const and the `name:` are in ONE statement (`export const apiKeyCreated = defineEvent({ name: 'apiKey.created' … })`), so this is a local read of the declaring file rather than a resolution. Both spellings count as an emit. The caveat is narrowed to what actually remains invisible: a name assembled at runtime.

  Reported with a working reference implementation attached, which is where the three lines came from.
- **@voltro/sql-postgres** — **A query with no free pooled connection waited forever, with no error and no log line.**

  `new pg.Pool({...})` was constructed without `connectionTimeoutMillis`, and node-postgres defaults it to `0` — wait indefinitely. Reported as: *"ein Query ohne freie Verbindung wartet unbegrenzt, ohne Fehler und ohne Logzeile."*

  The distinction that makes this worth a default: `statementTimeoutMs` bounds a query the SERVER is running; nothing bounded a query the CLIENT had not sent yet. Those are the two halves of "a request is stuck", and only one was covered.

  Defaults to 10 s (`DEFAULT_ACQUIRE_TIMEOUT_MS`), overridable per connection with `acquireTimeoutMs`; `0` restores the driver's unbounded wait. A bounded failure is more useful than an unbounded wait even when the pool would have freed up: it names the pool as the cause at the moment it IS the cause, instead of surfacing as unexplained latency somewhere with no connection information in it.

  **Also documented, from the same report:** with `changeStrategy: 'cdc'` a process needs `maxConnections + 1`. The LISTEN consumer cannot use a pooled connection, so `@effect/sql-pg` opens a standalone `new Pg.Client(pool.options)` that is outside `max` and outside every number derived from it. A per-pod budget built on `maxConnections` is short by exactly one, which surfaces as the last pod of a rollout failing to connect rather than as a pool warning.
- **@voltro/cli** — **Shipping `whats-new.md` for the right release depended on a human remembering a step between two other steps.**

  The module is generated from the top CHANGELOG section, and the command that writes a new one (`changelog-release.mjs --release`) did not regenerate it. So whether the published guide described the release you just installed came down to whether someone ran the generator in the window between the roll and the publish.

  Measured across the published tarballs rather than asserted:

  | `@voltro/cli` | ships | |---|---| | 0.28.0 | `# What's new in 0.27.0` | | 0.29.0 – 0.31.0 | each names its own version |

  So it has gone wrong once in the last six, not every time — and the four that are right are right because a human did the step, which is exactly the property being removed here. One in six is not a small number for a module whose whole job is *"read this FIRST when a task touches an area you have not worked in recently"*: on that release it was the one piece of the shipped guide guaranteed to be wrong about the version the reader had just installed. And it fails in the direction that reads as fine — a real version, described correctly, just not theirs.

  The roll regenerates now, in the same command, rather than as a checklist line: a step a human must remember between two others is the step that gets skipped on the release nobody is watching. A regeneration failure is loud and non-fatal, and `check-whats-new-version.mjs` (which already gates this in static-checks) stays as the backstop for the paths that bypass the roll.

  **If you are looking at a stale `whats-new.md` in your own project, this is probably not the cause.** The per-project copy is seeded once and never overwritten on boot — it refreshes only on `voltro agents-md --force`. That file being behind is the seeder's documented behaviour, not this defect, and no framework release fixes it for you.
- **@voltro/cli** — **The retention sweep was armed, announced at every boot, and never ran in a process that restarted more often than every five minutes.**

  It was `setInterval(sweepAll, 5 * 60_000)` with no initial run, so the first sweep was always five minutes away. `voltro dev` restarts on every file save, and at least one consumer runs `voltro dev` as their deployment: their `_voltro_schedule_claims` reached **86 214 rows / 33 MB** with the policy registered, and the boot printing `retention: N policy(ies) armed — rows older than the TTL are DELETED` every time.

  That is this file's own lesson one turn further in. We fixed "a standing delete that never introduces itself" and shipped a standing delete that introduces itself and then does not run.

  The first sweep now fires 30 s after boot, then on the interval. The delay is a compromise with the failures on either side: zero would put a multi-table DELETE in front of the first request of every boot, five minutes is what produced the report. The timer is `unref`'d, so it never holds a process open on its own.
- **@voltro/cli** — **`voltro dev` reported `engine: "in-memory"` while running a cluster engine, and a consumer built a diagnosis on it.**

  The line derived its label from `store === 'postgres' ? 'cluster-postgres' : 'in-memory'` — so mariadb, mysql, sqlite and mssql all printed `in-memory` while the block directly above had just built `cluster-sql` for exactly those stores. The boot therefore printed two lines that contradicted each other:

  ```
  workflow engine: cluster-sql, dialect=mariadb
  workflow engine selected · engine: "in-memory"
  ```

  A consumer read the second — later, more definitive-sounding — and concluded that `voltro dev` runs workflows on a different engine than a deployment does. That is a fair reading of a line that is simply false, and it sent them down the wrong half of an investigation into why their outgoing webhooks delivered in dev and produced zero rows under `voltro serve`.

  The label is now decided where the engine is chosen. A log line is an assertion the framework makes about itself, and this one had no reader that could catch it: it is not typed against the layer it describes, and nothing asserted the pair agreed.

  **Both boot paths also report WHICH workflow entity types they registered.** `API listening … workflowWorkers: 0` counts app-exported worker LAYERS — an advanced surface almost no app uses — and reads as "nothing consumes durable work here", which is the question the consumer could then answer from no other line. A delivery that fails with `Entity type 'Workflow/voltro.deliverWebhook' not registered` and one that never starts look identical from outside; this separates them before anyone reproduces anything.

### Internal (no consumer-facing effect)

- **@voltro/database** — The count of `descriptor.order` read sites is ASSERTED rather than stated: the prose said ELEVEN and there are twelve (three in `sqlCompiler.ts`, nine in `jsonEagerCompiler.ts`). Written from memory of a replacement run instead of from the file — the exact mistake two consumer rounds have been about, made inside the document describing it.

  `queryDescriptorOrderAbsent.test.ts` now pins `{ 'sqlCompiler.ts': 3, 'jsonEagerCompiler.ts': 9 }`, so a compiler growing or losing a read site fails the suite instead of drifting quietly into a number somebody quotes. No behaviour change.

---

## [0.31.0] — 2026-08-10

### Added

- **@voltro/i18n, @voltro/cli** — **`LOCALE_COOKIE` and `THEME_COOKIE` are exported from `@voltro/i18n`.**

  `voltro doctor` flags a hand-written `'voltro:locale'` and tells you to "import the constant … from `@voltro/ui-shadcn`" — which was the only package exporting one. An app on the framework's i18n and not on the shadcn kit could not follow that advice without adopting a UI kit for two strings. Reported by a consumer, who added that the rule "does not fire for us, and we think that is correct-by-accident".

  The names live in `@voltro/i18n` now (the package that resolves the locale from a request already hardcoded the literal), doctor's remedy names it, and `cookieNameParity.test.ts` asserts every declaration in the repo agrees AND that the remedy names a package which actually exports what it names.

  That second assertion is one `scripts/check-message-apis.mjs` structurally cannot make: it verifies a member EXISTS in the published surface, and `LOCALE_COOKIE` always did — just not anywhere the reader could import it from. A reachability claim needs its own check.
- **@voltro/cli** — **`voltro doctor` reports two things that were decidable and unreported: dead endpoints and never-fired events.**

  **A procedure requiring a scope no declared role grants.** A consumer shipped `webhooks.test` guarded by `webhooks:test` while their roles granted `webhooks:read` and `webhooks:write` and nothing else — uncallable by every user in every team, with nothing failing at boot to say so — and asked us to build the rule. The rule already existed: `rbac/unknown-scope`, in `voltro check`. It was in the wrong PLACE for them. `check` is the CI gate; `doctor` is what someone runs when something feels wrong, and a rule that only fires where you already suspect the problem fires for the people who did not need it. Doctor now surfaces the same finding from the same function — reused, not re-derived, because two answers to "which scope is ungranted" would diverge on the day it mattered.

  **A declared event with no emit call site.** Asked for as a dashboard column, with the number that motivated it: seven of eleven advertised events had no emit anywhere, a week of work to remove. From a partner's side that is the expensive failure — they write a subscriber, test it, see nothing, and cannot tell "not implemented" from "my endpoint is broken". It is source-decidable, so it lands before deploy for every app rather than for whoever opens a panel afterwards. ADVISORY, and it says so: an emit through a variable reads as missing here.

  Both reach `--json` and the human report. `serverOnly` and `authz` were each in one and absent from the other, twice, and "absent" read as "nothing to report".
- **@voltro/cli** — **`voltro dev` and `voltro doctor` report `@voltro/*` packages that are not on the same release.**

  A consumer bumped 34 packages to 0.30.2 and `@voltro/i18n` stayed on 0.30.0 — their bump script's pattern was `@voltro/[a-z-]+`, and `i18n` contains digits. Their point was not the typo:

  > *"Der erwähnenswerte Teil ist, dass eine Versionsschiefe innerhalb einer > Release-Familie nirgends auffällt: tsc, Tests, doctor und drei Builds waren > mit dem 0.30.0-Paket im Baum alle grün. Wenn ihr eine Stelle habt, an der das > billig zu prüfen wäre, wäre eine Warnung dort mehr wert als eine perfekte > Fehlermeldung anderswo."*

  `tsc` in four apps, 19 317 tests, `voltro doctor` exit 0, three production builds — all green with a package two releases behind. None of those checks is ABOUT version agreement, so none of them could have caught it.

  The framework ships in LOCKSTEP (`prepare-publish.mjs` stamps one version across every package), which is what makes this exact rather than a compatibility guess: two versions in one tree is a state the release process cannot produce. Reported at dev boot — where they asked for it, because that is where it is cheap — and in doctor's human report and `--json`. WARN, never fatal: we have no evidence a skewed tree cannot work, only that the combination was never released as a set.

  Distinct from the duplicate-instance check, which asks a different question ("is one package resolved at two versions?") and has a different fix.

### Fixed

- **@voltro/cli** — **`voltro db scan-credentials` on 0.30.0–0.30.2 did not look at the column credentials are in, and exited 0.**

  0.30.0 fixed a crash — the scan asked every table for a column called `subject`, `_voltro_row_history` has none, and the command died on postgres with `code=42703`. The fix replaced `subject` with `subjectId` on BOTH tables. That is right for one table and wrong for the other: `_voltro_audit_log.subject` is a `json()` blob and is the exact column the 0.28.0 upgrade note named — the one a reporting team found 117 rows of `jiraToken` in. `subjectId` is a flat opaque id that cannot hold a credential.

  So for three releases a security command ran cleanly, printed a scanned count beside a hit count, and had never looked where credentials are. **A clean answer from a scan that searched the wrong place is worse than the crash it replaced.**

  The default targets are derived from where a credential can physically land now: every `json()` column on `_voltro_audit_log` (`subject`, `actor`, `scope`, `metadata`, plus `input` / `outcome` — a procedure's arguments and result, the likeliest accidental home for a token), and `data` on `_voltro_row_history` — the full-row snapshot, which carries a credential column from ANY user table and outlives deleting the source row. The test that signed off on the narrowing is derived from the table declarations now, so it fails in both directions: a target naming a column that does not exist, and a column that exists and goes unscanned.

  A manual codemod under 0.31.0 tells anyone who ran the command on 0.30.x to run it again — a codemod note is delivered once at a version boundary and cannot be revised, so a correction has to be re-issued under a version nobody has reached.
- **@voltro/cli** — **`voltro dev` announced `ready` while a different process served its port.**

  Reported as an aside — "it fails with `Port 5190 is already in use` and the OLD process keeps answering" — and measured to be worse than that. With a squatter on IPv4 `127.0.0.1:5299`, vite's `host: true` bound IPv6 `*:5299` SUCCESSFULLY and the boot printed `listening on http://localhost:5299` and `ready in 56 ms`. Two listeners, one port, different stacks; every request went to the old process, with the old module graph. Not a failed boot — a boot that reports ready while your edits do nothing.

  `strictPort: true` was set and working: it asks "can I bind?", and the failure is "is somebody already serving this?", which on a dual-stack host is a different question with a different answer. Both dev boot paths (api and web) now CONNECT to `127.0.0.1` and `::1` before creating a server and refuse with a `bootRefusal` naming the port, the stack, and `lsof -nP -iTCP:<port> -sTCP:LISTEN`. Exit 1, no `ready` line. Deliberately dev-only: in production the ambiguous cases are real (a sidecar, a health proxy, a rolling restart sharing a namespace) and refusing a legitimate boot is the worse failure — `serve` keeps failing on the bind alone.
- **@voltro/cli** — **`voltro doctor`'s duplicate-version check could not see the tree the consumer who asked for it actually had.**

  They shipped the app on `@voltro/i18n@0.30.1` while `packages/ui-admin` and `ui-admin-shared` — which depend on it themselves — were left on 0.30.0. Two physical copies of a package carrying a React context; nothing failed at boot, no test caught it, and the framework's own dev-SSR diagnosis names that condition as a cause. They asked us to build the check. **It existed, and it was blind to their case.**

  It walked the app's `node_modules` plus every ancestor's, on the reasoning that the pnpm virtual store is reached through the symlinks those contain. True for one copy: an ancestor's `node_modules/@voltro/i18n` is ONE symlink to ONE version. A second version pulled in by a SIBLING workspace package is linked only from that sibling's own `node_modules`, which is neither the app root nor an ancestor of it — so it was structurally invisible from the directory doctor is run in.

  The pnpm store is read directly now, from its directory NAMES (`@voltro+i18n@0.30.0`), which is one `readdir` rather than a descent into thousands of inner `node_modules`. The peer-hash suffix pnpm appends is not read as a version — one package linked twice for two peer sets is normal, and reporting it would train people to ignore the rule.
- **@voltro/database, @voltro/sql-mysql** — **`json().default([])` emitted MySQL-only DDL and died mid-plan on MariaDB.**

  The emitter had one branch for both servers, under a comment asserting MariaDB 10.2.7+ accepts the MySQL form. It does not. Measured against the real servers:

  | | statement | result | |---|---|---| | MariaDB 11.8.8 | `SET DEFAULT (CAST('[]' AS JSON))` | `ERROR 1064 … near 'JSON))'` | | MariaDB 11.8.8 | `SET DEFAULT ('[]')` | OK | | MySQL 8.4.10 | `SET DEFAULT ('[]')` | `ERROR 1101 … can't have a default value` | | MySQL 8.4.10 | `SET DEFAULT (CAST('[]' AS JSON))` | OK | | BOTH | `SET DEFAULT (CONVERT('[]' USING utf8mb4))` | OK |

  MariaDB has no JSON *type* — the column is `LONGTEXT` with `CHECK (json_valid(...))` — so `AS JSON` is not a cast target it has; MySQL treats a parenthesised literal as a literal and demands a real expression. The failure was not graceful: the plan died mid-apply, leaving a boot migration part-applied.

  `CONVERT(… USING utf8mb4)` is emitted for both, chosen over splitting the branch because the declarative applier cannot tell the two servers apart — `sql.onDialectOrElse` collapses the family to one token — so a per-server answer would have meant plumbing the real dialect through three call sites. Both forms introspect back to something `normalizeDefault` unwraps, so the next plan is EMPTY instead of re-emitting the same op forever.

  `sql-mysql/src/jsonColumnDefault.integration.test.ts` boots BOTH servers, applies the schema and asserts the inserted row carries the default — verified red against the old emitter (`ER_PARSE_ERROR 1064`) before it was made green.
- **@voltro/cli** — **`locales:` declared the app's languages in `voltro dev` and demanded catalogs in `voltro build` — so an app on its own i18n stack got the right language in development and `lang="en"` in production.**

  `voltro dev` already separates the two: the catalogs decide the CATALOG wiring, `locales:` decides the language facts (`<html lang>`, the resolved locale, `meta.locale`). The build did not, which is this repo's dev/build drift in its worse direction — the environment nobody watches, on the day of the first deploy.

  Reported by a consumer on react-i18next, who left `locales:` out because it demanded `src/locales/<lang>.ts` they do not have, and got `<html lang="en">` plus `locale: 'en'` handed to every page's `meta` in a German app:

  > *"Der Parameter `locale` ist damit für uns nicht nur nutzlos, sondern > gefährlich: hätten wir ihm vertraut, wäre nach der Hydration jeder deutsche > Tab englisch geworden."*

  A value the framework hands you that is confidently wrong is worse than one it withholds.

  The build now emits a resolver-only i18n runtime when `locales:` is declared with no catalogs: `resolveLocale` (cookie > `Accept-Language` > default, negotiated against the declared set) with an identity `outerWrap`. No provider, no React, no catalog imports — and `voltro start` sets `<html lang>` and `meta.locale` from the real negotiation instead of falling back to `'en'`.
- **@voltro/cli** — **A plugin's depth was addressed as a 1 KB README that says "read the website" — a dead end for an agent working inside a repo.**

  We spent a round correcting a documented recommendation (`resolveSubjectId`) that the one team who had lived it had retracted. That consumer went looking for the correction in the channel our own `AGENTS.md` advertises as authoritative:

  ```
  resolveSubjectId   → 0 Treffer
  | notifications | node_modules/@voltro/plugin-notifications/README.md |
  ```

  > *"Diese Datei ist 1 039 Byte … Für einen Menschen ist das ein Klick. Für einen > Agenten, der im Repo arbeitet, ist es eine Sackgasse."*

  Their measurement was slightly off — the correction WAS in `agent-docs/`, in `whats-new.md` — and the truth is worse than their reading. `whats-new.md` is regenerated from the LATEST release section only, so the lesson would have vanished at 0.31.0 regardless. Same structure as a codemod note: delivered once, at a boundary, unrevisable. **A durable lesson needs a durable address.**

  Each plugin's docs page is now compiled into `agent-docs/plugins/<slug>.md` and the index points there first, README second — in the generated template AND in the per-project file the seeder writes, which is the one users actually read. The per-plugin modules stay out of the TOPIC table on purpose: 42 rows all titled "Plugins" would be a worse index than the dead end it replaces.
- **@voltro/database, @voltro/plugin-webhooks, @voltro/runtime** — **A `QueryDescriptor` without `order` took the SELECT compiler down, and the framework reported it as a database failure.**

  {"msg":"mutation.webhooks.test failed: e.order is not iterable", "dbCauseChain":"TypeError: e.order is not iterable"}

  `dbCauseChain` sent the reporter to the connection, the driver and the dialect. The database was never involved — the SELECT was never built. Nothing they could write in app code reached it either: the descriptor is constructed inside `@voltro/plugin-webhooks`, so `ctx.webhooks.testTarget(...)` was unusable and the outgoing-webhook path could not be exercised end to end.

  The field is DECLARED required, which is why this looked impossible. A descriptor is hand-built at ~130 sites and nearly all of them cast (`as never`) past the generic row type — and a cast switches the required-field check off for the whole literal. Four sites shipped `order: undefined`.

  Fixed as ONE read path (`orderClausesOf`) rather than a guard at the reported line: `order` was dereferenced bare in TWELVE places across two compilers (three in `sqlCompiler.ts`, nine in `jsonEagerCompiler.ts`), so patching the crash site would have moved it rather than removed it. The four lying call sites are corrected, and a source scan fails on a fifth.
- **@voltro/cli, @voltro/i18n** — **Dev SSR could build an `<I18nProvider>` the app's own `useT()` could not see, and the diagnosis we shipped pointed at a check that cannot observe the cause.**

  `ssr.noExternal: ['react-intl']` without `@voltro/i18n` is itself an instance splitter: Vite externalizes a node_modules dependency in SSR, so Node — not Vite — resolves everything IT imports. An externalized `@voltro/i18n` therefore got Node's react-intl while every Vite-transformed module got the bundled one. Two instances, two contexts, and a provider invisible to a `useT()` with an error byte-identical to having no provider at all. Both are now bundled and deduped together in `voltro dev`, `voltro build` and `voltro start`, pinned by `i18nSingleInstance.test.ts` across all three configs.

  The framework's diagnosis told readers to check with `pnpm ls -r --depth 10 @voltro/i18n react-intl`, and to report a framework bug if that showed one version of each. A consumer did exactly that — one version, one directory in the store, four fresh boots — and was still at a 500 on every SSR page. **The criterion was the defect: `pnpm ls` enumerates versions ON DISK and the failure is module INSTANCES in a process.** One file reached down two paths is two instances, and no package manager can see it. `@voltro/i18n` now registers each of its evaluations with the identity of the react-intl it is bound to, and the diagnosis reports the counts and the paths from the process where the render failed. When it counts one of each, it says the framework is at fault instead of making that conditional on a check that could not come back false.

  The transferable half: an instruction to VERIFY has to be able to return false for the cause you are diagnosing. A check that cannot turns "I don't know" into "the framework is at fault", and the reader stops looking.
- **@voltro/plugin-webhooks** — **A webhook routing filter's operators were ANDed in the documentation and first-match-wins in the matcher, so the documented range OVER-matched.**

  `{ gte: 100, lt: 1000 }` — the second example on the plugin's docs page — evaluated `gte` and returned, ignoring `lt`. A `total` of 5000 satisfied a filter declared as 100–1000. That is the worse direction of wrong: an under-matching filter delivers nothing and gets noticed, an over-matching one posts a partner data their own filter says they must not receive, and nothing anywhere reports it. Every present operator must now hold, and an operator object with no recognised key (`{ gtE: 5 }`, `{}`) matches nothing rather than everything.

  `gt` / `gte` / `lt` / `lte` have always been TYPED `number | string` and compared only when both sides were numbers — so a string bound type-checked, subscribed, stored and matched nothing, which reads exactly like an event that never fired. Strings now compare lexicographically, which is what makes `{ 'payload.at': { lt: '2026-01-01' } }` work on an ISO timestamp. Mixed types still match nothing, deliberately: `'10' < 9` depends on which side JavaScript converts.

  And the doc comment above the matcher called the equality form "v1" with operators as a future possibility, while `compareValue` implemented all six twenty lines below it. A consumer read that, refused a `resourceIds` filter for the whole life of the feature, and shipped a typed `ValidationError` telling their own users it was impossible. A comment describing an intention rather than the code under it is not a smaller doc — it is a wrong one, and more expensive than none.

---

## [0.30.2] — 2026-08-09

### Changed

- **@voltro/plugin-notifications** — **The docs argued for `resolveSubjectId` using a rationale its own source had retracted.** The page justified the option with a consumer's measurement — rows belonging to people with no auth user, which employee-keying would "reach" and `subject.id` would not — and cited their number. That team then reversed the decision and wrote the correction themselves: *the subject is whatever signs in; if your addressing unit is not that, you are addressing something nobody can read.* An inbox belongs to whoever can OPEN it, and only an account can. Keying by employee never delivered those rows — it made them look addressed, and charged a translation on every read path and every push.

  So the framework was teaching, in a permanent document, a lesson that the one team who had lived it had withdrawn. That is worse than an out-of-date example: it is an argument with a measurement attached, which is the most persuasive kind and here the wrong one.

  The page and the option's docstring now lead with the caution, say which question to ask first ("can the thing I am addressing sign in?"), point at translating once at the SENDING seam, and keep `resolveSubjectId` recommended for the case it was actually built for — an app whose sign-in identity genuinely IS its own id, which is a different situation from a second identity some accounts happen to map to.

  No behaviour change; the option works exactly as before.

### Fixed

- **@voltro/cli** — **`voltro doctor --write-authz-allowlist` deleted the list it exists to protect.** Measured by a consumer against their real file, one run, nothing else in between: 2 363 lines / 2 294 debt / 5 reviewed / 2 comment blocks before, and 31 lines / 0 debt / 5 reviewed / 0 comment blocks after. `doctor` went from exit 0 to exit 1 reporting 2 294 × "no access check" — executors that had been recorded as debt and were now recorded nowhere. They restored from backup.

  The writer built the file from the scan's CURRENT `unchecked` set, and an executor already in the allowlist is classified `allowlisted`, not `unchecked` — so it appears in neither input. A first write looks perfect; the SECOND one empties the file. That hole predates the debt/reviewed split (the old writer was `formatAllowlist(res.unchecked.map(f => f.tag))`, identical shape). Rescuing `reviewed` and not debt did not cause the loss but INVERTED it: the five explained lines survived and the 2 294 unexplained ones did not, which is backwards. A reviewed line at least names a human who can be asked again; a lost debt line names nobody. The reporter's sentence is the one to keep — **the debt is the part you must not lose.**

  The writer is **additive and byte-preserving** now. The existing file is kept verbatim — entries, comments, grouping, order — and only tags it does not already contain are appended under a marker saying nothing above it was touched. Removal is no longer a capability of this command; it is a hand edit, or it happens on its own when an executor gains a guard and its line stops mattering. The reporter's comment blocks survive as a consequence of the file not being rebuilt, not as a special case.

  The message changed too, because the old one was true and told nobody: `wrote 0 debt tag(s)` becomes `+0 new, 2299 kept, 0 removed`, and a no-op says so. `removed` is reported although it is structurally always zero — a success line has to name the quantity that changed.

  Also from the same report: a credential-shaped column that is ALSO `.unique()` is now its own finding (`plaintext-secret-lookup-key`) rather than being told to use `.encrypted()`. Encryption and equality lookup are mutually exclusive unless the cipher is deterministic, so the ordinary advice would make the column unfindable; the new advice names the hash-as-lookup-key shape the framework's own `_voltro_api_keys.hashedKey` uses, and the deterministic-cipher trade. Requested by the reporter, who hit it on two of five flagged columns.
- **@voltro/cli** — **The three items the previous round left open, closed.**

  **An `app.config.ts` that cannot be imported silently produced an app with NO plugins — and a schema that proposes dropping every plugin table.** A consumer measured 517 tables becoming 506 when they added ONE import, with six `matches no declared table` warnings and a `pluginRef` FATAL naming a plugin that had nothing to do with the cause. The reader was `catch { return [] }`. That is not a smaller schema, it is a wrong one: plugins contribute their tables through `extendSchema.tables`, so zero plugins means every one of those tables reads as undeclared, and the differ plans a DROP. `dev.ts` carries a long comment about this exact class after it cost a release — written beside `loadApiConfigDiagnosed`, while this sibling reader kept the swallow, which is the argument for one reader rather than two. It throws `AppConfigLoadError` now, naming the consequence and the fact that the failure ran at config-EVALUATION time; `voltro check` reports it and continues with an explicit "this report is INCOMPLETE" rather than a silent empty list. The reporter's own caution is carried into the code: they measured the IMPORT and the CALL, not the library, so this is not about any one package — any module doing something non-trivial when first evaluated reaches it.

  **`locales:` no longer forces you to adopt the framework's i18n.** Declaring `locales: ['de','en']` demanded `src/locales/<code>.ts` and killed the boot with `Failed to resolve import "../src/locales/de"`, so an app with its own i18n stack had to leave the option out — and then got `<html lang="en">` and `locale: 'en'` handed to every page's `meta`, for every visitor, in a German-language app. The parameter was not merely useless there, it was misleading: trusting it would have turned every German tab English after hydration. The two decisions are separate now. `locales:` declares the languages (`<html lang>`, the resolved locale, `meta.locale`); the PRESENCE of `src/locales/*` decides whether `<I18nProvider>` is wired. An app that ships catalogs is unchanged. An app that does not gets a boot line saying which mode it is in — silently not wiring a provider would have replaced one surprise with another.

  **The `raw-fetch` advisory no longer reads sharper than its evidence.** It led with "no SSRF guard", flatly. A consumer opened all 13 of their call sites and measured that every host was a compile-time constant, the two dynamic URLs were built from a constant base, and every user-supplied URL already went through their own wrapper — "the remaining value is traceparent + retry + http.allowHosts, not security". Counted against this repo: 132 raw `fetch(` sites, 64 with a literal URL. So the smell now states what is true of every site (the framework client is not being used) and scopes the SSRF half to where the URL is not a constant. Being sharper than the evidence has a specific cost: it teaches the reader to skim the one line that would have named the genuinely user-supplied URL.
- **@voltro/cli, @voltro/web, @voltro/database, @voltro/plugin-notifications** — **Seven findings a consumer had carried for two to four releases, all measured by them, all closed.**

  **No page could set its SSR `<title>`.** The generated shell bakes `<title>{app name}</title>`, and the render's head was APPENDED before `</head>` — so a response carried two title elements, and `document.title` is the FIRST per the HTML spec. The app name won on every server-rendered page; the real title appeared only after hydration. Everything that reads HTML without executing it — link previews, crawlers, a bookmark taken before hydration, a screen reader announcing the document — saw the app name. There was no app-side workaround: `.framework/index.html` is regenerated every boot and its title comes from `name`, which is one value per app. **FIVE call sites** spliced a head by hand (`ssrShell`, the static prerender and the SPA shell in `build.ts`, and two in `start.ts`); the fifth was found by the guard written for the first four. One `mergeHeadInjection` now, which replaces the shell's title when the render brought one. Verified in a real chromium against a real `voltro dev`: exactly one `<title>`, and it is the page's.

  **`voltro db scan-credentials` died on postgres — the default dialect, against a framework table.** It asked for a column named `subject`; `_voltro_row_history` has `subjectId`, `actor` and `scope`. Two defects, and the second is the one to keep: only the `COUNT(*)` probe was inside the try, so a missing COLUMN escaped as an unhandled error — jumping clean over this module's own "a missing target reports as missing" promise and over `scanExitCode`'s exit-2-for-a-vacuous-run. Both defaults and both guards are fixed; all three subject-shaped columns are scanned, an unavailable column reports as unavailable, and a missing table is said ONCE rather than once per column. Verified against live postgres: a planted credential in `actor` exits 1, an empty schema exits 2 with "NOTHING WAS SCANNED".

  **`doctor` described constrained executors as unconstrained.** An app that registers `setRowFilter` had all four of its filtered executors reported as "nothing constraining WHICH row" — for handlers that are, measured, already narrowed to the caller's own rows. It cannot be downgraded to a pass (the filter is a function from table to predicate; nothing static can say whether it covers a given table), but the wording pushed the reader toward the allowlist, where the line later reads as "checked and accepted" while meaning "the tool could not see it". The finding now names the row filter and what to verify, and the summary says once that the scan is blind there.

  **A standing DELETE never introduced itself.** Retention is registered by plugins the app never wrote a line about, with defaults in months, and nothing announced it at boot — a consumer lost 1 944 freshly-migrated rows to a 180-day default, then got it wrong a second time by setting the env var in a running pod rather than a file. One line per policy now: the table, the age, the column it is measured on, whether it is conditional, and the variable that changes it.

  **The notification inbox had no index for its own two read paths.** It declared `byInboxSubject(subjectId)` while the plugin itself issues `subjectId = ? ORDER BY createdAt DESC LIMIT ?` and `subjectId = ? AND readAt IS NULL`. Both are declared now, the unread one PARTIAL — 3 of 2 475 rows were unread in their fullest inbox.

  **Four notification routes were served and unreachable.** `archive`, `unarchive`, `markUnread` and `markAllRead` were registered as routes and missing from `notificationsRpcClientImports`, whose comment said "kept in lockstep with the exports". A comment is not a lockstep; `rpcSurfaceLockstep.test.ts` counts both sides.

  **Nothing reported a page with no `meta`.** 75 of 243 pages across three of their apps had none — never, not since a migration — each serving the app name as its title. `voltro doctor` reports them now. Their own first gate is worth repeating: it keyed on pages rendering a particular wrapper and ran green while 60 pages without it had no title at all. The condition is the PAGE, not the wrapper it happens to use.

---

## [0.30.1] — 2026-08-09

### Fixed

- **@voltro/testing, @voltro/sql-mysql** — **One integration suite was never skip-guarded, and the guard it called silently ran it anyway.** `describeIfAvailable(label, dependency, probe, suite)` was called with three arguments in `sql-mysql`'s `fileMigrationLedger.mariadb` suite, so `probe` bound to the SUITE body: `await probe()` executed it at file scope, its `beforeAll` and `test`s registered outside any `describe` and ran unconditionally, and the `describe.skipIf` underneath registered an empty shell. The file therefore passed when MariaDB happened to be up and hard-failed with `SqlError: MysqlClient: Failed to connect` when it was not — the exact opposite of the clean skip it was written to have, and a red `pnpm test` for anyone without the docker stack.

  `tsc` could not catch it: every dialect package's tsconfig `include` lists the src glob only, so `__tests__/` is not typechecked at all — the same gap that lets an incomplete parity fixture compile. So the arity is checked at runtime now, and `describeIfAvailable` throws a `TypeError` naming what it got instead of quietly running the suite. One of roughly twenty call sites was wrong; nineteen were right, which is why nothing looked off.
- **@voltro/cli** — **Four things `voltro doctor` knew and would not tell you.** All reported by a consumer, all measured rather than guessed.

  **The authz list was reachable by no route at all.** The human view truncated at 20 (`… and 14 more`) and `--json` had no `authz` section — measured, its keys were `root · scannedFiles · … · serverOnly`. Reading findings 21..n meant allowlisting the first 20, re-running, and resetting the file: a loop to read a list the tool already had. `--json` carries `authz` now (`counts`, `guardVocabulary`, `allowlist`, and every `unchecked` finding, never truncated), and the elision line names both the command and the field. The same defect, one section over, is recorded in `serverOnly`'s own comment — "the field was MISSING from `--json` entirely" — so this is that lesson applied rather than re-learned. Both surfaces read ONE scan (`scanAuthzForRoot`), because two derivations of one scan is how two views come to disagree about what was found.

  **The allowlist could not tell "reviewed and safe" from "debt".** Its header says `This is DEBT, not approval` — which is right, and which made it the wrong place for the other thing people legitimately need to record: an executor a human has read and found genuinely open, constrained by something the scanner cannot see. It was also the ONLY place, so the reporter resorted to comment blocks around groups of lines — a convention inside a file parsed line by line, which the next `--write-authz-allowlist` would have flattened without a word. A line is now either `<tag>` (debt, unchanged) or `<tag> reviewed=<why>`, the reason REQUIRED — `reviewed=` with no why is the claim without the evidence and is refused, since a bare tag is the honest alternative and always available. Doctor counts and prints the two apart, and `--write-authz-allowlist` preserves reviewed lines instead of downgrading them.

  **A hint that named 41% of the files was not a hint.** One hand-roll finding listed 2 641 of 6 374 files, and the reporter skipped the whole section because of it — including the lines pointing at 5 and 13 files, which were worth acting on that day. Findings now print FEWEST files first, and a finding above both a share (20%) and a floor (50 files) prints its ADVICE without the enumeration, marked as a codebase-wide pattern. Both bounds matter: the share is what makes it a pattern, the floor keeps a small app — where "3 of 8 files" is a large share and a perfectly readable list — out of it. The paths stay in `--json`.

  **A translation catalog that is never loaded said nothing.** `src/locales/{code}.ts` is imported by the web codegen only when the app declares `locales:`. Without that line the files are inert — no import, no provider, no error — and from the inside a catalog that is never loaded looks exactly like one that works. The reporter carried `de.ts` + `en.ts` in TWO apps for months, never wired, and measured that neither boot nor doctor mentioned it. Doctor now names the orphaned codes and offers both ways out: the exact `locales: [...]` line to paste, or delete the files (which is what they did). It deliberately does NOT report the reverse — a declared locale with no file already fails loudly at codegen, and a second, weaker voice for a problem that has a loud one is noise.
- **@voltro/cli** — **`encryptSteps` was derived twice, once per boot path.** Six hand-mirrored lines in `dev.ts` and in `serveApi.ts` — read the flow control off the definition, compare `=== true`, build the cipher, spread the result or nothing. They agreed today and nothing kept them agreeing, which is the shape that produced the `_voltro_outbox` error loop and every dev/serve scar in `packages/cli/CLAUDE.md`. The asymmetry a drift would produce here is the bad direction: step payloads encrypted under `voltro dev` and plaintext under `voltro serve`, with the declaration reading as protection in both.

  `stepPayloadCipherOptions(definition)` is the one derivation now, and it is slightly better than either copy it replaced: the workflow NAME in the boot-refusal message comes from the resolved control rather than from a second argument, so the flag and the name it is reported under are the same object. `flowControlParity.test.ts` pins that both paths call it AND that neither re-derives `encryptSteps === true` inline.
- **@voltro/runtime, @voltro/cli, @voltro/workflow** — **`debounce` never ran. Neither did a `batch` that flushed on its timeout — and `batch` could not be started at all.** Three defects, one boundary, all found by a consumer who adopted flow control against a live API and measured `attempts: 13, collapsed: 14, runs: 0` on a debounced workflow that never produced a run.

  **1 — the drainer re-entered the admission boundary it had just cleared.** A deferred start is judged twice on purpose: once on arrival, once when the drainer reconsiders the pending row. The second judgement is the one that ADMITS, and the drainer then started the workflow *through the facade* — deliberately, so a queued run takes exactly the code path an immediate one does. But the facade's start IS the arrival path, and arrival is where a deferring control defers. So the admitted start was deferred straight back into the row it came from: `collapsed` up by one, the row still pending, the engine never reached, one wasted pass per second, forever. `debounce` was 100% broken; `batch` was broken whenever it flushed on the timeout rather than by filling. `throttle` and `concurrency` survived only by an ordering accident — the re-entrant arrival happened to re-admit because the ledger row and lease are written *after* the start returns. The drainer's start now carries an internal `admitted` marker that skips the gate: it is the APPLICATION of a decision already made, and everything the arrival path would have done (pause, singleton eviction, the ledger row and lease, consuming the intents) the drainer does around it.

  **2 — a `batch:` workflow rejected every caller's start.** The declaration contract is explicit and enforced: the workflow's own `payload` is `{ items: Schema.Array(Item) }` while callers `start()` it with a SINGLE item, declared as `batch.item`. `batch.item` was required, asserted at declaration time — and then dropped during resolution and read by nothing. So the facade validated the caller's single item against the batch shape and threw `WorkflowPayloadError: missing required field(s): items` before the gate was ever reached. `batch:` was unusable end to end. The item schema is now carried through and is what an arriving start is judged by; a drained batch is judged by the workflow's own schema.

  **3 — the two halves of that boundary could be wired half-right, in both boot paths.** `startPayloadSchema` is pinned beside `admitStart` in `flowControlParity.test.ts` as a separate assertion, because passing one and not the other fails silently and differently.

  **Why no unit suite could see any of this.** `admissionDrainer.test.ts` fakes `startAdmitted`; `workflowRuntime.test.ts` fakes `admitStart`. Each is a complete test of its own half, and the defect lived strictly between them — the same shape as this repo's dev/serve parity scars, one level down. `flowControlDrainRoundTrip.test.ts` wires a real gate to a real facade over a real in-memory store and drives all four deferring controls from arrival to run. It was written red: debounce and batch-timeout failed, throttle and concurrency passed, which is exactly the diagnosis. It asserts the pending row is CONSUMED rather than merely that a run eventually happened — a debounce that re-collapses twice on the way is still broken, and `admitted: 1` alone would not say so.
- **@voltro/cli** — **`_voltro_outbox` was polled every five seconds by apps that never had it created.** Reported by a consumer as a permanent `Table doesn't exist` loop — and, they noted, "a permanent error loop that buries the real ones". The table appeared zero times in `voltro db plan`, which was correct for the gate as written and wrong for what the boot actually does.

  The two predicates had drifted. The table was created when the app declared at least one `*.outbox.ts` handler; the delivery worker was STARTED when at least one handler existed *including the framework's own* `voltro.webhook.emit`, which is registered whenever the app has a webhook surface. So an app with webhooks and no handler file got the worker, got `ctx.outbox`, and got a `ctx.webhooks.emit` inside a mutation writing through a table that was never planned. The 0.30.0 note claiming such an app "keeps the in-memory callback" described the intent, not the code.

  Widening the table's gate to match could not work: the webhook surface includes outgoing webhooks declared on EVENTS, and the migration path detects features by walking filenames, so it cannot see them without loading the app's modules. **`_voltro_outbox` and `_voltro_outbox_attempts` are therefore created for every sql app now** — small, dialect-neutral, empty unless something enqueues, the same trade `_voltro_wakeups` and the storage tables already take. Two empty tables against a class of divergence that has no symptom until production.

  It also fixes `voltro migrate`, which never passed the flag at all — and, in the same sweep, `voltro migrate` never detected `*.connection.ts` either, so the credential-vault tables were created by `voltro dev` / `voltro db apply` and silently not by `voltro migrate`. Migrate carried its own copy of the feature-detection walk; it calls the shared `detectFeatureMix` now, so there is one walk and one answer.

  **And the delivery worker no longer prints a wall.** An identical drain failure is reported once at `warn`, escalated ONCE to `error` after ~a minute of consecutive identical failures ("this is not transient. Enqueued effects are NOT being delivered"), and then suppressed until the cause CHANGES or it recovers — recovery says so, with how many passes it was broken for, because a failure that stopped being logged and one that got fixed must not read alike.

  **The 0.30.0 codemod note said the opposite, and it is corrected in place.** It told users that an app declaring no `*.outbox.ts` "falls back to the in-memory callback" — the intent, not the code. Normally a note under a published version cannot be revised (`selectCodemods` filters `from < version <= to`, so anyone who has already crossed 0.30.0 will never see a correction, which is why corrections are re-issued under a version nobody has reached). That rule is about a changed *instruction*, where someone who acted on the old one has to hear the new one. This is a false statement of fact with nothing attached for a reader to undo — the table is created by the declarative differ on the next `voltro dev` boot or `voltro db apply` — so the alternative was leaving every future 0.29 → 0.31 upgrader a sentence that is simply untrue.
- **@voltro/web** — **The second half of the SSR `useId` divergence: the client boot rendered a sibling to the app that the server did not.** `VoltroRuntimeProvider` renders `{children}` alongside a chrome slot (`chromeMounted ? <>…overlays…</> : null`), while the server rendered the page tree with no boot wrapper at all. A parent with two children forks React's tree-id path; a parent with one does not — so this shifted every `useId` in the app exactly as the router provider did, one level further up.

  It is filed separately from the router fix because the two are independent and **each is independently fatal**: measured on a pristine tree, fixing only the router still fails and fixing only this still fails. Both paths now render `RootChromeSlot`, one component owning the arity, with `chrome: null` on the server.

  The irony is worth keeping, because it is what made the defect invisible: the `chromeMounted` gate was added so the first client render matches the server DOM. It does — and that is exactly why hydration SUCCEEDS, React keeps the server markup, nothing throws, and the only casualty is the ids. The gate did not cause the fork; the slot forks whether or not it renders anything.

  **What must not change without re-measuring:** the number of forks above the page on each side. Nesting DEPTH is free — measured, any number of single-child providers above the router keeps ids aligned — but adding a sibling to the app on one path only (an overlay, a portal host, a second root element) reintroduces this. `ssrTreeIdParity.test.tsx` holds it in jsdom; `scripts/browser-ssr-hydration-ids.mjs` holds it in a real chromium against a real `voltro dev`.
- **@voltro/web** — **Every `useId` in an SSR app mismatched on hydration, on every page, since the route announcer was added.** Reported by a consumer against 0.30.0 and 0.29.0 with the two `dist` bundles read side by side — not a regression, and not something any of our tests could see.

  The router provider took ONE child on the server (`createElement(RouterContext.Provider, { value }, tree)`) and TWO on the client (JSX with `{content}` and the announcer, which compiles to `jsxs` with a 2-element array). React derives `useId` from the path of ARRAY SLOTS down to a fiber: a single child does not fork, a 2-element array forks and places the subtree at index 0. So the entire tree below the router sat at a different tree id on the two sides, and every id generated beneath it differed.

  **The failure is unusually quiet, which is why it lasted.** The second child is `announcerReady ? <RouteAnnouncer/> : null`, and `announcerReady` starts `false` — so the first client pass renders `null`, the DOM matches, hydration SUCCEEDS, and React keeps the server markup and merely warns about the attributes. Nothing breaks visibly; the console fills with `A tree hydrated but some attributes … didn't match` for every component that calls `useId`. With Radix that is every tooltip, dialog, accordion, collapsible, select and label.

  **There were TWO such divergences, not one, and each is independently fatal.** The router provider is the one the reporter found by reading the bundles; one level further up, the client's `VoltroRuntimeProvider` rendered the app ALONGSIDE a chrome slot (`{children}{chromeMounted ? … : null}`) while the server rendered no boot wrapper at all. Measured on a pristine tree: fixing only the router still fails, fixing only the chrome slot still fails, fixing both passes. So a report that names one of them is not a partial diagnosis to be discounted — it is half of the answer, and the half nobody had.

  Both paths now render ONE shared component at each level — `RouterProviderTree` for the router, `RootChromeSlot` for the boot — whose second slot is always present and `null` where there is nothing to put in it. The arity is identical by construction rather than by two call sites agreeing. `ssrTreeIdParity.test.tsx` renders one page through both paths and compares a `useId`, so a future change to the shape fails at the point of change instead of in a consumer's browser. It was written red first. Its FIRST version was the cautionary tale, though: it compared the server render against a bare `<Router>` and passed while the app was still broken, because the boot-level fork it did not model is the one that was left. It hydrates through the real `VoltroRuntimeProvider` now — every hydrating path in `mount.tsx` goes through it, so a bare router is not a shape that exists. A parity test that models less than the real boot proves only that the part it models agrees.

  **The methodological trap is carried in the test, because it cost the reporter an hour and would cost the next person one:** reading the id back from the DOM shows the SERVER's value on both sides — hydration deliberately does not patch ids, which is the very thing the warning says. A harness built that way reports the bug as absent. The id has to be captured from the render that computed it, and a second test proves the harness would still catch a fork.

  **Verified in a real browser, not only in jsdom.** `scripts/browser-ssr-hydration-ids.mjs` boots `voltro dev` on the SSR fixture from SOURCE, loads a `renderMode: 'ssr'` page, and asserts the client computes the same `useId` the server wrote AND that react-dom logs no mismatch. Removing either half of the fix makes it print the reporter's exact string — `A tree hydrated but some attributes of the server rendered HTML didn't match the client properties` — which is the only place that message can be observed at all: the DOM is identical, hydration succeeds, nothing throws, and there is no server-side signal.
- **@voltro/web, @voltro/cli** — **The server render discarded the request's query string.** `RenderPageOptions` had no `search`, and the SSR router context hardcoded `search: ''` with a comment noting that the client reads `window.location.search` on hydration. That is true, and it is precisely why the hardcoding was wrong: the client reading the real value is what turns a discarded query string into a divergence in an exported context value. The value was already computed in both per-request boot paths — the loaders receive it — and simply never reached the renderer.

  `renderPageToHtml` / `renderPageToStream` take `search` now, and `voltro start` and `voltro dev` both pass it. `build.ts` deliberately does not: a static prerender has no request and one artefact serves every visitor, so `''` is the truthful value there rather than a missing wire — and `ssrI18nParity.test.ts` encodes that difference, asserting the two per-request renderers pass it while leaving the prerender out on purpose.

  **Scope, stated rather than assumed:** `useSearchParams()` was ALREADY correct on the server. It reads `requestContext.url`, which both per-request paths populate with the full request url including the query. So this fixes `RouterContext.search` — exported, and readable by an app directly — and does not on its own explain a mismatch in a page that reaches the query through that hook. Reported alongside the `useId` defect by the same consumer.

### Internal (no consumer-facing effect)

- **@voltro/database** — `pendingAttribution`'s boundedness test no longer scores its property on the wall clock. It asserts that 12 000 registrations leave at most 10 000 entries and says nothing about how long 12 000 iterations take — but under the default 5 s timeout it had quietly become an assertion about the machine as well, and went red inside a 24-task parallel run while the whole file finishes in 480 ms on its own. That is the "a test that measures the machine" producer recorded in `packages/cli/CLAUDE.md`, and the fix is to decouple the property from the clock (an explicit generous timeout) rather than to shrink the loop — 12 000 is chosen to overrun the 10 000 cap, so a smaller burst would weaken the only thing under test. A non-vacuity assertion came with it: a cap of zero satisfies `<= 10 000` while proving nothing.

---

## [0.30.0] — 2026-08-08

### ⚠ BREAKING

- **@voltro/ui-shadcn, @voltro/i18n, @voltro/web, @voltro/cli** — The language-preference cookie is **`voltro:locale`**. It was `voltro:lang`. The exported constant is `LOCALE_COOKIE` (was `LANG_COOKIE`), and `parsePreferenceCookies()` returns `{ theme, locale }` (was `{ theme, lang }`).

  Every other name in the framework says `locale` — `resolveLocale`, `defaultLocale`, `config.locales`, `[locale]/…` routes, `meta({ locale })`, `RouteContext.locale`. The cookie was the one place the vocabulary broke, while holding a full IETF tag (`fr-CA`) — which is a locale, not a language.

  That inconsistency was not cosmetic. `voltro dev` shipped for eleven weeks reading `voltro:locale` while every writer wrote `voltro:lang`, so `<html lang>` was the literal `"en"` on every page of a German-default app. A reader and a writer that disagree on a string are invisible to `tsc`; a name nobody types the same way twice is what produced the disagreement.

  **Your source is migrated by `voltro update`. Your users' browsers are not.** The old cookie in an already-visited browser is no longer read, so each user falls through to `Accept-Language` and then `defaultLocale` once and re-picks their language. Nothing errors and nothing else is lost. There is deliberately no dual-read fallback: a framework that keeps reading the old name forever is one that never finished the rename, which is the exact condition this change removes. If the one-time reset is unacceptable for your users, copy the value forward at your own boot and delete the bridge once they have cycled through:

  ```ts
  import { LOCALE_COOKIE, getCookie, setCookie } from '@voltro/ui-shadcn'
  
  const legacy = getCookie('voltro:lang')
  if (legacy && !getCookie(LOCALE_COOKIE)) setCookie(LOCALE_COOKIE, legacy)
  ```

  `voltro:theme` is unchanged.

  **`voltro update` carries you across this** — codemod `0.30.0/01_locale-cookie-rename`.
- **@voltro/plugin-webhooks, @voltro/cli** — A deferred `ctx.webhooks.emit(...)` is a **transactional outbox row** now, not an in-memory after-commit callback. And `EmitOptions` gains **`immediate: true`** as the named way to opt out.

  The commit-ordering half shipped in 0.29.0: an emit inside a mutation rides the commit, so a mutation that emits and then throws no longer tells a subscriber about a change that did not happen. That fix was correct about ORDER and silent about DURABILITY — a process dying between COMMIT and the callback dropped the delivery with nothing recorded as owed, which is the at-least-once-FROM-ENQUEUE weakness `@voltro/plugin-cdc-out` documents about itself, arrived at by accident.

  The enqueue writes through `ctx.store` — inside a mutation, the transactional view — so the intent to deliver commits with the domain write or not at all. A crash is a retry instead of a loss. Delivery stays at-least-once, which is the strongest guarantee available without distributed transactions into the receiver.

  **What changes for you:** an emit inside a mutation returns `{ event, deliveries: [], deferred: true }` and its delivery rows appear after commit — as it already did in 0.29.0. New is that the deferral survives a crash, and that `{ immediate: true }` exists for the cases that genuinely want the POST now. `immediate` does not make the emit safe; it makes the trade visible at the call site, which the old un-transactional behaviour never did.

  The framework registers its own `voltro.webhook.emit` outbox handler in BOTH boot paths, gated by one shared `hasWebhookSurface` predicate — a deferral that is durable under `voltro dev` and not under `voltro serve` is exactly the drift the parity guard exists for. An app with no outbox wiring keeps the in-memory callback: ordered, not durable, and it says so.

  **`voltro update` carries you across this** — codemod `0.30.0/03_webhook-emit-durable-deferral`.
- **@voltro/protocol, @voltro/runtime, @voltro/cli, @voltro/plugin-webhooks** — **`WorkflowRunHandle.executionId` is nullable and `status` has three more members, because a start no longer always becomes a run.**

  With declarative flow control a start can be QUEUED (debounce / batch / throttle / concurrency / paused), DROPPED (over a `rateLimit` cap) or SKIPPED (a `singleton: { mode: 'skip' }` key was held). None of those has an execution id, and two of them may never have one.

  ```ts
  status:      'running' | 'queued' | 'dropped' | 'skipped'   // was: 'running'
  executionId: string | null                                   // was: string
  deferral?:   { mode, dueAt, retryAfterMs, intentId }          // new
  ```

  Keeping `executionId` a required string was considered and rejected. It would have meant inventing a value — an empty string, or the id the run WOULD have had — and both produce a handle that polls `status: 'unknown'` forever: a wait that never resolves and never errors, which is the worst of the three answers. For a `skipped` singleton it carries the INCUMBENT's execution id, which is a real, pollable run and the entire point of that mode.

  `ctx.workflows.wait(...)` on a handle with no execution id now throws with a message naming the status and, for `queued`, its `dueAt` — instead of polling forever.

  Two structural copies of the old shape went stale and are now the protocol type itself rather than hand-copies: `@voltro/plugin-webhooks`' `IncomingWorkflowFacade` and the CLI's `inspectStartWorkflow`. An incoming webhook that starts a debounced workflow gets a `queued` handle, which both copies said could not happen.

  **`voltro update` carries you across this** — codemod `0.30.0/04_workflow-run-handle-nullable-execution`.

### Added

- **@voltro/ai, @voltro/workflow** — **`@voltro/ai/workflow` — `aiStep` / `aiObjectStep`, a model call as a durable step that records what it cost.**

  ```ts
  import { aiStep } from '@voltro/ai/workflow'
  
  const summary = yield* aiStep({
    name: 'summarise-thread',
    prompt: `Summarise:\n${thread}`,
    store: ctx.store,
    tenantId: payload.tenantId,
    offload: true,
  })
  ```

  Journaling is NOT the difference, and saying otherwise would be selling something the framework already gives away: every `step()` is journaled, so a replay of a plain wrapped `generateText` already returns the recorded completion rather than re-calling the model. Four things are genuinely new:

  1. **What did this run cost?** A model call inside a workflow was invisible to `_voltro_ai_usage` unless the app remembered to call `recordAiUsage` by hand — so the spend ledger was systematically missing exactly the calls that run unattended. `aiStep` records it, attributed to the workflow and the step. 2. **The prompt is not silently copied into a second table.** `step({ input })` is written to `_voltro_workflow_run_steps` and rendered in the dashboard; for a prompt built from customer data that is a plaintext copy outside whatever boundary the app established for the source. The default records a DIGEST plus the length; `recordPrompt: 'full'` exists and has to be typed out. 3. **Provider failures retry like provider failures.** The default policy handles a 429 with its `Retry-After` and a 5xx, rather than every app rediscovering that a bare call fails the whole durable run on a rate limit.

  4. **`offload: true` frees the worker while the model thinks.** The run SUSPENDS on a durable deferred, the wait lives as a row in `_voltro_ai_inferences`, and a dispatcher owns the socket. Two hundred waiting runs become two hundred rows and four in-flight requests instead of two hundred parked fibers.

  Nothing here needs a third party to operate an inference tier — it needs something to own the socket while the run sleeps, and a server process is something. Both pieces already existed: durable suspend/resume (`awaitSignalSuspending`, built for human-in-the-loop waits) and a leased work queue with a coordinated drainer (the admission queue's own shape).

  The cost is stated rather than buried: a suspend/resume round trip adds the dispatcher's poll interval plus one engine wake, so it is a MODE. Under 5% on a six-second call; a doubling on a 200 ms one.

  Four guarantees, each ruling out a specific way this goes wrong:

  - the enqueue is idempotent (the row id derives from execution + step, so a replay cannot queue — and pay for — the same call twice); - the claim is a conditional update, so two dispatchers cannot both bill one call; - the order is perform → RESUME the run → mark the row, because a crash the other way round leaves a run waiting for a signal nobody will send again; - a give-up resumes the run WITH the failure — an abandoned queued call that never told its run is the one unrecoverable outcome here.

  `aiObjectStep({ offload: true })` renders the schema to JSON Schema for the dispatcher (a JavaScript Schema cannot be journaled) and still decodes on the awaiting side, where the real schema exists.

  The dispatcher rides the ONE shared builder both boot paths call, with a red-verified parity guard: the gap it prevents is the worst variant this repo catalogues — in production every offloaded call would suspend its run and never resume it, with no error and no log line.

  The Flow tab renders the queue: what is waiting and for how long, calls waiting past two minutes, claims whose dispatcher died, and the dispatcher's own last tick. A run parked on an offloaded call reads `suspended` with no step row yet, so this is the only view of the wait while it is happening.

  `StepRetryPolicy` is now re-exported from `@voltro/workflow/define` (type-only, so the browser bundle is unaffected): it is the type of a `step()` option, and anything defining a step has to be able to name it.
- **@voltro/cli** — **`voltro db scan-credentials`** — the credential scan as a command instead of a SQL snippet in an upgrade note.

  It counts rows whose Subject carries a credential-shaped key (`token` / `secret` / `password` / `apikey` / `credential` / `privatekey`) in `_voltro_audit_log` and `_voltro_row_history`, plus any `--table <name>[:<column>]` you add. Exit `1` on a hit so CI can gate on it.

  Why it is a command: the same check shipped as documented SQL (`subject::text ILIKE '%token%'`), which is postgres-only. Readers on MySQL/MariaDB translated it to a bare `LIKE` — case-SENSITIVE against the `utf8mb4_bin` collation our own migrator emits for a `json()` column, so `'%token%'` does not match `jiraToken`. A team ran it over 141 rows, got `0`, and nearly filed themselves clean; 117 held a working credential. Every dialect now casts to its own text type before `LOWER`, in code.

  **And a `0` can no longer mean two things.** Every line prints the number of rows SCANNED beside the number of hits; an empty table says "EMPTY … this is not a clean bill of health"; a missing table reports as missing rather than as zero; and a run that examined nothing exits `2`, not `0`.
- **@voltro/workflow, @voltro/runtime, @voltro/cli, @voltro/plugin-ai-flows** — **`apiSurface: compatible`, and the reason.** Making `workflow()` a SINGLE call signature (see below — it is what lets a `key` lambda receive the payload type) also means every result is now intersected with its message carrier, including the empty one. `@voltro/plugin-ai-flows`' golden therefore reads

  Workflow<"flow.run", …, typeof Schema.Never> & WorkflowMessagesCarrier<{ signals: {}, updates: {}, queries: {} }>

  where it used to stop at the first line. That is an ADDED intersection member, not a narrowing: a value of `T & M` is usable everywhere a `T` was, and nothing outside the package produces a value of that type. The gate flags it because one golden LINE was rewritten, which is the right thing for it to be blunt about — it cannot tell an addition spelled as a rewrite from a removal.

  It is also an improvement worth naming: before this, a workflow declaring `messages` fell through to the second overload and its payload/success/error types erased to `any`. That erasure is gone.

  **Flow control is a declaration now — `debounce`, `singleton`, `concurrency`, `throttle`, `rateLimit`, `batch`, `priority`, `timeouts`, `onFailure`, `encryptSteps` on `workflow({...})`.**

  Every one of these could already be hand-rolled, and that was the problem. A downstream app shipped "fifteen minutes after the last edit, narrate what settled" as ~120 lines: an idempotency key carrying the edit timestamp so every edit minted its own durable run, a re-check loop asking "what is due now and when should I wake next", a round cap so a run could not live forever, and an idempotent round so the superseded runs cost a diff instead of a model call. It works. It costs **twenty sleeping cluster entities to express "one job, latest deadline"**. It is now one line:

  ```ts
  debounce: { key: (p) => `tour:${p.rowId}`, period: '15 minutes' }
  ```

  The reason the obvious version is wrong is the same for all of them: **the decision has to be made before the run exists.** Once a run is enqueued the only tools left are cancel and sleep, and neither un-spends the entity. So this is not a primitive you call inside the body — it is a property of the declaration, evaluated at the ONE boundary every start funnels through (`start`, `child`, `run`, a trigger, a reaction, a cron).

  **One decision function, two callers.** `decideAdmission` is pure — no store, no clock, no service. The arrival path and the drainer call it with state read by the same two queries, so they cannot disagree; a disagreement would surface as a workflow running twice under a limit of one, on a replica nobody is attached to, under load.

  **Nothing is silent.** Every decision is a row in `_voltro_workflow_admissions` with its key, reason, `collapsed` count and `waitedMs`. A debounce that collapses nineteen starts into one is indisputably correct AND indistinguishable from nineteen starts vanishing unless something writes it down. `voltro workflows flow` and `GET /_voltro/inspect/workflows/flow-control` show it.

  Also in this change set:

  - **`awaitEvent({ event, schema, match })`** — wait on a CORRELATION rather than on an execution id. `awaitSignal` requires the sender to already know which run to wake, so a workflow waiting on a webhook that carries an issue key needed an app-maintained lookup table. The predicate is ordinary JavaScript over the decoded event, and the compiler checks it. - **`sleepUntil({ name, until })`** — the instant is journaled first, so a run that suspends and replays does not recompute the delta against a now that is already past the target and sleep the whole period again. - **`onFailure`** fires for every way a run fails to deliver, including the two that produce no run row at all (`timeouts.start` expiring a queued start; the workflow renamed away while starts were queued) — which is exactly why polling `listRuns({ status: 'failed' })` could never see them. - **`encryptSteps: true`** encrypts the journaled step `input` / `output` / `errorCause` with the cipher `governancePlugin({ fieldEncryption })` already registers. Declaring it without that plugin is a boot refusal, not a warning: a plaintext fallback would leave the declaration reading as protection. - **`voltro workflows pause|unpause <name>`** — a paused workflow COLLECTS. Never discards.

  A workflow that declares no control takes exactly the path it took before this existed, and an undeclared control costs zero round trips.
- **@voltro/cli** — A failed `voltro dev` SSR render now reports how many hot updates the process has absorbed since boot, and every failure carries `x-voltro-ssr-generation`.

  Not telemetry — PROVENANCE for a measurement. A consumer filed and unfiled the same item twice in one afternoon, in both directions, because the same route on the same code answered 200 and 500 depending only on which edits the watcher had processed since boot. Their conclusion is the right one and it belongs to both sides: otherwise two parties judge one item against two different module graphs and each concludes the other was careless.

  A hard restart on every edit would trade one broken feedback loop for a slower one — a 224-page app is not free to reboot. What costs nothing is letting every failing response say which graph produced it. `0` means nothing has changed since this process started, which is the only state in which a dev-SSR measurement is worth reporting; anything else prints an explicit instruction to restart and measure once from a fresh boot.

  Counted for suppressed hot updates too: a module we chose not to reload is still one whose bytes on disk no longer match what this process serves, which is precisely the divergence the number exists to expose.
- **@voltro/cli** — `voltro doctor` flags a framework cookie name written as a string literal (`voltro:locale`, `voltro:theme`, or the pre-0.30.0 `voltro:lang`) and names the constant to import instead.

  A cookie name the FRAMEWORK reads and the APP writes is a public API — and the only kind where both sides can disagree with nothing failing. Nothing throws, no page breaks: the resolver finds nothing and falls back to `Accept-Language`, so the symptom is a language preference that quietly stops working for the subset of users whose browser language differs from their choice. The least likely thing anyone tests.

  Raised by a consumer ahead of the `voltro:lang` → `voltro:locale` rename, in their words: *"your codemod will presumably rewrite the literal. Ours were two bare strings in two components, which is exactly the shape a codemod misses one of."* The codemod does rewrite every literal it can see. This rule covers what a codemod structurally cannot — and, more usefully, the NEXT rename, for which no codemod has been written yet.
- **@voltro/cli** — New `mobile` template kind + scaffolder support for Expo (React Native) apps. `voltro create-project <name> --mobile` (defaults to the `mobile-app` template) and `voltro add-app <name> --template=mobile-app` scaffold an Expo app that consumes your api with the same typed hooks. A `mobile` app deliberately gets NO port and is NOT part of `voltro dev`'s orchestration — Expo owns Metro (`expo start` / `expo run:ios`); the app connects to the sibling api over the network. `list-templates` shows the new kind; the template validation harness (`test-templates.mjs`) skips `kind: mobile` from its default sweep LOUDLY (the Expo/RN toolchain is heavy and simulator-bound — the template's pure logic is covered by its own tests). codemod: none — additive, no user-authored code changes. (The forward-looking design + the M0 gap — no RN-safe client boot yet — are in `plans/open/mobile/`.)
- **@voltro/client, @voltro/web** — `@voltro/client` now exports `buildApiRuntime` — the transport-level construction of one api's client stack (an rpc-client-over-WebSocket, its ManagedRuntime, a SubscriptionCache, an error bus, per-connection auth-header seeding). The WebSocket constructor is an INJECTED dependency, so React Native can build the SAME `ApiHandle` pieces the web client uses without pulling in `@voltro/web` — the keystone for mobile support (plans/open/mobile M0). `@voltro/web`'s `buildRuntimeAndClient` now DELEGATES to it (one implementation, no duplicate path; the web client-builder test suite stays green), and its `ResolvableHeaders` type is re-exported from `@voltro/client` (the owning lower layer) rather than defined locally. Also exported: `BuildApiRuntimeOptions`, `BuiltApiRuntime`, `ResolvableHeaders`. Additive — no consumer migration.
- **@voltro/cli** — `voltro dev` now tells you WHICH of two causes produced *"[React Intl] Could not find required `intl` object"*.

  That error is byte-identical whether there is no `<I18nProvider>` above the consumer or a provider built from a SECOND physical `react-intl` copy — React contexts are identified by object identity, so a duplicate library has a duplicate context and the provider is present and invisible. The two causes have opposite fixes, and no red/green experiment in the app can separate them: the app's own provider comes from the app's own import, i.e. the instance its `useT()` already uses.

  The dev server knows something the error does not — whether it supplied an `outerWrap` for that request. When it did, the 500 body and the log line now carry the duplicate-copy diagnosis and the one command that confirms it (`pnpm ls -r --depth 10 @voltro/i18n react-intl`), plus an explicit statement that the diagnosis is wrong if both resolve to a single version. It stays silent for an app that configures no locales, where "no provider" is the correct state.
- **@voltro/cli** — `voltro update --dry-run` now lists the codemods the target version puts **in range**, without installing anything and without touching your tree.

  The obstacle was not the one we thought. The codemods for a jump ship INSIDE the target `@voltro/cli`, which is not installed when the preview runs — so the target VERSION is known before installing and the target REGISTRY is not. A preview that confused the two would list the codemods of the version you are leaving.

  The registry is therefore republished as package METADATA (`voltro.codemods` in the published `package.json`, generated by `scripts/gen-codemod-manifest.mjs`, drift-checked in CI) and read with the SAME registry query that already resolves the latest version — project package manager first, `npm view` last. No tarball fetch, no temp install, no second package-manager surface. yarn and bun fall straight through to npm on purpose: `yarn npm info …` parses as `yarn run npm` on yarn classic and executes a same-named script, and that risk is not worth taking for a preview.

  Two honesty properties, both load-bearing:

  - **"In range" is not "will apply".** `appliesTo` is a function and cannot cross a registry query, so the list is the upper bound on what a run can touch. The output says so. - **"Could not look" never prints as "nothing to do".** A target published before this field existed, or an unreachable registry, produces an explicit *"This is NOT the same as no codemods"* — because the whole reason to preview is to decide whether to stash a dirty tree.

  Asked for twice by a consumer who established the answer by grepping their own call sites instead.
- **@voltro/plugin-webhooks, @voltro/cli, @voltro/devtools-ui** — The Webhooks panel's Events tab shows **two** facts side by side: whether the event was ever DELIVERED, and whether `emit(...)` ever RAN.

  `everDelivered: false` conflates three different things — no emit call site, a call site that ran before anyone subscribed, and one whose payload every target's filter excluded (or every target was paused). Only the first is a defect, and it is the one a consumer spent a week finding by hand: seven of eleven advertised events had no emit call site anywhere. Delivery history also ages out at 90 days, so a quiet-but-working event decays into looking dead.

  `_voltro_webhook_event_stats` carries one row per event, stamped on every emit **regardless of whether any target matched** — the axis delivery history structurally cannot see. Not tenant-scoped (the question is whether the CODE has a live call site, not whether a tenant has triggered it) and not retention-swept (a quarterly event must not read as dead). The write is best-effort and silent on failure: this is telemetry for a dashboard column and must never be the reason a delivery does not go out.

  **An unreadable stats table reports as UNKNOWN, never as "never".** "We did not look" and "it never fired" are different answers and only one is a finding.

  Two corrections rode along:

  - The existing activity label read *"{n} subscribed · NEVER emitted"* while being derived from delivery history. It says *never DELIVERED* now — it may only claim what it actually knows. - **The cloud dashboard never received `eventActivity` at all.** The proxy's output schema did not name the field, so Effect's decode dropped it silently and the column rendered locally but not in the cloud — the four-layer drift the maintainer rule exists to prevent, shipped since 0.29.0. Both dashboards now get it.
- **@voltro/cli, @voltro/devtools-ui** — **Bulk cancel and bulk replay — `voltro workflows cancel-many` / `replay-many`, plus a dashboard panel.**

  A bad deploy leaves four thousand runs that must all stop, or four thousand that must all be re-driven once the downstream is fixed. Doing that one run at a time through a dashboard is not a workflow, and doing it with raw SQL is how a `_voltro_workflow_runs` row ends up marked `cancelled` while the engine keeps executing it.

  ```
  voltro workflows cancel-many --workflow tourNarration --reason "bad deploy"
  voltro workflows cancel-many --workflow tourNarration --reason "bad deploy" --commit
  voltro workflows replay-many --status failed --mode redrive --limit 200 --commit
  ```

  Three decisions are deliberately stricter than the obvious design:

  - **`--limit` is required and there is no "all".** The cap IS the blast radius, and it costs one number. `truncated` in the result says whether more matched, so "did I get all of them" stays answerable without an unbounded verb ever existing. A result of "1000 cancelled" reads as "all of them" otherwise, at the exact moment that mistake is most expensive. - **It is a DRY RUN unless `--commit` is passed.** That is the opposite of the usual `--dry-run` flag, and deliberate: the default for a verb that can stop a thousand runs should be the one that stops none. The dashboard panel enforces the same order — the apply button does not exist until a preview has returned a number, because "this will cancel 412 runs" is a different sentence from "412 runs were cancelled". - **`--reason` is required for a cancel.** It lands on every affected run's `run-cancelled` event, so "why did four thousand runs stop on the 8th" has an answer in the same table an operator is already reading.

  The result is per-run, not a count: `succeeded`, `failed` (with the reason for each) and `skipped` (with what made each ineligible) are three different outcomes. A bulk op that reports "4000 cancelled" while forty failed is how people learn not to trust bulk ops.

  Eligibility follows the verb rather than a flag: a cancel acts on `running` and `suspended`; `replay --mode redrive` on `failed` only (redrive resumes from the step that died, which only exists for a failure); `replay --mode retry` on `failed` and `cancelled`. `--mode` has no default because the two cost very different amounts.

  Each verb delegates to the SINGLE-run operation beside it — the shared canceller, `retry`, `redrive` — so a bulk path cannot end up performing a different set of side effects from the button next to it.

  The dashboard panel is gated on a NEW capability, `canBulkOperateRuns`, rather than on `canPauseWorkflow`. The argument that made pause safe to expose is exactly why: a pause COLLECTS starts and never discards one, so its worst outcome is a backlog. A bulk cancel destroys work already in flight. In the cloud dashboard it is `owner`-only.
- **@voltro/workflow, @voltro/cli, @voltro/devtools-ui** — **`cancelOn` — stop a workflow's live work when a correlated event arrives.**

  ```ts
  cancelOn: [{
    event: 'jira.issue.deleted',
    schema: JiraIssueDeleted,
    match: (event, payload) => event.issueKey === payload.issueKey,
  }]
  ```

  Both sides are typed: the event from the entry's own `schema`, the payload from the workflow's.

  **Why a declaration rather than a race inside the body.** "Stop when the issue is deleted" is expressible with `awaitEvent` and an interrupt, and that works while the body is RUNNING. It does not work while the run is sleeping for six hours, suspended on a signal, or still sitting in the admission queue — which is the case cancellation was wanted for. The event has to reach a run whose fiber is not executing anything, and only something outside the body can do that. So it is swept: a coordinated tick reads events published since a durable watermark (`_voltro_workflow_watermarks`), resolves each declaring workflow's live runs, and cancels the ones that correlate.

  **It also discards QUEUED starts of the same workflow.** Cancelling only the running one leaves a debounced or concurrency-queued duplicate to start seconds later against the row that was just deleted — the exact outcome the declaration was meant to prevent, arriving late enough that nobody connects the two.

  Three rules that are stricter than they look, each protecting against a way this would otherwise be silently wrong:

  - **`match` is required.** The omitted case would mean "cancel every live run of this workflow", which is a legitimate thing to want and a catastrophic thing to acquire by forgetting a line. `match: () => true` says it out loud. - **A run that started AFTER the event is never cancelled.** A sweep catching up after a deployment gap reads an hour of history; without this it kills runs that started in the meantime, and the symptom looks nothing like the cause. - **An event that fails to decode is REPORTED and never matched.** Cancelling on an event you could not read is cancelling blind.

  The cancel itself goes through the same code the operator's cancel button uses — engine interrupt, row flipped, `run-cancelled` recorded with the event name, children closed — because a second implementation would inevitably have done three of those four.

  Wired through the one shared builder both `voltro dev` and `voltro serve` call, and shown in the dashboard as a `cancelOn:<event>` badge on the declaring workflow, so "which event stops this" is answerable without reading the source.

  Also in this change set, from a review of the above:

  **A DISCARDED queued start now writes a ledger row.** Both paths that drop one — an operator's discard button and a `cancelOn` event — deleted the pending row and recorded nothing. That is precisely the failure `_voltro_workflow_admissions` exists to prevent, committed by the feature that argues against it: from the outside, a start deliberately discarded and one that silently vanished are the same observation, a row that is no longer there. `outcome: 'discarded'` is a new member of the ledger's enum (a `_voltro_*` column change, so it rides the declarative differ on `voltro db apply` and on a `voltro dev` boot, on every dialect — no codemod).

  **The `cancelOn` sweep reports its own health**, in the Flow tab rather than only in a log line. `problems` is the field that matters: an event whose SHAPE changed makes cancellation silently stop firing — the run keeps going, which is the safe direction, and nothing about the run says a cancellation was attempted and could not be evaluated.

  Two bounds the first version was missing: the live-run read is paged (oldest-first, so a bounded sweep makes progress instead of re-reading the same page) and reports when it filled up; and a LISTING failure now HOLDS the watermark, because a tick that never evaluated those events must not advance past them. A cancel that was attempted and refused still advances — those are different failures and only one of them is worth retrying.
- **@voltro/workflow, @voltro/cli, @voltro/devtools-ui** — **`concurrency.pool` — one budget shared across workflows.** Without it, a concurrency limit bounds one workflow's runs; five workflows that each call a rate-limited provider hold five separate budgets nobody meant to multiply. Declaring the same pool name makes them compete for ONE:

  ```ts
  // embeddings.workflow.tsx AND summarize.workflow.tsx
  concurrency: { limit: 10, pool: 'openai' }
  ```

  `key` still partitions WITHIN the pool (`(p) => p.tenantId` in each member → a per-tenant shared budget). Every member must declare the SAME `limit` — the boot fails on a disagreement, naming every workflow involved, because two numbers for one budget is a contradiction and silently picking either would enforce a limit somebody did not write.

  Mechanically, the pool is spelled into the stored concurrency key (`pool<NUL><name><NUL><key>` — NUL separators so an app key function cannot collide with it by accident), so the pending row, the ledger row and the drainer all group pool-wide without any of them knowing pools exist. The count query drops its per-workflow filter exactly when a pool is declared; two UN-pooled workflows with a coincidentally-equal key stay separate budgets, and a test pins that boundary in both directions. The dashboard renders the pooled spelling as `pool:<name> · <key>`.

  Also in this change: the unreleased `concurrency.scope: 'replica'` option is GONE before ever shipping. It was resolved and then read by nothing — a knob that did nothing distinguishable — and it cannot be coherent in this model: deferred starts queue in the SHARED pending table and are drained by whichever replica has capacity, so a per-process count has no meaning. The limit is deployment-wide, enforced through the shared admissions ledger, full stop.

  codemod: none — `pool` is additive and `scope` never appeared in a published release.
- **@voltro/runtime, @voltro/cli, @voltro/devtools-ui** — **Server-side run filtering + a throughput/failure chart, across both dashboards.**

  The runs surface used to fetch the newest N rows and filter in the browser — fine at a hundred runs, useless at a hundred thousand, where the five failed runs you are hunting have long scrolled out of the fetched page.

  - **`ctx.workflows.listRuns(...)` and `GET /_voltro/inspect/workflows/runs`** gain composable server-side filters: `statuses` (several at once), `source`, `tagContains` (`q=` — the search-box semantic, where `tag` stays exact), `idPrefix` (matches the run id OR the execution id, so an operator never has to know which kind their log line carried), and a `startedAfter`/ `startedBefore` time range. The dashboards' filter bars send exactly these; the shared `WorkflowsPage` keeps its client-side filtering as a second layer, so an older api that ignores the params still renders a correctly-filtered page — just off a larger fetch.

  - **`GET /_voltro/inspect/workflows/stats`** returns ~48 buckets of run activity over a trailing window (`hours` up to 168, optional `tag`), each with started/succeeded/failed/cancelled counts plus per-workflow totals. Computed by the app itself and PROXIED to the cloud dashboard, so both dashboards render the same aggregation instead of two derivations that drift. When the window exceeded the scan cap the response says `truncated: true`, and the chart renders that as a warning — a silently-truncated chart shows throughput dropping at exactly the moment it spiked.

  - **`WorkflowThroughputChart`** (devtools-ui) — a dependency-free SVG stacked-bar chart (green delivered / red failed / grey cancelled / blue in-flight), rendered on the Runs tab and in per-workflow detail mode in the local AND cloud dashboards.

  The cloud runs subscription (`apps.inspectWorkflowRuns`) accepts the same filters — time range included — and applies them inside the reactive predicate, so deltas for filtered-out runs never reach the browser.

  - **The filter bar grows a TIME RANGE** (two `datetime-local` inputs), URL-persisted like the other filters. Deliberately NOT part of saved views: an absolute range goes stale the moment it is saved — "last Tuesday" is a moment, not a view — and silently re-applying it later filters to an empty page that reads as "no runs".

  - **The overview chart lists the busiest workflows** in the window (per-tag started/ok/failed), each linking into that workflow's detail view.

  - **`voltro workflows list`** gains the same triage flags (`--statuses a,b`, `--q`, `--source`, `--id-prefix`, `--since`/`--until` — an unparseable instant fails loudly at the flag rather than returning an empty page), and **`voltro workflows stats`** renders the chart in the terminal: a unicode sparkline for started/failed plus per-workflow totals, with the same never-silent truncation warning.

  codemod: none — all additive.

### Fixed

- **@voltro/cli** — **Re-issued the credential-purge query, because the correction to it could not reach the people who ran the wrong one.**

  `0.28.0/04_audit-redacts-subject-metadata` originally printed `subject::text ILIKE '%token%'` — postgres-only, and its natural MySQL/MariaDB translation (`LIKE`) is case-SENSITIVE against the `utf8mb4_bin` collation our own migrator emits for a `json()` column. `'%token%'` therefore does not match `jiraToken`. A team ran it over 141 rows, got `0`, and nearly filed themselves clean; 117 of those rows held a working credential.

  The 0.28.0 note was corrected — and that correction is unreachable for everyone it concerns. `selectCodemods` picks `from < version <= to`, so a project that has already crossed 0.28.0 never runs a 0.28.0 codemod again, however wrong its note turned out to be. **A codemod note is delivered once, at a version boundary, and is not a document you can revise.** When one is found wrong after its version ships, the correction has to be re-issued under a version users have not yet landed on. `0.30.0/02_audit-purge-query-recheck` is that re-issue.
- **@voltro/database** — A `bytes()` / `crdtText()` column read over a reactive subscription or query threw on the CLIENT: `rowSchema`'s wire mapping used `Schema.Uint8ArrayFromSelf`, whose encode leaves a raw `Uint8Array` — `JSON.stringify` turns that into a numeric-keyed object (`{"0":1,…}`) the decoder then rejects. Every other column type in that module already crosses in a JSON-safe form (timestamp → epoch-ms number, bigint → decimal string); bytes was the outlier. It now crosses as a base64 string (Uint8Array in the handler, string on the wire), regression-covered by a full JSON round-trip for both `bytes()` and nullable `crdtText()`. codemod: none — the prior behaviour threw, so there is no working consumer to migrate. (Surfaced while building the api-collab/frontend-collab CRDT templates.)
- **@voltro/cli** — The `@effect/cluster@0.60.0` patch cast a message's `deliver_at` to `BigInt` for EVERY dialect (the fix was for mssql's tedious driver, which infers INT and overflows post-2001 epochs). But `@effect/sql-sqlite-node` runs `safeIntegers(true)`, where a bigint `deliver_at` breaks the due-message comparison — the cluster workflow engine polls forever, never delivers the message, and the workflow HANGS. This silently broke every cluster/workflow integration path on sqlite since the 0.60.0 bump (the whole sql-sqlite cluster suite timed out at ~95s and read as "flaky under load"). The cast is now dialect-conditional — `BigInt` only for mssql, plain number elsewhere (the pre-0.60.0 behaviour pg/mysql/sqlite always accepted). sql-sqlite: 86/86 in 12s (was 7 hanging at 96s); mssql's overflow fix preserved.
- **@voltro/cli** — `voltro dev`'s console capture no longer destroys the error it is passing through.

  Node's `console.error` formats every argument with `util.inspect`. A React SSR failure carries the element/props graph, inspecting it can exceed V8's string cap, and `inspect` then throws `RangeError: Invalid string length` from `markNodeModules` — which REPLACES the error being reported.

  **The framework is what made that fatal rather than merely ugly.** `voltro dev` installs a console wrapper on every boot and its first act was an unguarded pass-through, so the RangeError propagated out of `console.error` itself. A consumer chased a one-line dev-SSR i18n bug across two rounds through this mask and only recovered the real message by neutralising `console.error` from their own app code.

  The pass-through now retries with bounded arguments and says that it did. Truncation that announces itself is the point: a message that silently stops looks like a short message, and the reader draws conclusions from it. Ordinary console output is untouched — the guard is a fallback, not a filter, and a wrapper that reshaped every line would be the mask with extra steps.

  Red-verified: restoring the unguarded call turns two of the three new tests red.
- **@voltro/runtime** — A malformed CRDT update written to a `crdtText()` column no longer crashes the mutation with a cryptic `Unexpected end of array` from deep inside Yjs, and can no longer be stored raw to poison later reads. The server merge now validates every incoming update — folding it against the stored state, or an EMPTY state on a first write (previously a first write stored the bytes unchecked) — and a non-decodable update throws a clear, column-named error naming what a client must send. Surfaced while exercising the api-collab CRDT template.
- **@voltro/cli** — The `events: declared but not wired` check no longer calls every webhook event dead when an app emits through a shared helper.

  Two independent defects produced that, both fixed:

  - **The emitter test required the webhooks service within 400 CHARACTERS of the `emit(`.** That is a claim about file layout, not about code. An app that funnels every emit through one helper has `import { useWebhooks as webhooks }` at the top and the call a hundred lines below. A consumer's only `.emit(` in their entire api reads `webhooks(ctx).emit(descriptor, payload)` and matched neither alternative. The qualifier now has to appear anywhere in the file, the same shape the bare `publish(` rule already used, plus the package specifier for the aliased-import case where no service identifier survives into the body.

  - **A funnel names no event, because the descriptor arrives as a VALUE.** A text scan cannot follow a value across a call boundary. That is not weak evidence of a dead event — it is no evidence, in either direction, and the check reported it as the strongest kind: 29 of 29 events flagged "never published" on every boot, for an app where all 29 were live.

  The producer half now **abstains** for webhook events once an indirect emitter is found, and says so: `N webhook event(s) NOT verified … Not a warning, and not a pass either.` Abstaining silently would be its own defect — a check that stops reporting is indistinguishable from a codebase that got fixed.

  The abstention is scoped to the webhook audience. An in-app event still has `publish(` to find, and a genuinely dead webhook event is still reported in a project whose emit sites name their events.
- **@voltro/cli** — `react-intl` joins `react` / `react-dom` in Vite's `resolve.dedupe`, in `voltro dev` and in every `voltro build` SSR config.

  It carries a React CONTEXT whose two ends resolve from different roots: the framework builds `<I18nProvider>` by loading `@voltro/i18n` through Vite's SSR loader from its own dir, while the app's `useT()` imports it from the app root. Two physical copies means the provider is present and INVISIBLE — `useIntl` reads the other instance's context and throws *"[React Intl] Could not find required `intl` object"*, byte for byte the error you get when there is no provider at all.

  A single-app fixture cannot surface this (only one copy ever exists), which is why the guard is the config rather than a test. Dev and build dedupe the same set on purpose — an app that renders in one and not the other is the boot-path divergence class.
- **@voltro/runtime** — **`column(...)` in an `.aggregate({})` spec crashed the memory store.** The documented way to project a grouped key (`groupBy(['status']).aggregate({ status: column('status'), n: count() })`) has always compiled on every SQL dialect — and threw `computeAggregates: unknown op 'column'` on `store: 'memory'`. Worse, only once the table held a row: an empty table never reaches the evaluator, so the aggregate looked healthy exactly until it had data. Found live against the reference app's `orderStats` aggregate; the memory evaluator now answers the op from the group's key (every row in the bucket shares it by construction), pinned by a parity test.

  codemod: none.
- **@voltro/database** — **In-memory Date predicates compared by REFERENCE, so every range boundary was off by one row.** `evaluatePredicate`'s comparator checked `lhs === rhs` before `>` — reference equality for objects — so two Date objects holding the SAME instant compared as "less than". `gte(startedAt, T)` EXCLUDED a row whose value was exactly T, `lt(startedAt, T)` INCLUDED it, and `eq`/`neq`/`in`/`notIn` never matched a Date at all unless it was literally the same object. SQL never had the bug (the compiler emits `>=`/`<`), which is what kept it invisible: the same query returned different rows on the memory store than on postgres, only at the boundary millisecond.

  Same defect class as the analytics sink that lost same-millisecond events — an instant-boundary comparison whose failure is one row, at one millisecond, in one store. All comparators now normalise Dates to their instant (`equalsValue` / `compareNumeric`), and `datePredicateBoundary.test.ts` pins every operator on both sides of the boundary.

  Affects everything the in-memory evaluator serves: the `store: 'memory'` store, the reactive engine's pre-filter, and unit-test fixtures — which also means a test that "passed" against a memory fixture and failed against SQL at a time boundary was this, not your code.

  codemod: none.
- **@voltro/cli** — **An event published from a MUTATION never reached its durable audience — no event-log row, no triggered workflow, while the mutation reported success.** Two independent defects, one symptom, both boot paths:

  1. **The events facade wrote through the mutation's TRANSACTION.** `publish` correctly defers the durable half to `lifecycle.afterCommit` — but by then the transaction is closed, so the `_voltro_workflow_events` insert failed (or vanished into a discarded overlay) and the deliberate `.catch(() => {})` on the emit hid it. The facade writes through the BASE store now: post-commit facts do not belong to a closed transaction. (The OUTBOX intent stays on the transactional view on purpose — it is written DURING the handler and must die with a rollback.)

  2. **The trigger's workflow start was deferred TWICE.** The start closure wrapped itself in the post-commit facade even though it is only ever reached post-commit — so it pushed its real `start` onto an afterCommit drain that had already finished. The delivery row optimistically said `started` with a minted execution id, and the engine never saw the run: no run row, no admission entry, no error.

  Found LIVE, not by a test: the reference durable app's advertised chain (mutation → `order.placed` → trigger → `orders.fulfill`) placed orders that never fulfilled. The action-shaped bridge tests stayed green throughout, because outside a transaction both stores are the same object and nothing defers — which is exactly the shape the new regression test builds: a mutation-formed context with a lifecycle and a tx store that refuses writes after commit, asserting the event row exists AND the workflow really started. Both halves red-verified.

  Publishes from actions, schedules, startup hooks and workflow bodies were never affected.

  codemod: none — no user-authored code changes; the fix restores the documented behavior.
- **@voltro/runtime, @voltro/cli** — **`guards.rateLimit` on a reaction was neither per-key nor a limit — two defects, both reported from production.**

  It reads as a per-key cap. The runner keyed the limiter on the **reaction name**, so one cap covered every row and every tenant that reaction watched: an app with a hundred tenants got a hundredth of the throughput it declared, and the busiest tenant starved the rest.

  And the limiter was **in-memory, per process**. With three replicas the effective cap was 3×, and nothing in the declaration said so — the same config produced a different limit depending on how many pods happened to be running.

  ```ts
  rateLimit: { limit: 10, windowMs: 60_000, key: (e) => e.new.tenantId }
  ```

  `key` partitions the cap; omitting it keeps the GLOBAL meaning, which is a legitimate thing to want (a cap on a scarce downstream) — just not what the field appeared to offer. The limiter is now a claim in the shared store, using the same INSERT-wins arbiter the cron scheduler relies on, so the cap holds across replicas. A read-then-write would not: two replicas both read N-1, both fire, and the cap is exceeded by exactly the number of concurrent replicas.

  That forces a FIXED window (a sliding one needs prior timestamps, i.e. a read), with the standard artefact: up to 2× the limit can fire across a bucket boundary. Stated rather than hidden, and a far smaller error than the N× it replaces — 2× transiently at a boundary versus N× permanently.

  Where no durable claimer is wired (dev on the memory store) the per-process fallback remains, and `attachReactions` now says so ONCE at boot rather than leaving it to be discovered. The partition key applies there too, so the per-entity half of the fix survives.

  Also: **`act` can shape the workflow's payload.**

  ```ts
  act: { kind: 'workflow', workflow: 'tourNarration', payload: (e) => ({ rowId: e.new.id }) }
  ```

  Without it the workflow's payload schema was dictated by the watched TABLE's row shape — every column travelling whether the workflow wanted it or not, and a `timestamp()` column arriving as a `Date` on MariaDB and a number elsewhere, so apps were normalising on both sides of an idempotency key. Omitting `payload` keeps the changed row, exactly as before.
- **@voltro/runtime, @voltro/voltro, @voltro/cli** — **`apiSurface: compatible`, and the reason:** `bindMutation` gained a seventh parameter and it is OPTIONAL. Every existing call site compiles and behaves exactly as before — omitting it skips the new check entirely, which is the deliberate default for a caller that cannot name a schema. `@voltro/voltro`'s golden churns only because it re-exports runtime. Nothing was removed, narrowed, or renamed.

  A TAGGED error a procedure does not DECLARE no longer reaches the browser as the full `ExitEncoded<…>` decode tree.

  It was a third category neither guard could see: the untagged-failure catch skips it (it has a `_tag`), `INFRA_ERROR_TAGS` skips it (it is not on a curated list), and the rpc encoder then cannot match it against the descriptor's `error:` union and ships the whole tree — ~2 KB for a one-line cause, with the message at the END so every tool that truncates shows the useless half. A consumer met it with `TenantScopeViolation`.

  **Adding that tag to the infra list would have been wrong**, and that is the interesting part. `effectStore.ts` documents `error: Schema.Union(TenantScopeViolation, StoreOperationFailed, MyDomainError)` as a supported declaration, so an app that DECLARES it must still receive it typed. Collapsing unconditionally would break that app to fix the other one.

  So the rule is a predicate, not a longer list: **tagged AND not representable by THIS descriptor's declared union** — `Schema.is(descriptor.error)`. The union IS the contract, so asking it directly cannot drift from what the encoder accepts. A call site that supplies no schema keeps the old behaviour exactly, rather than collapsing errors it cannot classify. Wired in dev AND serve: a sanitiser active on one boot path only is the drift class the parity guard exists for.

  `defectMessage` now prefixes the `_tag` when there is one. A `Schema.TaggedError` with no `message` field rendered as an empty string, so the collapsed `InternalError` arrived correct, small AND useless — half a fix for the tree it replaces.
- **@voltro/plugin-webhooks, @voltro/cli** — **`webhooks.subscribe(...)` could not write its own table.** From any authenticated executor it died with:

  ```
  TenantScopeViolation: cannot insert into tenant-scoped table without an
  authenticated tenant — subject.tenantId is null. Either authenticate first or
  pass tenantId explicitly in the row (admin tooling).
  ```

  `_voltro_webhook_targets` carries `.with(tenant())`; the mixin scopes by the REQUEST subject; the service is built once at boot with the app-level store and no subject. The READ path got its binding in 0.29.0 (`EmitOptions.tenantId`, from the acting subject). The WRITE path had the identical gap and no equivalent — and **both escapes the error message named were unreachable**: you cannot "authenticate first" against a subject-less service, and `SubscribeInput` had no `tenantId` to pass.

  `ctx.webhooks.subscribe(...)` now binds the acting subject's tenant, exactly as `emit` does, and `SubscribeInput.tenantId` exists for the admin tooling the message mentions. An explicit value at the call site wins; an explicit `null` survives (a deliberate system-wide subscription) rather than being replaced.

  **What this invalidates, and it cuts both ways:** a `count(*) FROM _voltro_webhook_targets` of `0` did not mean "unused". It meant "never worked". A consumer read their zero as "the feature is unannounced"; we read it as "not exposed". Neither was true, and the empty table looked like evidence to both of us. Anything downstream that rested on that zero — an exposure assessment, a "nothing to purge" — has to be re-asked now that a subscription can exist.

### Internal (no consumer-facing effect)

- **@voltro/cli** — Guard test (`clusterPatchDialectGuard.test.ts`) that fails fast if a dependency bump re-vendors the `@effect/cluster` patch with an unconditional `BigInt(deliver_at_in)` cast — the exact shape that hung the sql-sqlite cluster/workflow suite for a week (safeIntegers(true) + a bigint deliver_at → message never delivers → workflow hangs, misread as flakiness). Self-tested: the negative matcher catches the buggy line and passes the mssql-only conditional. Test-only, no consumer effect.
- **@voltro/cli** — One shared `walkSourceFiles`, and a guard that makes source-tree guards use it.

  Our codegen and agent suites create scratch fixtures INSIDE `packages/cli/src` (`mkdtemp(join(here, '.agent-fixtures-…'))`) because the codegen imports them through vite's module graph, which is rooted at the package. A guard that walks `src/` concurrently races them, and the failure is always the same shape: the whole FILE dies at COLLECTION time with `ENOENT` on a path nobody recognises, and it is green when re-run alone — the signature people write off as flake.

  **Third occurrence, and that is why this is a function rather than another paragraph.** `ledgerReadPortability` hit it with `readdirSync` + `statSync` (two syscalls, one gap) and `packages/cli/CLAUDE.md` gained "any new guard that walks a source tree must do both". `broadcastNamespaceCoverage` then hit it while that rule was written down and current: it had `withFileTypes` — half the rule — and descended into a `.scan-fixtures-…` directory another suite had just removed.

  `walkSourceFiles` has three properties, each load-bearing: one syscall per entry, dot-directories skipped (a scratch dir is never source, so this is right on its own terms), and a directory that vanishes mid-walk is skipped rather than fatal.

  Six guards migrated — one of which still carried the ORIGINAL `readdirSync` + `statSync` shape. `sourceWalkDiscipline.test.ts` fails if a file reads a package `src/` without importing the shared walker; its first version flagged four files that had just been migrated correctly (a single-level `readdirSync` enumerating package directories is the shape we WANT), so the rule is "import the walker", not "never call readdirSync".
- **@voltro/cli** — The cross-replica latency test can now tell a dropped MESSAGE from a dropped CONNECTION.

  It asserted zero loss over a raw subscribe — a stronger claim than the transport makes. Redis pub/sub has no retention, so when a subscriber's broker connection blips, everything published during the blip is gone by design. The shortfall looks identical to real loss, and the assertion reported the first as the second: `expected 163 to be 200` inside a full gate run (80 packages plus an 11-service docker stack on 12 cores), while the same test passed 8/8 in isolation — including under 12 busy loops.

  **A loss check that a contended machine can trip cannot be trusted about loss, which is the only thing it exists to say.**

  The bus already publishes the fact needed to separate them: `kind: 'gap'` with a PROVEN `missed` count. The test now records gaps and, when any occurred, skips the loss assertion LOUDLY with the count — a run that could not measure must not read like a run that measured nothing wrong. With no gap, a shortfall IS loss and still fails; red-verified by dropping every fifth delivery. The latency budget applies either way, guarded by a floor so the percentiles are never computed over a sample too small to mean anything.

  Production recovery for a real gap is unchanged and covered elsewhere (`busGapDetection.test.ts`): a live query is idempotent, so the bus detects the gap and re-runs.

---

## [0.29.0] — 2026-08-07

### ⚠ BREAKING

- **@voltro/web** — **`useLoaderData()` throws where no `loader` is declared, and `useOptionalLoaderData()` is the way to read where one may be absent.**

  `useLoaderData()` was `useContext(LoaderDataContext) as LoaderData<T>` — a cast over a context whose default was `undefined`. At a level with no `loader`, `const { project } = useLoaderData<Data>()` died at `Cannot destructure property 'project' of undefined`: a message naming the property rather than the mistake, and under `renderMode: 'ssr'` a throw that fails the entire server render instead of degrading. Reported by a consumer who spent a cycle on it.

  **What this is NOT: a `| undefined` return type.** That was the obvious fix and it is wrong. The router never renders a page that declares a `loader` without its data — a settled loader commits its data and the displayed route together, a pending one renders the `Pending` skeleton (or keeps the previous page), and one that threw renders the error subtree; three separate branches. Widening the type would have taxed every correct call site to model a state the router already prevents.

  So the return type is unchanged and the absence becomes loud instead:

  - a level with **no `loader`** throws, naming the cause and pointing at `useOptionalLoaderData()`; - **`useOptionalLoaderData()`** returns `undefined` there — for the one legitimate case, a component genuinely mounted both under routes that declare a loader and routes that do not; - a loader that legitimately resolves to `undefined` does **not** throw. Declaring a loader and returning nothing is a choice; having no loader is not.

  **An empty RESULT is not an absence.** A loader returning `{ items: [] }` returns exactly that through both hooks. `undefined` never means "the query found nothing" — only "there is no loader at this level". The wire already made this distinction (`pageLoaderRan`, documented as "not derivable from loaderData"); the hooks now honour it too.

  Internally the provider carries a `NO_LOADER_DATA` marker instead of `undefined`. It is still a provider, deliberately: dropping it for loader-less levels would let a page fall through to its layout's data and silently render a neighbour's value — worse than the crash it replaces, because nothing would report it. Server and client set the marker from the same fact (`descriptor.loader` / `segment.loader`), so a component cannot render on one side and throw on the other.

  Covered by `loaderDataAbsence.test.tsx` (both render paths, the shield, the empty-result and legitimately-undefined cases), red-verified by removing the marker.

  **`voltro update` carries you across this** — codemod `0.29.0/01_loader-data-absence`.

### Added

- **@voltro/runtime, @voltro/cli** — aggregate-derivation (sharpened): the mutation/action interceptor `meta` now carries the descriptor's DECLARED write-target table names (`meta.target`) for an audit/derivation consumer — purely additive, omitted when no target is declared. And `voltro doctor` gains a junction-FK finding: it flags a link/junction table whose FK wiring is suspect (composite-PK member unwired, an unwired id beside a real `reference()` sibling, pure link-only table), reading real declared column types (not regex) with a tight non-FK-id exclusion so a lone foreign id on a normal entity stays silent. The FK-walk DSL + `voltro audit map` are deferred (single-consumer). apiSurface compatible — additive exports + type-alias renumbering only. codemod: none.
- **@voltro/plugin-billing** — Stripe Checkout now collects a customer's VAT / tax ID (`tax_id_collection`), and reuses an existing customer's captured name + address so the tax ID attaches correctly. Additive; `automatic_tax` was already on.
- **@voltro/database, @voltro/protocol, @voltro/runtime** — New `rule()` primitive: `table(...).rule(name, predicate, { severity? })` declares a cross-table transactional invariant evaluated INSIDE the mutation transaction (predicate reads share the write's MVCC snapshot via the dialect-neutral query layer — correct on all four dialects, no per-dialect code). A violation rolls the transaction back and fails with the typed, wire-preserved `BusinessRuleViolation` (auto-merged into every mutation's error union at `mutationToRpc`, like `ScopeError`); `severity: 'warning'` logs + commits instead. codemod: none.
- **@voltro/cli** — Three CLI commands: `voltro typecheck` (runs `tsc --noEmit` using the APP's own TypeScript, resolved from the app's node_modules), `voltro info` (CLI/node/pm/dialect + every installed `@voltro/*` version, flags lockstep skew with a non-zero exit), and `voltro new <query|mutation|action|workflow|page> <name>` (scaffolds the correct file convention incl. the descriptor/executor split; refuses overwrite without `--force`). codemod: none.
- **@voltro/cli** — DNS-rebinding Host guard on the `voltro dev` inspect surface (the API dev server binds 0.0.0.0 and had no Host validation, unlike the web dev server's vite `allowedHosts`). Allows loopback names + any IP literal (an IP can't be DNS-rebound, so phone-on-LAN testing keeps working) + an operator `VOLTRO_INSPECT_ALLOWED_HOSTS` allowlist; any other Host domain → 403. Dev-only by design (prod is token-gated + served on a public hostname). codemod: none.
- **@voltro/datetime** — New `@voltro/datetime` package (Phase 1): UTC-storage + timezone-aware helpers on the TC39 Temporal standard, plus a request-scoped timezone-context seam (`@voltro/datetime/context`). The `.` entry is browser-safe (no `effect`, no `node:*`); `effect` is an optional peer for the context seam. `interval()`/`rrule()`/schema DSL types are deferred to later phases.
- **@voltro/runtime** — `defineExpectation()` — reactive data-quality contracts as standing reactions. An expectation over a table (`freshness`/`nullRate`/`rowCount`/`valueBounds`) is maintained incrementally from `store.onChange` CDC deltas by the same IVM engine that backs `defineAggregate({ incremental })` (O(1) per write, no re-query); it tips `holding`↔`violated` with the provenance (traceId/subject/procedure) of the write that caused it, observable via `ExpectationRegistry`. `freshness` also re-compares its IVM-maintained max against the moving clock (so "writes stopped" is detectable — a metric re-comparison, not a data poller). `*.expectation.ts` file discovery wires in the CLI. codemod: none.

  (apiSurface: compatible — the runtime golden churn this session is additions plus api-extractor renumbering its internal `Row_N` dedup alias; no public symbol was removed or resignatured, so no consumer breaks.)
- **@voltro/runtime** — `defineExperiment()` — online A/B / holdout experiments as live IVM aggregates. Per-variant metrics (count/sum/avg/conversionRate) are maintained incrementally from a table's CDC by grouping rows onto a synthetic variant column through one `AggregateMaintainer` (real-time lift/diff vs a baseline, no batch pipeline); assignment is a salted FNV-1a hash → `[0,1)` (client-reproducible, no `node:crypto`), holdout carved off the top so treatments don't perturb it. Observable via `ExperimentRegistry`/`useExperiment`. Correctness pinned by a brute-force oracle (incremental == full recompute) over insert/update/delete for all four metrics. `*.experiment.ts` file discovery wires in the CLI. codemod: none.
- **@voltro/cli** — `voltro doctor` gains two findings over declared events. **Delivery-semantics visibility** (informational): it now NAMES each declared event's delivery mode (`each` vs `latest`) — the mode decides what a MISSING message means (`each` counts a drop as a loss and tells the subscriber; `latest` supersedes and says nothing), and it was invisible after the fact everywhere except the devtools panel. **A scale WARN** for two declared-but-won't-scale shapes: a routing key with 3+ fields (every field fragments the subscriber set — distinct routes are the product of the fields' value spaces, so a payload discriminator smuggled into the key multiplies routes for nothing), and a `webhook:` block on a per-frame-looking event (`player.moved`, `cursor.moved`, `*.frameRendered` — every publish becomes N HTTP deliveries per second per target, and the plugin DEFERS the excess as pending rows rather than failing, so the symptom is a growing table). Both read the REAL declared descriptors — the routing-key field count comes from the same top-level schema-property reader the runtime validation uses, so it cannot disagree with the key the event routes on. Advisory, never blocking (same ladder as the orphan audit); surfaced in `--json` as `eventDelivery`. codemod: none.
- **@voltro/runtime, @voltro/cli** — Reactive-finops now EMITS cost events on real reactive work — the deferred second half of `attachFinops`. The Dispatcher gained a `recordCost?: (e: CostEvent) => void` sink (sibling to `recordDelivery`); each time a change re-runs an affected subscription and pushes it a delta, it records one `{ unit: 'recompute', amount: 1 }` event attributed to the subscription's tenant + traceId (both the row-set and computed-query delivery paths). Both boot paths (`voltro dev`, `voltro serve`) thread the finops runner's `record` into the dispatcher as `recordCost` — ONLY when `*.budget.ts` cost budgets were discovered, so an app with none allocates no `CostEvent` on the reactive hot path. Live attribution is observable via `CostRegistry`. codemod: none.

  (apiSurface: compatible — one additive optional field on `DispatcherDependencies`; no public symbol removed or resignatured.)
- **@voltro/database, @voltro/runtime, @voltro/local-first** — local-first (deepened toward a working vertical): `crdtText()` is now a real database column type (`@voltro/database`). It stores an encoded CRDT state as `bytes` + a pure `crdtManaged` marker, so the declarative differ treats it as an ordinary nullable `bytes` column — no special DDL, and it round-trips through a plan on every dialect with zero churn (`crdtColumn.test.ts`). The authoritative server-side merge is wired into the runtime write path: the MutationStore folds an incoming encoded update into the stored state with `mergeCrdtStates` (`@voltro/local-first`) before writing, so two concurrent clients converge, and the reactive engine broadcasts the merged result (`crdtMerge.test.ts`, in-memory store, order-independent convergence). `@voltro/local-first` also gains a client persistence CONTRACT — `PersistenceAdapter` + `createInMemoryPersistence()` — and `loadPersistedSyncQueue`, which drains the offline sync queue into it so writes survive a reload. The browser-safe merge primitives stay separate from any `database` handle (the column type is server-side in `@voltro/database`; the merge core is the pure `@voltro/local-first` `.` entry).

  Still seamed (documented, not built): the durable persistence backing (WASM-SQLite / Turso), the bi-directional sync WIRE transport, the presence channel (Redis/NATS), and the higher-level `localFirst` table mixin + client codegen discovery. codemod: none (purely additive — no user-authored code is affected; a `crdtText()` column is opt-in).
- **@voltro/local-first** — New opt-in `@voltro/local-first` package (Phase 4, first slice): CRDT + local-first primitives. `crdtText()` is a Yjs-backed CRDT text field behind our own `CrdtBackend` abstraction (the swap point for Loro later — nothing above the backend file imports `yjs`), with the deterministic merge primitive `mergeCrdtStates(a, b)` at its core (concurrent inserts converge order-independently, idempotent re-merge, empty-state identity). Ships the offline sync-queue as a pure reducer (`syncQueueReducer` — enqueue offline, FIFO drain on reconnect, requeue-on-fail with attempt counts), the connection-lifecycle state machine (`connectionReducer` + `deriveSyncStatus`), and `conflictPolicy()` / `lastWriteWins` for non-CRDT fields (deterministic, convergent tiebreak). React wrappers `useSyncQueue()` / `useConnectionStatus()` live under the `./react` subpath (`react` is an optional peer, kept off the pure `.` path). The `.` entry is browser-safe (no `node:*`, no `effect`). Client SQLite persistence (Turso/WASM), the bi-directional sync wire, the presence channel, and the `crdtText()` schema-DSL / `localFirst` mixin codegen wiring are declared as type-level seams (`./seams`) — deferred, not faked.
- **@voltro/local-first, @voltro/database, @voltro/runtime** — local-first (the vertical, integrated with existing framework infra): three of the four seams from the first slice are now BUILT against real, tested wiring, and the `localFirst` table mixin ships.

  **Bi-directional sync wire (`@voltro/local-first`).** `createSyncClient({ transport })` maps the pure sync-queue reducer onto a `SyncTransport` (two functions an app binds to its EXISTING primitives — `push` to the client's mutation caller writing the `crdtText()` column, `onRemoteState` to the reactive subscription streaming the row). A local edit merges optimistically + queues; reconnect drains to `push` with retry/attempt-bump; incoming merged state folds back via the CRDT. Tested against an in-memory dispatcher that mirrors the runtime's authoritative merge — offline edit drains on reconnect, a remote edit arrives and merges, two concurrent offline edits converge (`syncClient.test.ts`).

  **Presence / awareness (`@voltro/local-first` + `./react`).** `usePresence(roomId, { cursor, name }, { channel })` returns `{ presence, others, setPresence }` over a `PresenceChannel` — the SAME dumb string-payload pub/sub shape as the framework's `BroadcastProvider`, so a runtime binding forwards straight onto the app's broker (in-memory locally; Redis/NATS at scale, already shipped). Join/leave, announce-back discovery, cursor propagation, and TTL expiry live in the pure `createPresenceRoom`; `createInMemoryPresenceChannel` is the test/local transport. Tested pure (`room.test.ts`) and in a real DOM (`usePresence.test.tsx`): two peers see each other, updates propagate, a leaver drops, a silent peer expires.

  **Durable persistence (`@voltro/local-first`).** `createIndexedDbPersistence()` is a durable `PersistenceAdapter` over IndexedDB — no WASM, no added dependency. The IDB implementation is injected, so it is tested against a fake backend that survives a reopen — the durability the in-memory adapter lacks (`indexedDb.test.ts`).

  **`localFirst()` table mixin (`@voltro/database` + `@voltro/runtime`).** A marker mixin (adds no column) that opts a table into local-first sync + persistence; `isLocalFirst()` / `localFirstTables()` are pure discovery helpers, and the runtime SchemaRegistry reflects it as `hasLocalFirst(table)` (beside the existing `crdtColumns(table)`) — the discovery surface, with NO codegen change (a marker mixin rides `.with()` like any column type). The registry id is re-declared and pinned to the mixin by `localFirstMixinId.test.ts`, exactly like tenant/expires.

  Browser/server boundary preserved: the sync client, presence, and persistence are browser-safe (no `node:*`, no `@voltro/database`, no runtime); the authoritative CRDT merge stays server-side in `@voltro/runtime`. codemod: none — purely additive, all opt-in.

  What GENUINELY remains a runtime seam (infra + a thin app binding, not un-built framework code): a `SyncTransport` bound to a specific running app's mutation/subscription, a `PresenceChannel` bound to a provisioned Redis/NATS broker at scale, and (optional) a wa-sqlite/Turso durable adapter for cross-tab SQL. All three sit behind interfaces the tested code already speaks.
- **@voltro/cli** — Native mobile SDK generators: `voltro build api --target swift` emits a Swift Package and `voltro build api --target kotlin` a Kotlin Multiplatform module, generated FROM the app's capability manifest (the same procedure descriptors + JSON Schemas the TypeScript client codegen reads — no source is re-parsed). Each package ships type-safe models (Codable structs / `@Serializable` data classes + enums), a one-shot HTTP client (query/mutation/action), a WebSocket subscription client (streams), an auth/tenant-context helper, and a push-registration stub. Faithful type mapping (string/number/boolean/array/nested-object/enum, optional → Swift `Optional` / Kotlin nullable). Flags: `--target`, `--out`, `--name`, `--kotlin-package`; default output `<appDir>/sdk/<target>`. The generated SOURCE is golden-string tested; cross-language COMPILE (swiftc / Gradle) and the native runtime (native modules, the APNs/FCM push sender, OTA build pipeline) are out of scope. codemod: none.
- **@voltro/cli, @voltro/web** — Page `export const preload` convention: a page declares `ReadonlyArray<string | { tag; input?(params) }>` and the SSR render (dev + start; inert for SSG) runs each subscription server-side and seeds it, so a `usePreloadedSubscription` on that page renders with data on first paint instead of re-fetching on mount. Read directly from the page module in the render loops (a purely server-side directive; the client never needs it). codemod: none.
- **@voltro/client, @voltro/web** — SSR-preloaded subscriptions: `usePreloadedSubscription(api, tag, input)` (`@voltro/client`) — `useSubscription` that reads its FIRST value from the SSR hydration payload instead of flashing an empty state and re-fetching on mount, then upgrades to the live WebSocket stream. The value is seeded server-side during a render (a loader, a layout loader) with `seedPreloadedSubscription(api, tag, input, value)`, keyed by the SAME `stableKey([tag, input])` the SubscriptionCache uses, and carried into the hydration payload alongside the store seeds (mirroring their request-scoped-bag + resolver inversion; the `node:async_hooks` scoping stays in `@voltro/web/ssr`). Because the value flows through `useSubscription`'s `initialSnapshot` render branch — read identically on the server and the client hydration render — there is no hydration mismatch. When no seed exists for the key (a client-side SPA navigation the server never rendered), it behaves exactly like `useSubscription`. codemod: none.
- **@voltro/react-native** — **New package `@voltro/react-native` — the credential-free mobile primitives.** The React client already runs in React Native (the runtime has no DOM dependency); this adds the mobile plumbing on top of it that needs no per-tenant Apple/Firebase credentials and no native runtime.

  - **Device registration** — a `_voltro_devices` table (`@voltro/react-native/schema`: tenant + user scope, platform/token/locale/timezone, `(platform, token)` unique upsert target, per-user fan-out index) plus a `registerDevice(upsert, input)` client function. `resolveDeviceRegistration` normalises locale/timezone (input → env → ambient → `en`/`UTC` floor) into the row; `userId`/`tenantId` are the server's to stamp, never trusted from the client. - **`useBackgroundSync()`** — the interval / foreground-trigger state machine. The OS background-fetch registration stays the app's; the hook is a thin wrapper over a pure reducer (`backgroundSyncReducer` + `shouldSync`: single-flight, foreground-gated, interval-gated, forced triggers bypass only the interval). - **Offline-first defaults** (`offlineFirstDefaults`: local-first opt-out on mobile, sync on foreground + interval, status surfaced) and a standalone `useMobileConnectionStatus` (`connected | degraded | offline`) — deliberately not coupled to a transport or the in-flight local-first package. - **`defineDeepLink({ pattern, handler })`** descriptor + a pure matcher (`matchDeepLink('/orders/:id', '/orders/42')` → `{ id: '42' }`; segment-exact, scheme/host/query/trailing-slash normalised; params typed from the pattern literal).

  The root export is RN-safe (no `node:*`, no `@voltro/database`; React is an optional peer reached only through the hooks). The `_voltro_devices` declaration is the server-side `@voltro/react-native/schema` subpath.

  **Deferred as documented seams** (flagged in the package, not built): APNs/FCM **sender** adapters (need per-tenant Apple Developer / Firebase credentials); native module bindings (camera, biometrics, secure storage — need a native runtime); Swift/Kotlin SDK generators (open product decision); universal-links / App-Links file automation; and the `*.deepLink.ts` codegen discovery wiring (one additive file, landed after the current release — the descriptor shape is final, so until then links register via `matchFirstDeepLink`).
- **@voltro/runtime** — `defineCostBudget()` + `attachFinops()` — reactive FinOps: per-tenant / per-subscription compute-cost attribution + budgets. A `CostAccountant` folds each `CostEvent` (`{ tenantId, subscriptionId?, unit, amount, … }`) in O(1) into a standing per-tenant attribution accumulator (`total` + `byUnit` + `bySubscription` — the chargeback/showback answer) and every budget that watches its unit. A budget is a POLICY holding EVERY tenant to the same ceiling independently (mirroring `requireAiBudget`); its per-`(budget,tenant)` windowed counter crosses `ok`→`warn`→`exceeded` with the provenance of the causing event, recovers on a tumbling-window rollover (event- AND clock-driven) or an explicit `reset(tenantId)`, and is observable via `CostRegistry` (same shape as `ExpectationRegistry`: snapshot / get / breaches / subscribe). The engine is store-free + unit-testable; the descriptor + registry Tag are browser-safe. `*.budget.ts` file discovery, the `attachFinops` call in both boot paths, and the dispatcher/query cost-event taps are the forthcoming CLI wiring. codemod: none.
- **@voltro/cache, @voltro/ai** — Reactive semantic cache: `SemanticCache` (`@voltro/cache/semantic`) — an embedding-keyed LLM cache with a cosine-similarity vector index over the existing `CacheStore`, dependency-set capture as tags (`rowDep`/`tableDep`), and eviction by source change (`onSourceChange`/`onTableChange`, insert evicts table-coarse only). `@voltro/ai/semanticCache` wraps it: `semanticGenerateText`/`semanticGenerateObject` embed→lookup→hit-returns-cached (zero tokens) / miss-generates-and-stores under the captured deps, best-effort (a cache outage degrades to always-generate). Firing eviction on live writes is a one-line CLI-facade sink (documented; no runtime change). codemod: none.
- **@voltro/database, @voltro/runtime, @voltro/cli** — Field-level read permissions: a new `.readableBy(...scopes)` column modifier (Part B of the column-wire-visibility seam). A column marked `.readableBy('billing:read')` is stripped from query + subscription wire OUTPUT for any subject that holds NONE of the listed scopes, and present for one holding ANY of them — checked against the subject's EFFECTIVE scope set (raw subject scopes ∪ rbac role-derived scopes), with the `admin:full` bypass seeing every such column. It is the graded middle of the wire-exposure axis between a plain column (visible to everyone) and `.serverOnly()` (hidden from every client); the two compose (`.serverOnly()` still wins — hidden from everyone including admins). Enforced at the SAME Dispatcher read chokepoint as `.serverOnly()` (initial snapshot + every reactive delta) and at the `publicApi` one-shot REST GET in both boot paths (`voltro dev`, `voltro serve`). Subject-independent — and therefore memo-sharing — for any table that declares no `.readableBy(...)` column. Server-internal reads (`ctx.store.query`) still see the value; the strip is a wire concern only. Declaration rejects `.readableBy()` with no scope (that is `.serverOnly()`) and a blank scope string. codemod: none.

  apiSurface note: the one changed golden line is `OneShotQueryRunnerDeps.queryRows`, which gained a second `context: ServeRequestContext` parameter so the one-shot runner can apply the subject-aware strip. It is a callback the CONSUMER supplies, so an existing `(descriptor) => …` still satisfies the wider `(descriptor, context) => …` type — the change cannot turn compiling code into non-compiling code. Everything else is a pure addition (`readableBy`, `readableByColumns`, `ReadableByColumn`, `forbiddenColumnsForSubject`, `stripForbiddenForWire`).
- **@voltro/cli** — `voltro evolve` — schema-evolution copilot for changing EXISTING schema safely. Given a change (`rename-column`/`retype-column`/`split-column`/`drop-column`/`rename-table`) it reads the OBSERVED graph (`app.graph.observed.generated.json`) + the app manifest to enumerate the real blast radius (handlers that actually touch the table; declared-but-unexercised ones flagged UNKNOWN, never assumed safe), then proposes a reviewable plan: a codemod (rename-column gets a real transform that renames the `*.entity.ts` field AND chains `.renamedFrom('old')` so the differ plans a catalog RENAME not a lossy drop+create, and annotates the handler sites the blast radius found; reshaping kinds get a `manual` codemod with generated steps) + a branch-verified backfill plan (per-kind SQL tied to `planBranchProvision`, snapshotting the exact tables the affected handlers touch) + a `voltro check` verify step. Dry-run by default; `--write` applies via the existing `runCodemods` toolkit; `--json` for CI. codemod: none.
- **@voltro/env** — Live secret rotation: `refreshEnvValue(key, value, { previous, graceMs })` installs a re-resolved env value a running process serves immediately while holding the OLD value for a grace window (lazy prune-on-read, no timer); `rotateSecretLive(key, { graceMs })` (`@voltro/env/server`) re-resolves through the backend + does the cutover, and `getSecretWithOverlap(key)` returns `{ current, previous }` — the current/previous verifier pattern for app env. Bounds (documented): this updates what code reading a secret PER USE sees (outbound keys, webhook-signing, field-encryption); it does not reconnect a live DB pool built with the old credential. codemod: none.
- **@voltro/cache, @voltro/cli** — The reactive semantic cache (`SemanticCache`) is now wireable as a framework-managed opt-in. Set `cacheSemantic: true` in `app.config.ts` and both boot paths build a `SemanticCache` over the SAME `CacheStore` the query `Cache` uses (`CacheLayer.storeLayer` is now exported so one store instance is shared by both), provide it as a `yield*`-able handler service, AND wire row-granular eviction off the runtime's existing `store.onChange` — a live DB write to a source row drops every semantic entry that depended on it (`onSourceChange`). Gated end-to-end: an app that leaves `cacheSemantic` off builds no vector index, no service, and no eviction sink. codemod: none.

  (apiSurface: compatible — `CacheLayer.storeLayer` is a new export; nothing removed or resignatured.)
- **@voltro/web, @voltro/cli, @voltro/ui** — Framework SEO + a11y primitives: `seoAlternates()` (reciprocal absolute canonical + hreflang alternates + x-default, browser-safe), `PageMeta.noIndex` (emits robots noindex on every render path + excludes the route from the sitemap), build-time `dist/sitemap.xml` + `dist/robots.txt` generation (per-locale alternates, `WebAppConfig.seo.siteUrl`, `VOLTRO_SEO_NOINDEX` staging override, never overwrites a user `public/` copy), a dev-server disallow-all robots. Accessible `<Field>` defaults filled: Schema-`description` hints wired via `aria-describedby`, the RadioWidget error now associated, required marker `aria-hidden` + `aria-required`.
- **@voltro/cli** — File-convention discovery + boot attach for the standing primitives: `*.expectation.ts` (defineExpectation), `*.budget.ts` (defineCostBudget), `*.experiment.ts` (defineExperiment) are now discovered like `*.aggregate.ts` and attached in BOTH `voltro dev` and `voltro serve` (parity), each providing its registry (ExpectationRegistry/CostRegistry/ExperimentRegistry) as a handler Layer + a `GET /_voltro/inspect/{expectations,budgets,experiments}` snapshot. This is what makes the three primitives user-reachable via file convention. (Cost-EVENT emission — the dispatcher recordCost tap — remains the deferred secondary half, so budgets are declarable+observable but attribution stays 0 until it lands.) codemod: none.
- **@voltro/ai, @voltro/cli** — `voltro eval` — replay real recorded agent/AI runs and gate the deploy on the result. `defineEval({ name, cases, assert?, judge? })` (`@voltro/ai`) declares golden cases from recorded runs; `voltro eval` discovers `*.eval.ts`, replays each case against the CURRENT model, judges with HARD assertions (`contains`/`matches`/`equals`/`nonEmpty`/`maxLatencyMs`) plus an optional LLM judge (`generateObject`-backed, schema-constrained verdict), and exits 1 on any regression — a deploy-gate signal, `--json` feedable into CI. Reuses run-recording (`runAndPersist`/`threads`), the data-branch identity machinery (`branchNamespaceName`, `--branch`), and mirrors `voltro check`'s gate shape. The runner (`runEval`/`scoreCase`/`evaluateAssertions`) is pure over an injected replay + judge, so it is fully unit-testable without a provider. `*.eval.ts` is read only by `voltro eval` — it is deliberately not a boot/browser file convention. codemod: none.
- **@voltro/workflow, @voltro/cli** — Workflow **resume-from-step** — rewind a terminally-`failed` run to an operator-chosen step and re-execute from there, past the point it actually died. The generalisation of `redrive` (which only re-runs the failed step): resume resets the target step **and every step after it** (succeeded ones included), so a step that completed cleanly but on stale/wrong external state re-runs too, while the steps *before* the target replay from the durable journal. For the dead-letter case where the failure point is not the right recovery point.

  - Engine adapter `resumeRunFromStep` on `@voltro/workflow/cluster` (sibling to `redriveFailedRun`, sharing the one `@effect/cluster`-coupling core — a live-cluster contract test asserts the step before the target REPLAYS while the target + downstream RE-RUN). - `voltro workflows resume-from-step <runId> <stepName>` + the inspect action `POST /_voltro/inspect/workflows/runs/:id/resume-from-step` `{ step }`, wired into **both** `voltro dev` and `voltro serve`. Refuses a non-`failed`/discarded run and an unknown step; declines cleanly (no journal / still running / already succeeded).

  codemod: none

### Fixed

- **@voltro/client** — **`useAction(...).run` forked against the boot-window stub instead of waiting for the api.**

  `useSubscription` survives that window by design — it reads through the loading cache, reports no data, and delivers when the real client arrives. `run` had no such backstop: called from a mount effect it threw *"rpc / cache calls are not invokable on a not-yet-resolved api"*.

  A component that fetches once on mount therefore had a race it could not see. It usually lost on a cold load and won on an HMR reload, so the page "worked when you looked at it".

  **And the message REPLACED the real one**, which is the expensive half: one app reported api resolution while its upstream was answering `403`, and the `403` was invisible because the call never left the browser. SSR sharpened it — seeding a subscription via `initialSnapshot` makes `isAuthenticated` true on the very first render, so guards that gated mount effects behind "we have a user" stopped gating anything.

  `run` now waits for the api, which is what a caller expects and what the sibling primitive already does. The wait is **bounded** (15s) and the timeout says what happened: an api that never resolves is a real condition — a name matching no configured api, a supervisor that gave up — and hanging forever would trade a confusing error for no error at all.

  The callback also stays stable across the window now: it reads the handle through a ref at call time rather than being recreated the moment the api resolves.

  `isUnresolvedApi(useFrameworkApi(name))` still composes for a caller who wants the readiness bit itself.

  **Measured in a real browser against a real api process**, not only unit-covered — `node scripts/browser-action-boot-window.mjs` (chromium, `e2e-fixtures/web-action-boot` → `memory-api`), with the pre-fix shape restored as a negative control:

  | | on a natural cold load | with a 3s `authHeaders` resolver | |---|---|---| | before | `ERR: rpc / cache calls are not invokable on a not-yet-resolved api` after **17 ms** | same error after **3 ms** | | after | `OK: {"ok":true}` after **34 ms** | `OK: {"ok":true}` after **3039 ms** |

  The window really is only ~15–30 ms wide on a warm machine, so the page takes a negative control in the same instant the call is issued — invoking `handle.client` directly, which still throws. Without it, "run succeeded" would be indistinguishable from "the api had already resolved". The bound is exercised too: a resolver that outlasts the budget settles at 15006 ms with the timeout message, rather than never.

  One thing this does NOT claim, because an earlier draft did and was wrong: `run` waits for the api to RESOLVE, not to be REACHABLE. With the api process killed the supervisor still hands over a client in ~20 ms (an rpc client is built from a layer; nothing there needs a live socket), so the call goes out and fails with a genuine `Error in socket` — which is the point of the fix, a real transport error instead of a stub message that displaced it.
- **@voltro/cli** — **The atlassian credential codemod now tells you to grep for your own key, not just for `credentialsResolver`.**

  A team doing this migration found **four** call sites reading the PAT off the Subject and only one of them was the resolver: a session strategy stamping it into `metadata`, two delegation helpers building synthetic Subjects that carried it, and an avatar fetch. Fixing the resolver alone leaves the credential on the identity and the leak intact — which is the entire point of the change.

  The note said as much in passing and was easy to read past. It now says it first, and names the reason: the resolver is where the credential is READ, not where it got onto the Subject. It also passes on what the reporter did afterwards — an invariant test that fails if anything puts a token-shaped key into a metadata bag again, mutation-tested by restoring the old line.
- **@voltro/workflow** — First-deploy cluster convergence: N runners started simultaneously against a fresh database (no `@effect/cluster` schema yet) no longer silently fail to converge. Root cause was `@effect/cluster`'s first-boot storage migration racing the pg catalog (its Migrator creates the tracking table without `IF NOT EXISTS`, and its `LOCK TABLE` guard only exists AFTER that table does). A new `clusterMigrationGateLayer` serializes the FIRST migration behind a cross-dialect advisory lock pinned to a single reserved connection (so acquire+release share a backend and auto-release on crash — the pooled `withMigrationLock` leaks here because storage build checks out several connections), building the storages sequentially. Warm boots skip it. New knob `VOLTRO_CLUSTER_MIGRATION_LOCK_TIMEOUT_MS` (default 60s). codemod: none.
- **@voltro/workflow** — **The cluster first-boot migration gate deadlocked on sqlite — an app on `store: 'sqlite'` with a workflow would never finish booting.**

  The gate serializes `@effect/cluster`'s first schema migration behind an advisory lock pinned to a *reserved* connection, because acquire and release must land on the same backend. On sqlite the lock is a no-op on both sides — single-writer, single-process, no sibling to serialize against — but the reservation around it was not: `sql.reserve` takes the ONE connection an in-process sqlite client has, and the locked work is the library's storage build, which then asks the pool for another and waits on a connection its own caller is holding.

  It presents as a boot that never finishes, not as an error. Nothing logs.

  Sqlite now runs the migration without reserving. Every other dialect is unchanged — the reservation is load-bearing there, and removing it would leak a session lock onto an idle pooled connection that every late runner then blocks on.

  **Never released** (it landed after 0.28.0), but worth reading for how it was found. The webhook delivery suite is the only place we build the cluster engine against `:memory:` sqlite; five of its tests sat at their 30 s timeout while the *same* tests on postgres, mysql, mariadb and mssql passed, because those pools hand out a second connection. So the one configuration with no infrastructure — the likeliest first thing a new user runs — was also the only one nothing else covered.

  `clusterMigrationGate.test.ts` pins the connection count, not the outcome: a test asserting only "the work ran" passes on the broken code as long as its fake pool is willing to hand out a second connection, which is exactly the assumption the real sqlite client does not satisfy.
- **@voltro/cli** — **The credential-purge query in two 0.28.0 codemods was postgres-only, and its MySQL/MariaDB translation silently under-reported.**

  Codemods `03_atlassian-credentials-context` and `04_audit-redacts-subject-metadata` both told you to check your existing rows with `subject::text ILIKE '%token%'`. `::text` and `ILIKE` do not run on MySQL/MariaDB, so the natural translation is a bare `LIKE` — which is case-**sensitive** against the `utf8mb4_bin` collation our own migrator emits for a `json()` column. `'%token%'` therefore does not match `jiraToken`, and a credential key is almost always camelCase.

  A team ran the translated query against 141 rows, got **0**, and nearly reported themselves clean. 117 of those rows held a working credential; they caught it only because the count looked implausible and they printed a sample row.

  Both notes now use `LOWER(subject) LIKE '%token%'`, which is correct on every dialect we ship.

  **Why this is worse than a syntax error, which is the part worth keeping:** a query that fails to run gets fixed. A query that runs and returns good news when the answer is wrong is read as an all-clear — in the security-relevant half of a security-relevant codemod.

  `codemodSqlPortability.test.ts` now scans every codemod note for postgres-only spellings (`::text`, `ILIKE`, `table_schema = 'public'`). It distinguishes SQL a user would copy from prose ABOUT sql by the backtick, because the first version fired on the very sentence warning against the construct — and it carries a selftest, since a scan that silently stopped matching reads exactly like a clean tree.
- **@voltro/cli** — **Outgoing webhooks never delivered on the cluster engine — i.e. in every deployment.**

  `voltro.deliverWebhook` was provided per-emit: `execute(input).pipe(Effect.provide(deliverWebhookWorkflow.toLayer(…)))`, built fresh inside the emit callback. The in-memory engine tolerates that, because there the layer IS the registry. The **cluster** engine does not: a workflow must be registered as an entity type while the runtime is constructed, and an emit happens long afterwards. So every delivery died with

  ```
  Entity type 'Workflow/voltro.deliverWebhook' not registered
  ```

  **after** the mutation had already returned `200`. Zero deliveries, zero rows in `_voltro_webhook_deliveries`, nothing in the calling service's logs. Reported by a consumer on MariaDB + cluster-sql for whom the feature had never once delivered in any environment.

  The layer is now built at boot and registered in `allWorkflowLayers` alongside the app's own workflows, in both boot paths; the emit closure runs on that runtime. An app with outgoing webhooks and no workflows of its own now builds the workflow runtime too — otherwise the fix becomes a different silent failure.

  **The axis is the part worth keeping.** Both boot paths carried the *identical* construction, so no dev/serve parity check could see it — those compare the two paths to each other, and here they agreed. The difference was IN-MEMORY vs CLUSTER, and it looked like dev-vs-serve only because `voltro dev` defaults to the in-memory engine while a deployment uses the cluster one. **A difference between two configurations of ONE path is invisible to every guard that compares paths.**

  `deliverWebhookRegistration.test.ts` pins the boot registration across both paths (red-verified by removing it from one). It is the source half; the behavioural half needs a real SQL cluster engine and is not something a fake engine could stand in for.
- **@voltro/cli** — **`voltro serve` warned that framework-provided tables "are not a declared table".**

  The stale-`source` audit is called by both boot paths. `voltro dev` passed `allRegisteredTables()` — the process registry, which includes framework- and plugin-provided tables. `voltro serve` passed `discovered.tables`, which is only what the APP declares. So a query naming `_voltro_agent_messages` (or the audit trail, or the notification inbox) was reported as naming a table that does not exist — about a table that does.

  Same codebase, same version, two boots: dev silent, serve warning. It is the false positive fixed for dev in 0.27.0, still live on the serve path — now only where nobody is watching a terminal.

  **Why no existing guard saw it.** It is not a ctx field and not a missing call, so neither the derived boot-path audit nor its ctx-key axis applies: both paths call the *same* function and hand it *different sets*. That is the same variant as the schedule-subject divergence — each call site internally consistent, the difference visible only by comparing them. Reported by a consumer who noticed the two boots disagreeing on identical source.

  Both paths read the process registry now, pinned by a guard that fails if either reverts to the app-declared set.
- **@voltro/cli** — `.serverOnly()` columns are now stripped from a `publicApi` query's buffered REST GET response. The runtime dispatcher already strips every WS / `POST /rpc` snapshot (which is what the SSR web-router loaders and `usePreloadedSubscription` seeds fetch through, so the `__voltro_state__` hydration payload was already safe), but a query projected to a public REST endpoint has no subscription to drive it — it read the store directly and shipped the raw row, including any `.serverOnly()` credential column (e.g. `keyHash`), to the caller. Both boot paths (`voltro dev`, `voltro serve`) now route the one-shot public read through the same `stripServerOnlyForWire` choke point. codemod: none.
- **@voltro/runtime** — `.serverOnly()` columns are now stripped from ALL query + subscription OUTPUT at the Dispatcher's read boundary (initial snapshot + every reactive delta), not just `crud.*` echoes + the boot audit — so a hand-written query/subscription returning a raw row no longer leaks a server-only column to the wire. Server-internal reads (`ctx.store.query`) still see the column; the strip is wire-only and subject-independent, so it shares the read memo. codemod: none.
- **@voltro/cli, @voltro/i18n** — **`voltro dev` server-renders WITHOUT `<I18nProvider>`, so SSR could not be developed at all for a translated app.**

  There are three server renderers and each arranged the i18n wrapper for itself: `voltro build`'s prerender picked a wrap per locale, `voltro start` called the `i18n.resolve` baked into the generated `ssrEntry.ts`, and `voltro dev` — which loads `@voltro/web/ssr` directly and therefore has no generated entry to call — passed **no `outerWrap` at all**. Any component calling `useT()` / `<T>` rendered fine under `voltro start` and threw on the server under `voltro dev`:

  ```
  Error: [React Intl] Could not find required `intl` object.
         <IntlProvider> needs to exist in the component ancestry.
  ```

  Reported by a consumer whose 51 `renderMode: 'ssr'` pages were every one of them serving a spinner — and two further defects sat behind this one, because nobody could get a page far enough to see them.

  `@voltro/i18n/server` gains **`makeSsrI18nResolver`** (cookie `voltro:lang` > `Accept-Language` > default → the matching wrapper), and both the generated entry and the dev server now call it. The dev copy and the generated copy were going to be two hand-written versions of the same five lines, which is how they diverged in the first place. It stays React-free so the CLI takes no React dependency; dev loads the React half through Vite's SSR loader, as the prerender already did.

  Both dev render branches are covered — the page, and `prepareSpaLayoutShell`, which builds its own `renderInput` and matters because a translated ROOT LAYOUT above a client-only page hits `useT()` on the server exactly as a page does.

  **And the dev SSR failure path no longer hands the raw error to the logger.** A React SSR error carries the element/props graph; formatting it through `util.inspect` can exceed V8's ~512 MB string cap, at which point `RangeError: Invalid string length` from `inspect` *becomes* the reported error and the real message is gone. The consumer had to monkey-patch `console.error` from application code to recover a one-line i18n error. `boundedErrorText` reads `stack`/`message` only, caps the result, names the truncation, and includes `cause` / `AggregateError` children.

  Verified against a real `voltro dev` process rendering a fixture with two locales: the marker appears in the SERVER body, the `voltro:lang` cookie selects the German catalog (so the wrap is per-request, not a fixed default), and both branches were red-verified by removing their spread.

  codemod: none — no user-authored code changes shape; a page that was crashing now renders.
- **@voltro/cli** — **Every first visit to an SSR page hydration-mismatched, and `<html lang>` was a constant.**

  Two defects on one surface, and the second is why the obvious fix for the first did not work.

  **1. The halves disagreed on the no-cookie case.** The generated client entry resolved the locale from the cookie only — correctly refusing `navigator.languages`, which can diverge from what the server saw. But *dropping* the `Accept-Language` signal is not the same as *agreeing* with the server about it. With no `voltro:lang` cookie yet — every first visit — the server negotiated `Accept-Language` while the client fell through to `defaultLocale`. An English browser on a German-default app hydrated `de` over an `en` tree, so React discarded the whole server render: exactly what SSR was enabled to buy. It stopped the moment anything wrote the cookie, which is why one language switch made it un-reproducible for that developer.

  The client now **adopts what the server resolved**, from `<html lang>`, before falling back to the cookie and the default. `navigator.languages` is still never read.

  **2. `<html lang>` never carried the resolved locale.** `voltro dev` read a `voltro:locale` cookie. Nothing writes that name — `resolveLocale`, the generated entry, `@voltro/ui-shadcn`'s ProfileMenu and the docs all use `voltro:lang` — so the lookup always missed and the attribute was the literal `"en"` on every page of a German-default app. Measured by a consumer with three different `Accept-Language` values against `/login`: `<html lang="en">` all three times.

  That is wrong on its own terms: `<html lang>` is what a screen reader pronounces in, what Chrome offers to translate *from*, and what hyphenation uses. It is now the locale THIS request resolved — the same value the `<I18nProvider>` renders with — falling back to `voltro:lang`, then the app's `defaultLocale`, never a hardcoded `'en'`.

  **It also cost the reporter a wrong fix**, which is the part worth keeping: they shipped "adopt `<html lang>` as the client fallback" with green tests, because the tests asserted their belief about what the attribute contained. A `curl` is what caught it. Both halves are now asserted against a running `voltro dev`, red-verified by restoring the old cookie name.

  The docs said the client "mirrors cookie and default for hydration safety" — a sentence that reads as a guarantee and described the opposite of what happened. Corrected in both languages.

  Still open, narrower: `voltro start`'s `<html lang>` prefers the resolved locale but falls back to `'en'` rather than `defaultLocale` when an app configures a locale whose catalog file is missing.
- **@voltro/runtime** — **An undeclared throw reached the client as a ~2 KB decode tree instead of its message.**

  An executor threw a plain `TypeError`. The server logged it correctly. What the client got was the entire `ExitEncoded<…>` transformation — every member of the descriptor's `error:` union, the full type, and the actual cause on the *last* line. One consumer's account page rendered that verbatim where a reason belonged, and every app otherwise has to condense it heuristically to avoid putting a schema on screen.

  **The channel is the part worth keeping, and only a deployed process settled it.** The first attempt guarded the DEFECT channel — reasonable, and inert: an executor that throws is settled as a FAILURE by the async wrapper, so the encoded cause reads `_tag: "Fail"` and a defect-channel catch never fires. The existing `isInfraError` guard missed it too, because a plain `TypeError` has no `_tag`.

  So the rule is on the failure channel and is not a heuristic: **every error an app DECLARES carries a `_tag`** — that is the wire contract the client pattern-matches on — so an `Error` without one is exactly the set the descriptor's `error:` union cannot contain. A tagged error passes through untouched.

  An undeclared defect now collapses to the same small tagged `InternalError` the infra path already produced, carrying the message the server just logged. `message` only: no stack, no `cause` chain, no own fields — the same reasoning as `wireErrorFromCause`, where a nested object can hold a DSN or a token. Bounded at 500 chars with the truncation marked.

  **Measured against `voltro serve`**, an action doing `undefined.runWithEager()`, from the published fixture bundle:

  | | response | |---|---| | before | **543 bytes** of `ExitEncoded<…>` decode tree | | after | **185 bytes** — `{"_tag":"InternalError","message":"Cannot read properties of undefined (reading 'runWithEager')","traceId":…}` |

  The server log line and the client message are now identical, which was the ask. The unit test was green through BOTH states, because it exercised the pure function and not the channel it hangs on.

  **The asymmetry with `isInfraError` is deliberate.** A `SqlError` still collapses to the generic `'internal server error'`, because its message names internal `table.column` detail. An arbitrary app defect has no such known shape, and withholding its text too would leave the app exactly where it started — with a reason it cannot show.
- **@voltro/plugin-webhooks** — **The scope lookups read one page of the target table and answered confidently from it.**

  `scope` is an app-defined JSON blob, so matching it cannot be a SQL predicate — a JSON comparison is dialect-divergent, and on MariaDB a `json()` column carries `utf8mb4_bin`, which has already produced a case-sensitive `LIKE` that reported a clean `0` over 141 dirty rows. Filtering in JS is the right call. Reading only the first 1000 rows to filter was not.

  Past that many target rows, both callers returned a wrong answer rather than an error:

  - **`subscribe` minted a fresh secret for a LIVE endpoint.** Growing an endpoint inherits its secret precisely because the receiver verifies one signature for one URL. Not seeing the endpoint's rows meant inventing a new key, so half its rows then sign with a key the receiver does not hold — and the "these are not one endpoint" refusal never fires, because it only inspects what was fetched. - **`resolveTargets` threw `no target matches scope … the operation would have silently done nothing`** for a scope that does match. That message sits three lines under a comment about exactly this failure shape.

  Both now page until the table is exhausted, ordered by `id` (unique — `OFFSET` over a non-unique order can repeat or skip rows between pages, and mssql refuses `OFFSET` without an `ORDER BY`). Past 100k rows the scan THROWS: a scan that gives up quietly is the thing being fixed.

  `emit`'s fan-out pages too. Its cap was per-event (it has a real `event` predicate) and carried the comment *"sane bound — 1000 targets per event is plenty"* — but a bound whose overflow is a silent non-delivery is not a bound, it is a data-loss ceiling nobody is told about. Paging costs nothing in the normal case: one page, one round trip.

  **Why nothing caught it.** Every test harness in the package returns its whole row array from `query()` and ignores `take`/`skip` — which is precisely what a paging bug looks like from the inside. `targetScan.test.ts` uses a harness that honours them, and that is the only reason its assertions mean anything.

### Internal (no consumer-facing effect)

- **@voltro/plugin-ai-flows** — **`FlowStep` / `RunStep` are declared interfaces, so the api report stops churning.**

  `type FlowStep = typeof FlowStep.Type` is an alias to a mapped type, and TypeScript's declaration emit expands such an alias structurally rather than printing its name. Both types are reached from an exported table (`aiFlows.steps: json<ReadonlyArray<FlowStep>>()`), so a ~60-line expansion sat inline in `etc/plugin-ai-flows.api.md` — and its member order depends on which other packages were built in the same turbo run. Measured: a full-monorepo build and a single-package `--force` build emit `params` and `schema` (structurally identical, both `Schema.optional(Json)`) in different positions, so the pre-push drift gate rejected whichever order was committed. CI does not build the scope a dev machine does.

  An `interface` is a real declaration TypeScript prints by name. Both are pinned to their schemas by an `Equals` check that fails to compile on divergence, so the hand-written shape cannot drift from the runtime one.

  **`apiSurface: compatible`, and the reason matters more than the label:** the 36 removed golden lines are the collapsed expansion, not a removed capability. The type is structurally identical — the `Equals` pin proves exactness in both directions — so no consumer expression changes meaning. What changed is how the report SPELLS the same type.

  Verified byte-identical across exactly the two build scopes that disagreed before.
- **@voltro/cli** — boot-validate (sharpened): a `pnpm boot-validate:sqlite` lane (driver-gated, degraded-boot: probes the sqlite driver chain, DEGRADED+exit-0 when absent, else a real embedded-sqlite durable-CRUD round-trip incl. reopen) with a `--self-test`; plus completing the internal `ApiAppConfig.store` union with `'sqlite'` (the resolver already supported it). The Tier-B service lanes (mysql/mssql/clickhouse/redis + a boot-validate compose) are the remainder. Ships with a new `api-backend-sqlite` template.
- **@voltro/plugin-webhooks, @voltro/workflow** — **The delivery workflow is now covered against a REAL SQL cluster engine, not only the in-memory one.**

  `deliverWorkflow.integration.test.ts` runs the delivery workflow against every dialect with the workflow ENGINE in `memory` — a documented, defensible trade (a cluster cold start made it slow and flaky under CI contention). It is also why a cluster-only defect shipped: a suite named "deliverWorkflow end-to-end per dialect" reads like coverage and was structurally blind to entity registration.

  `deliverWorkflowTwoRunners.integration.test.ts` stands up a real SQL-backed cluster engine on MariaDB and asserts the workflow resolves AND its body runs. `purgeClusterState` is exported from `@voltro/workflow/cluster-suite` so a suite outside the dialect packages can use it. It is ONE file on purpose: two suites purging the same `cluster_*` tables wipe each other's runners mid-run, which fails as something that looks nothing like shared state.

  **Two things measured on the way, both worth more than the test itself:**

  - The old per-emit shape is what the shared cluster suite itself uses (`Effect.provide(handler.pipe(Layer.provideMerge(engine)))`) and it works *there* — because there is exactly one runner. In a deployment the boot runner owns the shard, and an ad-hoc participant registering the entity does not change where the message routes. That is the mechanism behind `Entity type 'Workflow/voltro.deliverWebhook' not registered`, and it is why no single-runner test could have caught it. - **A cluster test that hangs is usually not the cluster.** A stub handler returning `void` against a `success` schema of `{ finalStatus, attempts }` cannot be encoded, so the message is redelivered forever and `execute` never resolves — a 120s timeout with the row still in `cluster_messages`. Accumulated cluster state was blamed first, the purge added, and it still hung; counting the rows settled it. Look at the handler's return type before the cluster.

  **And the two-runner case IS covered now** — `deliverWorkflowTwoRunners.integration.test.ts` stands up a boot runner and a dispatcher against one live MariaDB and asserts BOTH directions:

  | boot runner A | dispatch | result | |---|---|---| | built WITHOUT the workflow's layer | B provides it at dispatch (the shipped shape) | **`not registered`**, body never ran | | built WITH it (what both boot paths do now) | same B | run completes, body executed |

  The negative control is the point: a test that can only pass cannot tell a registered entity from an unregistered one, and every single-runner test in this repo passes on the broken code. The defect case costs ~60s — the ENGINE retries an unroutable message before the failure surfaces — against ~3s for the fix. That is the price of having a reproduction at all; do not lower the timeout to tidy it.
- **@voltro/cli** — dev.ts refactor (dev-ts-decomposition Step 1+3): the SSE subscription-snapshot push now reuses the shared `buildSubscriptionsInspect` builder instead of an inlined byte-identical copy; the `_voltro_workflow_runs` refetch-and-emit shared by redrive + resume-from-step is one helper; and a live-span-leak GATE test asserts the inspect door gate sits above every ungated live-data branch. Pure refactor, zero behaviour change.
- **@voltro/cli** — **The message-API check called a real chainable member non-existent.**

  `.index()` is a genuine member of the table builder with two overloads. api-extractor prints an overloaded member as a call-signature *object*:

  ```
      index: {
          <const F extends readonly [...]>(fields: F, options?: …): Table<…>;
          <const IxName extends string, …>(name: IxName, …): Table<…>;
      }
  ```

  which matches neither of the check's line-shaped rules (`foo(` / `foo: (…) =>`). So a correct comment naming it was reported as naming something that does not exist, and the static check went red on `main`.

  **A false alarm is not the harmless direction here.** This check exists to be believed — its own failure text says "fix the message, or build the thing it promises". One that cries wolf gets its finding argued with instead of read.

  The lookahead is the part worth recording: matching the generic precisely does **not** work, because `<const F extends … Array<…>>` nests `>`, so a `<[^>]*>` character class stops inside it. The first version of the branch therefore matched nothing and looked like a fix. A call signature simply *starts* with `<` or `(` once trimmed; a data member starts with an identifier or `readonly` — which is what keeps `index?: { readonly where: string }` out, and with it the data-property bug the original rules exist to reject.

  Both directions are now selftest cases, since a rule that quietly stops matching prints exactly like a clean tree.

---

## [0.28.0] — 2026-08-06

### ⚠ BREAKING

- **@voltro/plugin-atlassian** — `credentialsResolver` receives `{ subject, store }` instead of a bare `Subject`.

  The Subject was the only input, so an app doing per-user Atlassian auth had nowhere to keep the caller's PAT except `subject.metadata` — from where it travelled with the identity into everything that persists a Subject. That is the other half of the credential leak a reporter found in their audit table, and the half that actually closes it: with a store handle, the credential never has to enter the Subject at all.

  **Worth saying plainly, because it corrects the ask:** the seam for keeping a token out of the Subject already existed. `connectionCredentials({ connectionId, baseUrl })` puts it in the framework's vault, and it is the right answer for most apps. What did not exist was a way for an app with its OWN token table to read it here — the doc comment said such an app "keeps working exactly as before", which was true and meant "keeps the token in the Subject".

  **Migration:** `(subject) => …` becomes `({ subject }) => …`. `store` is optional — absent when the app bound no data store — and the type forces a resolver that needs it to say what happens then. The codemod lists the three options in order of preference rather than rewriting the destructure, because the mechanical fix silently blesses the shape that caused the leak.

  Measured while fixing it, and worth knowing: the leak was ONE surface. Traces carry only `subject.type`, `@voltro/plugin-sentry` sends only `type` and `tenantId`, and the console sink prints `type:id`. Only the durable audit column held the whole Subject.
- **@voltro/plugin-audit** — `auditPlugin` redacts `subject.metadata` by default (`redactSubject`), and `resolveScope` now receives the call's `input`.

  **A reporter found a working Jira Personal Access Token in plaintext in 12 of 23 rows of their `_voltro_audit_log`.** Neither plugin involved was wrong on its own: `@voltro/plugin-atlassian`'s `credentialsResolver` took a `Subject` and nothing else, so a per-user PAT had nowhere to live but `subject.metadata`; this plugin serialised the Subject verbatim into a json column. Two correct contracts disagreeing about what a Subject IS — an identity, or a credential envelope — with nothing reconciling them.

  The reasoning is `redactInput`'s, word for word, applied to the field it did not cover: `metadata` is not a table column either, so no schema marker protects it, it is app-controlled so its contents cannot be reasoned about here, and the framework's own per-user-credential mechanism puts a credential in it. **No configuration avoided this** — `redactInput` covers the wrong field, `record: 'errors'` reduces the count rather than the leak, and a function `sink` means giving up the table, its retention sweep and its query helpers.

  **Migration:** none required — `type`, `id`, `tenantId` and `scopes` still land in the row. Opt back in with `redactSubject: 'none'` or, better, a function that names the keys you meant. **Rows you already have are not fixed by a safer default: purge and rotate.** The codemod carries the query.

  `resolveScope` also gains `input`, because the subject-only version covered the wrong half: a reporter's users belong to many teams, so their session carries no "current team", while their API-key subjects DO carry a `teamId` — which made subject-only worse than nothing for them, populating for key-authenticated calls and null for every human one, so a filtered view would have looked like it worked. The input is RAW, before `redactInput`; return the dimension, never the payload, because `scope` is not redacted.

  It is also resolved ONCE per event now. The `...(x !== undefined ? { scope: x } : {})` spread evaluated the resolver twice — invisible, because both calls return the same thing, until a resolver reads a store or counts.
- **@voltro/plugin-notifications** — `resolveSubjectId` may now return `string | undefined` **or a promise of one**, and the exported `makeSubjectId` helper returns `Promise<string>`.

  The seam exists for an app whose addressing unit is its own — an employee, a member, a contact. Every one of those is a ROW, so resolving one is a store read, so it returns a promise. The sync-only signature meant the call written in the option's own docstring (`resolveSubjectId: (ctx) => resolveCallerEmployeeId(ctx)`) did not typecheck for the only apps the option was built for. Reported by a consumer whose resolver reads `employees`.

  **Migration:** plugin configuration needs no change — a resolver returning a plain string still satisfies the widened type. If you call `makeSubjectId` directly, `await` its result. It is deliberately NOT cached: a per-connection cache would let the first call decide the answer for the life of the connection, and only the app knows its own invalidation.
- **@voltro/cli, @voltro/runtime** — `pluginRef` orphan rules now run under `voltro serve`. They were wired into `voltro dev` and nowhere else.

  The rule shipped inert (the collector read a builder `table()` had already consumed, so it produced zero rules for everyone), was fixed — and was still bound in exactly one of the two boot paths. So the behaviour a consumer would have lived through is: a row pointing at a deleted plugin row is cleaned up while you develop and left behind forever once you deploy. Nothing crashes; the two paths simply do different things.

  The wiring is a shared builder both paths call (`wirePluginRefRules`), not an inline block mirrored by hand, and the guard now asserts a SET of boot paths rather than reading `dev.ts` alone — the previous version passed every one of its assertions while production was unwired, because it never asked whether a second boot path existed.

  **Migration:** `PluginRefChange` is deleted — `isSoftDelete` and `applyPluginRefRules` take the change channel's own `ChangeEvent`. Its `rowId` / `tenantId` fields are gone; the id and the tenant are derived from the row, so put the row in `old`. In tests, reach for `@voltro/testing`'s `changeDelete` / `changeSoftDelete` instead of a literal.

  The copy is what made this feature fail twice. A second, hand-written shape is what let the original `onSoftDelete` tests assert against `{ op: 'delete', softDeleted: true }` — a combination the channel cannot emit — and stay green while the option could never fire.
- **@voltro/plugin-webhooks, @voltro/cli** — The outgoing fan-out is tenant-scoped, `ctx.webhooks.emit` is post-commit, and a target's routing filter has a real type.

  Four findings from a consumer building a real outgoing-webhook feature — 29 declared events, one URL per endpoint, third-party receivers.

  **Tenant confinement was the app's job and nothing said so.** `_voltro_webhook_targets` carries `.with(tenant())`, but the service is built once at boot with the app-level store and no subject, so the mixin had nothing to scope by: the target lookup was `eq('event', name)` and nothing else. Confinement rested entirely on each target's own `filter`. It looked safe because filters usually predicate on a globally unique app id — a cross-tenant match was impossible *by accident*, and stopped being so the moment they introduced a value deliberately equal across teams. `ctx.webhooks` now binds the acting subject's tenant onto every emit; a system emit (no tenant) stays unscoped, and an explicit `{ tenantId }` at the call site still wins.

  **`emit` dispatched before commit.** A mutation that emitted and then threw rolled its rows back while the POST went out. `ctx.workflows.start` is post-commit safe and documented as such; this was the one place the rule did not apply to itself. Inside a mutation the dispatch rides `afterCommit` now and the result carries `deferred: true` rather than an unmarked empty delivery list — which would read as "no endpoint wanted it".

  **`filter?: Readonly<Record<string, unknown>>` cost them a feature for a year.** They wrote in a comment that the filter was key-path equality and could not express "id is one of these", refused the capability in their own API with a typed error, and shipped that — while `in` had been supported the whole time. `WebhookFilter` now names all six operators. It is the one place where being wrong is silent in both directions: a predicate matching nothing reads as "no endpoint wanted it", one matching everything reads as working.

  **`subscribe({ scope, events })` inherits the endpoint's secret** — the last reason to read a plugin column. It refuses a scope whose rows do not all share one secret: that is not one endpoint, and signing it as one would re-sign half a group with a key the receiver does not hold.

  `codemod: none` — no user-authored code changes shape. The tenant scope and the commit ordering are behaviour, and both make a previously-possible wrong outcome impossible.

### Added

- **@voltro/cli** — `voltro serve` prints the connection-pool arithmetic at boot:

  ```
  db pool: max=10 per replica (DB_MAX_CONNECTIONS) × 4 replicas = up to 40 connections.
  ```

  Reported by an operator whose SECOND pod died on `Connection timed out`. The cause is arithmetic, not a bug — the framework opens one pool per process, so a fleet opens `pool × replicas` against a database limit that does not move with `replicaCount` — but nothing in the boot said what the pool size was, so the multiplication was invisible until the moment it failed. It failed on the second pod, which is the worst place to learn it: the first one proved the configuration "works".

  Set `REPLICA_COUNT` (Helm: `{{ .Values.replicaCount }}`) and the line does the multiplication; without it the line still names the formula. When `DB_MAX_CONNECTIONS` is unset it says UNSET rather than guessing a driver default — a wrong number from us is worse than an omission the operator can look up.

  `voltro dev` deliberately does NOT print it: one process, no replicas, no arithmetic. That exception is asserted by a test so a later parity fix has to argue with it rather than silently undo it.
- **@voltro/cli** — Webhook management and a MASKED data browser answer in production.

  **`webhookActions`** — subscribe / pause / resume / delete / rotateSecret / replay / repin. Without them the Webhooks panel, now visible in production, renders every target and answers 404 to every button: a read-only page over a management surface, which is the shape that makes an operator distrust all of it. Read lazily off the serve handle, because `serveApi` builds the service (it needs the delivery workflow's trigger) after the inspect manifest exists.

  **`inspectTables` / `inspectRows`, masked.** The browser reads arbitrary rows from arbitrary tables; the deciding question was never whether operators should see production data but WHICH. The schema already answers it — `.sensitive()`, `.encrypted()` and `.serverOnly()` are the three exposure axes this repo already maintains — so production shows the shape of every row and the value of everything unmarked. Inventing a fourth "do not browse" axis is precisely what the note governing those three warns against.

  **Masked, not omitted.** A dropped column reads as "this row has no email", which is a different and wrong fact; the cell says which marker hid it. A `null` stays `null`, because an empty optional column is not a secret and masking it would turn a half-filled table into a wall of markers.

  `voltro dev` deliberately does NOT mask: a developer owns their local database and the overlay's editor writes to it, so masking there would hide a secret the developer put in themselves. The asymmetry is asserted with that reason, and both paths go through one builder so the masking cannot exist in one and rot in the other.

  The eight workflow WRITE actions are decided and not yet built — they need the workflow runtime, proxy, run-recorder and definitions exposed on the serve handle, and threading them means either exporting four inferred types or casting at the seam. A context object reaching a boot path through a cast is the defect this repo has three write-ups about, so it gets the plumbing or it waits.
- **@voltro/testing, @voltro/database** — `makeSubscribeContext` plus `changeInsert` / `changeUpdate` / `changeDelete` / `changeSoftDelete` — test doubles for the OTHER context a user writes handlers against.

  `makeTestContext` covers `AppContext`. A `*.subscribe.ts` handler receives a change EVENT and a `SubscribeContext`, and there was no constructor for either, so every subscriber test hand-built both.

  Suggested by a consumer, after they named the failure class about their own contract test: *ein Harness, der die falsche Annahme des Codes teilt, prüft nichts.* Six instances turned up in one session, four theirs and two ours, and both of ours were in this gap — including `onSoftDelete` tests built on `{ op: 'delete', softDeleted: true }`, a combination the change channel cannot emit. The flag "worked" against a shape that does not exist while the feature could never fire in production.

  `changeSoftDelete` is the whole argument: there is no `op: 'softDelete'` and there never will be — a soft delete is an ordinary update that sets `deletedAt` — so an author who does not know that writes a delete. The knowledge now lives in a function name rather than in each author's head.

  `makeSubscribeContext().store` has no default and **throws naming itself** when touched. A silent empty store would let a subscriber reading the wrong table pass its test, which is the same silent-nothing the constructors remove.
- **@voltro/cli** — The eight workflow write actions answer in production: start, cancel, suspend, resume, discard, retry, signal, update.

  `inspectRedriveWorkflowRun` was already wired into serve on the reasoning that dead-letter recovery happens where the incident is. The rest of the family was dev-only, which made the redrive an odd exception rather than a policy — an operator could revive a terminally-failed run and could not retry, discard or cancel one.

  **The first attempt at this took the deps as `unknown` and cast at each use.** It compiled. It was also the wrong answer: a context object reaching a boot path through a cast is the defect this repo has three separate write-ups about, and the entire point of extracting these is that the two paths cannot drift — a cast is the hole a drift walks through. It was deleted rather than shipped.

  They are typed structurally now, by what the bodies actually use: a runtime that can run an Effect, a recorder that can record an event, a definition that can be interrupted or resumed. The engine ENVIRONMENT is a type parameter, because `interrupt`/`resume` are `Effect<void, never, WorkflowEngine>` and pinning `R` to `never` would have forced back exactly the cast being removed. The compiler found two real shape differences on the way — the event-type union and that environment — which is the property `unknown` throws away.

  Built inside `serveApi`, where the runtime lives, and delegated from `serveCommand`'s manifest — the same seam as the redrive and the scheduler's `fireNow`. An app with no workflows gets a refusal naming itself rather than a `TypeError` on undefined.

  The parity guard needed a correction of its own: entries that move into a shared spread leave the key scan's view, so a builder is now asserted against the serve PATH (both `serveCommand` and `serveApi`) rather than one file. Asserting `serveCommand` alone failed a correct wiring — the check was measuring the wrong thing, which is what it exists to catch elsewhere.

### Fixed

- **@voltro/cli** — `*.startup.tsx` and `*.email.tsx` run in production. And every dev/serve difference is now derived and enforced rather than remembered.

  **The two gaps.** A startup is documented as a boot hook and ran under `voltro dev` only — so an app that opens a connection there, warms a cache or starts an SSE bridge got none of it where it is deployed, with no error, because nothing was asked to happen. Declared mail templates were registered in dev only, so `ctx.mail.send({ template })` resolved locally and could not resolve in production. Both go through one shared runner now, proven by boot: `startup ran` appears once in each path.

  **The audit is the real change.** Three defects of this class shipped in a single day — `pluginRef` orphan rules, the inspect router, and plugin RPC interceptors (which meant `plugin-audit` recorded nothing in production). Each was found by accident, each after the rule against it had been written down twice, and each while the existing parity guard was green — because that guard compares the things somebody thought to compare.

  `devServeSurfaceAudit.test.ts` asks the general question instead: which boot symbols does `dev.ts` call that the serve path never reaches? Every answer is wired, justified in writing, or counted as backlog, so a new one is a red test rather than a discovery six months later.

  Two refinements it took three wrong answers to find, both making the check WEAKER on purpose — a guard that cries wolf is one the next reader switches off:

  - `dev.ts` EXPORTS the builders serve imports, so anything called inside `buildStore` / `loadDiscovered` / `buildResolveSubject` is reached. Ignoring that reported read replicas, relations registration and auth composition as production gaps. None of them are. - a symbol absent from serve's source may still be reached — through a namespace, or through shared runtime code (write attribution is stamped in `bindMutation`, so both transports have it).

  **Backlog: three entries, counted.** Boot SEEDS still run in dev only. Whether production should auto-seed on every pod start is a real question — a rolling deploy would run it once per replica — so it is recorded with that reasoning rather than decided in a sweep. Plugin services are still provided to workflow STEPS in dev only.
- **@voltro/cli** — The declared-event consumer scan resolves IMPORTS instead of guessing from the event's name, and stops walking once no further file can change the answer.

  Two defects, one report. A consumer had ten events consumed in one sibling file and got one `no-consumer` warning for a **live** subscription: the scan matched the wire name's last segment as a substring of the file, nine matched by accident because the import identifier happened to contain it, and the tenth did not — `import employeeAttendance from '…/employeeAttendance.event'` carries the FILE name, while the event is `employee.attendanceChanged`. The rule was comparing a name to a name and calling agreement evidence. It now also credits a binding imported from the event's own module and used outside the import, which is additive: it can only turn a "no" into a "yes", never invent an orphan.

  Their workspace also tripped the sibling-file bound (`stopped after 4000`). The scan is a fold now, settling once every event is both published and consumed, so the walk ends at the file that answers the last question — for them, inside the first sibling app. The bound is raised to 20000 for the genuinely-orphaned case that does read the whole tree, and a walk that STOPPED no longer reports truncation: that scan was complete.
- **@voltro/cli** — The declared-event wiring check knows about the webhook audience.

  A consumer declared 29 events for outbound delivery and got 58 warning lines on every boot — 29 "never published" and 29 "no `useEvent` consumer" — all wrong. They publish through `ctx.webhooks.emit`, which the producer scan did not look for, and their consumers are rows in someone else's deployment, which no `useEvent` scan can ever see.

  An event declaring `webhook:` now counts an `emit(` as publishing, and is not reported as missing a `useEvent` consumer. Both halves are qualified: the emit must appear in a file that reaches the webhooks service (`emit` is far too common a method name to accept bare), and an event WITHOUT a webhook audience is unaffected — which keeps the finding that mattered. The same reporter had seven of eleven advertised events with no emit call site at all; ticking one returned 200, showed the endpoint healthy, and delivered nothing forever. That case is still reported.

  The warning's own reasoning is right and unchanged — a consumer with no producer waits forever and looks exactly like a quiet channel. A check that is wrong 58 times is one nobody reads the 59th.
- **@voltro/cli** — `plugins`, `env`, `dataCache` and `subscriptions` answer in production. The "unaudited" inspect backlog is audited.

  Each was carried as an entry nobody had looked at, and the audit found the same thing four times: nothing dev-specific, the inputs already present in `serveCommand`, and the entry simply never moved. Which is how a backlog like that forms — an omission is invisible from the manifest, so it survives every reading of the file.

  **`metrics` is the one that did not resolve that way, and it is a bigger finding.** serve builds a metrics collector, hands it to a single consumer, and never wraps its interceptors with it. Production is therefore not COLLECTING the numbers the endpoint would report — wiring the entry alone would have shipped an honest-looking zero, which is worse than the 404 it shows today. It stays listed, with that reason, because fixing it is a behaviour change rather than a manifest line.

  `subscriptions` reads the dispatcher lazily off the serve handle, the same way `events` and `members` already do — serveApi owns it and is built after the manifest object exists.

  Backlog: three entries left (`inspectTables`, `inspectRows`, `cluster`), down from eight, and the count is asserted so it cannot grow quietly.
- **@voltro/cli** — Schedules and the workflow read endpoints answer in production. They were wired into `voltro dev` and nowhere else.

  **The written reason for the schedules omission was wrong**, and a reader asking the obvious question — why would schedules be missing when they run in the core? — is what exposed it. The comment said dev computes `nextFiringAt` and the EFFECTIVE coordination from values in its own boot closure. Neither holds: `nextFiring` is a pure function exported from `@voltro/runtime`, and serve already computes the coordination itself as `effectiveScheduleCoordination`, with the same cluster→advisoryLock→single degradations, twenty lines above the manifest it never handed it to.

  The incoherence that gives it away is in the same object: serve wires `inspectFireSchedule`, so an operator could **run** a schedule they could not **list** — while `voltro inspect schedules --failing` is documented as a post-deploy gate, against the deployment, which was the one place it did not answer.

  The six workflow READ endpoints are the same shape, next to the same tell: `inspectRedriveWorkflowRun` is wired in serve on purpose ("dead-letter recovery happens where the incident is"), which is an argument for looking at a run before it is an argument for reviving one. All six are plain queries of `_voltro_workflow_*` tables.

  **The guard that was supposed to report this was itself under-reporting.** It brace-counted to find the manifest's keys, a `{` inside a string literal truncated the walk, and it found 8 dev-only entries where there are 34 — while its own non-vacuity check (`> 5`) passed the whole time. A guard that finds a third of the truth reads exactly like one that found all of it. It is indent-anchored now, with a floor near the real number.

  What remains dev-only is the WRITE surface — arbitrary row edits, seeds, a migration rollback, and the eight workflow control actions — each named with its reason, plus eight read endpoints carried as an explicit, counted backlog. The `webhookActions` entry says plainly that it is a decision nobody has taken rather than a surface anyone rejected.
- **@voltro/cli** — The inspect ROUTER is shared. Every endpoint wired into `voltro serve` this week was answering 404.

  `voltro dev` dispatched through twenty-one handlers before reaching `handleInspectRequest`; serve called only the last one. So schedules, webhooks, the workflow reads and writes, the data browser and cluster all had their manifest entries in production and no URL that reached them. **The data was wired and the door was not.**

  The parity guard was green throughout, and correctly so by its own definition: it compares which keys a manifest CONTAINS, and this is a missing router CALL. Same shape as every guard in this repo that has needed correcting, one layer further out — a source rule is a map, and this was territory. It was found by booting a fixture and curling it, ninety seconds of work that no amount of reading would have replaced.

  The shared branches are one function both paths call, in one order. `voltro dev` keeps its own overlay-only chain (client-log ingest, the trace ring, the timeline, dashboard mounts) — those answer questions a production process has no data for.

  **And `cluster` nearly got certified wrong by the same smoke.** With no manifest entry, `handleInspectCluster` answers **200** with a hardcoded single-process memory shape: `dialect: 'memory'`, `runnerHost: 'localhost'`, `runnerStorage: 'none'`. On a postgres deployment that is a confidently wrong answer to "is my cluster healthy" — worse than the 404 it replaced. The fixture is a memory app, so the fallback and the truth agreed and the endpoint looked fine. Wired properly now, from the same builder both paths call.

  Guard additions: both paths must call the shared handler, and neither may dispatch a shared handler on its own — two copies of a router is the same defect as two copies of a wiring.
- **@voltro/runtime, @voltro/cli** — `makeQueryFinalizer` — the tenant + soft-delete scoping composition now exists once, and both boot paths call it.

  `applyTenantScope` and `applySoftDeleteScope` were extracted so that "there is no second copy that could drift" — the words are in that file's own header. Then the COMPOSITION became the second copy: `voltro dev` wrapped the pair in a local helper, `serveApi` inlined the same pair in the other spelling.

  They agreed, which is the dangerous state rather than the safe one. Nothing kept them agreeing, and a third scoping concern would have landed in whichever file the author had open — producing a live query that filters one way in development and another in production, with no error on either side.

  This is the variant a source-reading parity guard cannot catch: both paths supply something, both are right at their own call site, and they disagree about CONTENT. Detection does not help; one function does, because the disagreement then has nowhere to live. `bootPathParity.test.ts` additionally refuses a direct call to either primitive from a boot path, with a non-vacuity check so deleting the finalisation entirely cannot satisfy the rule.

  Where the paths are genuinely allowed to differ is an `observe` hook: `voltro dev` warns about an org-less subject, an empty tenant scope and a non-indexed predicate; production pays for none of that. The observer receives a COPY — the first version handed it the live descriptor, so a warning could have rewritten the query, reintroducing divergence through the seam built to stop it. Its own test caught that.
- **@voltro/cli** — **Plugin RPC interceptors did not run under `voltro serve`.** `plugin-audit` recorded nothing in production, `plugin-sentry` reported nothing from it, and `plugin-rbac` published no scopes there.

  `wrapInterceptorsForKind` was called in `dev.ts` and in no other file, so every plugin's `interceptMutation` / `interceptAction` / `interceptQuery` was dead on the production wire.

  Measured, not inferred: with `auditPlugin({ sink: 'console' })` installed and one action invoked over the wire, `voltro dev` logs the audit line and `voltro serve` logs nothing. After the fix both do.

  It is the worst instance of the dev/serve class this repo has found, and it hid in the shape that makes the class hard. Nothing crashed. Nothing warned. The plugin manifest reported `interceptMutation: true` for each plugin — accurately, since the plugin does declare the hook. A consumer reading their DEV database found audit rows exactly where they expected them. Nobody was lying anywhere; the wire simply never called the hook.

  **`metrics` is fixed by the same change and was the sibling defect.** serve built a metrics collector, handed it to one consumer and never wrapped its interceptors with it — so the endpoint would have reported an honest-looking zero. There is one collector now, the interceptors feed it, and the inspect endpoint reads that one off the serve handle.

  Both are shared builders called by both paths, and `bootPathParity.test.ts` refuses a path that composes interceptors itself.

  **If you run `@voltro/plugin-audit` in production: your trail has a hole for every release before this one.** Nothing was written. The rows you have are the ones your dev and any `voltro dev` deployment produced.
- **@voltro/cli** — Plugin services reach workflow STEPS in production; the seed policy is stated instead of silent; and two loaders that could only ever work under `tsx` are fixed.

  **Workflow steps.** `makePluginWorkflowStepLayer` was provided in dev only, so a step that yields a plugin service worked locally and failed in production with `Service not found` — the plugin's `onWorkflowStep` had nothing to attach to. Same builder, same metrics collector, both paths.

  **Seeds are a decision now, and it is written down.** Production does NOT auto-seed: a rolling deploy starts N replicas, so an auto-seed runs N times, and the idempotency that makes that safe belongs to the app. `voltro db seed` from a pre-deploy job is the deliberate step, where migrations already live. What was wrong was the SILENCE — an app with seeds booted in production and nothing said they had not run, which is indistinguishable from them running and finding nothing to do. `voltro serve` reports them now, by name, with what to run instead.

  **And wiring seeds into production surfaced a crash on the first boot.** `seedRunner` and `emailDiscovery` loaded app modules with a raw `import(pathToFileURL(file))`, which resolves the `.ts` SOURCE. Plain node cannot load it — the framework's own `@voltro/*` sources use extensionless relative imports — so it works under `voltro dev` (tsx resolves them) and dies under `voltro serve` with `Cannot find module …/packages/database/src/columns`. Both go through `importAppModule` now, and a guard refuses a declaration loader that does not. They were invisible for exactly as long as their surface was dev-only.

  **The dev/serve backlog is now ZERO.** Every difference is either wired or a written decision, and the audit asserts the count is zero rather than "small".
- **@voltro/cli** — Production records per-rpc metrics, and its traces carry the app's name.

  Both found by the second axis of the dev/serve audit — **the same function called with different arguments**, which is the variant that does not crash and the one a "does serve call this?" scan cannot see.

  `makeMutationRunner` / `makeActionRunner` took a `recordMetric` in dev and not in serve, so the metrics endpoint in production reported plugin buckets and no rpc buckets: half an answer, which reads like a whole one. Proven by boot — one action now yields `rpc action.people.add` alongside the plugin bucket, where before there was only the plugin one.

  `buildTracingLayer` took a `serviceName` in dev and not in serve, so the same service appeared in a collector under its app name from development and as `voltro-app` from production. It stayed invisible because the runtime reads `OTEL_SERVICE_NAME` itself — so the production-hardening docs' instruction did work, and only an operator who had NOT followed it would have seen the difference.

  Neither is dramatic. They are recorded in this shape because the class is: a difference in an argument, in code that runs on both paths, with nothing failing.
- **@voltro/cli, @voltro/devtools-ui** — The Webhooks panel answers in production, and its Events tab shows **"subscribed, never emitted"**.

  The inspect entry was wired into `voltro dev` and nowhere else, so the panel read a dev database — where nobody has real third-party subscribers — and replied "not configured" against the deployment that has them. All three layers it shows are plain reads of the app's own tables; nothing about them is dev-specific. It is the shared builder both boot paths call now.

  That omission is worth separating from a deliberate one. `inspectSchedules` is absent from serve on purpose, with a written reason (dev computes `nextFiringAt` from values in its own boot closure, and reporting a guess to a post-deploy gate is worse than reporting nothing). Webhooks had no such reason — **and from the manifest the two read identically.** `bootPathParity.test.ts` now requires every dev-only inspect entry to be named with its justification, and carries the other eight as an explicit, counted backlog rather than as silence.

  The new column comes from a consumer's suggestion. They shipped a create dialog offering eleven event checkboxes of which four were wired: ticking `team.updated` returned 200, showed the endpoint enabled and healthy, and delivered nothing, forever. Nothing in the framework could catch that inside their code — but the deployment knows which events have targets and which have ever produced a delivery, and the difference is the defect.

  `everDelivered` is **not** derived from the deliveries list. That list is the most recent 200 rows, so an event delivered steadily but long ago would have read as never delivered — the exact false positive the column exists to avoid producing. It is its own bounded query, one per distinct subscribed event. The cell reports facts (`N subscribed · never emitted`), never a verdict: a target subscribed a minute ago is not a fault, and any threshold would be wrong for someone.

### Internal (no consumer-facing effect)

- **@voltro/cli** — `bootPathParity.test.ts` — dev/serve parity enforced from DERIVED sets rather than a curated list.

  Seven capabilities have shipped wired into `voltro dev` and absent from `voltro serve`, each a silent production no-op, each found by a human noticing. The rule against it has been written in two `CLAUDE.md` files, with a checklist, since long before the seventh.

  Every previous guard is per-feature and asks "does path X mention thing Y", so a NEW wiring is invisible to all of them — nobody remembered to add it. This one derives three sets from the source: modules that bind a change channel, imported symbols called inside an inline `onChange` body, and modules exporting a `wire*` / `attach*` boot builder. Each must be reached by both paths or appear in `DEV_ONLY` with a written reason. A new wiring is included automatically and fails until someone wires serve or says why not, which inverts the default.

  Red-verified against the shipped `pluginRef` state: three failures, one from each rule. The inline rule is the one that matters — the module rule alone would not have caught it, because that wiring lived inside `dev.ts`.

  Two entries currently justify themselves: the dev inspect CDC bus and the `VOLTRO_TIMELINE` recorder. A `DEV_ONLY` entry that is no longer asymmetric fails, so the exception list cannot decay back into a curated one.
- **@voltro/cli** — The dev/serve audit gained its THIRD axis, for the one place it costs most.

  The first two axes are "a call serve never makes" and "the same call with different arguments". The third — one value present in both paths and BUILT differently — has no general check, and it is the variant that costs the most while showing the least: both paths supply something, both are right at their own call site, and they disagree about content. Historically that was a cron reading one tenant in dev and every tenant in production, silently, because `tenantId == null` means "system".

  What is checkable is the one constructor where it would hurt most. Every field a handler can reach comes from `makeAppContextBuilder`, so its input key set is the closest thing to an enumeration of the context surface — and the two paths are compared key for key, in both directions.

  They currently agree, with one justified exception (`onEventEmit`, the dev overlay's SSE tap). Red-verified by dropping `store` from serve's call.
- **@voltro/cli** — The `memory-api` e2e fixture grew the declarations its smokes were pretending to cover.

  Every gap here was found by booting, not by reading:

  - **no marked column**, so `GET /_voltro/inspect/data/rows` returned 200 and proved the endpoint answers — nothing about what it withholds. It now carries `.sensitive()` and `.serverOnly()` columns and an action that writes them over the real wire, so the production masking is asserted against a running server: `email` and `internalNote` come back `{ "__masked": … }` from `voltro serve` and in plaintext from `voltro dev`, with zero occurrences of the protected values anywhere in the response. - **no schedule**, so `inspectSchedules` and its FALLBACK both answered `{ schedules: [], coordination: 'single' }` — indistinguishable. One schedule makes the two answers different. - **no webhook-audience event**, so `inspect/webhooks` was empty either way.

  Two things the fixture cannot prove, said here rather than implied:

  - **`.encrypted()` is absent on purpose.** One encrypted column makes the whole table unwritable without a registered field cipher — the insert fails even when that column is left unset — and this fixture is also the driverless, plugin-light serve smoke. That axis is asserted in `inspectDataBrowser.test.ts`. - **`voltro serve` runs neither boot SEEDS nor `*.startup.tsx`.** A seeded row appeared in dev and never in production, which is why the fixture inserts through an action instead. Whether that is deliberate is a separate question and not answered here — it is recorded because it was discovered, and because an app with a `*.startup.tsx` gets it in development and not where it runs.

---

## [0.27.0] — 2026-08-05

### Added

- **@voltro/plugin-audit, @voltro/plugin-versioning** — `scope` — the app's own scoping dimension on `_voltro_audit_log` **and** `_voltro_row_history`, supplied by a `resolveScope` option on each plugin.

  The last thing between a consumer and deleting a 2900-row, 300-call-site hand-rolled audit trail. Their trail and its retention are per-TEAM; a tenant has many teams, so `.with(tenant())` is one level too coarse and every view they render filters by team first. It is the same column `_voltro_webhook_targets.scope` already carries: opaque json in, opaque json out, equality filtering.

  **Deliberately not `metadata`.** They offered to carry `teamId` there and filter in memory, and were right to dislike it: `metadata` is documented as the app's free-form note — the noun a diff cannot contain — so filtering on it builds a read path against a column whose contract says it is not one. Two columns, two jobs.

  The app supplies the value, because the framework does not know what a team is — which is the whole reason the column is opaque. `auditPlugin` derives it from the call (`(ctx) => ({ teamId: ctx.subject.metadata?.teamId })`); `versioningPlugin` from the changed ROW (`(row) => ({ teamId: row.teamId })`), because that is what that plugin has and where a per-table dimension lives. Configure both or half of every view is unfiltered.

  Neither resolver can fail the write it annotates: an underivable scope is `null`, the same answer as not configuring one.

  codemod: none
- **@voltro/plugin-notifications** — `notificationsPlugin({ resolveSubjectId })` — the app names its own addressing unit.

  An inbox belonged to `subject.id`. That is the framework's answer and not always the app's: a shift change, an absence request or a task reminder is addressed to a PERSON, and a person does not necessarily have an auth user. A reporter measured it on 14 670 rows — 4 677 addressable through a user, and **668 live, read rows belonging to four people who have none**.

  The worse half is what follows a migration without it: every producer resolves person → user and **silently delivers nothing** for anyone missing one. That is the failure this plugin's own docstring warns about, one level up and structural rather than accidental.

  One function, thirteen call sites — the same seam `auth.resolveScopes` already offers for this shape. Absent keeps `subject.id`, so nothing changes for an app whose units line up. A resolver that returns `undefined`, an empty string, or throws falls back to the subject rather than failing the read: an inbox must not go down because one caller has no employee record, or because a lookup hit a database that was briefly unavailable.

  **`useEvent` already returns what the same report asked for.** `{ status, missed, lastMiss }`, where `status === 'live'` is the connected flag — the ask was for discoverability, not an API, so the docs now name the case that motivated it: a wall display nobody is standing at keeps rendering the last thing it received, and from across the room stale and current look identical.

  codemod: none
- **@voltro/plugin-presence** — The presence tracker gets a perf suite — the last of the four realtime surfaces without pinned numbers — and the four are now documented side by side.

  The presence figures existed in the docs (a heartbeat, a 10 k roster) and were measured once by hand, which cannot fail. Same gap the event bus had, closed the same way: a `*.perf.test.ts` that prints what it measured and asserts the SHAPE rather than the microseconds.

  Measured across all four, each asserted by a test:

  | primitive | operation | cost | scales with | | --- | --- | --- | --- | | Events | `ctx.events.publish` | 4.3 µs (~232 k/s) | nothing | | Events | delivery to a subscriber | 0.027 µs | subscribers, cheaply | | Presence | a heartbeat | 0.16 µs | nothing | | Presence | a roster read, 10 k members | 547 µs | the ROOM | | Records | a live-query re-diff, 5 000 rows | 3 062 µs | the RESULT SET | | Broadcast | cross-replica over real Redis | p50 1.1 ms · p99 11.2 ms | the network |

  **The comparison is what was missing, not the numbers.** Publishing an event costs about a thousandth of re-diffing a large live query, and that ratio is what should decide between them — a 60 Hz value belongs in an event, because the same value written to a table wakes every subscriber of every query reading it and each pays the full walk.

  Two of the four are flat and two are not. That is the property the tests assert: the per-member cost of a roster read must not grow with the room, and the per-row cost of a diff must not grow with the result set. Either one growing is the difference between expensive and unusable.

  codemod: none
- **@voltro/cli** — A capability matrix for the realtime surface — fifteen things people build, each mapped to the primitive that carries it, each asserted by a test.

  "Nothing is missing" is not a checkable sentence. This turns it into one: `realtimeCapabilities.test.ts` asserts every row's primitive is still exported, so a capability that loses its primitive to a rename goes red in CI rather than being discovered by whoever tries to build it.

  It caught one on its first run — the matrix claimed `useUpload` lived in `@voltro/plugin-storage` and it is in `@voltro/client`. A row pointing at the wrong package is exactly what a table in a document does silently.

  It asserts EXPORTS rather than behaviour on purpose. Behaviour is what the other suites are for, and duplicating them here would make this a slower copy of them. What it catches is the gap between "we support that" and "the thing that supports it still exists".

  The same table is in the docs, with the three capabilities people usually reach for wrongly called out: a value changing many times a second is an EVENT and not a row (writing it to a table wakes every subscriber of every query reading that table, each paying a full re-diff); "who is online" is presence rather than a table; and "did anything get lost" has a computed answer in `missed`, so nobody needs to build a heartbeat of their own to find out.

  codemod: none
- **@voltro/cli** — The ten hard questions a realtime system is judged on, with this framework's answer and — enforced by a test — the proof behind each.

  `realtimeProperties.test.ts` fails if a row's proof disappears: a property may not be CLAIMED without something in the repository that demonstrates it. Red-verified by re-pointing one row at a test that does not exist.

  The questions, because they are the deliverable rather than the mechanism: is a missed delivery reported or silently dropped; can a late arrival tell "nothing happened" from "I was not listening"; is a SUBSCRIPTION authorized or only the connection; does a subscription outlive its credential; does the link heal itself after an outage; does a degraded network lose messages or only slow them; does fan-out cost grow with subscribers; are channels typed or strings; is a declared event nobody publishes reported; is cross-replica traffic separated per app by default.

  **Why this replaces a benchmark against hosted competitors.** A table of our measured numbers beside someone else's published ones is not a comparison, it is two things in a row. Measuring a hosted product honestly needs its accounts, regions, tiers and retry policies, and a wrong number about someone else's product is worse than no number. What decides a choice is not the microseconds anyway — it is whether the system answers these questions at all, and every answer above is checkable against this repository by anyone.

  codemod: none
- **@voltro/cli** — A real competitive measurement — against socket.io, on this machine, in the same topology.

  This was declined twice on the grounds that a benchmark needs the competitor's accounts and regions. That reasoning holds for hosted products and **does not hold for socket.io**, which is an npm package: it can be installed, run and measured here with the same method. Declining it was over-broad.

  Back to back, two server instances sharing one Redis, client on B, emits on A:

  | | p50 | p99 | delivered | | --- | --- | --- | --- | | Voltro cross-replica | **1.29 ms** | 6.80 ms | 200/200 | | socket.io + redis-adapter | 1.89 ms | **3.81 ms** | 200/200 |

  **~32% faster at the median, ~44% worse at the tail.** Both lossless. The p99 is ours to improve and is published rather than omitted, because a benchmark you only show when you win is advertising.

  **The topology is what makes it a comparison.** The first attempt measured socket.io on a plain localhost websocket with no adapter and came out 3x faster — which proved nothing: that is one hop, ours is two through a broker. It would have flattered socket.io and been dishonest in their favour, which is the same defect as flattering ourselves.

  Also measured and NOT published as a headline: socket.io's `emit` to 100 subscribers costs 13.7 µs against our 2.7 µs, but at that point ours has already run every listener while socket.io has only enqueued to 100 sockets — zero had arrived when the measurement ended. Two different quantities; comparing them would have been the same mistake in the other direction.

  `scripts/bench/socketio-cross-replica.mjs` carries the method and the numbers so they can be re-taken. Deliberately a script, not a test: keeping a competitor in the dependency tree to hold a number green is the wrong trade.

  codemod: none
- **@voltro/plugin-webhooks** — **A subscription is a SET of events, and the service now has a word for it.**

  `subscribe({ events: [...] })` creates the rows in one call; a `{ scope }` selector addresses them as a group wherever a target id is accepted — `pauseTarget`, `resumeTarget`, `updateTarget`, `deleteTarget`, `rotateSecret`, `listDeliveries`.

  A row is one event, but a subscription — as every webhook UI models it, ours included — is one URL with a list of event checkboxes. Without a name for the group, five checkboxes are five rows and every operation a user thinks of as single becomes a fan-out the app writes by hand: N pauses, N updates, N delivery reads merged and re-sorted, and a rotate that is delete + re-subscribe.

  **The shared secret is why this is correctness and not ergonomics.** The receiver verifies ONE signature for ONE url, so N rows for one endpoint must sign identically — and there was no way to say so. `subscribe` mints a secret per call, `SubscribeResult` surfaces it once, `TargetPatch` cannot set it. So ticking a sixth event meant reading the secret column back out of `_voltro_webhook_targets` through the app's own database handle. That is exactly the coupling `listDeliveries` was added to remove, re-entered through a different door one release later.

  `subscribe` now mints one secret for the whole set, and `rotateSecret({ scope })` rotates every row to the same new value — which also replaces the delete-and-re-subscribe that minted new target ids and orphaned the delivery history.

  **`secret` is deliberately still not patchable.** Adding it to `TargetPatch` would close the same gap by making a live credential app-writable, trading a coupling for a weaker invariant. The reporter proposed the constraint and declined that shortcut themselves.

  A scope matching no row is an error rather than a no-op: "pause the endpoint" that pauses nothing and reports success is the silent shape this selector exists to avoid.

  codemod: none

### Fixed

- **@voltro/cli** — Cross-replica delivery is now tested over a network that is not loopback.

  This closes the one item repeatedly written off as needing external infrastructure — "two real pods over a real network". That was the wrong variable. What a loopback number cannot show is a path with LATENCY, JITTER and a bandwidth ceiling, and injecting those is not only possible in the test stack, it is BETTER than a real network for a test: reproducible, and degradable on purpose.

  `toxiproxy-test` joins `test/docker-compose.yml` as a degradable path to `redis-test`. Measured through it:

  | condition | p50 | p99 | delivered | | --- | --- | --- | --- | | 20 ms ± 10 jitter | 26 ms | 89 ms | 200/200 | | + a 50 KB/s ceiling | 188 ms | 354 ms | 200/200 |

  Seven times slower at the median under the second, and not one envelope lost. That is the property the new suite asserts: **degradation costs latency, never messages.**

  The latency BUDGET is deliberately left in the healthy-path suite. Asserting it here would produce a test that goes red when the network is bad rather than when the code is — and the second row above is exactly that case.

  codemod: none
- **@voltro/plugin-audit, @voltro/cli, @voltro/runtime** — **`@voltro/plugin-audit` could not boot — a release blocker, reported within a day.** 0.26.0 attached `interceptAction` and `interceptQuery` and declared neither scope, so the boot permission audit (`level: fatal`) refused to start EVERY app carrying the plugin, whether or not it had opted into query auditing. The audit inspects the presence of a hook, not what it does, so the identity passthrough counted.

  The manifest declares both now — and `interceptQuery` is **attached** only when `recordQueries` is on, with its scope declared conditionally the way `store:write` already is. That is the reporter's suggestion and it is the better half of the fix: listing the scope unconditionally clears the boot while making every deployment DECLARE that it intercepts queries when almost none do, and a permission manifest is worth reading only if it describes what the plugin actually touches.

  Their diagnosis of why it escaped is what the guard is built from: *a plugin's own test suite exercises the plugin, not a boot with the plugin installed*. The same shape as the `gc-snapshots` dialect bug one round earlier — the check that would have caught it is the one nobody ran on the affected path. There is now a test over the WHOLE `packages/plugin-*` set asserting that every hook a plugin ships has its scope named in its source, red-verified by reproducing 0.26.0.

  **The stale-`source:` warning fired on the framework's own tables.** It resolved against the app's discovered entities, so every table the framework contributes conditionally — `_voltro_agent_messages` / `_voltro_agent_threads` behind a `*.agent.tsx`, and every plugin's `extendSchema.tables` — read as missing. The reporter got two warnings on every boot, for two sources that were correct, about a table the framework itself had created.

  Their argument for why that is worse than cosmetic is the one that shaped the fix: this warning exists because a stale `source` is otherwise silent, so its entire value is being trusted. Firing on correct rows teaches the reader it is noise, and the next real one arrives into a warning nobody reads.

  It resolves against the full live set now — app entities + plugin `extendSchema.tables` + framework tables, the same set auto-migrate emits DDL for — which means it runs after that set is assembled rather than inside `loadDiscovered`. Both boot paths do it, pinned by an ordering test.

  codemod: none
- **@voltro/plugin-broadcast, @voltro/cli** — The broadcast namespace is normalised silently, and the silence reintroduces the hazard the namespace removes.

  Found by probing the broadcast surface the way the events and records surfaces were probed. `broadcastPlugin` accepted all nine bad shapes tried — whitespace, a bare `>`, a trailing dot, an empty string, no options at all — and the sanitiser handles every one of them correctly. **No declaration-time refusal is warranted, and that is the finding**, not a gap.

  What the probe surfaced is one step on: `my app` and `my.app` BOTH resolve to `my-app`. Two deployments configured DIFFERENTLY therefore share a channel, which is precisely what this option exists to prevent — arrived at by way of the option itself. The docs already say that staging and production of one app share a name and only this variable separates them, which is exactly the case where someone types two values believing they differ.

  Nothing refuses: the resolved value is broker-safe either way, and failing a boot over a dot would be worse than the collapse. Both boot paths log the substitution when it changes what was written, and the message names the COLLAPSE rather than only the substitution — the substitution alone reads as cosmetic. Silent when the value survives unchanged, and silent for the derived app-name default, which is not something an operator can act on.

  codemod: none
- **@voltro/plugin-broadcast, @voltro/cli** — **`broadcastPlugin()` with `REDIS_URL` set no longer stays silently on `memory`.** `REDIS_URL` counted for RESOLUTION but not for INFERENCE — it took an explicit `connection` option to be considered — so the plugin fell through to the in-process bus while the branch that would have read the variable sat directly below. Two doc strings promised the fallback ("inferred from … `REDIS_URL`", "falls back to `REDIS_URL`").

  The asymmetry is what made it expensive rather than merely wrong: cache, kv and ratelimit all follow `<NAME>_REDIS_URL` → `REDIS_URL`, so an operator sets one variable, reads `cache backend resolved: redis` in the boot log, and concludes the bus did the same. A reporter did exactly that, on a single-replica deployment where the difference is unobservable — it appears on scale-up, as "some screens miss some events".

  The caution the opt-in encoded is obsolete: every channel now carries the app-derived namespace, so attaching to a shared server no longer means two apps read each other's traffic. The test that pinned the old decision is reversed with that reasoning in it rather than deleted.

  **The producer scan sees a locally bound publisher.** `\.publish\s*\(` misses

  const publish = ctx.publish if (publish === undefined) return await publish(descriptor, {}, payload)

  — which is not a corner case but the shape a handler writes when it guards the optional publisher. A reporter spent a quarter hour hunting for a missing publish they had just written, because the warning said their working event was dead. A false negative here is a missed warning; a false POSITIVE is a warning that lies about working code, and that is the expensive direction.

  A bare `publish(` now counts, but only in a file that mentions `ctx.publish` or `ctx.events` — `publish` is too common a name to accept unqualified, and the qualifier also covers the `async ({ publish })` destructuring the dotted form misses for the same reason. Both directions tested.

  codemod: none
- **@voltro/plugin-broadcast** — `broadcastPlugin` refuses a request it cannot honour instead of downgrading it silently.

  Probed the way the events, records and presence surfaces were: five plausible mistakes, **five accepted**, and every one produced the same outcome — the in-process memory bus with a successful boot.

  | written | got | said | | --- | --- | --- | | `provider: 'redes'` (typo) | memory | nothing | | `url: 'http://x'` | memory | nothing | | `url: ''` | memory | nothing | | `provider: 'redis'`, no url anywhere | memory | nothing |

  On one replica each of these is indistinguishable from working. They appear on the second, as "some screens miss some events" — which is the report that led here, and it cost a consumer a deployment.

  The asymmetry that decides it: **an app that configures nothing has taken a default, and memory is the honest answer. An app that writes `provider: 'redis'` has stated a requirement**, and answering a requirement with a downgrade is the shape removed everywhere else in this codebase.

  So configuring nothing still takes memory, an explicit `provider: 'memory'` is still honoured — saying it out loud must not be worse than saying nothing — and a bare redis url still resolves without naming the provider. What throws is only the case where the request cannot be met: an unknown name (listing the valid ones, so the fix does not need the docs), a url whose scheme names no provider, and a named provider with no url anywhere (naming the variables that would satisfy it).

  Red-verified: with the refusal removed, the two tests that assert it go red.

  codemod: none
- **@voltro/cli** — Cross-replica delivery is now tested across a broker OUTAGE, not only a healthy or a degraded link.

  The suites here proved delivery on a working link, and one proved it on a throttled one. None broke the link. That is the failure an operator actually meets — a redis restart, a failover, a partition that heals — and it was the last untested shape in the realtime stack.

  **The property asserted is recovery, not delivery.** A broker that is down cannot carry messages, and claiming otherwise would be exactly the sort of guarantee this repo keeps removing. What must hold is that the link heals BY ITSELF: after the outage, delivery resumes with no process restart, no app-side retry and no resubscribe. A subscriber that silently stays dead after a blip is the worst realtime failure there is, because the screen keeps rendering and nothing reports it.

  The test proves the link worked BEFORE it breaks it, so a zero at the end cannot be blamed on a link that never worked. Red-verified: leaving the proxy disabled gives 0 recovered deliveries instead of 10.

  Also probed, and correct as found: a throwing listener does not kill the publish, does not stop its healthy siblings receiving, and does not leave the bus unusable afterwards. The 5 MB payload the bus accepts is fine — the size gate sits at the public seam (`ctx.events.publish`) and measures the ENCODED wire form, which is the representation that can actually be rejected downstream.

  codemod: none
- **@voltro/protocol, @voltro/runtime, @voltro/cli** — **The credential bound covered one auth shape and the sentence did not say so.**

  We wrote that "an event subscription can no longer outlive the credential that authorized it" and, a release later, that "the bound now covers EVERY realtime primitive". Both were true only for the `voltro:session` cookie: `sessionExpiryFromHeaders` read that cookie and nothing else, so for an app authenticating with Bearer JWTs the bound was always `undefined` — a no-op that reads as a guarantee.

  A reporter found it by expecting black screens an hour after a deploy and getting none. Their framing is the one to keep: **the guarantee was not false, it was scoped to an auth shape the sentence did not name** — and we had corrected a different sentence in the same release for exactly that reason.

  There was also no seam to close it with. `StrategyResolution` was `{ matched, subject }`, so the strategy — the only place in the system that verified the token and holds its `exp` — could not report it.

  It can now: `{ kind: 'matched', subject, credentialExpiresAt? }`, optional, with absent still meaning no bound. The shared JWT strategy reports its verified `exp`, which covers all six catalog providers (auth0, clerk, kinde, oidc, supabase, workos) in one place rather than six near-identical lines that drift.

  The expiry rides WITH the subject through the chain and is recorded on the per-connection channel that already carries subject overrides, so `ConnectionInfo` reads it instead of re-deriving from headers. Two sites deriving one fact is what let the cookie path and the bearer path disagree. Both boot paths do it, in the same change.

  Tested for both shapes — including that `resolveScopes`, which rebuilds the subject, does not drop it. That would have reopened the hole for every app using the seam we point people at for this kind of augmentation.

  codemod: none
- **@voltro/cli** — **Records and presence are now PROVEN cross-replica, not asserted.**

  Asking one question across the whole surface — *which primitive is proven cross-replica against a real broker?* — gave an answer no amount of bug-fixing had:

  | primitive | before | | --- | --- | | events | seven suites: partition, broker outage, degraded network | | records | **none** | | presence | **none** — zero broker use in all three of its suites |

  "Multi-replica works" was proven for events and asserted for the other two, and they run through different code: events go bus → bridge → subscriber, records go `store.onChange` → broadcast → the peer's `injectExternalChange` → dispatcher → subscription. Only one had been driven end to end.

  **Presence** now proves what a consumer had to measure by hand with `redis-cli PUBSUB NUMSUB` because the framework was telling them the opposite: a member tracked on A appears in B's roster, opaque `meta` survives the hop, and a leave on A removes it from B. A roster that only ever GROWS across instances is the failure that looks like success.

  **Records** cost three wrong attempts, and the reason is worth more than the test. Two independent in-memory stores cannot model this: `injectExternalChange` NOTIFIES without persisting — deliberately, because replicas share a DATABASE and the peer re-reads storage they have in common. With separate stores the notification arrives (measured: called exactly once) and the re-read finds nothing, so no delta is emitted. Correct behaviour against an incorrect topology — and reported as a defect it would have sent someone hunting the bus for a bug that is not there. The suite runs one postgres, two stores, two dispatchers.

  Two harness errors along the way are recorded in the files rather than quietly fixed: `tracker.track()` alone is a LOCAL write (the route calls `announce(track(...))`), and a predicate literal is `{ column, op, value }` — using `kind` instead of `op` matched nothing, so the missing delta was correct. Both would have been reported as framework defects.

  codemod: none
- **@voltro/cli** — The `no-consumer` half of the event audit sees sibling apps.

  It read the API app's own tree, and in a monorepo the `useEvent` calls are not there — they are in the web apps beside it. A reporter had ten declared events, all ten consumed, all ten calls in ONE file in a sibling app, and got ten `no-consumer` warnings. A check that is wrong ten times out of ten carries no signal, and they ranked the two halves themselves: the producer half found them a dead trigger node that had not fired since a migration; the consumer half found nothing and spent the attention the producer half needed.

  The siblings are not guessed from directory layout. `pnpm-workspace.yaml` declares them, so this reads what the workspace already says — a project outside a workspace costs nothing, which is the common single-app case.

  Bounded at 4000 files, and LOUDLY: hitting the bound logs that a `no-consumer` line below may mean "we stopped looking" rather than "nothing consumes it". A silently truncated scan is the same false confidence one layer down, which is the defect this whole audit exists to remove.

  codemod: none
- **@voltro/protocol** — `defineEvent` refuses four authoring mistakes it used to accept.

  Found by probing what it lets through rather than by reading it: nine plausible mistakes were tried, nine were accepted. The surface had exactly two refusals, one of which (`latest` + `webhook`) is a model for the rest.

  **Whitespace in a name is the severe one — a production-only silence.** The name becomes a broker SUBJECT segment, and NATS refuses a subject containing whitespace and delivers nothing, with no error on the publishing side. An app that works on Redis stops working when the transport changes: silently, on one broker only. Refused at declaration, where the author can still see the string, and the message names the dot form to use instead.

  **`guards: []`** is refused because the enforcement in `bindEvent` runs only for a non-empty list — so it reads at the call site as if the event were protected and secures nothing. That is the declared-and-inert shape this codebase keeps finding; an omitted field is the honest spelling for unguarded.

  **`webhook.rateLimit.perMinute: 0`** defers every delivery forever, and there is no "unlimited" spelling for the field, so 0 is almost always someone reaching for one. **`webhook.version: 0`** would make a subscriber pinned to 1 read the event as *behind* — the opposite of what a version bump means.

  Each message says what is wrong, why, and what to write instead; a test asserts that every refusal is more than one line, because a message that only names the rule leaves the reader guessing at the reason, and the reason is usually what they needed.

  codemod: none
- **@voltro/database, @voltro/runtime, @voltro/cli, @voltro/plugin-presence** — **`pluginRef` declarations survive `table()` and are readable as `table.appliedPluginRefs`.** They did not, and the consequence reached further than the reporter could see.

  `pluginRefSpecOf` reads a column BUILDER; `table()` materialises builders into plain field descriptors. So the declaration vanished the instant the table existed, and the column read as an ordinary `text()`.

  A consumer's CRUD generator and their contract test both derive "which column carries the tenant" from the schema, both asked `type === 'reference'`, and a `pluginRef` column answered no — so generated junction handlers dropped the tenant sub-query and a favourite could point at another tenant's row. Their test missed it for the same reason the generator did: **a checker sharing the assumption of the thing it checks.** They caught it only because they happened to teach discovery about `pluginRef` before the generator; the other order ships the regression.

  **On our side it was worse and they could not have known.** The framework's own orphan-rule collector walked the column bag asking `pluginRefSpecOf`, got `undefined` every time, and produced ZERO rules on every real schema — so `orphanPolicy: 'delete'` did nothing, for the second release running. Its wiring test stayed green because it asserted the collector was CALLED, never that it returned anything.

  A test against a real `table()` then found a THIRD defect immediately: the collector read `spec.target().name`, and a table's property is `tableName`, so every target resolved to undefined and the boot refusal fired for every `pluginRef`. The fixtures returned `{ name }` — confirming the wrong assumption rather than testing it.

  That confusion had spread. The stale-`source:` resolver in BOTH boot paths built its table set the same way, producing an empty set — and `unresolvedSources` returns nothing for an empty set by design, so the warning silently stopped firing. **The fix for one false positive had turned the other into silence.** A narrow source guard now catches the shape; its own first run flagged a correct workflow read, which is recorded in the file, because a guard that opens with a false positive gets muted.

  **The presence broker warning fires after the bus attaches.** It ran at plugin activation, and the broadcast bus attaches later — the reporter measured 634 ms, then confirmed with `PUBSUB NUMSUB` that presence was cross-instance while the log said otherwise. The check is deferred and re-reads the transport at fire time. This exact warning had just found them a real misconfiguration and then kept reporting the fault after the repair, which is how a warning spends the credibility it earned.

  codemod: none
- **@voltro/plugin-presence** — `presencePlugin` refuses a `timeoutMs` that expires members between heartbeats.

  The presence surface, probed the way events, records and broadcast were: five plausible mistakes tried, five accepted. Zero and a negative are the obvious two; the one worth the rule is a value SMALLER than the client's heartbeat, because that is the mistake with a plausible motive ("expire people quickly") and a silent failure.

  `timeoutMs` is one half of a contract whose other half lives in the client. A member is online for `timeoutMs` after its last heartbeat, and `usePresence` beats every 15s by default. Below that, every member expires between beats — the roster flaps empty and nothing reports it, because an empty roster is also what "nobody is here" looks like.

  A consumer wrote that pairing down themselves ("our 10s heartbeat is the other half of the contract"), which is evidence the rule is real AND that it was left to the reader to work out. It is stated in both languages now, and the message names the CLIENT side, since a message naming only the server value sends the reader looking for the number in another package.

  A long window is still fine — a signage terminal beating once a minute is a real deployment. The rule is a floor, not a range.

  codemod: none
- **@voltro/protocol** — `defineQuery` refuses three contradictions it used to accept — the same probe that found four on `defineEvent`, run against the records surface.

  That symmetry is the point rather than a coincidence. `guards: []` was refused on events an hour after it was accepted on queries, and a rule that holds for one primitive and not another is worse than no rule: the framework's answer then depends on which file the author happened to open.

  - **`guards: []`** reads at the call site as if the procedure were protected and enforces nothing — the check runs only for a non-empty list. - **An empty `source`** (`''`, `[]`, or a blank entry) declares reactivity and subscribes to nothing: one snapshot, never an update, indistinguishable from "nothing changed". It is worse than a STALE source, which the boot warning can at least name — this one names no table at all, so nothing can report it. - **`internal: true` + `overridesPlugin`** removes the plugin's route and puts something not wire-reachable in its place, so callers get a 404 for something that used to work with no diff that says so. It extends the existing `assertWireSurfaceConsistent` contract rather than adding a second rule beside it.

  Also settled, by reading the runtime rather than declining again: **`rewind` needs no rule.** It replays the pruned ring on attach — with `each` that is "catch up on what you missed", with `latest` it is "here is the current value". Both are meaningful, so the combination that looked suspicious is fine, and a test now pins that decision so the next reader does not re-open it.

  codemod: none
- **@voltro/cli** — The one multi-replica scenario with no test: a replica that goes away, misses traffic, and comes back. The existing suites prove two replicas REACH each other, not what happens when one stops being able to.

  It covers both directions of the claim that `missed` is COMPUTED and never estimated. **Under-reporting** is the silence this primitive exists to remove. **Over-reporting** is the freshly-started replica announcing a loss for messages it was never owed — measured once at 5000, and the reason every delivery carries `prior`.

  The accounting identity is the assertion: every envelope owed after the resume point is either replayed or reported, and the two must sum to what was owed.

  **The first version of that test was vacuous, and the reason is worth recording because it is the fourth instance this session.** It published six envelopes into the default ring of 64, so the ring held everything, `missed` was always 0, and the identity was true by arithmetic for any implementation at all — sabotaging the computation to under-report by one left it green. The ring is now deliberately SMALLER than the traffic (`ringSize: 3`, ten publishes), the non-vacuity assertions come FIRST, and the same sabotage now fails it 8-to-9.

  `describeIfReachable`, verified both ways: with `REDIS_PORT=1` it reports two named skips rather than returning green having tested nothing.

  codemod: none
- **@voltro/runtime** — A resume replay could be **overtaken** by live traffic, delivering serials out of order.

  Found by testing the case a busy app produces and the reconnect tests do not: a backlog being replayed at the same moment new envelopes are accepted, because a real reconnect does not pause the publisher. Measured — resuming from `n=3` over a ring of 8, with an ordinary re-entrant publish from the listener at `n=6`, delivered `[4, 5, 6, 9, 7, 8]`.

  The cause is an ordering that is right for a different reason. The listener is registered BEFORE the replay on purpose: it closes the window between reading the ring and going live, so nothing published in between is lost. What it does not do on its own is keep the two streams in sequence — a live delivery reaches the listener immediately and jumps ahead of the entries still queued behind it.

  Out-of-order is worse than loss for anything that folds state: a display applying an older frame after a newer one shows the past and stays there. And `n` arriving non-monotonically undermines the serial every gap number is computed from.

  Live deliveries are now buffered for the duration of the replay and flushed after it, in arrival order, synchronously before `subscribe` returns — an async flush would reopen the window the early registration exists to close. Both properties hold: nothing is missed, and nothing overtakes.

  The new tests also pin two things the quiet reconnect case cannot see: no serial is delivered twice when an envelope is in the ring at the moment of attach, and traffic arriving during a replay is not reported as a gap.

  codemod: none
- **@voltro/cli** — The socket.io comparison published one run per side. Both its numbers were noise, and it is corrected here with five runs each.

  | | p50 median | p50 range | p99 median | p99 range | | --- | --- | --- | --- | --- | | Voltro cross-replica | **0.68 ms** | 0.58–0.86 | 7.01 ms | 4.50–13.36 | | socket.io + redis-adapter | 1.31 ms | 1.16–1.73 | **4.52 ms** | 4.33–7.83 |

  The earlier table claimed "32% faster at the median, 44% worse at the tail". The median advantage is nearer **2x** — the p50 ranges do not overlap at all — and the tail gap sits INSIDE the overlap, so it is weaker evidence than a single pair of numbers made it look.

  **A single measurement presented as a fact is the defect this framework spends its time removing, and it was committed in its own benchmark.** The correction is the finding.

  **Where the tail comes from, measured rather than guessed.** Splitting the publish path: our own code — building the envelope, the Effect fiber per message, the handoff — costs p50 **0.056 ms** / p99 **0.444 ms**. Waiting for Redis to acknowledge costs p50 1.17 ms / p99 6.43 ms.

  So roughly 0.4 ms of a 7 ms tail is ours and the rest is the broker round-trip, which socket.io pays too. The `Effect.runPromise` per message was the leading hypothesis and the measurement cleared it. There is no code-level tail defect to fix — on this machine the number is dominated by Docker's network stack.

  codemod: none

---

## [0.26.0] — 2026-08-04

### ⚠ BREAKING

- **@voltro/protocol, @voltro/plugin-webhooks, @voltro/cli** — `defineEvent({ webhook: { retry } })` is removed. It never did anything.

  The field was typed, documented as "default retry policy for new subscriptions", and read by nothing — `grep` for `spec.retry` across the repo returned no hits. Setting it produced no error, no warning and no effect: the value was dropped where an event descriptor is projected into an outgoing webhook descriptor, and a comment there explained why (the plugin's `RetryPolicy` is a richer shape than the two numbers the protocol carried, so forwarding it blind would install a policy nobody wrote). A test pinned that dropping as correct.

  The reasoning was sound and the result was still wrong, because none of it reached the user: they wrote a typed option and got silence. This is the third instance of that exact shape in this feature — `broadcast({ channel })` was declared, named in its own doc comment as the multi-deployment answer, and forwarded by nothing; an event's `guards` were accepted, serialised into the manifest, reported by doctor and counted in the devtools panel while nothing enforced them. Two were found by consumers. This one was found by walking the option surface and asking, per field, who reads it.

  Retry belongs on the SUBSCRIPTION, where the full `RetryPolicy` shape is available and typed. If you set it on the event, delete it — nothing changes at runtime, because nothing was reading it.

  **The guard that exists for this class did not catch it, and that is the more important half.** `declaredOptionsEnforced.test.ts` checks a hand-maintained list of options; it can only re-verify the ones somebody remembered to add, and it covers no nested field at all. It was green throughout. Deriving that list from the type rather than curating it is filed as follow-up — the same lesson as `procedureWireReachability.test.ts`, which was satisfied at every site it knew about while the defect sat at a site it did not consider one.
- **@voltro/workflow** — The `retry:` field on a workflow `step({...})` is now **ENFORCED**, not dashboard metadata. The framework compiles the declared policy to an Effect `Schedule` and retries `execute` accordingly — so `step({ retry: { maxAttempts: 5 } })` actually retries five times, no hand-written `Effect.retry` needed.

  It became a real, innovative policy while it was at it — the conditions you actually want, default-correct:

  - **Error classification** — `retryableErrors: ['ProviderDown', 'RateLimited']` (retry only these typed-error `_tag`s; everything else fails fast) or `retryable: (error) => boolean`. Retry the transient, fail the permanent. - **A time BUDGET, not just a count** — `maxElapsed: '5 minutes'` stops retrying once that much wall-clock has elapsed, even if attempts remain. A deadline. - **Jitter** — `jitter` (ON by default) spreads retries so a fleet doesn't re-hit a recovering dependency in lockstep. - **Capped backoff** — `maxDelay` ceilings exponential growth; `strategy` (`exponential` / `fixed` / `linear`), `baseDelay`, `factor`, `step`. - **Provider-driven backoff** — `respectRetryAfter` honors a `retryAfterMillis` / `retryAfter` hint on the error as a floor (a 429 `Retry-After`).

  Retries run inside the one step and are transparent to the durable engine; the step's final outcome is recorded, and the serialisable knobs still feed the dashboard. `stepModule.retry(…, Schedule)` remains for full hand-written `Schedule` control.

  **BREAKING, and check the first half before the second.**

  **A step that declared `retry:` and nothing else ran ONCE. It now runs up to `maxAttempts` times.** In 0.25.0 the field's own type said so — *"Pure metadata — does NOT change retry behavior on its own"* — so trusting it was correct. If `execute` is not idempotent (a charge, an email, an outbound POST), that is real duplicate work beginning on this upgrade, with nothing in your code changed to cause it. Per step: make the effect idempotent, or set `maxAttempts: 1`, or narrow with `retryableErrors: [...]` so only transient failures retry.

  The second half is the one you can see in your own source: the old docs told you to ALSO wrap the step in `stepModule.retry` / `Effect.retry`, and a step that did both now retries TWICE. Keep the declarative `retry:` (it also drives the dashboard) and drop the redundant wrapper — or, if your hand-written `Schedule` did something the policy can't express, keep it and drop `retry:` from that step.

  `codemod: 0.26.0/02_step-retry-enforced` (manual) prints both, and fires for any project declaring `retry:` on a step — not only those with a manual wrapper.

### Added

- **@voltro/plugin-audit, @voltro/plugin-versioning** — **The audit trail can name its own actor, and it covers more than mutations.**

  *B1 — the actor is a snapshot now, not a reference.* The argument that decides this lives inside ONE row: `_voltro_row_history.data` is a full-row snapshot, deliberately, so it survives what happens to its source — while the same row's `changedBy` is a foreign key that does not. One record, two philosophies: the row's state preserved forever, its author only until someone exercises a right to be forgotten.

  That right is one we grant. `@voltro/plugin-governance`'s `governance.erase` (`delete | anonymize`) is ours and recommended, so a deployment can install auditPlugin + versioningPlugin + governancePlugin and have the third render the first two unreadable for precisely the subjects an investigation is about. Anonymisation is the worse half because it looks like it worked: the join SUCCEEDS and returns "Anonymised" for every entry that actor ever produced, retroactively rewriting history that was correct when written. A rename does the same, silently.

  Both tables gain `actor json {id,type,displayName,email}`, resolved from the `actors` row at WRITE time — the moment the identity is still true. `email` is read opportunistically, because the framework's own columns are `id`/`kind`/`displayName` and apps commonly extend it; insisting on a fixed shape would make the field useless where it is needed most. Resolution is best-effort and never fails the mutation it records, and absent stays absent — a fabricated placeholder is the thing this column exists to prevent.

  `_voltro_audit_log` also gains `metadata json` the app writes: the noun a diff cannot contain. "Anna removed Bernd from the Frontend sub-team" is one row-delete plus a membership row, and no column-level detail reconstructs the sentence a compliance reader needs.

  *B2 — actions and queries are audited too.* The interceptor was mutation-only, measured by the reporter against their own data: all ten rows carried mutation tags, so a successful login, a GDPR export and a third-party write from an action left no trace at all. For a compliance trail that is a LARGER hole than a missing name — the question "who exported this" had no row to be missing one on. `interceptAction` and `interceptQuery` were available slots the plugin simply never filled.

  Actions record by default (they write). Queries are opt-in via `recordQueries`, because a read-heavy app writes one row per read and a trail that drowns in reads is worse than one missing them — nobody searches it. Turn it on for the surfaces where the READ is the sensitive act, usually with `include`.

  codemod: none
- **@voltro/runtime** — The credential bound now covers EVERY realtime primitive, not just events.

  An event stream got it first; live queries and `*.stream.ts` streams are the same kind of standing grant and did not have it. All three now end when the credential that authorized them expires, and the clients' existing reconnect re-opens them as a NEW request — fresh subject, guards re-run for real.

  It is ONE function (`boundByCredential`) that all three call rather than the same three lines in three binders. A value derived independently at several sites is the shape this repo has been bitten by repeatedly: every site looks correct and they disagree the moment one is edited.

  Where the halves sit, because they are easy to conflate: the per-delivery guard re-check catches RESOURCE revocation (its resolver does a live lookup); the credential bound catches the ROLE case, whose scopes were captured when the subscription opened and never change. Neither covers the other.

  codemod: none
- **@voltro/runtime** — An event stream now re-authorizes on EVERY delivery, as a live query already did. Events were the weaker of the two for the same kind of grant.

  `servePipeline` states the reasoning for the identical case and it applies verbatim: a subscription is a LONG-LIVED grant, and the scopes that justified it can be withdrawn while it is still open — a role revoked, a resource un-shared, a membership ended. Without a re-check the socket keeps delivering what the subject may no longer read. Live queries have re-authorized per delivery for some time; event streams were checked once, at subscribe, and never again.

  **This corrects a conclusion drawn in this repo one change earlier.** That change argued a re-check was unbuildable at this seam because the subject is captured per request, so re-checking it always confirms. True of the scopes ON the subject — and wrong as a general claim, because `checkGuardsEffect` runs the async RESOURCE-SCOPE resolver, which does a live lookup. For a resource-scoped guard (`{ scope: 'arena:read', from: 'arenaId' }`, the shape events use) the re-check catches revocation for real. The two mechanisms cover different halves: this catches resource revocation, and the credential bound added alongside it catches the role case by refusing to outlive the token.

  A denial ENDS the stream rather than dropping the delivery. A silently skipped delivery is indistinguishable from "nothing happened", which is the one outcome this primitive exists to eliminate; the client is told, and its reconnect gets the refusal as a typed error. An unguarded event pays nothing — the closure short-circuits before any effect is built.

  Red-verified, and the first version of that verification FAILED to go red: the test asserted only that the stream failed, and `Effect.timeout` also fails, so a stream that never ended satisfied it. It asserts the failure VALUE now — a scope denial, explicitly not a timeout.

  codemod: none
- **@voltro/plugin-notifications** — `archive` / `unarchive`, `markUnread` and `markAllRead` — the four procedures that were keeping an app off this plugin.

  The reporter's comparison was fair and worth repeating: our surface is richer than theirs on the parts we have (quiet hours, channel preferences, delivery logs) and was missing the ones a user touches most. **Archive was not merely a missing procedure — the word appeared nowhere in this plugin's types.** `readAt` covers read; the delivery table's `status` is the delivery outcome (`sent | failed | skipped`). Neither is an archive, and archiving is the action that empties an inbox. An inbox nobody can clear is one they stop opening.

  `archivedAt` is therefore its own column and its own state: archiving does not mark an item read, and an archived-but-unread item still counts toward `unreadCount`. A UI that conflates them cannot show what a user did.

  `markUnread` exists because an inbox without a way back is a one-way ratchet, and `markAllRead` because marking two hundred items one at a time is not a feature. It reports how many rows it changed — a caller showing "12 marked read" must not be told 200 because that is how many rows exist.

  All four are subject-scoped like `markRead`: an inbox action must not reach across subjects because an id happens to be guessable.

  codemod: none
- **@voltro/database, @voltro/runtime** — `pluginRef(table, { orphanPolicy })` — point at a plugin-owned row from an app table, with a declared rule.

  ```ts
  favouriteOf: pluginRef(aiFlowsTable, { orphanPolicy: 'delete' })
  sharedFlow:  pluginRef(aiFlowsTable, { orphanPolicy: 'null' }).nullable()
  ```

  No foreign key is emitted, and that part was already right: the plugin owns its table and may rename it — the `_voltro_` migration did exactly that across ten tables — so a cross-boundary FK would turn every rename into a coordinated migration of every app pointing at it. `ai_flow_runs.flowRef` is a plain string for the same reason, and `plugin-storage` ships `assetRef({ fk: false })`.

  **What was lost with the FK is not the constraint but the ORPHANING RULE**, and a reporter's census shows the shape of it: 711 app→app references carrying an `orphanPolicy`, against 2 pointers at plugin rows. Not because pointing across the boundary is rare — because there was no pattern, so each one becomes a hand-written subscriber that cleans up on delete. Bespoke referential integrity, re-implemented per app, and nothing notices when someone forgets one.

  Four decisions, each answering an edge case they raised:

  - **Tenant — fail closed.** A referencing row whose tenant differs from the deleted row's, or which has none, is left alone. Deleting across a tenant boundary because a scope was missing is the one outcome worse than an orphan. - **Soft delete — opt in per reference** (`onSoftDelete`). A soft delete is a state the target can undo, so cascading on it destroys rows a restore cannot bring back; and plugin tables are inconsistent here by design (`_voltro_ai_flows` has `deletedAt`, `_voltro_ai_flow_runs` does not), so a guess would be wrong for half of them. - **Rename — the target is a table VALUE**, resolved through the handle the plugin exports, so a rename carries the rule with it. Referencing by string would reintroduce the coupling the missing FK exists to avoid. - **`'keep'` is a policy, not the absence of one.** Same behaviour as omitting it, arrived at deliberately and reviewable as such. The default stays `'keep'` — a default that deleted rows would be a footgun.

  codemod: none
- **@voltro/protocol, @voltro/runtime** — `defineStream` accepts `guards:`, and they are enforced.

  A stream was the ONE realtime primitive that could not express authorization at all. Queries, mutations and actions carry `guards:`; `StreamProcedureDescriptor` had no such field. Whatever protection a `*.stream.ts` had was hand-written inside its executor, where nothing could verify it existed — not the boot audit, not `voltro doctor`, not a reviewer reading the descriptor. The absence was invisible in exactly the way that matters: a stream with no authorization and a stream whose authorization lives in its body look identical from outside.

  Checked at subscribe AND before every element, the same as a query's, for the same reason `servePipeline` already gives: a stream is a long-lived grant and the scopes that justified it can be withdrawn while it is still open. The guard INPUT is the call's decoded input, so a resource-scoped guard (`{ scope: 'feed:read', from: 'id' }`) sees which resource was asked for.

  A denial ENDS the stream rather than dropping the element. A skipped element is indistinguishable from "nothing to send", and the client must learn it lost access rather than infer it from silence. An unguarded stream pays nothing.

  Deliberately NOT wired into the manifest, doctor or the devtools panel in this change. The event-`guards` defect was reporting surfaces showing protection that nothing enforced; enforcement without reporting is the safe direction of that same asymmetry — it works and is merely not displayed yet.

  codemod: none
- **@voltro/protocol, @voltro/runtime, @voltro/cli** — An event subscription can no longer outlive the credential that authorized it.

  Guards are checked once, at subscribe. That is not an oversight to patch: the subject comes from THAT request's layer, so re-checking it later inside the stream asks the same captured object and always gets the same answer. A "re-check on subject change" built at that seam would be a control path that always confirms — worse than no check, because it looks like one.

  The honest bound is a fact the token already carries. `ConnectionInfoValue` gains `credentialExpiresAt` (unix seconds, verified — an unverified decode would let a client forge a far-future expiry and lift the very ceiling this imposes), and `bindEvent` ends the stream there. Absent means no bound, so the failure direction is the behaviour that already existed.

  **It is seamless, and that costs nothing to build.** `useEvent` already treats a clean end as a reconnect reason — a server never legitimately finishes a stream a client still wants — so it re-opens immediately. A reconnect is a NEW request: the subject is resolved afresh and the guards run again for real. Still entitled, it continues and the app sees nothing; no longer entitled, the reconnect is refused loudly instead of delivering forever on a dead credential. No application-side reconnect handling.

  `sessionExpiryFromHeaders` is a SHARED helper both boot paths call, and `SESSION_COOKIE_NAME` moved to `@voltro/protocol/session` so it has one definition rather than one per reader — dev and serve deriving one value twice is how the two paths come to disagree silently.

  This bounds EXPIRY, not revocation. A role revoked mid-session is not observed until the credential runs out, and the docs say so in both languages rather than implying more. Revocation belongs at the session seam — a revoke event that ends the connection is one place instead of one per primitive, and this reconnect machinery would then carry it for free.

  codemod: none
- **@voltro/plugin-webhooks** — Three additions that were the whole distance between a consumer and deleting their own webhook tables.

  **`scope` — an opaque app dimension on `_voltro_webhook_targets`.** Stored and returned verbatim, never interpreted; `listTargets(event, scope)` filters on equality against it. `.with(tenant())` is one level too coarse for real deployments: their endpoints are scoped to a TEAM and a tenant has many teams, so every read filters by it and every write guards on it.

  The precedent is theirs, and it decided a migration: `_voltro_presence.meta` is json the framework stores and never interprets, and it is the ONLY reason their presence migration was lossless — three denormalised columns went straight in. An earlier review of theirs called that plugin lossy and they withdrew it. The general form they derived is the right one: **a plugin that stores rows in an app's database on the app's behalf needs one place for the app's own dimension.**

  **`listDeliveries` / `getDelivery`.** There was no service method over `_voltro_webhook_deliveries`, so a management view could only query the table directly — which they declined, correctly: the 0.24.0 `agent_messages` rename taught them what app code coupled to a framework table name costs, and that one was survivable only because it was a rename. `listDeliveries` omits `payload` and `responseBody` so a list view does not pull response bodies for 200 rows; `getDelivery` adds them. Timestamps are normalised to ISO regardless of what the dialect returned, and an unparseable payload comes back verbatim rather than throwing — a management view must render a malformed row, not 500.

  **`updateTarget` and `testTarget`.** Editing a URL previously meant delete + re-subscribe, which rotates the secret (every receiver reconfigured) and orphans the delivery history. The patch writes only the keys present, so an absent key leaves the column alone while an explicit `null` clears it; `event` and `secret` stay unpatchable (a different event is a different subscription, and the secret has `rotateSecret`). `testTarget` sends ONE delivery, bypassing fan-out and the filter — a filter excluding the probe would make a healthy endpoint look dead — but NOT `active`, so a paused target queues exactly as an emit would and the test tells the truth about production.

  codemod: none
- **@voltro/cli, @voltro/runtime, @voltro/workflow** — **Cross-replica workflow WAKE** — a triggered workflow now starts ~immediately across replicas, instead of waiting up to the storage-poll interval. When you trigger a workflow whose cluster shard is owned by ANOTHER replica, that replica used to pick the run up only on its next poll tick (up to 10s), because Voltro's single-runner topology has no runner-to-runner push. Now, on a trigger the framework publishes a tiny "wake" onto the SAME Redis/NATS broadcast bus a multi-replica deployment already runs for cross-replica reactivity; every replica subscribes and, on a wake, re-polls cluster storage right away — so the shard owner reads the new run now.

  - **Dialect-agnostic** — it rides the broker, not the SQL dialect, so it works identically on postgres / mysql / mariadb / mssql (unlike a pg-only LISTEN/NOTIFY). No effect on sqlite (single-process, already immediate). - **Degrades cleanly** — with no broadcast broker (single replica, or the in-process memory transport), there's nothing to wire and the poll interval (`VOLTRO_WORKFLOW_POLL_INTERVAL`) remains the bound. The wake is a latency optimisation, never a correctness dependency: a dropped wake just falls back to the poll. - Built by ONE shared builder wired into BOTH `voltro dev` and `voltro serve` (boot-path parity), fires on the fire-and-forget `start` / `child` triggers, and skips its own wake (the triggering replica already polled locally).

  `codemod: none` — additive; new opt-in behaviour that activates only when a cross-replica broker is present.
- **@voltro/runtime, @voltro/workflow, @voltro/cli** — Workflow dead-letter management — a dead-letter VIEW + `discard`. Because the framework applies no retry of its own, a `failed` run is terminal: it is the dead-letter. `voltro workflows list --dead-letter` shows the queue of unhandled failures (`status = 'failed' AND discardedAt IS NULL`); `voltro workflows discard <id>` acknowledges one so it drops off that view. Discard is an ACK, not a re-classification — the run stays `status: 'failed'` (outcome + audit trail survive) and gains a `discardedAt` timestamp (mirrors how `cancelled` coexists with the status); `--status failed` still lists it, marked `discarded`. Discarding a non-failed run is refused; discarding is idempotent. New `discardedAt`/`discardedBy` columns on `_voltro_workflow_runs` (ride the declarative differ — no codemod), a `discard` inspect action + `--dead-letter` list filter, and `discardedAt` on the `WorkflowRunSummary` / `deadLettered` on `WorkflowRunListFilter`. Note: like the other workflow inspect ACTIONS (retry/cancel/…), discard is wired on the `voltro dev` inspect surface. `codemod: none` — additive schema + a new opt-in CLI/inspect surface; no user-authored code is affected.
- **@voltro/workflow, @voltro/cli** — Workflow failover across replicas is now **tunable and proven**. When a replica running a durable workflow crashes, a surviving replica takes the run over and continues it from the last completed step (completed steps replay from the journal, not re-run) — on any SQL store (postgres / mysql / mariadb / mssql). That already worked; what's new:

  - **Two operator knobs** for how fast a survivor reclaims a crashed replica's in-flight work — which is a lease-expiry floor (~35s default), NOT a polling one, so lowering it is the lever, and a push mechanism wouldn't help: `VOLTRO_WORKFLOW_FAILOVER_LEASE` (seconds; default 35) and `VOLTRO_WORKFLOW_FAILOVER_HEARTBEAT` (seconds; default 10, keep ≈ lease/3). Lower the lease for faster failover, at the cost of false-positive reclaims if a healthy replica stalls (GC / DB-latency) longer than the lease. Exposed as `failoverLeaseSeconds` / `failoverHeartbeatSeconds` on the workflow engine layer and read from env by `voltro serve`. - **A live multi-PROCESS failover test** (`@voltro/sql-postgres`) that boots two real cluster-runner processes against one postgres, SIGKILLs the one running a 3-step workflow mid-step, and asserts the survivor resumes it from the journal — the completed step ran exactly ONCE across the crash. This exercises the hard-crash (lease-expiry) path a clean shutdown can't, and is the guarantee behind the docs. - Production-hardening docs (en + de) now cover the failover model, the `POD_IP` requirement, the at-least-once step boundary, and the tuning tradeoff.

  `codemod: none` — additive config; nothing user-authored changes.
- **@voltro/workflow, @voltro/cli** — `VOLTRO_WORKFLOW_POLL_INTERVAL` (seconds → `messagePollSeconds` on the workflow engine layer) tunes NEW-message pickup latency across replicas. When you trigger a workflow whose shard is owned by the SAME replica, it starts immediately (a same-process push); when ANOTHER replica owns the shard, that replica picks it up on its next storage poll — up to 10s by default (Voltro's single-runner topology has no cross-runner push). Lower this for latency-sensitive multi-replica workloads, at the cost of more idle poll queries; it has no effect on a single replica. This is distinct from the failover knobs (a crash-reclaim lease, not new-message latency). The lower-idle-load alternative is a pg LISTEN/NOTIFY wake, not yet wired. `codemod: none` — additive.
- **@voltro/workflow, @voltro/runtime, @voltro/cli, @voltro/devtools-ui, @voltro/voltro** — <!-- apiSurface: compatible — reasoned, not rubber-stamped. Three golden lines churn, all WIDENINGS (the direction the gate's rule is not about), and the actual consumers typecheck green against them: 1. `WorkflowRunEventType` gained `'run-redriven'` (in @voltro/workflow AND the @voltro/voltro re-export). It is a framework-EMITTED union — a reader gets a superset; every value that was one of the old members still is one. 2. `workflowEngineLayer`'s return went from `Layer<WorkflowEngine>` to `Layer<WorkflowEngine | Sharding | MessageStorage>` — it now EXPOSES the two cluster services it already built internally (so the re-drive adapter can reach the same instance). It is a framework-internal engine builder wired only by dev.ts / serveCommand (both cast loosely); every value-level use (`provideMerge`, `ManagedRuntime.make`) still compiles. @voltro/runtime + @voltro/cli, its real consumers, were typechecked after the change — green. Nothing was removed or narrowed. -->

  `redrive` — re-drive a terminally-`failed` workflow run from the step it died on, reusing its durable journal. The operator counterpart to `retry` (fresh execution, empty journal) and to `resume` (which only re-drives a *suspended* run): a plain `failed` run is a terminal `Complete(Failure)` in the cluster store that `resume` will not touch. Fix the downstream cause, then `voltro workflows redrive <runId>` (or `ctx.workflows.redrive(runId)`, or the inspect `redrive` action) and the engine re-delivers the run — every completed step **replays from the journal** (NOT re-executed) while the failed step(s) re-run. Ideal for a long multi-step pipeline where redoing steps 1…N‑1 is expensive or unsafe and you did NOT pre-declare `suspendOnFailure`.

  Under the hood a single isolated adapter (`@voltro/workflow/cluster` `redriveFailedRun`) reaches into `@effect/cluster`'s `MessageStorage`/`Sharding` to clear the terminal `run` reply plus each failed step's journaled reply, then re-polls storage — the same primitive the engine's own `resume` uses, minus its suspended-only guard. A live cluster **contract test** boots a real engine, fails a multi-step run, re-drives it, and asserts the completed step did NOT re-run, so an engine upgrade that moves those internals fails loudly instead of silently corrupting a re-drive.

  Works in `voltro dev` AND `voltro serve` — dead-letter recovery matters where incidents happen. Refuses a run that is not a not-yet-discarded failure (use `retry` for a fresh run, `resume` for a suspended one), and declines cleanly (`redriven: false` + a `reason`) when there is no durable journal (e.g. the memory store). Records a `run-redriven` lifecycle event. `codemod: none` — a new opt-in action + SDK method; no user-authored code is affected.
- **@voltro/workflow** — `suspendOnFailure` — resume a workflow from where it failed, reusing completed steps. Declare `suspendOnFailure: true` on a workflow and a failure of its top-level body no longer becomes a terminal `failed` run — it **suspends** with the durable journal intact, so `voltro workflows resume <id>` (or `ctx.workflows.resume`) re-drives it from the point of failure: every completed activity replays from the journal (NOT re-executed) and only the failed activity runs again. This is the durable-execution way to make a long multi-step workflow recoverable across a transient downstream outage without re-doing prior work — the opposite of `retry`, which starts a fresh execution with an empty journal. Maps to `@effect/workflow`'s `SuspendOnFailure` annotation. A suspended-on-failure run records `status='suspended'` WITH the failure reason (`errorTag`/`errorMessage` + a `suspend-on-failure` event, and it reaches the error reporter), so it is distinguishable from a plain sleep/signal suspension; it shows under `--status suspended`, NOT in the dead-letter view (it is recoverable, not dead). Default `false` — a failure stays terminal. `codemod: none` — a new opt-in workflow option; no user-authored code is affected.

### Fixed

- **@voltro/runtime, @voltro/cli, @voltro/plugin-webhooks** — Three gaps named in the previous change set, closed.

  **`onSoftDelete` could not fire.** A soft delete is not a `delete` event — it is an UPDATE that sets `deletedAt` — and the rule matcher only looked at `op === 'delete'`, so the option existed and the event it needed never arrived. The matcher detects the null → non-null TRANSITION on `deletedAt` (the value alone would re-fire on every later write to a tombstoned row) and the boot wiring forwards updates as well as deletes.

  The tests were green throughout, because they passed `softDeleted: true` alongside `op: 'delete'` — a shape the change channel never produces. They proved the flag worked against something that does not exist.

  **`assertNoTagCollisions` ran only in `voltro dev`.** A plugin/app tag clash aborted boot in development and was checked nowhere in production, so a collision dev refuses could ship and whether it shadowed a route or crashed depended on what codegen happened to emit. It runs in `serve` now, honouring `overridesPlugin` identically from the same descriptors.

  **`listDeliveries` filtered after the read.** `status` and `since` cannot go into the predicate, so taking exactly `limit` and then filtering silently returned too few — ask for 200 deliveries since Monday and you get however many of the newest 200 rows fall in that window, with no signal the answer was truncated from the wrong end. It over-fetches when a post-read filter is in play, then applies the limit.

  That last test also passed against the old code at first: the fake store ignored `take` entirely, so nothing about paging was being tested. Modelling `take` made it red-verifiable, and it is — 1 row instead of 5 without the fix.

  codemod: none
- **@voltro/cli, @voltro/runtime** — Two defects reported from a MariaDB deployment.

  **`gc-snapshots` and `restore-snapshot` were postgres-only, silently.** `table_schema = 'public'` was hardcoded at four sites. On MySQL/MariaDB the schema IS the database name, so every one matched nothing — and "matched nothing" prints the same line as "there is nothing": the reporter had a real `presence__dropped_20260803032340` while the tool said "dropped 0" and exited 0.

  It compounds because `VOLTRO_SOFT_DROP=1` is the right default for an unattended migrate job, so every drop becomes a snapshot and they accumulate forever when the reclaim tool cannot see them — the safety net becomes litter.

  Two sites were in `gc-snapshots`, which is what was reported. The other two are in **`restore-snapshot`**, which nobody had reached yet: that is the command you run AFTER something went wrong, and it would have answered "no snapshot found" for one that exists. Beneath the predicate sat a second postgres assumption the first one hid — `"double-quoted"` identifiers, which MySQL/MariaDB reject, so even a matching query could not have executed. Both are dialect-resolved now (`quoteIdent` was already imported and unused).

  **A stale `source:` is now reported at boot.** `source` is matched by NAME against change events, so one naming a table that no longer exists leaves the query not broken but permanently QUIET — it serves its first snapshot and never updates, which is indistinguishable from "nothing has changed". The reporter hit it on the 0.24.0 agent rename: two queries kept the old string and the app booted clean with zero warnings. It is a string, so `tsc` cannot see it, and the codemod's promise that a missed rename "fails loudly with relation does not exist" is true of a SQL reference and false of this.

  Resolved against the declared table set — which the boot already holds, so it is free — with a did-you-mean for the prefix-rename case that produced it. It WARNS rather than refusing: a table can legitimately live outside the declared schema, and a boot failure for those would be the worse trade. Computed in `loadDiscovered`, so dev / serve / doctor / check all see it, with a parity test that fails if it is wired into only one boot path.

  Both red-verified against their own reverted fix.

  codemod: none
- **@voltro/runtime** — The events docs promised an authorization guarantee the code does not provide.

  "Guards are re-checked when the subject changes, not per delivery. Revoke a role and the stream ends." There is exactly ONE `checkGuardsEffect` call on the event path — in `bindEvent`, at subscribe — and no subject-change hook, no revocation path that touches a live subscription. A subject whose role is revoked keeps receiving, and this primitive reconnects forever by design, so "until the stream ends" can be a very long time.

  The documentation now says what happens: checked once, at subscribe, never again; if a permission change must take effect immediately, do not model the authorization boundary with an event subscription. en + de, agent-docs regenerated.

  Correcting the sentence rather than implementing the re-check is deliberate, and the reasoning is the same one that made this worth finding: a security guarantee that is stated and not kept is worse than one that is absent, because readers build on the sentence. Re-checking on subject change is a real feature with real design questions (what ends the stream, how a subject change is even observed on a long-lived socket) and it should not be improvised inside a doc fix.

  Same shape as the three defects already fixed in this pass — `webhook.retry`, `presencePlugin({ sweepIntervalMs })`, and the guards themselves, which were accepted, serialised into the manifest, reported by doctor and counted in the devtools panel while nothing enforced them. That one was about whether the check runs at all; this one is about how long its answer stays true.

  codemod: none
- **@voltro/runtime, @voltro/protocol, @voltro/cli** — **`ctx.events` is typed as what it actually is.** It was declared as the old string-emitter facade (`emit(name, data)`) long after that facade stopped being installed there, so the documented and taught call — `ctx.events.publish(descriptor, key, payload)` — was a `tsc` error while the runtime carried only `publish`. A consumer could not tell which of the two was lying and measured it with a cron probe:

  EVENT_PROBE {"eventKeys":["publish"],"publishType":"function","emitType":"undefined"}

  Exactly the inverse of the declared type. Their workaround was a cast in the one primitive whose entire justification is typing.

  What let it drift is the part worth recording: the builder installed the publisher with `as never`, so the compiler had the answer the whole time and was told not to give it — beside a comment in the same file stating that `emit` is gone. A context field is the one place this repo already treats such a cast as a defect in its own right; it is removed, so `tsc` is the guard now.

  **`overridesPlugin: true` on a query / mutation / action.** Correcting the premise first, because it matters for anyone reading the same report: sharing a NAMESPACE with a plugin already composes. `assertNoTagCollisions` compares FULL tags, so `notifications.list` beside the plugin's `notifications.inbox` has always been fine. Only an identical name collides, and that stays an error — two handlers behind one tag is not something a caller can reason about.

  What was missing is the deliberate replacement. The two escapes available before were to rename your procedure or to `alias` the whole plugin away, and both move the split from a domain boundary to "who built it" — for a frontend developer, the worst possible partition. The flag drops the plugin's route rather than merely permitting the pair (permitting it would leave two handlers bound, the state the check exists to prevent) and logs which routes it replaced.

  Explicit, never inferred: silently letting the app win would mean a plugin upgrade that adds a route could shadow an app procedure with no diff to read.

  Three smaller ones from the same report: the `defineSchedule` timezone error now says that an ABSENT field is a `tsc` error and reaching the message means an EMPTY one (usually `process.env.TZ ?? ''`); the empty-relations warning names the `_relations.register.ts` entry that must go with the file; and `db apply` no longer says "nothing to apply" one line above "installing change triggers on 500 table(s)" — it says "no DDL to apply", which is what it meant.

  codemod: none
- **@voltro/plugin-presence** — `presencePlugin({ sweepIntervalMs })` is now read. It was declared, documented as "Sweep interval for stale rows. Default 60s.", and the sweep ran on `timeoutMs / 3` regardless — so setting it did nothing, and the stated default was wrong as well: with the 30s window the real interval was 10s, not 60s. The one number a reader could have checked the option against disagreed too.

  It defaults to a third of `timeoutMs` (a vanished member is gone within roughly 1.3x the online window, which is the right relationship for almost every room) and an explicit value now wins.

  The interval was also derived at TWO sites — the one the sweep ran on and the one reported to the inspect surface — computed identically and independently. That is how a reported value and a real one drift apart with neither site looking wrong; it is derived once now.

  Found by walking the plugin option surface and asking, per field, who reads it — the same pass that found `webhook.retry`. Fourth instance of this class in this feature. `declaredOptionsEnforced.test.ts` pins it, red-verified against the reverted fix.

  codemod: none
- **@voltro/client** — `useEvent` crashed instead of waiting when its api had not resolved yet. Every other hook survives that window because it reads through `LoadingSubscriptionCache`, whose `subscribe` is a non-fetching no-op; `useEvent` forks its own fiber on the api handle's runtime, and the loading baseline's runtime is a stub with `runPromise` and nothing else — so a mount without a `<VoltroRuntimeProvider>` above it, or during the boot window before the client resolves, died with `runtimeRef.current.runFork is not a function`.

  It now stays `idle` until the api resolves, then subscribes. The gate is worth more than the crash it removes: this subscription retries a dropped connection forever on purpose, and the loading baseline's client is a proxy that throws on every call — so a fork that had "worked" would have spun rather than failed.

  The gate asks whether the runtime can fork rather than comparing the handle against the stub by identity, because a host that loads a bundled copy of `@voltro/client` alongside the resolved one — `@voltro/web`'s dist does — has its own stub object, and identity would answer "resolved" for a stub. Capability is true of every real runtime and false of every stub, in any number of copies.

  codemod: none
- **@voltro/database, @voltro/runtime, @voltro/cli, @voltro/plugin-versioning, @voltro/plugin-audit** — Two features shipped one commit earlier were declared and inert. Both are now wired, and both are the exact defect class the change set they arrived in was about — declared, and nothing reads it.

  **`pluginRef` was a library, not a feature.** `applyPluginRefRules` and `pluginRefSpecOf` had no caller anywhere. An app could declare `orphanPolicy: 'delete'` and the rule would never run: the column worked, the engine was correct, and nothing connected them. It is collected at boot from the registered tables and applied on the post-commit change channel, out of band so it can never back-pressure the change stream.

  `collectPluginRefRules` also implements the edge case that was only a comment before: a `pluginRef` naming a table no installed plugin registers **refuses at boot**, naming both sides. A declared rule against an absent plugin would sit there looking enforced.

  **`_voltro_row_history.actor` was always null.** The column existed and the row builder read `event.actor` — which nothing ever set. The versioning plugin now resolves the snapshot from the `actors` row it already has store access to.

  `resolveActorSnapshot` moved to `@voltro/database` for that: two plugins need it, it is the only package both depend on, and `actors` is a core table declared there. Putting it in the runtime was the first attempt and wrong — plugin-audit deliberately does not depend on the runtime.

  Both are guarded by WIRING tests, not only unit tests of the engines: in both cases the engine was correct and entirely inert, which no unit test could see. Red-verified by removing the wiring.

  codemod: none
- **@voltro/workflow** — **Multi-replica workflow runners now get a DISTINCT cluster identity** — a real sharding + failover correctness fix. `workflowEngineLayer` set only the cluster runner's *listen* address (from `POD_IP`), never its *advertised* address, and the advertised address IS the identity `@effect/cluster` keys `cluster_runners` and every owned shard on. So every replica fell back to the library default (`localhost:34431`) and they all registered as the SAME runner: one identity owning all 300 shards, no distribution, and failover that "worked" only because the colliding processes happened to poll the same rows.

  Now both the advertised (`runnerAddress`) and listen addresses are set from the `POD_IP`-derived identity, so two pods with distinct `POD_IP`s are two distinct runners — shards distribute across them (verified: 3 runners → 100 shards each, was 1 → 300) and a crashed replica's shards are genuinely handed off to a survivor. Surfaced by a new multi-process chaos test that needs three real, distinct runners to hand a run off twice.

  Requires `POD_IP` (or `VOLTRO_WORKFLOW_RUNNER_HOST`) injected per pod — the same requirement the boot already warns about; it now actually determines identity, not just the (inert, under SingleRunner) listen address. `codemod: none` — no user-authored code changes; `cluster_runners` is ephemeral and re-registers on boot, so stale old-identity rows age out on their own.

---

## [0.25.0] — 2026-08-04

### ⚠ BREAKING

- **@voltro/plugin-broadcast, @voltro/plugin-presence, @voltro/cli** — **Two Voltro apps pointed at one Redis or NATS were publishing into each other's channels. The option documented as the fix for that was never read.**

  Every framework channel was a flat constant with no per-app component — `voltro:changes`, `voltro:events`, `voltro:members`, `voltro:presence` — and the providers pass channel names to the broker verbatim. So a shared broker made one app's change events wake another app's matchers, one app's presence deltas land in another app's roster (adding members that can never leave: there is no owner for membership to time out), and, since events were unified, one app's events arrive at another app's clients.

  `BroadcastPluginOptions.channel` existed for this. Its own doc comment named it as the answer for several deployments sharing one broker. It was declared, it was documented, and **nothing ever forwarded it out of the options object** — proven by test before it was replaced. Setting it did nothing, silently, while looking like a solution.

  It is now **one namespace for all four channels**:

  ```ts
  broadcast({ provider: 'redis', namespace: 'shop-prod' })
  ```

  A per-channel override would have been the wrong shape even working: escaping cross-talk means changing four names, three of which had no option at all, and fixing one of four is a half-fix that reads as a whole one.

  **The default derives from your app's name**, so two different apps separate without anyone configuring anything. That ordering is deliberate — a namespace you must remember to set is one two apps forget to set, and the failure is silent in the worst direction.

  **The one case derivation cannot see**, stated plainly rather than papered over: staging and production of the SAME app share a name, the code and every fingerprint. Nothing derivable tells them apart. If one broker serves several deployments of one app, `namespace` or `VOLTRO_BROADCAST_NAMESPACE` is not optional — it is the only thing that can work.

  Resolution: `broadcast({ namespace })` → `VOLTRO_BROADCAST_NAMESPACE` → app name. Values are lowercased and reduced to `a-z0-9_-`. The reason, measured against nats:2 rather than assumed — the first version of this note had it wrong:

  | In a name | What NATS does | | --- | --- | | a `.` beside a `>` (`shop.>`) | matches `shop.other` — wildcards are token-level, tokens are dot-separated | | a name that IS `>` or `*` | matches EVERY subject on the server | | whitespace | rejects the subject outright — the app receives nothing at all |

  A wildcard inside a token is inert (`shop>:changes` does not match `other:changes`), so the dangerous inputs are narrower — and different in kind: the whitespace case is not a leak but a silent hard failure. A name reducing to nothing falls through to the next candidate rather than becoming an empty prefix. Redis is indifferent to all three; the sanitiser is the strict intersection.

  The codemod rewrites `channel` → `namespace` and strips a trailing `:changes` (the framework appends the channel kind itself, so carrying the old value verbatim would produce `myapp:prod:changes:changes` — a channel nobody publishes to, and silent). A non-literal value is carried verbatim and flagged for review rather than guessed at. It also tells you the old option never took effect, which is the part a rename would otherwise hide.

  Namespaces are resolved ONCE per boot and threaded to all four wirings; `dev`, `serve` and the plugin bind context call the same helper, because four independent derivations of one value is four chances to produce a replica that publishes where nobody listens.
- **@voltro/runtime, @voltro/cli** — **Each declared event now travels on its own cross-instance channel (`voltro:events:<name>`), and a replica subscribes only while it has a local subscriber for that event.**

  No user-authored code is affected — hence `codemod: none`. The channel name is internal to the transport; `defineEvent`, `ctx.events.publish` and `useEvent` are unchanged.

  Previously every event shared one channel, so every replica received, JSON-decoded and materialised a route for every event of every peer — including the ones it served no clients for. With five replicas and one high-rate event whose subscribers all sat on one of them, four replicas did that work and threw the result away.

  **The operational consequence to plan for:** during a rolling deploy, replicas on different framework versions use different channel names, so cross-replica delivery is degraded for the length of the rollout. Local delivery on each replica is unaffected throughout, and the two sets converge when the rollout completes.

  Interest is tracked per EVENT (not per route) and the transport re-reads the desired state when its async `subscribe` resolves — a subscriber that arrives and leaves inside that window would otherwise leave a live subscription behind, a leak that grows with reconnect churn and never reports itself. Registering the interest listener replays what is already subscribed, so a client that attached between the bus being built and the transport being wired is not left unwired.
- **@voltro/runtime, @voltro/cli** — **`ctx.events.emit('name', data)` is gone. `ctx.events.publish(descriptor, key, payload)` is the only emitter, and it drives BOTH audiences.**

  The string emitter and the declared event were two ways to say the same thing, and only one of them can be checked. `emit` matched a workflow trigger BY NAME: rename the event on one side and the trigger silently stops matching, the workflow never runs again, and nothing errors. That is the exact defect a consumer reported having with their own string channels — two spellings of one event, both subscribed, one dead since the day it was written — so shipping the typed event while keeping the untyped emitter would have shipped the fix and the defect together.

  ```ts
  // before
  await ctx.events.emit('orders.paid', { orderId, total })
  triggerWorkflow({ event: 'orders.paid', workflow: 'fulfil' })
  
  // after
  yield* ctx.events.publish(orderPaid, { orderId }, { total })
  triggerWorkflow({ on: orderPaid, workflow: 'fulfil' })
  ```

  **Nothing was lost with it.** `publish` still writes `_voltro_workflow_events`, still starts every matching trigger, and still records a delivery row per trigger — it does that from ONE call, on the SAME commit boundary as the client fan-out. Two emitters could disagree about whether the thing happened; one cannot. A trigger failure still cannot fail the mutation that published, for the same reason a broker outage cannot.

  The codemod is `manual`, and the reason is the actual guidance: the rewrite needs a routing `key` and nothing can derive one. The key decides WHO receives the event, so a guessed `{}` compiles and fans every event out to every listener, while a guessed field fans it out to none. Both fail silently, which is what this change is about. The printed steps say how to choose one.

  **Also: a subscriber can now PUBLISH a declared event** (`ctx.publish` in `*.subscribe.ts`, present only when the app declares any). A row changing and a thing happening are different statements, and usually only the second is what a client cares about — nobody watches `attendance` rows, they watch "attendance changed". Without the bridge, a table-derived event has to be published from every mutation that touches the table, and from the next one somebody adds: fail-open by omission, which is the shape a declaration exists to remove. Best-effort by nature — it fires after the commit, so there is no transaction left to couple to. When the event must not be lost, publish it from the mutation.
- **@voltro/plugin-webhooks, @voltro/cli** — **`defineOutgoingEvent` is gone. An outbound webhook event is an AUDIENCE of a declared event.**

  ```ts
  // before — events/order.completed.webhook.tsx
  export default defineOutgoingEvent({ id: 'order.completed', payload: P, version: 1 })
  
  // after — events/orders.event.ts
  export const orderCompleted = defineEvent({
    name: 'order.completed',
    key: Schema.Struct({}),
    payload: P,
    webhook: { version: 1 },
  })
  ```

  This completes the unification. One declaration, and `ctx.events.publish` reaches connected clients, workflow triggers AND subscribed HTTP targets from the same call, on the same commit boundary. Two declarations of one thing drift — the defect the event primitive exists to remove — and keeping both forms would have shipped the fix beside it.

  **The codemod is a `transform`, and the contrast with its sibling is the useful part.** The string-emitter codemod had to be `manual` because the rewrite needs a routing key and nothing can derive one: only the author knows who should receive an event. This one needs no key. A webhook event is delivered to subscribed TARGETS, not to a key, so `key: Schema.Struct({})` is the correct answer rather than a guess — and everything else maps 1:1.

  `defaultRetry` and `defaultSigning` are deliberately NOT carried across. The plugin's shapes are richer than a browser-safe descriptor can hold; dropping them silently would remove a policy the author wrote, and inventing the missing fields would install one they did not. The transform leaves them as a compile error and says so — configure them at subscribe time, where the full shape is typed. `globalRateLimit` becomes `rateLimit`: "global" only ever meant "not per-target", and beside three audiences that word would read as "across all of them".

  **Nothing downstream changed shape.** `OutgoingEventDescriptor` survives as the internal form the delivery workflow, the JSON-Schema export and the dashboard's event list all read; a declared event is PROJECTED onto it. Giving declared events a parallel path would mean each of those consumers handles two shapes, which is how two shapes drift apart.

  Webhook DISCOVERY now merges declared events into the same `outgoing` bucket it always produced, in both boot paths — so the six consumers of that bucket are untouched.

### Added

- **@voltro/plugin-broadcast, @voltro/runtime, @voltro/cli** — **A dropped broadcast message used to leave a client stale forever. It is now detected and repaired.**

  This was the one correctness gap the change bus had that the event bus did not, and the asymmetry is what gave it away: an event computes exactly what a subscriber missed and tells it, while a ChangeEvent was fire-and-forget with no serial and no accounting.

  The failure is quiet and permanent. Replica B's broker connection blips and misses a change replica A published. B's clients keep their sockets — so the client-side reconnect never fires — and their live queries never re-run. They show stale rows until something else happens to touch the same table, which on a quiet table can be never. Nothing errors, nothing logs, and the only symptom is a user saying the page "didn't update".

  **Detection.** Every change now carries a per-origin serial. A receiving replica tracks the highest it has seen per peer, and a jump is an EXACT count of what vanished — not an estimate. A first message from an origin reports nothing however high its serial: a replica that just started missed nothing, and reading that as a gap would make every new pod refresh everything on its first remote change.

  **Recovery.** There is nothing to replay — pub/sub keeps no log — and that does not matter, because **a live query is idempotent**. `Dispatcher.refreshAll()` re-runs every live subscription through its own descriptor, so every guard, row filter and tenant predicate applies unchanged. A refresh is a re-query, not a push: if the snapshot has not moved the subscriber sees nothing, so one dropped message does not repaint the fleet.

  Deliberately blunt — it refreshes everything rather than reasoning about which tables the lost changes touched. We do not know, and guessing narrower would reintroduce exactly the silent staleness this repairs.

  Detection and recovery are separate: a bus used without a dispatcher still DETECTS and logs the loss. Both boot paths wire the recovery, in two steps — the bus must subscribe before anything can be missed, and the dispatcher does not exist yet.

  Nothing to configure. It follows from having a broker.
- **@voltro/protocol, @voltro/runtime, @voltro/client, @voltro/cli, @voltro/testing, @voltro/voltro** — **`defineEvent` — the axis the framework did not have.** Voltro modelled "what IS" (a table, watched by a reactive query) extremely well and had exactly ONE server→client fan-out path: a query re-runs because a table changed. Anything that is not row state — a game starting, a door opening, a payment terminal confirming — had to invent a table, and two independent consumers built the same three bugs on top of a reactive list: a `seen` set, an `initialized` flag so page load does not replay the history into a live system, and a `limit` that silently truncates. Our own `plugin-presence` does it too.

  ```ts
  // events/gameLifecycle.event.ts — browser-safe, may hold several
  export const gameStarted = defineEvent({
    name: 'games.started',
    key: Schema.Struct({ arenaId: Schema.String }),
    payload: Schema.Struct({ gameId: Schema.String, startedAt: Schema.Number }),
    guards: [{ scope: 'display:read' }],
  })
  
  // any handler with a ctx — action, mutation, workflow, cron, subscriber
  yield* ctx.events.publish(gameStarted, { arenaId }, { gameId, startedAt })
  
  // the client
  const { missed } = useEvent(gameStarted, arenaId ? { arenaId } : null, (payload) => {
    scene.switchTo('running', payload.gameId)      // payload is typed from the descriptor
  }, { onMissed: ({ count }) => resync(count) })
  ```

  **`missed` is computed, never estimated.** Every delivery carries `(origin, n)` and the server keeps the highest serial per origin, so a loss is arithmetic — what you were owed, minus what could be replayed. A dropping buffer discards silently BY DEFINITION, and silence is the one outcome nothing can be built on: a display cannot tell "no game started" from "I missed the start signal".

  **A first attach and a reconnect are different events.** "Never replay history" and "never lose a message" read as one contradiction and are two questions: a fresh subscription starts empty (opt in with `rewind`), a reconnect resumes from the last serial that subscription saw. `useEvent` does the second for you, including after a deploy or a proxy timeout.

  **Publishing is server-only.** A client-originated event is an action that publishes, which deletes the entire "who may write to this channel" authorization surface. **Inside a mutation, publish fires on COMMIT and not at all on rollback** — riding the buffer the transactional view already uses for ChangeEvents, so it needs no SQL trigger. Serials are assigned AT commit, so a rollback burns no number and leaves no permanent hole.

  **Cross-instance delivery is wired**, not just seamed: events ride `@voltro/plugin-broadcast` (Redis / NATS / memory) on their OWN channel — `voltro:events`, not `voltro:changes`, because sharing one would make every replica decode every message of the other kind to discover it does not want it. Additive like the change bus: local fan-out happens first and a broker outage degrades cross-replica delivery without touching the publishing pod's subscribers. A malformed message on that shared channel is dropped with a log line rather than injected — a bad serial would corrupt a route's watermark and make every later `missed` on it wrong, permanently.

  **`triggerWorkflow({ on: descriptor })`** ships with it, additively. A workflow trigger reads the event's NAME off the descriptor, so a rename moves the trigger with it — where the string form (`event: 'games.started'`, still accepted) leaves the trigger matching nothing and the workflow simply never runs again, with nothing to notice. Shipping it now means a third string namespace never comes into existence even briefly; removing the string form is a separate breaking change with its own codemod, and nothing here has to be undone for it.

  `apiSurface: compatible`, and the distinction is worth stating because the API report reads it as a REMOVAL: `triggerWorkflow` / `defineEventTrigger` show as changed lines rather than added ones in `@voltro/voltro`'s goldens, since their parameter went from `T` to `T | (descriptor form)`. That is a WIDENING — the direction the gate's rule is not about. Every call that compiled against the old signature still compiles, and a function accepting the wider union is still assignable wherever the narrower one was expected. The umbrella package is listed here for exactly that reason: it re-exports both symbols, so its goldens churn even though nothing it re-exports narrowed.

  Also: `key` is the routing address and the tenant is derived from the subject (never caller-supplied); payloads are capped at 7,500 bytes on **every** dialect so switching broadcast transport is never a behaviour change; a duplicate event name fails the boot because an event name IS an rpc tag; `voltro doctor` reports declared events with no producer or no consumer — the class a consumer found by hand in their own inventory (four dead channels in twenty); and `testEventBus()` ships in `@voltro/testing` WITH the primitive, driving the real bus so a suite cannot pass on payloads production rejects.

  Four defects were found and fixed while building it, each pinned by a test: `Queue.unsafeOffer` does not slide on a sliding queue (it keeps the oldest and rejects the new — the wrong end for an event); a delivery dropped before a client's first read was invisible until the gap detector seeded from the attach watermark; a clean stream close was not a reconnect reason, leaving a display at `status: 'live'` receiving nothing after a deploy; and `Rpc.make` with `stream: true` puts the declared error inside the stream schema, not on `errorSchema`, so the guarded-QUERY half of the `ScopeError` union rule had never been asserted.
- **@voltro/protocol, @voltro/runtime, @voltro/cli, @voltro/devtools-ui** — **`defineEvent({ delivery: 'latest' })` — for streams where only the current value matters.**

  The default (`'each'`) is unchanged: every delivery counts, a subscriber that falls behind keeps the newest and is told exactly how many it lost. That is the right reading for a lifecycle event, and it is what you get by not deciding.

  `'latest'` says the opposite, and it is a **semantic** rather than a performance knob: a newer delivery SUPERSEDES a pending one, the server retains one value instead of a ring, a reconnect hands over the current value, and no gap is reported — because nothing was lost. For a 60Hz stream of positions, frame 1 stopped being interesting the moment frame 2 existed, and reporting it as "missed" trains a consumer to read normal operation as degradation.

  ```ts
  export default defineEvent({
    name: 'player.moved',
    key: Schema.Struct({ arenaId: Schema.String }),
    payload: Schema.Struct({ playerId: Schema.String, x: Schema.Number, y: Schema.Number }),
    access: 'authenticated',
    delivery: 'latest',
  })
  ```

  The test for which one you want: **would a consumer be wrong to miss one?**

  **`delivery: 'latest'` combined with `webhook` is REFUSED at declaration.** The two contradict each other — `latest` says a superseded delivery did not matter, while a webhook delivery is a durable side effect at a third party that cannot be superseded once sent. The combination also multiplies badly: a 60Hz event with an HTTP audience is 60 deliveries per second per subscribed target, and the webhook rate limit DEFERS the excess as pending rows rather than dropping it, so the symptom is a growing table rather than an error anyone would look at. Split them: the high-rate event for clients, a coarser one for the outside world.

  The declared semantic is read from ONE map, by the bus (for retention) and by the bridge (for queue depth), so the two cannot come to disagree about whether a drop counts as a loss. The devtools events panel badges a `latest-wins` event, because two events with identical numbers otherwise mean opposite things about a missing message.
- **@voltro/protocol, @voltro/plugin-webhooks, @voltro/cli** — **A declared event can now reach subscribed HTTP targets too — one declaration, three audiences.**

  ```ts
  export const orderPaid = defineEvent({
    name: 'orders.paid',
    key: Schema.Struct({ orderId: Schema.String }),
    payload: Schema.Struct({ total: Schema.Number }),
    webhook: { description: 'An order was paid', version: 2 },
  })
  
  yield* ctx.events.publish(orderPaid, { orderId }, { total })
  // → connected clients (useEvent) + workflow triggers + subscribed HTTP targets
  ```

  Without it, an app that both fans an event out to its screens and posts it to a partner declares the thing twice, in two shapes — and the two drift. That is the defect a declaration exists to remove, one level up from the string channel it already removed.

  Three implementation decisions worth knowing:

  - **`webhook:` is namespaced**, not spread across the descriptor. These settings are meaningless to the other audiences, and a top-level `retry` would read as if it applied to client delivery — which is at-most-once by design and has no retry at all. - **The block is structurally typed in `@voltro/protocol`** (plain numbers and strings), and the plugin maps it onto its own shapes. Protocol is browser-safe and must not reach a plugin; that dependency direction decides where the adapter lives, not preference. - **A declared event is PROJECTED onto the descriptor the plugin already reads** rather than given a parallel path. The delivery workflow, the JSON-Schema export and the dashboard's event list all keep reading one shape — a second path would mean each of them handles two, which is how two shapes drift.

  `retry` is deliberately not forwarded blind: the plugin's `RetryPolicy` is richer than the two numbers protocol carries, and inventing the missing fields would put a policy in place nobody wrote. Configure it at subscribe time, where the full shape is typed.

  `defineOutgoingEvent` still works and is unchanged. Removing it is cleanup with its own `transform` codemod, not part of this.
- **@voltro/runtime, @voltro/cli** — **Instance membership — which replicas are alive, and when one stops being.**

  Cross-instance FAN-OUT was already solved: a publish goes onto a channel and whoever listens receives it, and nobody needs to know who the other instances are. That is what makes pub/sub cheap.

  **Membership is the question a channel cannot answer**, because a channel says nothing about who is on it. An instance that dies simply goes quiet, and quiet is indistinguishable from "nothing happened" — a crashing process does not get to send a goodbye.

  That gap is invisible until state is OWNED per instance. Presence is the motivating case: replica 2 holds the WebSockets of the clients connected to it, so when replica 2 dies its members must disappear from replicas 1 and 3, and nothing on the event channel will ever say so.

  `InstanceMembership` announces this process on its own broadcast channel (`voltro:members` — separate from events for the same reason events are separate from changes) and reports `joined` / `left` / `restarted` to any consumer. Wired into BOTH boot paths through one helper; visible at `GET /_voltro/inspect/members`.

  **Liveness is measured on the RECEIVER's clock.** `lastHeardAt` is when *we* received a heartbeat, never a timestamp the sender put in it — trusting the sender reintroduces exactly the problem `.version()` exists to avoid: an instance whose clock runs slow would look permanently overdue, one whose clock runs fast would look alive forever, and neither would report anything wrong.

  Three decisions that are easy to get backwards, each pinned by a test:

  - **Three missed beats, not one.** A single missed beat is a GC pause or a broker hiccup, and evicting on it makes a healthy cluster flap — every flap dropping and re-adding that instance's owned state, which a presence roster shows as everyone briefly leaving and coming back. - **A returning instance with a NEW `startedAt` is a RESTART, not a heartbeat.** Whatever state a consumer held for the old process is gone with it; resuming would show a roster of clients connected to nothing. - **A stale self-echo is ignored.** Brokers replay, and a replayed message from a previous incarnation carries an older `startedAt` — without the id guard that reads as "this instance restarted", and every consumer drops the state it is holding for *itself*.

  **It is a presumption, not a fact**, and the docs say so: a network-partitioned instance is alive and still serving its own clients; it just cannot be heard. Each side of a partition marks the other down and drops its state. That is the correct degradation — you show what you can actually reach — and it is why `/_voltro/inspect/members` reports what THIS replica observes rather than a merged "cluster view". Presenting one would invent a consensus nobody has; the disagreement is the diagnostic.

  Single-instance deployments get a registry whose only member is themselves, which is the true answer and means no consumer needs a "do we have a cluster" branch — that branch is how a feature comes to work in dev and not in production.
- **@voltro/cli** — Restore drill — `voltro data restore <dir> --drill [--drill-url <url>]`. "A backup you have never restored is a hypothesis"; the drill turns it into a fact by restoring the artifact into a THROWAWAY database (from `--drill-url` / `DRILL_DB_URL`) and verifying it, WITHOUT ever touching the live DB. It refuses a drill target that resolves to the live connection (a drill that `--clean`s production is the disaster it exists to rehearse against). After the restore it introspects the throwaway DB and compares its schema fingerprint to the backup's stamp: zero tables → FAIL (empty / unreadable dump), fingerprint disagrees with the stamp → FAIL (the restore didn't reproduce what was backed up), tables + matching fingerprint → PASS. Exits non-zero on any FAIL, so a scheduled CI job turns a silently-broken backup into a red build. The verify is schema-level (introspect + fingerprint); a full app boot against the restored DB is a heavier follow-up. Pure decision logic (`resolveDrillTarget` / `assessDrillResult` / `connKey`) covered by 14 unit tests; the native round-trip is integration-tested where a matching `pg_dump` is available. `codemod: none` — a new opt-in flag; no user-authored code is affected.
- **@voltro/database, @voltro/cli** — Opt-in rolling-deploy refuse gate — `VOLTRO_ROLLING_DEPLOY=1`. The rolling-deploy safety classifier shipped as a `voltro db plan` advisory (a `⚠`, never a block), because the framework can't know the deploy strategy and a maintenance-window / scale-to-zero deploy has no overlap window. Operators who ALWAYS rolling-deploy can now opt into a hard gate: with `VOLTRO_ROLLING_DEPLOY=1` set, `voltro db apply` (both the auto-diff and the reviewed `--plan` path) REFUSES (exit 2) a plan containing a rolling-unsafe operation — a dropped/renamed column, a narrowed type, an added constraint — instead of warning, so an un-split breaking change fails the deploy rather than breaking pods at runtime. Override a specific apply with `--force`. Unset (the default) leaves the advisory behaviour untouched. The decision is a pure `assessRollingDeployGate` in `@voltro/database` (testable without a CLI, reusable by the cloud migration wall). `codemod: none` — a new opt-in env var; no user-authored code is affected.
- **@voltro/runtime** — Schedule (cron) observability metrics. The framework scheduler now emits three registry series on every firing — `voltro_schedule_runs_total{schedule,status}` (firings by name + `succeeded`/`failed`), `voltro_schedule_duration_seconds{schedule}` (histogram), and `voltro_schedule_last_success_timestamp_seconds{schedule}` (a gauge holding the UNIX time of the last SUCCESS). Emitted from the single scheduler seam, so EVERY app's crons get them with no per-handler wiring, scrapeable via `@voltro/plugin-prometheus` (`GET /metrics`), `GET /_voltro/inspect/metrics`, or the OTLP export — the same registry as the RPC/HTTP/subscription metrics. A cron fires unattended, so its failure mode is silent; the last-success gauge is the series to alert on (`time() - voltro_schedule_last_success_timestamp_seconds > interval × N`), because a failure counter alone can't catch a job that stopped firing at all. A failure moves the counter but deliberately NOT the gauge. `codemod: none` — additive metric emission; no user-authored code is affected.
- **@voltro/database, @voltro/runtime** — **`.version()` — optimistic locking, and the answer to "which write is newest".**

  Two clients read the same row and both write it. Until now the second silently won and the first user's change was gone with no trace — the shape of every "my edit disappeared" report. Mark the column and the store owns it:

  ```ts
  table('documents', { id: id(), title: text(), version: integer().version() })
  
  yield* ctx.store.update('documents', id, { title, version })   // the version the client READ
  // → VersionConflict { expected: 3, actual: 7 }
  ```

  `VersionConflict` is a typed error carrying **both** numbers, because "someone else changed it" is not actionable while "you had 3, it is now 7" is. It reaches the client typed, so a UI can offer reload-and-re-apply rather than showing a crash.

  **A timestamp cannot do this job**, which is why `.version()` rejects one at declaration: two writes in the same millisecond are indistinguishable and replica clocks disagree, so a comparison that looks right in a test loses rows under load. This repo has already lost rows to exactly that — an analytics sink dropped 7 of 40 events written in the same millisecond as the query bounding them. An integer the database owns is totally ordered and needs no clock.

  Three decisions worth knowing: the caller's version is an **expectation, never a write** (it is stripped from the patch, so a client cannot pin its own and win every race); an update with no expectation stays last-write-wins but the version **still advances** (one that moved only for careful writers would sit still while a careless write changed the row — worse than none); and a row deleted underneath you conflicts with `actual: null`, which is how you tell "deleted" from "changed".

  Enforced in the store wrapper every dialect passes through, NOT in the four hand-written `DataStore` implementations. Twice now a correct fix landed in one of those and the other three kept the bug — a per-dialect copy of a subtle decision will drift, so the decision stopped being per-dialect.

  **`expires()` — a row with an end date.**

  ```ts
  table('inviteLinks', { id: id(), email: text() }).with(expires())
  ```

  After `expiresAt` passes the row is not returned by reads. Null means never, so adding the mixin to an existing table does not make its rows vanish; `.includeExpired()` opts out for a deliberate admin read.

  **Read the split before relying on it.** Visibility and storage are two guarantees and only one holds everywhere: reads filter on **every dialect, immediately**, while the physical delete is a **postgres-only** retention sweep. An expired row is therefore invisible everywhere and still present in the database on four of five dialects. That is deliberate — making visibility depend on the sweep would mean a row that vanished on postgres and kept serving on MariaDB, which is the per-dialect divergence class this repo has three scars from — but it means an expired row is not unreachable. If the value must actually be gone, delete it.
- **@voltro/runtime, @voltro/workflow, @voltro/cli** — Workflow (durable-execution) observability metrics. The workflow run-recording seam now emits three registry series on every terminal outcome — `voltro_workflow_runs_total{workflow,status}` (`succeeded`/`failed`), `voltro_workflow_duration_seconds{workflow}` (histogram), and `voltro_workflow_last_success_timestamp_seconds{workflow}` (last-success gauge). Because the framework applies no retry of its own, a `failed` run is TERMINAL — it is the dead-letter state — so the failed counter IS the dead-letter rate, and the last-success gauge going stale is the "this workflow stopped completing" alert (`time() - voltro_workflow_last_success_timestamp_seconds > N`), mirroring the schedule metrics. A failure moves the counter but not the gauge. Same registry as the RPC/HTTP/subscription/schedule metrics → scrapeable via `@voltro/plugin-prometheus`, `/_voltro/inspect/metrics`, or OTLP. `@voltro/workflow` stays free of a `@voltro/runtime` dependency: the recorder is injected as an optional `recordRun` hook on the recording options (mirroring `emit`/`wakeups`), supplied by the CLI in BOTH boot paths. `codemod: none` — additive metric emission + a new optional hook; no user-authored code is affected.

### Changed

- **@voltro/plugin-presence, @voltro/protocol, @voltro/cli** — **Presence no longer touches the database.** A heartbeat used to rewrite one row per client every 15 seconds, swept by a coordinated background job — a lot of write amplification for a datum that is meaningless 30 seconds later, and it made the most ephemeral thing in the framework the one backed by the most durable store.

  It is now an owner-partitioned map in memory, announced between replicas over the broadcast channel. **`usePresence` is unchanged** — same signature, same live roster, no client code moves.

  **Why this needs no CRDT.** Phoenix's tracker uses ORSWOT because it lets any node track any key, so two nodes can genuinely write one key concurrently. We have an invariant they do not: every entry is owned by exactly one instance — the one holding that client's WebSocket — so concurrent writes to one key from different owners are impossible by construction. The merge collapses to partition by owner, union across owners, and a departing owner takes its whole partition.

  The part Phoenix gets free from BEAM monitors — knowing an owner is gone — is what `InstanceMembership` had to supply, and it is the wire nothing else can provide: a crashing process does not send goodbyes for the thousand clients it was holding, and on the channel it is simply quiet.

  **The staleness filter is gone, and its absence is the change.** The table version had to compare every row against a timeout because a row outlived the client that wrote it. An entry now leaves when its client does and a whole partition goes when membership says its instance did, so an entry that exists is one an instance is currently vouching for. A timeout could only add a way to be wrong. The coordinated sweep is gone with the rows it swept.

  **`_voltro_presence` remains DECLARED and is never written.** The name is the reactivity key: `presence.list` declares `source: '_voltro_presence'` and the framework routes change events by table name, so the plugin injects a synthetic change whenever the tracker moves and every subscribed client is pushed a fresh roster through the path it already used. Removing the declaration would make the `source` resolve to nothing — which the boot audit reports correctly, and which would silently stop every roster from updating. One empty table is the accepted cost of not introducing a second push mechanism.

  `PluginBindContext` gains `instanceId`, `membership` and `broadcast`, so any plugin holding per-replica state can say who owns an entry and learn when that owner is gone. Both boot paths supply all three, asserted by the parity guard — two out of three is silently wrong rather than broken.

  A defect found while building: the tracker's route key (`tenant + '::' + channel`) is ambiguous once a channel contains the separator, and a round-trip masks it because both readings rebuild the same key. It surfaces only where something reads the PARTS — a client applying a delta — so the parts are stored beside the members and the encoding is now write-only.
- **@voltro/voltro** — **The umbrella package re-exports the event surface, and two trigger signatures widened.**

  `@voltro/voltro` is a one-install re-export of runtime / database / protocol / workflow, so everything this release added to those reaches consumers through it too. Almost all of that is a pure addition — `defineEvent`, `EventBus`, `bindEvent`, the delivery semantics, the presence sweep.

  Two lines are not additions, and they are the reason this entry exists: `defineEventTrigger` and `triggerWorkflow` now accept **either** the original spec **or** the descriptor form (`{ on: gameStarted }`). Their parameter type is a union where it used to be a single shape.

  `apiSurface: compatible` because widening a PARAMETER cannot break a caller: every call that compiled against the old shape still matches one arm of the union. The check flags it as non-additive because the golden line changed rather than appeared, which is the right default — a narrowed parameter looks identical in a diff and would break every call site.

  No codemod: nothing a user wrote stops compiling.

### Fixed

- **@voltro/runtime** — **A change watched by N identical live subscribers cost N reads and N diffs. It now costs one of each.** Fifty screens open on the same list re-ran the same query fifty times per change and recomputed the same delta fifty times — in memory that is wasted CPU; against SQL it is fifty round trips.

  `diffRows` costs the WALK, not the delta: ~31µs at 50 rows, ~289µs at 500, ~3.1ms at 5000, and one changed row costs what zero does. So on a large list the diff share is worth as much as the read share.

  Both are keyed by the resolved read descriptor; the diff share additionally keys on the previous rows' OBJECT IDENTITY. That second half is a safety property rather than an optimisation: a patch computed against another subscriber's base silently corrupts its rows, and it is the one failure on this path that neither a test nor a log would catch. Reference identity cannot be wrong about it — two subscribers share only when they hold the literally same array, which is exactly when the read share already served them together. A late joiner holds a different object and gets its own diff.

  Recorded because the route was not straight, and the wrong turns are the instructive part. The read memo was removed mid-release as dead code on a measurement that was broken: `handleChange` is dispatched with `void`, so the counter was sampled before the reads had landed, and the "1.00 reads" that condemned it was an artefact of the sampling. The diff share was separately shipped once with a test that could not prove it — counting deliveries, which happen either way, stays green with the share disabled — and was removed for that reason before being rebuilt. The proof needs no mocks: a shared diff is the SAME OBJECT in every delivery, so counting distinct patch identities is exact, and disabling reuse turns one shared patch into fifty.
- **@voltro/cli** — **The domain-event audit tables are bounded now.** `ctx.events.emit(...)` writes one row to `_voltro_workflow_events` plus **one per matching trigger** to `_voltro_workflow_event_deliveries`, and nothing in the framework ever deleted from either. Both are registered with the boot retention GC on a 30-day default, env-tunable via `VOLTRO_WORKFLOW_EVENTS_TTL_HOURS` and `VOLTRO_WORKFLOW_EVENT_DELIVERIES_TTL_HOURS`.

  Same family as `_voltro_schedule_claims`, whose sweep landed one release ago after a consumer measured 35,128 rows in 14 days. The comment there already named the pattern — *"the one table of this family with no sweep"* — and two more members of the family were sitting next to it. The delivery log is the faster half: three triggers on one event write four rows per emit.

  Found while validating a consumer's request for a client-facing event primitive. Their report's core complaint is that they had modelled events as durable rows and the table grew without bound; the primitive we would have pointed them at does exactly that, in framework-owned tables, with no bound at all.

  **Read the delivery TTL as the deduplication window, not as housekeeping.** The idempotency check looks for an existing delivery row with the same `idempotencyKey`, so once a row is swept its key is no longer deduplicated. With the default key (`<eventId>:<triggerId>`, and `eventId` is fresh per emit) a duplicate cannot occur and the sweep costs nothing; it matters only for an app supplying its own key that can re-emit the same stable value more than 30 days apart. That app raises the env var, which is what it is for.

  Deliberately **not** status-filtered, unlike `_voltro_outbox`: there a `dead` row is an incident an operator can requeue, while a 30-day-old `starting` delivery has no requeue path and no reader — filtering would preserve evidence nobody can act on and leave the table unbounded for exactly the rows a crash produces.
- **@voltro/cli** — **A declared event never reached the generated rpcGroup, so `useEvent` could not work in a real app.**

  `codegen.ts` keeps its own `walk` with its own list of file patterns, and `*.event.ts` was not on it. The machinery below it was complete — `loadExports` has an event branch whose comment says an event descriptor MUST reach the client group, and the emitter has an `eventToRpc` case — but nothing ever handed either of them an event file. A project with two declared events generated a rpcGroup containing neither, the browser's `RpcClient` had no procedure to subscribe with, and the entire client half of the primitive was unreachable.

  **Nothing reported it, and that is the part worth knowing.** The server builds its own event rpcs in `makeEventWiring` and merges them at runtime, so `voltro dev` logs `events registered count:2` and looks completely healthy from the side anyone would check. It was found by booting a fixture and grepping the generated file, not by any test.

  This is the third copy of one defect. `fileConventions.ts`, `dev.ts`'s walk and `codegen.ts`'s walk each keep a separate pattern list, and a convention added to one is silently absent from the others — the same shape as the earlier gap where events were discovered by neither boot path. `walkConventionCoverage.test.ts` now asserts the two walks agree on what a client-facing descriptor is, against a real directory tree.

  Also fixed alongside it: **one descriptor exported under two names generated two of everything.** `export { fireArena }` plus `export default fireArena` is the same object under two keys, and `loadExports` pushed an entry per export name — producing `export const arenaFireRpc =` twice, a redeclaration. The failure was split in the worst way: `voltro dev` booted fine (the generated file is transpiled, not typechecked, and the runtime map overwrote the duplicate key) while the app's own `typecheck` and `voltro build` failed on generated code the user never wrote. Deduped by descriptor IDENTITY, not by name — two DIFFERENT descriptors sharing a name is a real conflict and must still be reported rather than silently collapsed into one endpoint.
- **@voltro/runtime** — **A freshly-started replica no longer tells every client it missed thousands of messages.**

  Measured: a pod joining a route where a peer was at serial 5000 reported `missed: 5000` on its first delivery. Nobody had missed anything — that replica simply had not been listening, and a client attaching to it was never owed a peer's history.

  The cause was one number the subscriber could not see. From a subscriber's seat, two opposite situations look identical: an origin absent from the attach watermark plus a first delivery carrying a high serial. It can mean the serials in between reached this instance and were lost on the way out (a real loss, which the gap detector exists to report), or that this instance never had them at all.

  A delivery now carries `prior` — the route's watermark for that origin immediately before the envelope was accepted. `prior > 0` proves the earlier serials reached the bus, so a jump is a genuine local drop and is still reported exactly as before; `prior === 0` proves they did not, so there is nothing to report and the first delivery establishes the baseline. A real drop occurring right afterwards is still caught.

  This also makes it safe for a replica to hold a cross-instance subscription only while it needs one — see the channel-partitioning entry, where "this instance was not listening" stops being a rare startup case and becomes the normal one.
- **@voltro/runtime, @voltro/cli** — **An event's `guards:` were never checked. Any client that could open the socket could subscribe to any declared event.**

  `defineEvent` accepted them. Its own doc comment called them *"WHO MAY LISTEN — the same vocabulary as a query's guards"*, with a worked example. `eventToRpc` declared `ScopeError` in the wire contract whenever they were present. `manifestBuild` serialised them, `doctorCommand` and `advisoryGuardAudit` reported on them, `voltro check` counted their scopes, and the devtools events panel showed a guard COUNT per event.

  Nothing enforced them. `bindEvent` read the resolved subject for the TENANT and for nothing else, so a declaration that read as an access-control rule was decoration.

  That is the worst shape this class of hole can take: everything *around* the enforcement existed, so it looked enforced from every angle an author or an operator would inspect it from — the manifest, the dashboard, the doctor, and the type of the error the rpc could return. The one thing missing was the check.

  Guards now run BEFORE the subscribe, from the same descriptor every one of those readers uses, with the routing key as the guard input — so a resource-scoped guard (`{ scope: 'arena:read', from: 'arenaId' }`) can see which arena was asked for. `bindEvent`'s error channel is `ScopeError` rather than `never`, which is what `eventToRpc` had been promising all along.

  The ORDER is pinned too, not just the check: failing after `bus.subscribe` would leave a refused client holding a live subscription, and the first version of that test read the subscriber count after the stream had already ended — where the scope's finaliser has unsubscribed and the count is 0 either way. It measures while the subscription would be live now, and asserts the admitted case is 1, or the denial assertion proves nothing.

  Tenant isolation was never affected: it comes from the subject on both sides and is not something a caller can ask for.
- **@voltro/runtime** — **A `latest` event re-sent its current value to a client that already had it.**

  Found by building the cross product of delivery semantics against attach kinds — each was individually covered and the combination was not.

  `each` answers "you are already up to date" with silence. `latest` re-sent the retained value on every resume, on the reasoning that a last-value-wins delivery is idempotent. It is idempotent in a store and not on a screen: a reconnect handing back the value already displayed is a re-render, and on a flaky connection that is a visible flicker with nothing behind it. There is no reason for the two semantics to differ on that question.

  The comparison is per `(origin, n)`, not by serial alone — under `latest` the retained entry can come from ANY replica, so a bare number would read another pod's serial as our own and skip a value the client has genuinely never seen. Pinned by a test that publishes locally, injects remotely, and resumes current with respect to the local origin only.
- **@voltro/runtime** — **`await ctx.events.publish(...)` in an async handler published NOTHING, silently.**

  `ctx.events.publish` returns an `Effect`. An Effect is not thenable, so `await` hands the object back unrun: no delivery to clients, no cross-replica publish, no webhook, no workflow trigger — and the handler returns success. Nothing errors, nothing logs, and `tsc` is satisfied because awaiting a non-Promise is legal.

  Found the hard way: a two-replica end-to-end fixture published from an async handler, the action returned `{ ok: true }`, and a full broker trace showed no event traffic at all. The first two hypotheses (a subscribe/publish race, then a stale build) were both wrong, and the diagnosis only landed after instrumenting the broker on both sides.

  It matters because **both handler styles are supported and shipped**: the docs show the `Effect.gen` + `yield*` form, which works, while the mutation TEMPLATE ships an `async (input, ctx) => { … }` handler. An author following the template and reaching for `ctx.events.publish` gets the one spelling that cannot work.

  It is also inconsistent with the rest of `ctx.*`. `ctx.store.insert`, `ctx.cache` and `ctx.kv` are Promise-based precisely so async handlers can use them — `makeAsyncKv` / `makeAsyncCache` exist for that reason. `ctx.events` is the one member that is not, and the difference is invisible at the call site.

  `publishEvent`'s Effect is now also awaitable: the returned value carries a `then` that runs it, so `await ctx.events.publish(…)` performs the publish and resolves with the same result `yield*` produces. Both spellings work, neither is silent, and the Effect-first form remains the documented one.
- **@voltro/runtime** — **Publishing an event is 2.1× faster, and the reason is worth knowing: observability was setting the throughput ceiling.**

  Measured on the publish path, single core:

  | | before | after | | --- | --- | --- | | `bus.publish` (1 subscriber) | 5.07µs | **1.45µs** | | `bus.publish` (100 subscribers) | 5.13µs | **1.48µs** | | `ctx.events.publish` (encode + size gate + bus) | 8.60µs | **4.12µs** |

  The cause was one line. `Effect.tagMetrics('event', name)` is the natural spelling for labelling a metric and it modifies a FiberRef to build a label context on EVERY call: **4.2µs**, against 0.7µs for a metric instance tagged once via `Metric.tagged`. Before the change the metric was roughly **90% of the cost of publishing an event** — the route encoding, the replay ring and the size gate together came to 0.35µs.

  The tag cache is bounded by construction: its keys are DECLARED event names plus a two-value drop reason, so it cannot grow with traffic. A label carrying user data would make it a leak, and the test pins the boundedness rather than a size.

  Two things the measurement corrected, both recorded because the guesses were plausible:

  - **The replay ring was NOT the bottleneck.** `ring = ring.slice(drop)` reallocates a 64-element array on every publish once full, which looked like the obvious cost. Fixing it to an in-place `splice` moved 5.76µs to 5.07µs — real, and nowhere near the metric. It is kept because the allocation is what a garbage collector notices, but it was not the answer. - **Fan-out is nearly free.** 1 subscriber and 100 subscribers cost the same; 1000 costs 3.07µs. The per-publish work dominates, not the delivery loop.

  Guarded behaviourally rather than by timing — a timing assertion goes flaky on a loaded CI machine and then gets deleted, after which the regression it guarded is invisible again. Reverting to `tagMetrics` leaves the cache empty and the test goes red.
- **@voltro/cli** — **A source-tree guard failed the whole test FILE when a fixture directory vanished mid-walk.**

  `netHarnessPackages.test.ts` walked with `readdirSync(dir)` then `statSync(p)` — two syscalls with a gap. The codegen suites create their fixture modules inside `src/` (`mkdtemp(join(here, '.codegen-…'))`) and remove them in `afterEach`, and they have to live there: the codegen imports them through vite's module graph, which is rooted at the package. A directory removed inside that gap makes `statSync` throw `ENOENT`, which fails the file at COLLECTION time — no assertion, a path nobody recognises, and green the moment you re-run it alone.

  This is the FOURTH file to grow that shape, and the rule was already written up in `packages/cli/CLAUDE.md` for `ledgerReadPortability.test.ts`. It surfaced now because two new codegen suites landed in the same directory, which is the point: the latent version was indistinguishable from machine load.

  Fixed on the reader, per that rule: `readdirSync(dir, { withFileTypes: true })` gives the name and the kind from ONE syscall, so there is no gap; and dot-directories are skipped, which is right regardless — a scratch directory is never source.
- **@voltro/plugin-presence** — **A client that vanished stayed in the presence roster forever, and `presencePlugin({ timeoutMs })` did nothing.**

  One cause, two symptoms. A member left the roster only when its client explicitly CALLED `leave`. A closed laptop, a dropped network or a crashed tab call nothing — and the owning replica is still alive, so `dropOwner` never fires either. Those entries stayed, and every screen kept showing people who had gone home.

  The tracker's own comment asserted the opposite ("an entry is removed when the client leaves") and argued from it that a staleness filter "would only add a way to be wrong". The premise was false, so the conclusion protected the bug. A stale comment describing a cluster-coordinated sweep that had been deleted in an earlier rewrite made it read as already-solved from a second angle.

  `timeoutMs` was the second half of the same defect: accepted, shown in the plugin's own usage example, and logged at boot — read by nothing. The same shape as `defineEvent({ guards })` and `broadcast({ channel })`.

  `sweep()` now removes members whose client stopped heartbeating, and `timeoutMs` drives it. It touches **only this instance's own partition** — another owner's entries carry timestamps from THEIR clock, and judging them against ours is exactly the mistake instance membership exists to avoid: a peer that is gone is dropped whole, on a signal, never on a guess about clock skew.

  It needs no cluster coordination, and that is a consequence of the design rather than a shortcut: the table version had shared rows, so one replica had to evict them or they would fight. Owner-partitioned presence has no shared state, so every replica sweeps its own and there is nothing to coordinate.

  Removals are ANNOUNCED — a local removal nobody broadcasts is a member every other replica keeps showing. The sweep runs at a third of the timeout, so a vanished member is gone within roughly 1.3× the window rather than up to 2×.
- **@voltro/cli** — **`voltro build` could delete output it had just written.** The post-build orphan prune compared each file's mtime against `Date.now()` taken at build start — two different clocks. Linux stamps inode times from a COARSE clock (`ktime_get_coarse_real_ts64`) that advances once per timer tick, so a file written microseconds AFTER the cutoff can carry an mtime a tick BEFORE it, and the strict comparison then removed it.

  The consequence is the exact failure the prune was designed to avoid: a bundle that is missing pieces mid-run. The wipe-before-build version had the same effect for a different reason, and this reintroduced it in a narrower window.

  The comparison now carries a one-second tolerance. The two directions are not symmetric — too small deletes a fresh artefact, too large lets an orphan survive until the next prune — so the margin sits on the side of keeping. Real orphans are minutes or builds old.

  Found by the release gate on Linux, where the suite's own concurrency case failed while asserting a precondition that held: the file it checked was fine, a different one was pruned. It had never failed on macOS, whose timestamp granularity differs. The suite now pins the tolerance directly — a file stamped just before the cutoff must survive, and one past the tolerance must still go, so the margin cannot quietly widen into a no-op.
- **@voltro/cli** — **A `source:` that names no table is now reported at boot.** It was silent, and the silence is the defect: `source` is matched BY NAME against change events, so one naming a table that does not exist matches nothing — the query returns its first result and never updates again. Not a broken subscription, a permanently silent one, which from the outside is indistinguishable from "nothing has changed".

  ```text
  1 query declares a `source` that names no table:
    agent.messages: source 'agent_messages' is not a declared table — did you mean '_voltro_agent_messages'?
  ```

  Found by a consumer applying the `agent_messages` → `_voltro_agent_messages` rename we shipped. Their two agent queries went quiet, their live typewriter stopped updating (the reply arrived on page reload), and every layer agreed everything was fine: boot clean, zero warnings, `tsc` green — `source` is a string. Their own invariant test missed it too, because it compared DECLARED sources against READ tables and both sides named the old table, so they went stale together and agreed.

  Our codemod's reassurance — *"a missed one fails loudly with 'relation does not exist'"* — is true of a SQL reference and NOT of a `source:` declaration. That sentence is what sent them past the `grep` hits it had printed.

  The check is set membership against data the boot already holds, so it closes a class: a rename is one way in, a typo is another, a plugin table whose plugin is not installed is a third. The suggestion is what makes it actionable, and it is why edit distance alone is not enough — `agent_messages` → `_voltro_agent_messages` is eight edits, so a prefix match wins outright. Wired into `voltro dev` AND `voltro serve`. **Warn, not refuse**, deliberately: an app can be carrying one right now and booting happily, so refusing would turn an upgrade into an outage for a defect the framework never mentioned.

  **And every mutating `inspect`-backed command now sends the write credential.** `voltro schedule run`, `voltro workflows start|resume|signal` and `voltro inspect invoke` all sent the bearer and none sent `x-voltro-inspect-write`, so they were refused by their own server while the identical `curl` with both headers worked — using tokens `voltro dev` had minted into the project's own `.env.local`. Five call sites, one omission, in the shared helper none of them owned; it is attached there now for every mutating method. From the ENVIRONMENT only: the read token is published in the runtime registry so a read works from any directory, and publishing the write token beside it would leave no second factor.
- **@voltro/cli** — **`voltro serve` built the instance-membership registry TWICE per process.**

  `serveCommand` builds one before `bindDataStore` — it has to, because a plugin handed a registry that appears later would silently never learn that a peer died — and `serveApi` built a second. So one serve process ran **two heartbeat timers announcing the same `instanceId`**, held two subscriptions to the members channel, and handed the presence plugin and the event layer different objects for one fact. `serveCommand`'s was also never detached at shutdown, so its timer ran until process exit.

  `voltro dev` builds exactly one, which makes this the dev/serve divergence class again — both paths typecheck alone, and nothing errors either side. It was found by booting `voltro serve` for the first time in this area and reading its log: `membership: announcing` appeared twice with the same id.

  `serveCommand` now hands its registry to `serveApi`, which builds one only when nothing was passed (a direct `serveApi` call in a test or an embedder).

  Pinned by a two-replica integration test that boots real `voltro serve` processes against a real Redis and asserts exactly one `announcing` line per process — plus the boot REFUSAL when `VOLTRO_SESSION_SECRET` is unset, which is the first thing a deployment hits and must name both the variable and the command that fixes it.
- **@voltro/protocol, @voltro/cli** — **Two guards for the two defect shapes this area kept producing.**

  Every defect found while hardening the event primitive was one of two things, so they are checked now rather than rediscovered:

  **"Declared but never read."** `defineEvent({ guards })` was accepted, documented as "WHO MAY LISTEN", declared as `ScopeError` in the wire contract, serialised into the manifest, reported by doctor and counted in the dashboard — and enforced nowhere. `declaredOptionsEnforced.test.ts` requires each option to be READ on the event's own path, and it took three attempts to make it able to fail:

  - v1 asked whether the symbol appeared anywhere outside a reporter. It does — on the query path — so it stayed green through a revert that removed the event enforcement entirely. It proved that SOMETHING checks guards, which was never in doubt. - v2 scoped it to the file and still passed: replacing the CALL left the import behind, and an unused import satisfied it. - v3 matches a CALL or a property read, with comments AND imports stripped. `guards` carried a doc block naming itself the whole time it was dead, so a rule satisfied by prose would have passed on the case it exists for.

  **"Derived twice."** The broadcast namespace could have been derived by four wirings; the membership registry WAS built twice per serve process; the file-convention pattern list exists in three copies and `*.event.ts` was missing from one. None of them errors — two namespaces that disagree are simply invisible to each other. `derivedOnceGuard.test.ts` pins one construction per boot path and requires the second consumer to take the value as a parameter.

  Both are red-verified against the actual reverts, not against a hypothetical.

  Also fixed here: the two-replica boot test hardcoded the expected event count and went red when the fixture grew two more. It derives the number from the fixture now — a count written down beside the thing it counts rots on the next change.

  And `nats-test` is in the CI stack. It was added to `docker-compose.yml` without being started, so every NATS integration test skipped — and the gate's no-undeclared-skips step is right to call that a coverage claim nobody honours.

### Internal (no consumer-facing effect)

- **@voltro/workflow** — **The cluster resume test tore the first runner down at a point the clock picked, and asserted a property only the engine can place.** It waited for `step1`'s SIDE EFFECT, slept one second, then killed runner A and asserted that the resumed runner B did not redo `step1`. Alone that held; inside the full gate it produced `expected ['A','B'] to deeply equal ['A']` — B re-ran the step, correctly.

  The one second was a guess that `step1`'s journal write had landed. A step's side effect and its durable record cannot be atomic, so a teardown between them re-runs the step on resume — the framework is at-least-once at a step boundary and the docs say so, telling users to make side effects idempotent for exactly this reason. The assertion is therefore legitimate only at a teardown point chosen AFTER the write, and nothing in the test chose one.

  An `armed` step now sits between `step1` and the nap, and the teardown waits for it. `activityExecute` returns only once a step's result is durably recorded — that is what lets replay skip it, and what the idempotency-key scenario in the same suite already depends on — so `armed` starting IS the journal write having landed. No duration is left in that path.

  Distinct from the ceiling raises around it, which address a resumed run needing longer than the timeout under load. This one is not a timeout: no amount of waiting turns a re-executed step back into a skipped one.
- **@voltro/cli** — The dev-SSR streaming tests defined "the shell" as *every chunk that arrived before 0.6 × the deferral delay* — an assertion about the machine wearing the shape of an assertion about the renderer. Under a loaded CI runner the shell lands after that deadline, the derived `shell` string comes out EMPTY, and the failure reads `Expected SHELL_LAYOUT_EAGER_OK`, as though the renderer had dropped a field. Green on every developer machine.

  The shell is now everything BEFORE the chunk carrying the deferred value, and the claim the first-byte deadline was reaching for is stated as what it actually is: the eager field's chunk index is strictly lower than the deferred value's. No duration remains in that path. The one clock that stays is the lower bound on WHEN the deferred value arrived — a slower machine only makes that more true.

  It still fails a buffered implementation, which is the point of the suite: one chunk means the deferred index is 0, the shell is empty, and the eager-field assertion fails.

  **Found because a red suite had been reporting green.** CI's test step ends in `| tee`, and GitHub's default `run` shell is `bash -e` — *without* `pipefail` — so the step's exit status was tee's. `@voltro/cli#test` failed, turbo exited 1, and the step reported SUCCESS; the comment above it asserted pipefail was on. It surfaced only because the failing package died before printing its summary, which tripped the undeclared-skip check further down. One line later and the gate would have gone green on a failing test. The workflow now sets `defaults.run.shell: bash` so no future piped step can reintroduce it.

---

## [0.24.0] — 2026-08-02

### ⚠ BREAKING

- **@voltro/ai, @voltro/cli** — **`agent_threads` and `agent_messages` are `_voltro_agent_threads` and `_voltro_agent_messages`.** The last two framework-owned tables sitting in the user's namespace; the other ten moved in 0.22.0 and these were not in that set.

  The collision it ends is the obvious half. The half that cost a consumer something is `versioningPlugin`: its "framework and plugin tables are out of the default" keys on the `_voltro_` prefix, so `agent_messages` was IN the default versioned set — and `runAssistant` patches the streaming assistant row about every 100 ms while it types. Under `timing: 'in-transaction'` that is a row-history write per throttle tick, on the hottest path in the app. They found it while adopting the versioning inversion and excluded both tables by hand; that `exclude:` entry can go now.

  **The rows move themselves.** `.renamedFrom()` on both, so the next `db apply` or auto-migrate boot emits a catalog-only `ALTER TABLE … RENAME TO` on every dialect — no copy, no row rewrite. `AGENT_THREADS_TABLE` / `AGENT_MESSAGES_TABLE` are exported and carry the new names, and the synthesized `<agent>.messages` query moved with them, so typed code is unaffected.

  The codemod is `manual` for the same reason the 0.22.0 one was: what a transform cannot see is raw SQL written by hand against those names.

### Added

- **@voltro/plugin-auth** — Brute-force account lockout. After 5 failed credential attempts (wrong password OR wrong MFA code) within 15 minutes, sign-in for that email is refused with a `429 account_locked` for 15 minutes; a completed login clears the counter. The counter is keyed by email — an unknown address locks exactly like a real one, so the lock can't be used to probe which accounts exist. **On by default** (a security default); tune or disable via `authRoutesPlugin({ lockout: { maxAttempts, windowSeconds, lockSeconds } })`. Apps that spread `authTables` get the new `loginAttempts` table automatically on the next `voltro db apply` / `voltro dev` boot — it rides the declarative differ, no codemod.
- **@voltro/cli** — Backup provenance stamp. `voltro data backup` now writes a `voltro-backup-stamp.json` sidecar next to the native dump recording the dialect, the authoritative live-schema fingerprint, the `@voltro/cli` version, and the timestamp — a native `pg_dump`/`mariadb-dump` artifact is otherwise opaque about what it is. `voltro data restore` reads the stamp BEFORE touching the DB and acts on two failures that are silent until they corrupt: a CROSS-DIALECT restore (postgres dump into a mysql DB) is REFUSED (override with `--force`), and a SCHEMA/CODE fingerprint skew WARNS to run `voltro db apply` after the restore. A backup with no stamp (older/hand-made) restores with a caution, not a hard stop. Docs additionally clarify that point-in-time recovery (PITR) is a database/provider concern (WAL/binlog archiving) the framework deliberately does not reimplement, and that a backup you have never restored is a hypothesis. `codemod: none` — new CLI output + a restore-time guard; no user-authored code is affected.
- **@voltro/database, @voltro/sql-postgres, @voltro/runtime, @voltro/cli** — Per-statement query timeout via `DB_STATEMENT_TIMEOUT_MS` (or `ConnectionConfig.statementTimeoutMs`). A runaway query — a missing index, an accidental cartesian join — no longer pins a pooled connection indefinitely: it is cancelled once it outlasts the deadline, its connection returns to the pool, and the caller gets a normal error instead of a hang that, under load, exhausts the pool and stalls the whole app. Applies to the **runtime query path only** — migrations (`voltro db apply`) run legitimately long statements and are never cancelled by it. **Wired for postgres today** (the default dialect), where it maps to the server-side `statement_timeout` — a real server-enforced cancel (SQLSTATE `57014`), not a client-side disconnect that leaves the query running. Other dialects accept the field but currently ignore it (mssql's driver exposes no per-request timeout, MySQL/MariaDB's `max_execution_time` bounds SELECTs only, SQLite has no pool to protect). New `isQueryTimeout` classifier in `@voltro/runtime` recognises a timeout cancel across dialects. Off by default (unset = no timeout — unchanged behaviour). `codemod: none` — a new opt-in env var / config field; no user-authored code is affected.
- **@voltro/database, @voltro/cli** — Rolling-deploy safety classifier + `voltro db plan` advisory. A migration can be fully data-safe (every op auto-applies) and still break a zero-downtime rollout: during the overlap window old pods run the previous code against the already-migrated schema, so a dropped/renamed column, a narrowed type, or an added constraint makes those old pods 500 on reads or have their writes rejected. This is an axis ORTHOGONAL to the lossy/blocked data-safety gate — a `dropped()` column is blessed for data loss and still breaks an old reader.

  `classifyRollingDeploySafety(op)` (a pure function in `@voltro/database`) returns a per-operation verdict with a reason + an expand/contract remedy; `voltro db plan` now lists the unsafe operations under a `⚠`, separately from the lossy/blocked summary. Advisory, NOT a refusal — the framework can't know the deploy strategy, and a maintenance-window / scale-to-zero deploy has no overlap window. The classifier is consumed by both the self-hosted advisory and (later) the cloud managed-hosting migration wall. Docs bless the expand/contract pattern. `codemod: none` — new API + CLI output only; no user-authored code is affected.
- **@voltro/runtime** — Configurable graceful-shutdown deadline via `VOLTRO_SHUTDOWN_GRACE_MS` (milliseconds, clamped to 1s–5min, default 10s). After `SIGTERM`/`SIGINT` the runtime runs its finalizers (connection-pool close, plugin `onDeactivate`, analytics flush, trace persist) and then exits — but installing the signal handler removes node's default kill, so a finalizer that never completes would otherwise hang the process forever; the deadline caps that. Operators set it to sit just under their orchestrator's hard kill (k8s `terminationGracePeriodSeconds` minus the preStop sleep, ECS `stopTimeout`) so the app drains and exits cleanly on its own before SIGKILL truncates it mid-drain. A non-numeric / non-positive value falls back to the 10s default (never a `NaN` deadline that fires immediately). `codemod: none` — a new opt-in env var; no user-authored code is affected.
- **@voltro/client, @voltro/runtime, @voltro/cli, @voltro/protocol** — WS-rpc mutation idempotency. A retried mutation carrying the same `idempotency-key` is deduplicated at the server: the first result is replayed and the handler does NOT run twice — so a network-blip retry, a reconnect resend, or (with a stable key) a double-click can't create a duplicate order / double charge. It reuses the same engine + `_voltro_idempotency` table as the REST path, so setting `idempotency` in `app.config.ts` now protects BOTH surfaces. `useMutation` / `useAction` mint a per-call key automatically and attach it to the rpc frame (over `RpcClient.currentHeaders`, merged with the auth headers) — pass `mutate(input, { idempotencyKey })` with a stable key for higher-level dedup. The key is scoped by `(tenant, subject, mutation)` so one subject's key can never replay for another, and the stored output is round-tripped through the mutation's output Schema so a `Date`-bearing replay reproduces the original exactly. Off by default (no `idempotency` config → no dedup). `@voltro/client` now peer-depends on `@effect/rpc` + `@effect/platform` (already transitive via `effect`).

### Fixed

- **@voltro/runtime, @voltro/cli** — **A `*.schedule.ts` or `*.subscribe.ts` body written as an Effect silently did nothing.** Not "was rejected" — ran, recorded a success, and never executed.

  ```ts
  export const handler: ScheduleHandler = () =>
    Effect.gen(function* () { yield* reconcileInvoices() })   // never ran
  ```

  Both call sites accepted the value and dropped it. `scheduler.ts` did `await def.handler(ctx)`, and an Effect is not a thenable, so `await` returned it unchanged. `subscriberRunner.ts` tested `result instanceof Promise`, which an Effect is not, so the branch was skipped. Neither raised anything. In an Effect-first framework the natural thing to write was the thing that quietly did nothing — worse than a type error, because a type error is visible.

  Both handler types now accept sync, Promise **and** Effect forms, and one shared `settleHandlerBody` decides what a body IS, so the two call sites can no longer disagree about it. They keep their different DISPOSAL, deliberately: a schedule AWAITS its body (a firing that failed must not record as a success), a subscriber does not (a slow body must not back-pressure the change stream).

  Reported four releases ago. It sat because it was listed as "open" at the bottom of a feedback round and never entered the backlog — the register that now holds that tail is `plans/open/framework/consumer-reported-tail.md`.
- **@voltro/plugin-auth** — **Brute-force lockout could be entirely inert, on by default, with nothing in the log to say so.** The postgres store fails OPEN on a store error — correct, a DB hiccup must not lock every user out of an app — but it failed open in silence: `recordLoginFailure` swallowed its write error, `isLockedOut` then read "not locked", and the security control that the release notes describe as **on by default** counted nothing at all.

  The reachable case is not a hiccup. An app that enumerates its tables by hand instead of spreading `authTables` never migrates `loginAttempts`, so every write fails with `relation "loginAttempts" does not exist` — permanently, invisibly.

  Behaviour is unchanged: still open, still no throw into the login flow. What is new is that each failure logs `[auth] lockout … failed — brute-force protection is not counting`, which also separates the two cases by hand: a transient error logs once, a missing table logs on every failed sign-in.

  Found by the release gate, and the finding is uncomfortable in a useful way — the contract suite that runs against a LIVE postgres had been extended for lockout, and its hand-written fixture DDL was never given the new table. The fail-open then converted "relation does not exist" into a plain assertion failure, which is the only reason it was visible at all. The fixture now asserts that it covers every table the plugin declares, in both directions, and that assertion runs without postgres so the drift cannot be introduced on a machine where the pg half skips.
- **@voltro/cli, @voltro/database** — **`voltro db apply` now installs the change triggers the boot diagnostic tells you to install.** It did not, and said it did.

  0.23.0 added a check that compares declared reactivity against the triggers actually in the database, and it works — a consumer's first boot on 0.23.0 reported 500 of their 525 tables as having no change trigger. The remedy it named was `voltro db apply`, and `db apply` answered:

  ```text
  schema diff: 0 operations, 0 blocked
    (schema is up to date)
  db apply: schema is up to date — nothing to apply
  ```

  Both were telling the truth. Reactive triggers are emitted only by the two FULL-schema emitters — the CREATE-everything path for a fresh database, and the framework bootstrap — so every table an existing app has added through the PLANNER since it was created never got one. The planner has no trigger dimension to notice with, so `db plan` correctly reports zero operations while 500 tables sit untriggered. Their database had 27 triggers, all 27 on framework tables.

  The consequence is the one the diagnostic describes: a single instance is unaffected (its own writes reach its own subscribers in-process), so this stays invisible until you scale out, and then subscriptions quietly stop seeing other instances' writes.

  `db apply` and `db apply --plan` now converge triggers as an explicit, reported step — **including when the schema diff is empty**, which is not an edge case here but the reported one. It is deliberately NOT a planner operation: a trigger carries no data, its DDL is idempotent, and it is derived entirely from `isReactive`, so it converges rather than diffs.

  **A second defect found while fixing the first: a custom `cdcChannel` made the check report every reactive table as missing.** The detector derived the trigger name itself (`framework_changes_<table>`) while the emitter puts the channel in the name on any non-default channel — two derivations of one name, disagreeing exactly where nobody looks. They read one function now.

  Also: **`_voltro_schedule_claims` had no retention.** One row per (schedule, minute-bucket), append-only, and the only table of its family without a sweep — `_voltro_schedule_runs` (the OUTCOME of a firing) had one; its coordination twin (the RACE for the same firing) did not. Measured by the same consumer at 35,128 rows in 14 days across 31 schedules. Now pruned at 30 days by default (`VOLTRO_SCHEDULE_CLAIMS_TTL_HOURS`), which is safe because a claim only ever answers a question about one minute bucket and the scheduler asks about the current one.
- **@voltro/cli, @voltro/database** — **`voltro serve` could not boot from its own bundle on any app with binlog CDC enabled**, and reported it as a build problem.

  ```text
  [voltro] serve bundle failed to load: n7 is not a constructor
  [voltro] FATAL: production `voltro serve` requires a precompiled serve bundle …
    Run `voltro build` before serving
  ```

  The bundle was neither missing nor unloadable. `@vlasky/zongji` — the binlog reader — sat on `NATIVE_RUNTIME_LEAVES`, the list of packages routed through a runtime CJS shim instead of being inlined, under a comment describing it as a leaf with "a compiled `.node` binding". It has none: it is a pure-JS ESM package. Through the shim its consumer broke, from the opposite direction to the `pg` regression the same list already documents — the shim's `module.exports` IS the ESM namespace `{ default: ctor }`, esbuild's `__toESM` wraps it again, and `(await import('@vlasky/zongji')).default` came back as the namespace object. Measured both ways:

  ```text
  shimmed:  typeof mod.default === 'object'   → `is not a constructor`
  inlined:  typeof mod.default === 'function' → constructs
  ```

  Inlining it removes the shim's interop from the path. A new guard asserts the RULE rather than the list: every entry on `NATIVE_RUNTIME_LEAVES` must actually carry a compiled binding, or be one of the two documented dynamic-import cases.

  **And the message that hid it is fixed.** The launcher wrapped the bundle's IMPORT and its RUN in one `try`, so every runtime fault the app's boot could raise came out as "serve bundle failed to load … Run `voltro build`", with the stack discarded. The reporter had just run it. The two are separated now: an import that throws is still a build problem, and an import that succeeds followed by a `runServe` throw is reported as itself, with its stack, and does not fall through to a second execution path.

  **`voltro db files` ran a migration and could not record it, on MariaDB.** The file-migration writer passed an ISO-8601 string into `_voltro_migration_plans.appliedAt`, a `DATETIME`, which MariaDB rejects (`ER_TRUNCATED_WRONG_VALUE`). The INSERT runs AFTER `up()`, so the side effects landed and the bookkeeping did not: a probe migration inserting one row grew 1 → 2 → 3 → 4 across four invocations with zero `source='file'` ledger entries, and the deploy could never complete — `db apply` refuses while a file migration is pending and nothing could ever record it.

  The PLANNER's writer had the conversion, inline, with a comment describing this exact rejection. The file writer was a second copy without it, so the common path worked and the escape hatch was broken exactly where it is reached for. One `appliedAtValue` now, shared, covered against a live MariaDB. A ledger write that fails after a successful `up()` also gets its own error type: the recovery is the opposite of the ordinary one — do NOT re-run — and it used to surface as a generic `Failed to execute statement`.

  **`voltro build` never removed output from earlier builds.** Content-hashed chunks mean every build writes new names and nothing overwrites the old ones; nothing reads them either, so they accumulate and ship in the image. Measured by a consumer at 11,048 files where a clean build produces 2,131. Now pruned after the build, keyed on mtime — deliberately not a wipe before it, which opens a window in which the bundle does not exist.

  **A completed drain now says so** (`drained in 80ms`), and one cut at the deadline says that instead. Previously the only trace of either was whatever a shutdown hook happened to log, so "drained in 80 ms" and "was cut at 10 s" looked identical.
- **@voltro/runtime** — **A request the SSRF policy blocks is now a catchable failure instead of an uncatchable defect.**

  It was `Effect.die(new SsrfBlockedError(...))`. A blocked outbound request is a decision the policy made about a URL the CALLER supplied — and as a defect the caller could not do anything about it: the fiber collapsed, it surfaced as an untagged 500, and a handler that wanted to fall back to a queue, return a typed error to the client, or skip an optional enrichment had no way to.

  It rides inside `HttpClientError.RequestError` rather than being raised on its own, because `HttpClient.HttpClient`'s error channel IS `HttpClientError` — failing with a foreign type would not typecheck for any consumer. So `catchTag('RequestError')` and `catchAll` both see it, `description` names the policy, and the `SsrfBlockedError` survives as `cause` for a caller that wants the specific reason.

  Not breaking: the error channel already carried `HttpClientError`. What changed is that the failure now arrives on it.

  Reported three releases ago. It sat because it was listed as open at the bottom of a feedback round and never entered the backlog — see `plans/open/framework/consumer-reported-tail.md`.

### Internal (no consumer-facing effect)

- **@voltro/database** — `VOLTRO_SOFT_DROP=1` convergence is now proven against a live postgres, not argued.

  The planner-side fix — treating `<name>__dropped_<YYYYMMDDHHMMSS>` as framework-managed so an undeclared snapshot is never re-proposed for dropping — shipped some releases ago. What never existed was the assertion, and a consumer had told us so: *"still not testable from a host without the DB"*. That was their constraint, read as ours. The docker stack is exactly what it needed.

  The test soft-drops a COLUMN and a TABLE for real, asserts the data survives under the snapshot name, and asserts the **re-plan is EMPTY** — the property, not the statements. Verified red by disabling the planner's snapshot awareness: both cases then die in `applyPlan`'s convergence check, which is the original defect.

  Two harness mistakes are recorded in the file because each produced a failure that reads exactly like the framework defect under test: an unscoped re-plan against a SHARED database proposes dropping every table in it, and a file-wide scope puts the first test's table into the second test's residue.

---

## [0.23.0] — 2026-08-02

### ⚠ BREAKING

- **@voltro/plugin-versioning, @voltro/database, @voltro/cli** — **`versioningPlugin({ tables: string[] })` is gone. Row history is ON by default for every table your app declares, and the two escape hatches take table VALUES.**

  ```ts
  versioningPlugin({})                              // every app table
  versioningPlugin({ exclude: [domainEvents] })     // opt one out — by value
  versioningPlugin({ include: [aiFlowsTable] })     // add a PLUGIN's table
  ```

  The old shape had two failure modes and both were silent:

  - you listed six tables, forgot the seventh, and nothing ever told you its history was missing; - nothing cross-checked the strings, so `'invoces'` recorded nothing — forever — while the plugin reported itself active at boot.

  Opt-out fixes the first (forgetting is now the safe direction) and values fix the second (`tsc` catches a misspelling at the call site, exactly as it does for `reference(() => table)`).

  **Framework- and plugin-owned tables are OUT of the default**, and that is not tidiness. There are 34 of them, and the busiest — `_voltro_cdc_log`, `_voltro_events`, `_voltro_undo_log`, `_voltro_workflow_events`, `_voltro_webhook_rate_windows` — are append-only logs. A full row snapshot per write there is the history of a history, at the highest write rate in the system. `include` is the supported way to version one anyway, and it works whether or not your app declares the table — which answers "can I version a plugin's table I do not own": yes.

  **The set resolves LAZILY, on first use.** `versioningPlugin()` is called in `app.config.ts`, before a single table has registered; resolving at construction would produce an empty set and record nothing, silently, which is the defect this change removes. Both boot paths register the app's tables during discovery and activate plugins afterwards.

  A table named in BOTH `include` and `exclude` throws at construction rather than picking one — only the author knows which was the mistake.

  The boot log prints the **resolved** count (`versioning active · tables: N`), not the configured one: with an opt-out default, "how many did I configure" is not a number anybody has, and "how many am I recording" is the one worth seeing.

  **Check your storage budget once after upgrading.** If you previously versioned three tables out of forty, you now version forty. The retention sweep (`VOLTRO_ROW_HISTORY_TTL_HOURS`) still bounds age.

  `isFrameworkOwnedLiveTable` is now exported from `@voltro/database` — one copy of that rule, since a second copy of it is how a per-dialect difference in what `voltro dev` does got shipped once already.

### Added

- **@voltro/cli, @voltro/plugin-ai-flows, @voltro/plugin-notifications, @voltro/plugin-presence, @voltro/plugin-versioning, @voltro/plugin-webhooks** — **The direction into a plugin's table — first half.** There were two doors OUT of a plugin's schema (`tables: false` on rbac, `alias` on ai-flows) and none in, so an app with grown data either ran a second source of truth beside the framework or did not use the plugin. A consumer named the cost: five plugins unused, not one of them because the plugin was worse than what they had.

  `planAdopt` decides whether a move is safe and in what order it must run — the half that costs hours when you get it wrong, and the half that needs no database. It refuses three things rather than guessing:

  - **a NOT NULL target column nobody maps to.** The alternative is a silent zero that reads as real data forever after. - **a target table that already holds rows.** Adopt MOVES rows into a table; it does not merge into one somebody else already wrote. - **a typo on either side of the map.**

  And it states, before anything runs, the thing that is expensive to discover late: differing typeid prefixes (`afl_` → `aifl_`) mean every row gets a new id, so every reference elsewhere must be rewritten from a translation table — **including ids embedded in JSON columns**, which is where the reporter's own hand-written migration had its hardest step.

  A source column nobody carries across is reported but not fatal: it is deliberate often enough, and "I forgot this column" and "I decided" look identical in a map file.

  The field mapping itself stays the app's — units, merged fields, a status vocabulary that does not line up are domain knowledge, and a tool inventing them silently corrupts data.

  **The move itself ships with it**, behind `voltro db adopt --from … --into … --map … [--apply]`. **Dry run by default** — `--apply` is the only way anything is written, because the interesting failure is irreversible and the interesting output is the refusal. A refused plan prints no steps at all, rather than a preview of something that will not happen.

  The ordering is the product, not the SQL, and every step is there because skipping it loses data you find out about later:

  1. **snapshot** the source into `<table>__adopt_snapshot` — a real table in the same database, so the restore path is a statement rather than an operational procedure at 2am. It keeps the columns the adopt deliberately left behind. 2. **copy**, with the mapping's raw expressions. 3. **verify by count** — this catches the one failure that is otherwise invisible: a `WHERE` inside a raw expression silently dropping rows. 4. **drop the source, last**, and only if the counts match.

  Two things it refuses to do, both because the alternative is a silent partial state: it never drops the source on a count mismatch (both tables stay, and it says so), and it never removes the snapshot after a failed verify — the snapshot exists for exactly the run that goes wrong. `--keep-source` copies and verifies without dropping at all.

  Verified against live postgres (`sql-postgres/__tests__/adoptExecute.integration.test.ts`): the rows move, a unit conversion and a two-field merge come out right, the snapshot holds the originals including the dropped column, a failed adopt leaves the source standing, and a refused plan runs nothing.

  **Reference rewriting after an id re-mint is deliberately NOT automatic.** The ids live in the app's own columns and inside its JSON, and only the app knows where. The translation table is what we owe it; the rewrite is what it owes itself. Doing that automatically is the one place in this command where being wrong would be silent.

  Also in this drop, from the same report: every table-carrying plugin exports its table handles, so `reference(() => pluginTable, { onDelete: 'cascade' })` works across the boundary with database-enforced integrity — verified by a planner test against the real `_voltro_ai_flows`, including that the plugin table is created before the app table that points at it.
- **@voltro/cli** — **`voltro doctor` reports where a plugin's surface meets one the app already has.** An app that did not start on a green field already has a table for half the plugins it installs, and whether it uses them is decided at that seam — which the framework knew both sides of at boot and said nothing about.

  A consumer measured it across eleven table-carrying plugins: eight model a concept they already had a table for, and every overlap was found when it hurt — `rbac` at the role model, `notifications` on switch-on, `ai-flows` at a blocked boot. Half an hour to several hours of diagnosis, three times.

  Three findings, all exact:

  - **a plugin table whose `.renamedFrom()` names a table you declare** — saying explicitly that the plugin's empty table is the INTENDED outcome and not a failed migration, which is the sentence that was missing; - **an exact rpc tag collision** — already fatal at codegen, named here because the codegen error does not mention that `alias` is the way out; - **a shared rpc namespace** — advisory. It is what makes a plugin unusable without anyone noticing: your `notifications.list` and its `notifications.inbox` coexist while one namespace means two things.

  Deliberately exact, with no name-similarity guessing: a fuzzy matcher over 27 plugin tables produces the noise that gets a check switched off, which is how the authz scan became ignorable on that same repo. The advice names `tables: false` / `alias` only for plugins that actually accept them.

  Also confirmed while answering the same report, and pinned by test: `versioningPlugin({ tables: [...] })` already works on a plugin-owned table the app never declares — it watches by NAME and contributes only its own history table. Nothing validates those names, so a typo silently records nothing; that is the cost of the decoupling and it is now stated.
- **@voltro/data-transfer** — **A bundle can be imported into a schema that has moved on.** `classifyImportDrift` compares what a bundle carries against what the target declares and classifies each difference the way `db plan` classifies schema operations, instead of the one all-or-nothing fingerprint comparison that came before.

  | difference | verdict | |---|---| | a column the SCHEMA dropped | values discarded — said out loud, and the loader skips it | | a NULLABLE / DEFAULTED column the schema added | filled, not refused | | a column whose TYPE changed | **refused** | | a NOT NULL column with no default the bundle cannot fill | **refused** | | a table the target does not have | **refused** — nowhere to put the rows | | a table only the target has | not drift (a `--tables` scope, or added since) |

  Before this, a bundle exported before a column was added could not be imported at all, even though the difference was additive and harmless. The only escape was `--force`, which this package's own doc comment describes as failing "mid-load with raw DB errors after rows may have landed" — an escape hatch that trades a clean refusal for a dirty one.

  The line it draws is the one a transport primitive has to draw: a row that lands INCOMPLETE is recoverable and is reported; a row that lands WRONG is not, so a changed column type refuses. That is the same distinction the reporter praised in the planner — additive is safe, the destructive one is blocked with the remedy in the message.

  **It does not replay authored data migrations, and should not.** A bundle carries no migration ledger, so ordered data steps stay on the physical path (`data restore` → `db apply`), where the restored database brings its own `_voltro_migration_plans` and the diff moves forward from there — which is exactly what the report concluded and demonstrated row by row.
- **@voltro/web, @voltro/cli** — **`LoaderContext.search`** — the raw query string (leading `?` included, `''` when absent), filled identically on client navigation, `voltro dev` SSR and `voltro start` SSR.

  `pathname` is query-free by contract, and for DATA that is right — a loader keyed on `?tab=2` caches badly. It is wrong for CONTROL FLOW, which is what a loader does since 0.22.0 made it throw `RedirectError` correctly: a redirect target routinely depends on a query parameter, so **the only place a redirect belongs was the only place with no access to one**.

  ```ts
  const mode = new URLSearchParams(ctx.search).get('mode')
  throw new RedirectError(`/?error=${code}${mode ? `&mode=${mode}` : ''}`)
  ```

  The reported case: a player page redirects an unknown wristband code back to the entry page and must preserve `?mode=kiosk`, or a kiosk terminal drops to normal mode after every failed scan. Both workarounds are bad — moving the redirect into a component gives up the 303 (back to what 0.22.0 just fixed), and `window.location.search` exists only on the client-navigation path, so a fresh SSR request loses it.

  **The testability half is why it is a FIELD and not advice.** Because `pathname` is a free-form string in the spec, their loader test passed `'/evo5/abc?mode=kiosk'` — a shape the runtime never produces — and was green for as long as production dropped the parameter on every request. In their words: *wo der Harness etwas liefern kann, das die Laufzeit nicht hat, wird ein kaputter Pfad grün.* A separate field makes that mistake impossible rather than unlikely.

  Both SSR paths derive it through one shared `splitPathAndSearch`, with a test that fails if either grows its own copy back — a two-line `url.split('?')` is exactly what two independent boot paths write for themselves and then disagree about. A prerendered (SSG) page has no request, so its `search` is `''`.
- **@voltro/plugin-notifications, @voltro/plugin-presence, @voltro/plugin-flags, @voltro/plugin-versioning, @voltro/plugin-webhooks, @voltro/cli** — **Every table-carrying plugin now exports its table handles, so an app can point a column at a plugin row.**

  ```ts
  import { aiFlowsTable } from '@voltro/plugin-ai-flows'
  
  export const flowFavourites = table('flow_favourites', {
    id:     id({ prefix: 'fav' }),
    flowId: reference(() => aiFlowsTable, { onDelete: 'cascade' }),
  })
  ```

  A consumer measured **711** app→app references against **2** app→plugin ones and diagnosed it exactly: *"Das liegt nicht daran, dass man selten auf Plugin-Zeilen zeigen will. Es liegt daran, dass es dafür kein Muster gibt — und man deshalb aufhört, es zu wollen."*

  **The pattern existed and was unreachable.** `plugin-storage`'s `assetRef()` is, by default, a real foreign key to `_voltro_storage_refs` with `onDelete: 'setNull'` — database-enforced integrity across the plugin boundary, shipping since it was written. It was simply impossible for every plugin that kept its `table(...)` handles module-local: `notifications` declared six as private `const`, `presence` one, and `flags` / `versioning` / `webhooks` exported theirs from a module but not from the package entrypoint.

  So this needed no new primitive and no new machinery — it needed the `export` keyword in seven places. `pluginTableExports.test.ts` fails on the eighth: a plugin whose tables nobody can name is a plugin nobody can point at, and that is invisible, because everything still compiles while the app quietly writes a plain `text()` column plus a hand-rolled cleanup subscriber.

  **Two corrections that came out of building it**, because designing on the stated model would have produced the wrong thing:

  - **`orphanPolicy` has no runtime semantics.** Its own doc comment says so — it is planner metadata deciding how existing orphans are cleaned up *before* the FK constraint is added. Runtime referential integrity comes from the FOREIGN KEY (`onDelete`), executed by the database. A proposal to have "the framework execute the orphan policy over the post-commit channel" described machinery that does not exist and did not need to. - **A foreign key across the plugin boundary survives the plugin renaming its table.** Referencing the table as a VALUE is what makes that true; the 0.22.0 `_voltro_` namespace move was catalog-only and the constraint travelled with it. A `text()` column holding ids would have told you nothing.

  `fk: false`-style decoupling remains available — declare a plain `text()` column — but it should be a deliberate choice, not the default that an unreachable handle forces.
- **@voltro/runtime, @voltro/cli** — **`serveApi` / `startRpcServer` take a `host`.** Absent → the wildcard, which is what a container needs and stays the default. It exists because of what a wildcard bind does to a server that its OWN process then connects to.

  **The bug it closes had been read as "flaky tests" for eight occurrences.** A test boots a server with `{ port: 0 }`, fetches it, and the fetch never returns — the test dies at its timeout on an operation that takes 20ms. It moved between files and packages every time, which is what made it look like machine contention.

  It is not. A wildcard bind lands on `:::<port>` — IPv6. The client fetches `127.0.0.1:<port>` — IPv4. Those are two independent binds of the same number, so a lingering IPv4 socket on that port takes the connection instead: the kernel completes the handshake into ITS backlog, `lsof` reports `ESTABLISHED`, and the server under test never receives a `connection` event. The request then waits against a peer that will never answer.

  **Every symptom follows from that**, including the ones that made "the machine is busy" look right: it needs earlier files in the same process (they leave the IPv4 sockets), it is intermittent (an ephemeral-port collision), and a diagnostic report taken mid-hang shows an idle event loop with an empty JavaScript stack — because there is genuinely nothing to run. It reproduces at rest, roughly one run in nine, with no docker stack and a load average of 3, and it has failed on a dedicated CI runner.

  Found by instrumenting `net.Server.prototype.listen` and catching a hung run: `listener#3 bound :::53011 … closed after 0 connection(s)` while its client sat in `fetch`. That instrument ships behind `VOLTRO_TEST_DIAG=1` (`packages/cli/src/integrationDiagnostics.ts`) together with the harness-level fix — a port-0 bind with no host goes to the loopback, so server and client share an address family and a collision becomes an ordinary `EADDRINUSE` at bind time instead of a silent hang.

  Measured after: **0 failures in 25 consecutive runs** of the suite that previously failed about one run in nine.

  Production is untouched: the wildcard is still the default, and nothing here runs outside a test process.

### Fixed

- **@voltro/database, @voltro/sql-postgres** — **`cdcChannel` was an option that did nothing.** Setting it produced zero change events and zero errors.

  The store read it and issued `LISTEN <channel>`. The DDL never received it: `emitSchemaSql` hardcoded `pg_notify('framework_changes', …)` inside the trigger function. So a store configured with its own channel listened somewhere nobody ever sent, and — because a NOTIFY with no listener is not an error — nothing said so. Measured before the fix:

  ```
  cdcChannel=(default)      → events received: 1
  cdcChannel=my_own_channel → events received: 0
  ```

  It could not have worked even with the channel threaded through, because the trigger function had ONE database-global name. `CREATE OR REPLACE FUNCTION framework_notify_change()` is a single `pg_proc` row, so two schemas applied with different channels overwrote each other and the last one won — everything applied earlier then emitted on somebody else's channel, silently. The function and the per-table trigger are both named after the channel now, so channels coexist. The default keeps its old names, so nothing existing is renamed.

  `applySchema` / `emitSchemaSql` / `emitFrameworkBootstrapSql` take the channel as an optional third argument defaulting to `DEFAULT_CDC_CHANNEL` (now exported, so the store and the DDL cannot drift apart again). Pass the SAME value to the store and to `applySchema`: they are two halves of one contract, and giving only one still yields silence.

  **Why it surfaced now.** `cdcAttribution.integration.test.ts` failed twice in CI with `delivered 0×` and never once locally. Forty test files write to that one postgres in a gate run, and on the shared default channel every NOTIFY they emit lands in this suite's consumer — its assertions depended on traffic it does not control. It now uses a per-run channel and is hermetic by construction rather than by luck. Verified: 4/4 in the suite, 101/101 in `sql-postgres`, 1335/1335 in `database`.

  Stated plainly because the earlier attempt at this failure was not: raising that suite's delivery wait from 10s to 20s was tried first and changed nothing, which is what a patience bound does when the problem is not patience.
- **@voltro/cli, @voltro/data-transfer** — **`voltro db apply` never ran file-based migrations, and the deployment topology we recommend has no other path that does.** A data step authored in `migrations/` would never execute in staging or production — silently, because the planner still converged the schema, so the Job went green and the deploy succeeded.

  A consumer mapped it exactly while working out how a months-old dump lands on today's schema:

  | command | ran `migrations/*.ts`? | |---|---| | `voltro dev` (boot) | yes, before the diff | | `voltro db files` | yes | | `voltro db apply` / `--plan` | **no** | | `voltro serve` | no (fingerprint check only) |

  Their pipeline is the documented one — a pre-upgrade Job running `db plan --json` → `db apply --plan`, pods on `voltro serve`. Nothing in it ran a file migration. And file migrations are the escape hatch for precisely what a state diff cannot infer (table splits, cross-table data moves, USING-expression type changes), which makes them exactly the steps whose absence a schema diff cannot detect: the shape is right either way.

  **Two different answers, because the two paths are not the same problem.**

  - **`db apply`** diffs live, so it now runs pending file migrations FIRST and then diffs — the same order boot uses, with nothing to invalidate. If one fails, the diff does not run: a half-migrated database with the schema already reshaped underneath it is harder to reason about than one that stopped where it broke. - **`db apply --plan`** applies a plan computed and REVIEWED against an earlier state, so it **refuses** when any are pending, before touching anything. Running them first would reshape the schema and trip the fingerprint guard immediately after — a half-applied deploy plus a drift message the operator did not cause. Running them after would apply a plan reviewed against a state that no longer exists. The refusal names the three commands that recover it, because a message that stops a deploy without restarting it is half a message.

  ### Also from the same report

  **`data backup` says what it did not do.** It runs the native dump and nothing else, while this module's own header claimed it reused "the shared content-addressed asset pipeline for blobs" — true of the logical `data export --assets`, never of a native backup. The consumer had retired the system this data came from, which made that artifact their entire rollback story, and they found out by listing the output directory. The command now prints `assets: 'NOT included — use \`voltro data export --assets\`'`, and the header and CLI summary no longer claim otherwise.

  **`data backup` prefers `mariadb-dump` on MariaDB.** The `mysql | mariadb` branch spawned a fixed `mysqldump` and took whichever was on PATH. Oracle's MySQL 8 client queries `information_schema.COLUMN_STATISTICS`, which MariaDB does not have, so the dump died after the first table — leaving a partial `db.sql` that looks like a file. MariaDB has shipped `mariadb-dump` / `mariadb` since 10.5 for exactly this split, and on a MariaDB install `mysqldump` is a symlink to it anyway, so preferring the real name costs nothing and removes the guess. NOT fixed with `--column-statistics=0`: that flag does not exist on `mariadb-dump`, so it would break the correct client to accommodate the wrong one.

  **`NativeToolError` shows the child's stderr.** It was being CAPTURED and then never rendered — `Data.TaggedError` with no `message` prints the Effect default, so the failure above surfaced as `NativeToolError: An error has occurred` and diagnosing it meant reconstructing the argv by hand out of our source.

  **`db plan` / `db apply` name the rows a default will fill.** "47,000 existing rows in `todos` will get the default for `slug`" is a sentence a reviewer acts on; a plan line that reads the same whether the table is empty or not is one they scroll past. The PLANNER cannot say this — it is pure by design and does no row counts, which is the property that lets a plan be computed in CI, reviewed and saved — so the count is taken at the command layer, which holds both the classification and the connection. Asked for as the one thing a state diff structurally cannot catch: it gets the shape right and is silently wrong about values.
- **@voltro/cli** — **`voltro doctor`'s authz scan could not see an app's guards, and said so without anyone being able to act on it.** An app exporting 17 guards was told `guard vocabulary: framework names only — no exported require*/assert* found in this app`, and the scan reported **447** findings of which **5** were real.

  The inference was handed the DISCOVERY file set — dev.ts's `walk()`, which returns only convention-named files (`*.query.ts`, `*.mutation.ts`, `schema.ts`, …). Guards do not live in those. They live in `lib/access.ts`, which that walk never yields, so the vocabulary read every file EXCEPT the ones that could have taught it anything. It reads the whole source tree now.

  The report came with a measurement rather than an argument, which is why the cause was findable in one hop: they moved two throwaway exports into a file the discovery set does cover, re-ran, and took it back.

  | | before | after two names | |---|---|---| | ✗ no access check | 447 | 246 | | ✓ vocabulary | 91 | 294 |

  Two names out of seventeen removed 201 false findings. And the 91 originally recognised were **coincidence**: one of their guards is called `requireScope`, which collides with a framework name, so it was in the set without the inference ever having run. "Partially working" was zero inference plus one collision.

  **Why no test caught it.** Every unit test of `inferGuardVocabulary` passed throughout, because the function was never wrong — the caller handed it the wrong files. The vocabulary is computed by an exported `root`-taking function now, tested against real trees, because the defect lives in *which files reach the function* and no test that hands it strings can see that.

  They declined to write the allowlist ratchet, and were right to: *"442 false lines in a file that says DEBT lead the next reader further astray than no file at all."* The ratchet is worth using now that the vocabulary is.
- **@voltro/database** — **`voltro dev` sent plpgsql to MariaDB and could not boot.** With auto-migrate on, an app whose plugins declare a reactive `_voltro_*` table (plugin-versioning, among others) failed at startup with `Unknown data type: 'trigger'` — the framework bootstrap emitting `CREATE OR REPLACE FUNCTION … RETURNS trigger AS $$` to a driver that has no such thing. Reported against 0.22.1 and measured on the SHIPPED build rather than inferred from source, on all five dialects.

  Two emitters write schema DDL — `emitSchemaSql` for user tables and `emitFrameworkBootstrapSql` for `_voltro_*` — and each carried a hand-written copy of the reactive-trigger block. Only one had the postgres gate. The trigger function is plpgsql and `pg_notify` has no equivalent elsewhere (the other dialects get cross-instance capture from a binlog/CDC reader), so the gate is a gate and not a missing implementation.

  It is one function now, and the tests assert the OUTCOME rather than the presence of a gate: the two emitters must agree, per dialect, about whether a reactive table produces plpgsql.

  **Why the existing dialect tests did not catch it.** They already passed a reactive table through the emitter — `table()` sets `isReactive: true`, so every case in that file did. Their "every dialect gets the same shape of DDL" test compared `CREATE TABLE` / `ADD COLUMN` / `CREATE INDEX` and simply did not list the trigger block, so the one statement kind that legitimately differs per dialect was the one kind nothing looked at. It is asserted explicitly now, per dialect, including the exception.

  The block is byte-identical in 0.21.0, so this is not a regression — it was reachable only with auto-migrate enabled, which is why it surfaced now.
- **@voltro/cli, @voltro/protocol** — **`internal: true` took the rpc server down instead of taking a procedure off the wire.** The flag shipped in 0.22.0. Marking five procedures with it produced

  TypeError: Cannot read properties of undefined (reading 'key')

  and no server — on `voltro dev` and, identically, on `voltro serve`. The consumer isolated it by toggling one at a time (an action alone, mutations alone), confirmed the codegen half was correct (603 → 598 procedures, zero dangling references), and left the flag commented out.

  Each boot path builds TWO things from the discovered procedure lists — the rpc GROUP and the HANDLER MAP, several hundred lines apart. Only the group consulted the filter. `RpcGroup.toHandlersContext` then looks a bound handler's tag up in the group, gets `undefined`, and reads `.key` off it.

  `serveApi.ts` already carried a comment describing that exact crash in the opposite direction — a handler bound with no group entry, for the undo and connection built-ins — and it did not generalise to the new filter. Both paths now filter ONCE and read the filtered bindings, so the group and the handler map cannot be built from different sets.

  Three further holes came out with it:

  - **A `internal: true` STREAM was still served in production.** `serveApi`'s group filtered queries, mutations and actions and not streams, so dev crashed at boot while serve quietly kept the stream on the wire — two paths, two wrong behaviours, and the silent one in production. - **The dev inspect invoker** routed internal procedures. It is filtered too: an internal procedure is the one MOST likely to have no guard ("only server code calls this" is the reason people write them), so an admin-token surface is a narrower door, not a closed one. - **`internal: true` combined with `publicApi` or `exposeAsTool` now THROWS at declaration.** Those projections add a REST route / an agent tool and never consulted the flag, so a procedure carrying both was off the WebSocket and still served over HTTP — the same hole, one surface across. Neither silent resolution is acceptable (dropping the route breaks a live endpoint invisibly; keeping it defeats the flag), so the author decides while both fields are still in front of them.

  **Why the parity guard was green.** It reads each assembly site's source and asserts it mentions `isWireReachable`. Every site did; the handler map is not an assembly site by that definition and never calls an `xToRpc` lifter, so the offender scan was structurally blind to it. There is a shape-based check for the binding loops now, and — because the defect satisfied every source-level rule stated — a test that BOOTS a server with an internal procedure present. That is the one that fails.
- **@voltro/database** — **A plugin table whose `.renamedFrom()` names a table the APP owns made every boot after the first one impossible.** Reported against `@voltro/plugin-ai-flows@0.22.1`; the mechanic applies to any plugin table carrying `.renamedFrom(<a name the app declares>)`.

  Boot one decided correctly and said so:

  ```txt
  ✓ CREATE TABLE _voltro_ai_flows (27 cols)
    # .renamedFrom('ai_flows') NOT applied — 'ai_flows' is still declared by this
    # schema … that is the intended outcome when an app owns a table of the same name.
  ```

  Boot two, over exactly that state, refused:

  ```txt
  auto-migrate: REFUSED — 2 blocked operation(s)
    - rename-table : both 'ai_flows' and '_voltro_ai_flows' exist in the database
  ```

  **The guards were evaluated in the wrong order.** Guard 1 ("the old name must not still be declared") answers the question completely: if the app declares the old name, the marker is INAPPLICABLE and there is no rename to have a conflict about. Guard 2 ("the target must not already exist live") asks a follow-up — *which of these two holds the real rows?* — that only makes sense once a rename is actually on the table. Guard 2 ran first.

  So the planner blocked the boot over the exact state it had itself produced one pass earlier and documented as intended, and the state was not stabilisable: dropping the empty `_voltro_*` table just let boot one recreate it. Neither remedy in the message worked either — the rows belong to the app's own schema, and letting the rename run would take them.

  The reporter's case makes it worse than a name collision: the plugin is a port of *their* engine, so it carries the names of the tables it grew out of. They had to unregister the plugin — losing its inspect endpoints — to boot at all. The changelog's "**Nothing is required of you**" was false for precisely the case guard 1 exists to protect.

  Guard 1 runs first now. The test that pins it models TWO passes, because one pass is what the original test did and one pass is green either way.
- **@voltro/cli** — **`voltro update` bumped `@voltro/*` and left what `@voltro/*` requires behind.** The `@effect/*` packages are peer dependencies, so a user app declares them directly. When a release moved its peer range, `update` rewrote every `@voltro/*` spec, installed, and left the app pinned to the old peers:

  0.22.1 requires @effect/rpc ^0.76.0 @effect/platform ^0.97.0 the app declared @effect/rpc ^0.75.1 @effect/platform ^0.96.2

  pnpm warns about that and installs anyway. The app compiles and boots — on a dependency graph the framework was never tested against, which is the worst shape a version mismatch takes: nothing fails, so nothing points at the cause. Found by a consumer while diagnosing something unrelated.

  `update` now reads the peer requirements off the freshly installed `@voltro/*` packages (disk, after the install — no second per-package-manager registry query to get wrong, and no exposure to the yarn-classic hazard where `yarn npm info …` parses as `yarn run npm`), aligns the app's declared ranges, and re-installs if anything moved.

  Three rules keep it from doing damage:

  - **Only peers the app already declares.** One resolved transitively is not ours to add — that would change the app's dependency surface on its behalf. - **Only when the declared floor is genuinely BELOW the requirement.** An app pinned ahead, or pinned exactly at the floor with different syntax (`0.76.0` vs `^0.76.0`), is left alone. Those are choices, not drift. - **Only ranges it can judge** (`^`, `~`, `>=`, exact). A union, an upper bound, `workspace:` / `catalog:` — left alone. Under-reporting an exotic range is safe; rewriting one we did not understand is not.

  If two framework packages disagree about one peer, that is REPORTED with both names and skipped — it is our bug, and resolving it inside a user's upgrade would hide it.
- **@voltro/cli** — **`voltro update` now also reports a peer that NOBODY declares.** The alignment added alongside this rewrites ranges an app already declares; a second consumer hit the other half of the same problem.

  Their `apps/voltro-api/package.json` declared the three `@effect/*` packages. The workspace ROOT did not — and the root's `@voltro/client` / `web` / `database` / `protocol` / `ai`, the ones all three frontends use, all require `effect ^3.22`. It resolved `3.21.4` transitively. The peer was unsatisfied workspace-wide, the install succeeded, and nothing said a word.

  That matters more than a version skew usually does because **Effect types are nominal**: two copies produce red `tsc` on `rpcGroup.generated.ts` while the server runs green — the exact symptom `voltro doctor`'s duplicate-install check describes. Doctor already caught it after the fact, with cause and recipe, and the reporter says so; the point of this is to stop the state being created.

  It is REPORTED, not repaired: the fix is to declare a dependency the app never declared, which changes its dependency surface. That is the user's call.

### Internal (no consumer-facing effect)

- **The four 0.22.0 codemods gain the gate tests the convention asks for.**

  `codemodRegistry.test.ts` asserts that every `*.codemod.ts` on disk is registered and that ids are unique — registration, not behaviour. The way a `manual` codemod actually fails is an `appliesTo` that is too broad, so the note prints for projects with nothing to do. That is not cosmetic: a note everyone sees is a note nobody reads, and the next one in the series announces a boot refusal or an irreversible deletion.

  Fifteen cases, both directions for each codemod. The silent direction is the one that needed pinning — `rename-index` must not fire on an app that merely READS a plan (additive there), the plugin-table move must not fire on an app that installs none of the three, and `cache.scope` must not fire on a logger scope or an OAuth scope, both of which are ordinary English in any codebase.

  Verified by breaking a gate rather than by watching green: widening `TOUCHES_SCOPE` to match everything fails exactly the two silent-direction cases and nothing else. A test that has never been seen to fail is not evidence that it checks anything.

  **One finding, pinned rather than quietly fixed.** `04_inspect-write-credential` gates on `envTokenAuthResolver|InspectAuthResolver|authResolver`, and the third alternative is not inspect-scoped — any project with its own unrelated `authResolver` gets the note. It is the loosest gate in the set. There is a test asserting the current behaviour, so tightening it is a deliberate act with a failing test to update, rather than a silent change to who hears about a credential split.
- **The test harness sends ANY hostless bind to the loopback, not just `port: 0`.**

  The first version rewrote only ephemeral binds, and that was worse than not doing it at all: it made the two halves of a single test disagree about address family.

  `devHealthServer`'s conflict case caught it in the next gate run. That test binds ephemerally, then asks for the SAME port again and expects `EADDRINUSE` to degrade the handle to `port: null`. With only port-0 rewritten, the first server took `127.0.0.1:P` while the second — an explicit port, so untouched — took `:::P`. Those do not collide. The expected conflict silently stopped happening and the assertion read `expected 51500 to be null`.

  The failure is worth keeping in view because it is the same mechanism the harness exists to remove, produced by a half-applied fix: two binds of one port number in different families are two independent binds. A test that names its own interface still keeps it, and production is untouched — the wildcard remains the default there, because a container must be reachable from outside.

  Re-verified after: `devHealthServer` 5 passed, `mcp` 13, `protocol` 301, `runtime` 1038, and an instrumented run still reports `bound 127.0.0.1:<port>`.
- **The net harness is shared across every package that binds a listener, and a derived guard keeps it that way.**

  The bind fix itself ships with the `host` option in this same release. What did not ship with it was reach: the mitigation lived in `packages/cli/vitest.config.ts`, written where the symptom appeared, so the other eleven packages whose tests bind a real listener never had it. `@voltro/mcp` then failed a release gate with the identical signature — bound, zero connections, its client stuck in `fetch` — and that read as a NEW problem rather than as the containment being too narrow. It is the second time this repo fixed a real-listener flake inside one package's config.

  `test/harness/setup.ts` is now loaded by all twelve. It is deliberately NOT in `@voltro/testing`: that package is published, and a `net.Server` monkey-patch does not belong in a shipped API surface.

  `packages/cli/src/netHarnessPackages.test.ts` DERIVES the required set — any test file calling `.listen(` / `createServer(` / `serveApi(` / `startRpcServer(` — instead of curating a list that would rot exactly the way the original mitigation did. Remove a package's config and it fails naming that package; verified by deleting `@voltro/mcp`'s and watching it go red. It also asserts the derivation matches more than five packages, because a guard that silently matches nothing reads exactly like a clean repo.

  The harness covers `@voltro/cli`'s `unit` project too, not only `integration`: the light mock-server suites live there, and `devHealthServer` — one of them — is among the files this failure mode has hung.
- **Thirty-two tests reported `passed` when their service was absent. They skip now, and a derived check keeps it that way.**

  The rule is not new — `voltro/CLAUDE.md` states that a suite needing a live service must SKIP rather than pass, that all 36 suites with the hand-rolled shape were converted, and that a new one must never be added. It was enforced by prose, so it rotted: seven files had grown it back.

  Measured, not inferred:

  ```
  $ PG_PORT=1 vitest run plugin-ratelimit/src/postgresStore.test.ts
  Tests  5 passed (5)
  ```

  Five tests that connected to nothing. The shape is a `beforeAll` probe plus `if (!ok) return` in each body: an absent dependency becomes a PASS, the only trace is a shorter duration, and vitest swallows the `console.warn` meant to say otherwise. Two of the seven — `concurrency.pg` and `jsonArrayWrite.pg` — exist specifically to prove atomicity under real concurrency, so a green there was evidence for a claim nobody had checked.

  All seven now use `describeIfReachable` (`plugin-ratelimit` ×2, `plugin-broadcast`, `plugin-flags`, `plugin-versioning`, `integration-harness` ×2), and four packages gained the `@voltro/testing` devDependency they were missing — the import would have typechecked clean and died at runtime with `Cannot find package`, which this repo has been bitten by before.

  Verified in BOTH directions, because only one is obvious: with no service, `2 passed | 3 skipped` where it used to be `5 passed`; with the stack up, 5/10/4/2 tests actually run and pass.

  `packages/cli/src/noHandRolledReachability.test.ts` makes the rule mechanical. It DERIVES the offenders from source rather than curating a list, and it strips comments first — the first version flagged two files whose only offence was a comment *explaining* the anti-pattern, and a check that punishes documenting a hazard teaches people to stop documenting it. It also excludes itself, since it must state the pattern in order to forbid it, and asserts the supported helper is used by more than twenty files, so an empty repo could not make it vacuous. Red-checked by reintroducing the guard into `plugin-flags`: it fails and names the file.
- **@voltro/runtime, @voltro/cli, @voltro/mcp** — Two `@voltro/runtime` tests asserting that a symbol is EXPORTED carried vitest's 5s default timeout around a dynamic `import('./index')`. That silently added a second assertion nobody meant to make — "…and a cold import of this package's whole barrel completes within 5 seconds" — which is a claim about the MACHINE.

  In a full uncached monorepo run the package's import phase alone was 88s and the file went red while every assertion in it would have passed. Given an explicit 60s ceiling: the timeout is now a backstop rather than the assertion, which is the same correction already applied to `coordinatedSchedule.test.ts`.

  **Three more files had the same shape**, and they are the ones this repo's maintainer notes already list as "rotating victims" of full-monorepo runs: `cli/src/adminExportServe.test.ts`, `cli/src/connectionServe.test.ts` and `mcp/src/http.test.ts`. All three BOOT a real listener and make real HTTP round-trips — the last one boots two servers — against the same 5s default. Each went red in an uncached full run under load ~19 and green alone seconds later, with every assertion in them passing either way.

  That is worth naming precisely, because "it passes in isolation" has been the signature of both machine load AND a defect the suite carried itself, and this repo has been wrong in both directions. Here it is neither: the suites are correct and the timeout was measuring the wrong thing. A test whose claim is "these two endpoints compose" should not also be claiming how many milliseconds that takes on a saturated machine.

  **And one of the four turned out NOT to be the machine.** With the 60s ceiling in place, `connectionServe.test.ts`'s "callback route is NOT mounted" test consumed the entire budget in a full parallel run — 60006ms — while its four siblings in the same file took 82ms, 50ms, 38ms and 1ms. A test that is 700× slower than its neighbours is hanging, not slow, and the raised ceiling is what made that readable: at 5s it looked like every other saturation red.

  The cause is **not** known. It does not reproduce alone (3 runs) or as a whole file (4 runs), which leaves the full-parallel context and nothing more specific. So this does not claim a fix. Every request in that file now carries `AbortSignal.timeout(10_000)`, which turns the next occurrence into a named `TimeoutError` on a specific request instead of an anonymous test timeout that eats a minute of the run and reports nothing — the difference between an observation and a diagnosis.

  Recorded rather than resolved, because "it passes in isolation" has been the signature of both machine load and a real defect in this repo, and this one has not been told apart yet.
- **The 0.23.0 versioning codemod gains its gate test.**

  Same reason as the four before it: `codemodRegistry.test.ts` covers registration, not behaviour, and what a `manual` codemod gets wrong is an `appliesTo` that fires for projects with nothing to do. This note is long and carries a storage-budget warning, which makes a spurious print worse than usual — a long note on an app that is unaffected is the most reliable way to teach someone to stop reading them.

  Four cases, both directions. Verified by breaking the gate: widening `TOUCHES_VERSIONING` to match everything fails exactly the two silent-direction cases.

---

## [0.22.1] — 2026-08-01

### Fixed

- **@voltro/cli** — **Two things a QUERY could not do that a mutation beside it could — and a redirect that answered 500 in dev and 303 in production.**

  ### `useAggregate` in a subscription handler: `Service not found`

  The documented way to read an aggregate from a handler (`useAggregate(def).read(...)`) failed at runtime with `Service not found: @voltro/AggregateRegistry`, on every delivery.

  Both boot paths hand the subscription/query executor a `provideEffect` callback, and each had written its own — smaller — layer set:

  | path | provided to an Effect-returning query | |---|---| | `voltro dev` | store + actionBase (**no** `mergedUserLayer`) | | `voltro serve` | store, and nothing else | | either, handler path | the full set |

  So a query could not `yield*` the aggregate registry, the cache, the kv store, a plugin's service or the app's own `layers:` — while a mutation in the same app could. `useAggregate` was one symptom of the set being wrong, not a bug of its own.

  The cost the reporter measured is worth repeating: a subscription whose delivery fails renders NOTHING, so their working-time card showed `00:00` — no error, no empty state. Silence is the worst failure shape a data path has.

  Both paths now provide the same set, and `serveApi` reaches it through the single helper its handler path already used (it had two, one complete and one not).

  **Why their tests could not see it**, in their words: `@voltro/testing` supplies `aggregateRegistryLayer(...)` and the existing example uses it, so the suite provided exactly what production lacked — *"wenn der Layer im Test nötig ist und in der Laufzeit fehlt, ist er genau der falsche Default."* That is the sharpest line in the report. A harness that hands the code under test something the runtime does not is a second implementation, not a harness. The runtime supplies it now; the layer stays available for tests that genuinely stand alone.

  ### A loader that throws `RedirectError` answers 303 in dev too

  `agent-docs/routing.md` promises a 303 with `Location`. `voltro start` did it; `voltro dev` caught the same throw as a render failure and answered **500 with no `Location`**.

  The divergence was written down as intended — *"each path maps it onto its own convention"* — and recording it is what let it stand. A redirect is CONTROL FLOW. Two renderers that disagree about that disagree about what the app does, and the one they disagreed on is the one every developer and every dev-environment probe hits first. It cost the reporter a rollout: a readiness probe walked a redirecting route, got the 500, and the deployment never became ready.

  One mapper (`loaderControlResponse`) now answers for both, brand-checked rather than `instanceof` because the error crosses a bundle boundary — and since the CLI cannot import `@voltro/web`, a test reads that package's source so a renamed brand cannot silently put dev back to 500.

  `NotFoundError` → 404 comes with it, for the same reason.
- **@voltro/client, @voltro/cli** — **A hot reload no longer takes every page down, and a route that owns the whole origin says so.**

  ### `defineStore` and module re-evaluation

  The duplicate-name guard keyed on the NAME alone and threw. It is right about the danger — two stores sharing a name silently share state — and wrong about one case: a module RE-EVALUATING, which is exactly what a hot reload does.

  A consumer measured the cost on one running dev server:

  | | status | bytes | |---|---|---| | fresh boot | 200 | 43629 | | touch any `*.store.ts` | 500 | 2328 | | three further requests | 500 | — |

  One edit to a store — or to anything importing one — took all 225 of their pages down for the rest of the session, with no self-recovery. Their workaround memoised registrations by name on `globalThis`, which works and costs the thing HMR is for: editing a store's initial state stopped taking effect until a restart.

  Registration is keyed by ORIGIN **and evaluation PASS** now — the calling module's stack frame, without `line:column` so that shifting the call down a line is still the same origin. The same module may redefine its own store and gets the LIVE handle back, so state survives the edit. A DIFFERENT module still throws, and the error now names both files, because "rename one" is only actionable if you know which two to look at.

  Origin alone was not enough, and the existing suite caught it: two `defineStore('x')` calls in ONE file share an origin, so origin-keying merged them — the exact silent state-sharing the guard exists for. A module body runs synchronously, so two registrations in one file land in the same pass; a hot reload re-evaluates in a LATER tick. Same origin AND same pass is a collision; same origin, later pass is a reload.

  (`line:column` would separate those two as well, and was rejected: stripping the position is what lets an edit ABOVE a `defineStore` call shift it down a line without reading as a new origin — and that edit is the common case this is about.)

  When the runtime gives no usable stack, it behaves like a collision rather than a redefinition: if the two cannot be told apart, silently sharing state is the worse outcome, and that is what the guard exists for.

  ### A route whose first segment is dynamic

  `voltro doctor` reports a page route like `[id]/[playerCode]` — its FIRST segment dynamic, so it answers every two-segment URL on that origin, `/api/health` included.

  The pattern is not a bug; that is what it means. It is invisible until something requests such a URL, and then the page renders, its loader runs, and the failure reads as an application error rather than as a route claiming a path nobody meant it to. Advisory, and scoped so it stays readable: `orders/[id]` is not flagged — a dynamic segment under a literal one is bounded by that literal.

  The same report described this as REST routes losing to page segments. They do not compete: `restRoutes` are served by the API server and pages by the web server, on different ports. The three-segment probe paths they adopted worked because the pattern is two-segment, not because precedence changed.

  ### Two items from the same report were already shipped

  Both were measured against 0.20.1 and landed in **0.21.0**, so they need no change — only saying so:

  - `Could not resolve "@voltro/cli/startEntry"` during the start-bundle build → fixed by `resolve @voltro/cli/{start,serve}Entry from the CLI, not the app root`. It is the same class as the `tsx` bare-specifier bug: a specifier for a package the APP never declared is invisible from the app root under strict pnpm. - `voltro schedule run <name>` exists, with `--process`, `--trigger manual|external` and `--url`. Note it POSTs to a mutating inspect endpoint, so it now needs `VOLTRO_INSPECT_WRITE_TOKEN` outside dev.

---

## [0.22.0] — 2026-08-01

### ⚠ BREAKING

- **@voltro/protocol, @voltro/runtime, @voltro/cli** — **`cache: { scope: 'tenant' }` — one entry per org, none shared across orgs.**

  BREAKING only in the "more precise is still breaking" sense: the union `'subject' | 'global'` gained a member. Every scope you already wrote still compiles; what can stop compiling is code that consumes the union EXHAUSTIVELY (a `switch` with an `assertNever`, a `Record<QueryCacheScope, …>`). Almost always framework-internal rather than app code — the codemod is a `manual` note so the compile error is recognised rather than debugged, because a transform cannot tell a switch that wants a `tenant` branch from one whose author should look at the query and decide.

  `scope` took `'subject' | 'global'`, and for an org-wide figure neither fits. `'subject'` recomputes it per PERSON; `'global'` shares one entry across every caller. A consumer put it exactly:

  > `'subject'` rechnet pro Person neu … bei 18 Mitarbeitern also bis zu 18 > identische Berechnungen derselben Zahlen. `'global'` würde über > Mandantengrenzen hinweg teilen. Für Daten, die aus `subject.tenantId` > abgeleitet sind, ist das kein Cache, sondern ein Leck.

  They took the 18 computations of a nine-table statistic rather than write the leak. That was the right call, and it should not have been a call.

  ```ts
  export const last12Months = defineQuery({
    name: 'globalStatistics.last12Months',
    input: Schema.Struct({}),
    output: Stats,
    source: ['invoices', 'employees'],
    cache: { ttl: '5m', scope: 'tenant' },
  })
  ```

  Rubric, now three-way: does the resolved predicate depend on the caller? On the PERSON → `subject`; on their ORG only → `tenant`; on neither → `global`.

  **A caller with no tenant BYPASSES a `'tenant'` cache** rather than falling back. Falling back to `global` is the leak the option exists to avoid; falling back to `subject` silently turns a cache the author sized per org into one sized per person. Note this covers `tenantId: null` as well as absent — an anonymous subject's `tenantId` is `string | null`, so `null` is the shape that actually arrives, and treating only `undefined` as absent would key every tenantless caller under one literal `:t:null` entry.

  **And `scope: 'global'` over a `tenant()`-scoped table is now reported.** The option alone would have left the leak one word away, in a field whose two legal values differ by one word. `cacheScopeLeaks` runs in the discovery both boot paths share — so dev and serve cannot disagree about what a leak is — and rides the same gate as the `.serverOnly()` audit: `voltro dev` warns, `VOLTRO_SERVER_ONLY=strict` refuses. It names every offending table rather than the first, is silent for `'global'` on reference data (the case the option exists for), and is silent for a query with no declared `source`, where it has nothing to reason about and a guess would be a warning nobody can act on.

  **Not a replacement for modelling.** The same consumer moved that statistic to a `defineAggregate` with `tenantId` as an indexed column, which puts the tenant boundary in the DATA rather than in a cache key — better for a rollup, and they say so. `scope: 'tenant'` is for the other case they name: a query that must be FRESH and is merely expensive, where an aggregate's refresh interval is the wrong instrument.
- **@voltro/cli** — **A destructive inspect endpoint now needs its own credential. One token for "list my routes" and "erase this person" was one token too few.**

  `/_voltro/inspect/*` is not read-only. Plugins mount POSTs on it that DO things: `plugin-governance`'s `/erase` is an irreversible GDPR right-to-be-forgotten deletion and `/export` a full personal-data dump; `plugin-storage` mints and revokes object access. Every one of them sat behind the same bearer as reading a route list, so anything that could read the sitemap could erase a person.

  An earlier change in this series made plugins DECLARE `inspect:write` for a mutating endpoint, and shipped with the note that this "governs what a plugin may mount, not who may call it". That was a footnote under a GDPR erasure endpoint, not a fix. This is the half that closes it.

  **A mutating method requires `VOLTRO_INSPECT_WRITE_TOKEN`, sent as the `x-voltro-inspect-write` header ON TOP of the bearer** — an additional factor, not an alternative credential: the read token still has to be correct to get there. GET / HEAD / OPTIONS are unaffected. Unset → refused, with the same posture as the read token: *the absence of a secret is not consent.*

  **`voltro dev` mints it** per project, exactly like the read token, and the dashboard proxy injects it for loopback targets under the same three conditions the bearer already had (loopback only, never over a caller's own header, only when a value exists). So the dev loop is unchanged and no developer handles a secret. **Nothing mints it for `serve` / `start` / a bare harness** — in production a destructive endpoint should take a deliberate act to enable.

  **The design decision worth knowing.** `InspectAuthResolver` gained a REQUIRED `method` parameter. The alternatives were both worse: optional-and-skipped is fail-open at exactly the call site most likely to be added carelessly, and optional-and-refused makes the exported resolver hostile to every legitimate read caller — which is how a security default gets replaced with a custom resolver that does less. A required parameter puts the check in the compiler, and it earned that immediately: `tsc` named **eight** more mount points than the four found by hand, including `pluginInspect.ts` (where the destructive plugin POSTs actually live) and `start.ts` (production).

  BREAKING for a caller of `envTokenAuthResolver` / a custom `InspectAuthResolver` CALL SITE — the second argument is required. A resolver IMPLEMENTATION is unaffected: `(headers) => …` still satisfies the type. The codemod is a `manual` note; a transform cannot know whether a given mount is a read or a write, and guessing on this surface is how the boundary would be lost again.
- **@voltro/database, @voltro/cli, @voltro/devtools-ui** — **An index whose name changed is now renamed, not rebuilt.** The planner gained a `rename-index` operation; where it applies, `voltro db apply` emits one catalog-only statement instead of `DROP INDEX` + `CREATE INDEX`.

  The cost this removes is not hypothetical. Auto-named indexes are `<table>_<column>_idx`, and **no dialect renames an index when the column under it is renamed** — verified on postgres 18, MySQL 8.4 and MariaDB 11.8. So every `.renamedFrom()` column rename, itself a metadata-only operation, dragged a full B-tree rebuild of that column's indexes behind it: on a large table, minutes of IO and — without `CONCURRENTLY` — a write lock. The same probe confirms the replacement is free: postgres reports an unchanged `relfilenode` across the rename, which is the definition of "no rebuild happened".

  It is deliberately narrow, because the cases left out are the ones that cannot be made safe by inspection. **sqlite** has no rename statement, so it keeps drop + create rather than have the plan disagree with what runs. **UNIQUE** indexes are constraint objects whose rename syntax diverges by dialect. **Expression** indexes have no comparable key text (the DB normalises it). And **two same-shaped indexes renamed at once** is ambiguous — nothing says which became which, so both rebuild. A rebuild is slow; renaming the wrong catalog object is worse.

  **Why this is BREAKING for a purely additive change.** Widening a union that the framework PRODUCES breaks every exhaustive `switch` a consumer wrote over it. That is not a theoretical reading — it broke one inside this repo, which is how the second half of this entry was found.

  **Also fixed: the dashboard could not render two operation kinds.** `add-unique-composite` and `drop-unique-composite` were missing from `@voltro/devtools-ui`'s hand-written `OperationKind`, from its `formatOp` switch, and from the CLI's inspect payload — for as long as those ops have existed. Nothing crashed; the row just rendered blank, which reads exactly like a plan that has no such step. The CLI's copy of the union is now DERIVED from the planner's, the renderer's switch is exhaustive by construction (no `default:` arm — that would swallow the next one), and a parity test in `@voltro/database` fails on any divergence in the one copy that genuinely cannot be derived.
- **@voltro/database, @voltro/plugin-notifications, @voltro/plugin-scim, @voltro/plugin-ai-flows, @voltro/cli, @voltro/devtools-ui** — **Ten plugin-owned tables move into the `_voltro_` namespace, and the planner learned to carry a table across instead of dropping it.**

  `plugin-notifications` (`notification_inbox`, `notification_preferences`, `notification_deliveries`, `notification_topic_subscriptions`, `notification_quiet_hours`, `notification_held`), `plugin-scim` (`scim_users`, `scim_groups`) and `plugin-ai-flows` (`ai_flows`, `ai_flow_runs`) registered framework-OWNED tables into the USER's table namespace while every other plugin used `_voltro_*`. An app with a same-named table collided with the framework.

  **Nothing is required of you.** The tables carry their rows across on the next `voltro db apply` — or a `voltro dev` / `voltro serve` boot with auto-migrate — as a catalog-only `ALTER TABLE … RENAME TO`. Run `voltro db plan` first if you want to see it; it prints the renames without touching anything.

  **The blocker was a missing primitive, not the rename.** A rename and a drop+create are structurally identical to a differ — old table gone, new table present — and for a TABLE the difference is all of the data, so the planner had no way to express one and this sat as a known gap. It can now:

  ```ts
  table('_voltro_notes', { id: id({ prefix: 'note' }), body: text() }).renamedFrom('notes')
  ```

  `ALTER TABLE … RENAME TO` is catalog-only on every dialect including sqlite, so unlike `rename-index` this has no dialect gate. Three guards each block a way to destroy data rather than move it, and none of them is silent:

  - **the old name must not still be declared** — an app with its OWN `notes` table keeps it, which is exactly what makes reclaiming a name into `_voltro_` safe for you. A legitimate outcome, so the plan runs and the `create-table` line says why the marker was not applied; - **the target must not already exist live**, and **two tables may not claim one old name** — both REFUSE TO PLAN, because only you can say which table holds the real rows, and the quiet alternative (an empty plan reading "schema up to date" while the old table still holds everything) loses data by inaction.

  A marker whose old table is simply absent is a quiet no-op, so it survives a staged rollout.

  **The half that nearly shipped broken, twice.** Index names are DERIVED (`<table>_<col>_idx`, `<table>_pkey`) and no dialect renames an index when its table is renamed. Diffed raw, a table rename planned as DROP the primary-key index plus re-add it as a plain UNIQUE — which postgres refuses outright, so the rename could never converge. Live index names are now projected through the table rename and the real ones emitted as `rename-index` ops.

  The second half was subtler and postgres-shaped: `<table>_pkey` is NOT a catalog object on four of the five dialects — every introspector fabricates that entry from the table's current name. Emitting a rename for it failed outright (`ERROR 1176: Key 'notes_pkey' doesn't exist`, measured on MySQL 8.4.10), and postgres is the one dialect where the fabricated name happens to be real, so hand-verifying the statement there proved nothing about the rest. It is projected for the diff and emits no DDL at all — after the rename the next introspection fabricates the new name on its own.

  Covered end-to-end against a live database now, not by hand — and on EVERY dialect, not just the one that happened to work. `runDialectParity` gained a `rename-table` scenario that applies a real plan to a real database and asserts the rows survived AND the re-plan is EMPTY; it runs on postgres, mysql, mariadb, sqlite, mssql and turso.

  **Why BREAKING when your code does not change.** Two reasons. Any raw SQL you wrote against those table names by hand — a reporting query, a dashboard view, a `db.raw(...)` — now names a table that does not exist; `tsc` cannot see that, so the codemod prints the list. And `MigrationOperation` gained a `rename-table` kind, which breaks an exhaustive `switch` over it (the same reason `rename-index` was breaking).

  **A `_voltro_` table needs an explicit `id({ prefix })`** — a typeid prefix cannot be derived from a name starting with `_`. All ten already had one; this only matters if you declare your own.

### Added

- **@voltro/database, @voltro/protocol, @voltro/runtime, @voltro/cli, @voltro/plugin-versioning** — **A change event now says WHICH CALL caused it, and `_voltro_row_history` records it.**

  A row diff carries no intent. The same `DELETE` on a join table is a member being removed, a team being deleted, a user being deleted, or a membership expiring — and `before`/`after` cannot tell those apart, because the difference is not in the data.

  A consumer running the audit plugins beside their own hand-written `auditLogs` table put it exactly: `_voltro_row_history` can say *"`userTeams` row X changed"* and show the JSON, and cannot say *"Anna removed Bernd from the Frontend sub-team"*. Their audit UI renders the sentence, so they kept 2900 rows and 300 call sites of their own.

  `ChangeEvent` and `PluginChangeEvent` gained `procedure` — the rpc tag of the call doing the writing — and `_voltro_row_history` gained a column for it. `traceId` says which CALL; this says which call it WAS.

  **It cost one field rather than a hook**, which is the part worth stating. The same consumer asked for a per-table `annotate: (op, before, after, ctx) => …` that would let them write the sentence themselves. That hook as specified cannot produce their example: `subTeamMemberRemoved` is not in the diff either, so an annotator would face the identical problem one layer up. The tag was already resolved at the boundary that establishes write attribution (it is the same string the span is named after), and it was simply not travelling.

  Available for a background write too — a schedule or startup write carries its own procedure with no request identity beside it, and the field is not conditional on its neighbours.

  **Four copies of one shape.** `WriteAttribution` → `attributionFields()` → `ChangeEvent` → `PluginChangeEvent` → `RecordedWrite` → the versioning row's builder AND its reader. `plugin-versioning`'s own source records that this class has bitten it twice already: `traceId` / `subjectId` were added to the bridge and silently dropped, first by the row builder and then by the row reader, and nothing failed either time because a missing optional field reads as an honest absence. `procedureAttribution.test.ts` pins the chain, including that an unattributed event stays byte-identical to one from before the field existed (the key OMITTED, never `procedure: undefined`).

  **Nothing to run.** The new column is additive and framework tables ride the declarative differ on every dialect, so a boot picks it up wherever it picks up your own schema changes.

  Two related asks from the same report are NOT in this change, deliberately:

  - **An actor SNAPSHOT (`{ id, email, name }`) frozen on the audit row.** It would close the "who was this, eight months ago" question, and it collides with the erasure endpoint we also ship: a frozen email retained for years is exactly what a right-to-be-forgotten request must reach. Denormalising PII into an append-only table is a design decision that has to include how `plugin-governance`'s `/erase` finds it again, and shipping the first half alone would hand every user a compliance trap. - **`changedFields` beside `data`.** Cheap and uncontroversial; it wants the previous version at write time, which the writer already fetches for the version number. Left out only to keep this change to one idea.
- **@voltro/cli** — **`voltro doctor` gained an authz scan, and the check it replaces was measured wrong in both directions.**

  A consumer audited a 598-executor app by hand, closed **28** authorization holes, and then classified what `subject-write-no-guard` had said about the same code: it named **10 of the 28**, and **33 of its 68 findings** pointed at handlers that were already correct. A reviewer who spot-checks three findings, sees three correct handlers, and closes the tab has behaved rationally.

  Three things were wrong with the old rule, and each is answered:

  **It could not see the app's own guards.** The guard list was the framework's (`requireScope`, `assertCan`, …). That app's guards were `requireTeamAccess`, `requireRoadmapManageAccess` and friends in its own `lib/`, so every call site read as unguarded — 9 of the 33. The vocabulary is now INFERRED from the app's source (an exported `require*` / `assert*`). Once the vocabulary is known the check can be INVERTED, which is the only form that finds anything: not "this file looks wrong" but "this file references no check at all".

  **It looked only at writes.** One of the 28 was a READ — an executor that took an inquiry id and returned the whole message thread with every participant's name, email, avatar and roles, to any authenticated user in the org. Reported clean, because a read writes nothing. Queries and streams are scanned now.

  **`subject.id`-in-the-write was a proxy for the wrong thing**, and the reason is worth stating because it inverted the incentive: `storeMiddleware` stamps `createdBy` / `updatedBy` from the request subject for any table carrying `audit()`, so a handler on such a table never writes `subject.id` itself. The better an app used the actor mixin, the fewer of its writes the actor check would even look at. 17 of the 28 misses were that shape.

  **An inline ownership check is now reported as informational, not as a hole.** `row.userId !== subject.id → new AccessDeniedError({})` is correct code; it is worth SEEING (only that one file knows the rule) and it is not a finding. That was 24 of the 33.

  **The ratchet is what makes it usable on an existing app.** A first run reporting 221 unreviewed handlers is not actionable, and a check nobody can act on gets switched off. `voltro doctor --write-authz-allowlist` records today's unchecked executors into `voltro-authz-allowlist.txt`; every later run fails only on ADDITIONS. The file says DEBT and not approval, in those words, because an allowlist that reads as sign-off is worse than none. It is keyed by rpc TAG, not path, so moving a file can neither re-open a hole nor hide one — and it is consulted LAST, so an executor that gains a real guard is reported as guarded and its line simply stops mattering.

  Findings are ordered by blast radius (op weight, plus the target table being `tenant()`-scoped or referenced by other tables). With dozens of findings, ordering is what decides whether the first three anyone reads are the ones that matter.

  **And the scan now points at the declarative form.** `guards: [{ action, resourceType, resource }]` answers "may this subject act on THIS row", and an app whose relationships live in its own tables registers its own tuple source instead of copying data into a framework table — which covers the data-dependent membership checks apps hand-roll. The reporting app used it on **0 of 359** mutations, and it is the second app observed to build 100+ imperative checks beside an unused policy engine. That is a discoverability defect, so the message that flags an imperative check is where it gets said.
- **@voltro/protocol, @voltro/cli** — **`internal: true` keeps a procedure off the wire. There was no way to say that.**

  `publicApi` and `exposeAsTool` opt IN to wider surfaces. Nothing opted OUT of the default one: every discovered `*.query.ts` / `*.mutation.ts` / `*.action.ts` / `*.stream.ts` was value-imported into `rpcGroup.generated.ts` and callable over the WebSocket by any authenticated browser session.

  A consumer found what that costs. Their app had grown 18 procedures named `*Internal` — the convention a Convex port carried over for "only other server code calls this". All 18 were in the client group. One of them:

  ```
  auditLogInternal.createFromAction
    input: { actorId, actorType, actorEmail, actorName, resourceType,
             resourceId, eventType, before, after, teamId, … }
  ```

  No guard, every field client-supplied, zero callers. Any authenticated user could write audit rows attributed to anyone. This is the same argument as `.serverOnly()` on a column, one level up: **a naming convention is not a boundary.** If the only thing keeping a procedure off the wire is that nobody wrote a client call for it, it is on the wire.

  ```ts
  export const createFromAction = defineMutation({
    name: 'auditLog.createFromAction',
    input: Schema.Struct({ /* … */ }),
    output: Schema.Void,
    internal: true,          // no client-group entry, no route in dev or serve
  })
  ```

  Server code calls it by importing its executor directly, which is what a server-to-server caller already does.

  **It is honoured on all three paths, and that is the load-bearing part.** The generated client group, `voltro dev`'s rpc group and `voltro serve`'s rpc group are three INDEPENDENT assembly paths. A boundary honoured by two of them is worse than one honoured by none: the docs would say internal, the browser would agree, and production would still route the tag — invisible from outside, in the one place people stop looking once a flag exists. All three now consult one exported predicate (`isWireReachable`), and a source-reading test fails if any assembly site stops consulting it, or if a FOURTH one appears.

  Available on queries, mutations, actions **and streams**. A stream without it would have been a hole in the same boundary; `tsc` caught that omission.

  **Not a substitute for a guard.** An internal procedure still runs with whatever authority its caller has. This removes the wire surface, not the need to check who is asking — `voltro doctor`'s authz scan still covers it.

  `internal` is compared as `!== true`, so a descriptor whose flag is absent, `undefined`, or anything other than exactly `true` stays reachable. An accidental de-routing is an outage, and outages caused by a security flag are how the flag gets reverted.

### Fixed

- **@voltro/workflow** — **The cross-dialect cluster-engine suite poisoned the database it tests against, and got less reliable the more you ran it.**

  `clusterTestSuite.ts` (shipped as the test-only `@voltro/workflow/cluster-suite` subpath) left every run's state in the `cluster_*` tables and nothing removed it. Each scenario names its workflows with a per-run suffix, so two runs never collide — they ACCUMULATE. A later runner then finds messages addressed to `ClusterCron/clusterCron_<oldSuffix>` entity types that no process registers a handler for any more, retries them every 10 seconds forever, and holds a connection each time. Eventually the pool cannot be acquired and whichever scenario happens to be running dies with `SqlError: Failed to acquire connection`.

  Measured on mysql, one file, back to back:

  | after | `cluster_messages` rows | |---|---| | run 1 | 15 | | run 2, with the purge | 15 | | run 2, purge disabled | 30 |

  The failure therefore named the victim and never the cause: the losing file passes perfectly in isolation against a fresh database, so it read as machine load, and the standard response — re-run it — made the next run worse. This is the second producer behind the "rotating victims" this repo had a maintainer note about; the first was a half-provisioned mssql.

  State is purged in `beforeAll`, not `afterAll`, on purpose: a run that crashes cannot clean up after itself, and its leftovers are the likeliest to be there. Same reasoning as the boot path's `reapTestFixtures`. `cluster_migrations` is deliberately left alone — it records the cluster library's installed schema version, and clearing it would make the library re-run migrations it already applied.

  The general lesson, because it is not specific to this fixture: **"it passes in isolation" is a symptom, not a diagnosis.** It is equally consistent with machine load and with shared state the suite itself poisons, and only the second one can be fixed. Ask what a suite LEAVES BEHIND, and count it, before recording a red as environmental.
- **@voltro/cli, @voltro/runtime** — `voltro dev` could stop restarting altogether. After a save, the supervisor printed `file changed — restarting` and then nothing: no new server, the old process still holding the port and the browser's websocket, and no reconnect ever. Only killing the tree by hand recovered it. Reported as "after a change to a backend service nothing ever reconnects again".

  **Two independent missing deadlines, on the same path.**

  1. The supervisor's SIGKILL escalation could never fire. It was guarded by `!proc.killed`, and node sets `killed` as soon as a signal has been successfully **sent** — so it was already `true` on the line after the SIGTERM. The 1.5s grace window was decorative, and the stop waited on the child forever in an uninterruptible release. 2. The child had nothing to escalate against. Installing a SIGTERM listener removes node's default kill, so the only thing that ends the process is the handler reaching `process.exit()` — and it got there via `Promise.all(fibers.map(Fiber.interrupt))` with no bound. One finalizer that never completes (a pool drain against a database that is gone, a wedged plugin `onDeactivate`, a `quit` on a dead socket) and the server ignores SIGTERM outright.

  Both are bounded now, and a child that needs SIGKILL says so (`child ignored SIGTERM — escalated to SIGKILL`) instead of costing every restart the full grace in silence.

  **Shutdown hooks now actually run.** Thirteen teardowns sat on `process.on('beforeExit')` — the CDC detach, the subscriber and reaction detach, the scheduler, the workflow runtime, the retention sweep, trace persistence — and `beforeExit` is not emitted when something calls `process.exit()`, which is how a signalled process ends. A listening server never drains its event loop naturally either, so they had never run at all. Two more were on `process.once('exit')`, which fires but drops async work; both bodies were async. They are all on the signal path now.

  That includes the one users can observe: the `ctx.onShutdown(cb)` callbacks a `*.startup.ts` registers, whose contract says "on SIGTERM / SIGINT". Under `voltro dev` they had never fired.

  **`voltro serve`'s production drain was being truncated.** dev and serve each installed their own SIGINT/SIGTERM listeners next to the runtime's, so two owners raced to call `process.exit` — and the runtime's, registered first during `startRpcServer`, won as soon as the launch fiber interrupted (~40ms, measured). Whatever serve's careful sequence had not reached by then did not happen: plugin deactivate, analytics flush, in-flight request drain, connection pool close. There is one owner now; `onProcessShutdown` is how a boot path contributes teardown to it.
- **@voltro/database, @voltro/cli** — **A framework table that changed SHAPE never reached an existing database, and what `voltro dev` did depended on your dialect.**

  `_voltro_*` tables were stripped from BOTH sides of the boot diff and evolved by a separate emitter instead — `CREATE TABLE IF NOT EXISTS` + `ADD COLUMN IF NOT EXISTS` + `CREATE INDEX IF NOT EXISTS` on **postgres**, and on every other dialect just the CREATEs. Nothing there could change a column's type or nullability on any dialect.

  So the framework could declare a shape for one of its own tables that a boot would never reach — and reach it on postgres while silently skipping it on MariaDB, in a framework whose every other layer is built on dialect parity. 0.21.0 shipped exactly that: `_voltro_api_keys.hashedKey` gained `.maxLength(64)`, and on MariaDB that column's unbounded UNIQUE is what keeps the table out of binlog capture — so the fix for the CDC exclusion was itself excluded, on the dialect where it mattered.

  **The filter was symmetric, and that was the bug.** The reason to exclude framework tables is the DROP direction: a live `_voltro_*` / `cluster_*` table no app declares must not plan as a lossy drop. That reason says nothing about a table we DO declare. It is asymmetric now — undeclared framework table, never dropped; declared framework table, diffs like any other — so framework tables ride the same planner, the same classification and the same applier as user tables, on every dialect. The second, weaker path is gone rather than fixed: patching it would have kept two paths.

  **Nothing to run.** A boot applies framework-table changes wherever it applies your own. No `voltro db apply` step, no dialect-specific instruction.

  Two things worth knowing, because they are how a half-finished version of this looked correct:

  - the live filter existed in TWO places — the plan, and the convergence RE-PLAN `applyPlan` runs before it records a fingerprint. Widening only the first made the re-plan see a declared set full of framework tables against a live set with none, so it proposed `create-table` for all four and `applyPlan` correctly refused. The framework's own convergence proof caught it; there is one filter now. - the fingerprint fast-path now covers framework tables too, so a release that changes only one of them invalidates it instead of being skipped. That re-fingerprints once and self-heals on the next apply.

  **Why the rule said otherwise.** The codemod exemption for DB tables rested on a test whose every assertion exercises `planMigrations` — which does see framework tables — and whose header concluded that a `voltro dev` boot reconciles them. Evidence about the planner, conclusion about the boot path. Its parenthetical gave it away: "(add the column, add the table)" are exactly the two cases the old emitter could do, one of them on postgres only. Both are covered now, and `sql-postgres` / `sql-mysql` carry `frameworkTableEvolution.*.integration.test.ts` — boot a real database on both dialects, assert the column changed, assert the next boot has nothing to do, and assert an undeclared `cluster_*` table is still never dropped.

  An earlier revision of this change shipped a boot WARNING (`frameworkShapeGap`) naming the work the additive emitter could not do. It is deleted. It was the right answer to the wrong problem — it described the divergence rather than removing it, and told MariaDB users to run a command postgres users did not need.
- **@voltro/cli, @voltro/plugin-governance, @voltro/plugin-storage, @voltro/plugin-billing, @voltro/plugin-mail, @voltro/plugin-moderation, @voltro/plugin-search** — **A plugin mounting a destructive inspect endpoint declared `inspect:read`, and nothing checked.**

  `inspectEndpoints` mapped to `inspect:read` in the boot permission audit — one hook, one permission, regardless of what the plugin actually mounted. The permission is named "read" and the endpoints did not have to be. Measured across the shipped plugins after a consumer noticed it from the outside: seven mount a non-GET inspect endpoint, and **six declared `inspect:read` alone**. Among those endpoints are `plugin-governance`'s `/erase` — an irreversible GDPR right-to-be-forgotten deletion — and `/export`, a full personal-data dump, plus `plugin-storage`'s `/share` and `/revoke`.

  `inspect:write` already existed as a permission, and `plugin-flags` already declared it. So the convention was right and simply unenforced — the "declaration nobody checks" shape.

  The requirement is DERIVED from what the plugin mounts now: any endpoint whose method is not GET / HEAD / OPTIONS requires `inspect:write`, and the boot audit names the offending `METHOD /path` so the fix is not a guess. Adding a POST to a plugin panel forces the declaration at boot, for every future plugin too. This is the same shape `extendSchema` already had, where one contract field ships two distinct capabilities.

  The six plugins are corrected. A source-level test asserts the shipped set keeps passing its own rule — the check that would have caught this originally, since it was found by a consumer rather than by us.

  **Scope, stated plainly:** this governs what a PLUGIN may mount, not who may call it. Those endpoints still sit behind the same single inspect token as reading a route list. Treat that token as an admin credential. The inspect docs said "read-only introspection surface" and now say what is actually there.
- **@voltro/database, @voltro/voltro** — A `text().maxLength(n)` **narrowing** could not be applied. `db plan` counted it under `blocked`, and every route refused: `db apply`, `db apply --force`, `migrate`, and `VOLTRO_DESTRUCTIVE_OK` (which only relaxes `lossy`). There was no acknowledgement flag anywhere.

  0.21.0's own change log said the opposite — *"narrowing is deliberately NOT blocked — blocking it would leave the remedy just as unusable as the silence did"* — and the code refused every route. So the feature that was supposed to make the MariaDB hash-long-unique remedy usable made it **visible** without making it **applicable**, which is a smaller step than it reads.

  **The cause is one argument.** `mkPlanned`'s fourth parameter is the refuse-marker, and I used it to attach the count query as a hint. The field's own doc comment says it is for ops "with no resolution" — a narrowing that ships the query which resolves it is the opposite of that. The query now rides in the `reason`, where the CLI already prints it, and the operation is `needs-backfill` and appliable.

  Unblocked, the failure mode is the honest one the message already describes: if a value IS longer than the new bound, the database rejects the ALTER and the migration fails loudly. Better than a gate that cannot be opened.

  Reported by a consumer who had run all four of their check queries first — 2900 rows on one column, 46 on another, **zero** offending values, fixed-width trace ids and SHA-256 digests sitting exactly at their bound — and then found no way to say so. Their data was provably safe and the tool still refused.

  No test pinned `blocked`, which is why this shipped: the CLASSIFICATION was right the whole time, so reading it alone showed nothing wrong. There is now an assertion on the flag itself, verified red against the 0.21.0 behaviour.
- **@voltro/database, @voltro/runtime, @voltro/cli** — **Two places where the framework had documented a shortcoming instead of removing it.**

  ### The framework bootstrap is one statement kind again

  `emitFrameworkBootstrapSql` also emitted `ALTER TABLE … ADD COLUMN IF NOT EXISTS` and `CREATE INDEX IF NOT EXISTS`, and the ADD COLUMN half was **postgres-only**, because that is the dialect with the syntax. While framework tables were filtered out of the boot planner, this was their only evolution path — so a release that added a framework column reached a postgres user's database on boot and a MariaDB user's never. One `voltro dev`, two behaviours, decided by the driver.

  The planner owns framework-table evolution now, so those steps were not merely redundant: they were a second, weaker path. And the index step actively hurt — it ran BEFORE the planner could add a column, so an index over a newly-added column failed the boot (pg 42703) instead of waiting one step. The ADD COLUMN step existed to paper over exactly that ordering, which is a good sign the ordering was wrong.

  What remains is `CREATE TABLE IF NOT EXISTS` (plus enum types, cyclic FKs and reactive triggers, which the planner does not manage). It exists for one reason: `applyPlan` records into `_voltro_migration_plans`, so that table has to exist first. It is dialect-uniform, because nothing is left in it that only one dialect can express.

  Its tests asserted the opposite — one was literally named *"non-postgres path delegates to plain emitSchemaSql (no ALTER evolution)"*, recording the divergence as intended behaviour. They now assert the same DDL SHAPE on all five dialects.

  ### A guard and its executor share one query

  A relationship guard (`guards: [{ action, resourceType, resource }]`) is answered by the app's registered `TupleSource`, and for any real policy that means loading something — the draft whose `teamId` decides access, the membership row. Then the executor loads the same row for the actual work. Two queries for one row, on every guarded call.

  A consumer proposed a new guard form (`resolve(...)` then `check(row => …)`) so the framework would hand the loaded row down. The framework already had the answer: a request-scoped, batching, caching loader that the executor uses. The tuple source just could not reach it — its signature was fixed at boot, several layers above the request.

  `TupleSource` now receives `load`, the REQUEST's loader — the same one `ctx.load` gives the executor. Reading through it makes the second read free. Measured rather than asserted: one query with the shared loader, two without, with the "without" case kept as a control so the claim stays falsifiable.

  `load` is `undefined` outside a request — a boot seed, a schedule tick, a plugin's startup hook. That is a real answer and stays one; a source must fall back to its own query there rather than assume a cache with no lifetime.

  Wired in `makeAppContextBuilder`, the single builder both boot paths call, so dev and serve cannot drift on it. It is an async-local rather than a parameter for the same reason `writeAttribution` is one: guard evaluation runs through `checkGuardsEffect` in browser-safe `@voltro/protocol`, which must not learn a runtime loader type, and only one of its three call sites has a request context in scope. The isolation property is pinned by a test with two concurrent requests — a process-wide cache here would be a tenant-isolation bug, not a performance detail.

  Not breaking: a `TupleSource` implementation that destructures the fields it already used keeps compiling.

---

## [0.21.0] — 2026-07-31

### ⚠ BREAKING

- **@voltro/database, @voltro/runtime, @voltro/workflow, @voltro/voltro** — `text().maxLength(n)` on an EXISTING column now actually applies. It planned zero operations and reported "schema is up to date" while the live column stayed `longtext` — the documented remedy for MariaDB's hash long-unique was a silent no-op, which is worse than no remedy because you stop looking.

  **The differ was not comparing lengths wrongly — it could not see them.** `maxLength` was absent from the schema snapshot entirely: it lived on the column definition, was read only when rendering CREATE DDL, and never reached the comparison. A consumer pinned the mechanism with a contrast: a column bounded AT CREATION was `varchar(64)` (DDL path, fine); one bounded afterwards stayed `longtext` (diff path, blind).

  It is now carried on both sides — declared from the definition, live from introspection — compared as its own dimension, and rendered by the applier from the declared snapshot (a bare `text` tag would emit DDL that applies successfully and changes nothing, the silent no-op the applier's convergence check exists to catch).

  **Three guards against the real risk, which is not missing a change but re-emitting one forever:**

  - Only the VARCHAR family contributes a live length. MariaDB reports `character_maximum_length = 4294967295` for `longtext` and 65535 for `text`; postgres reports NULL. Reading a type's theoretical maximum would make a declared `text()` differ from its own live column on every boot. - Only `text()` columns are compared. `id()` renders as `VARCHAR(64)` on mysql/mariadb while its declaration carries no length — measured on live MariaDB while building this, and it would have emitted an ALTER for every id column, forever. - Never on sqlite (no length-enforced type), and never when the caller omitted the dialect — an unknown dialect behaves exactly as before this field existed.

  Proven by round-trip suites against live MariaDB and live Postgres: the same schema re-plans to NOTHING, a length change produces exactly one operation on the right column, applying it lands the new width, and re-planning after that is clean.

  Classification follows the nullability precedent: **widening is `safe`** (no data can be lost), **narrowing is `needs-backfill`** and says so, with the count query to run first. Narrowing is deliberately not blocked — blocking it would leave the remedy just as unusable as the silence did.

  **Also: `_voltro_api_keys.hashedKey` is now bounded at 64**, since the value is `sha256Hex(token)` and narrowing it can never fail. The other four unbounded unique columns in framework tables are deliberately left alone, each with the reason at the column: `_voltro_kv.key` is the caller's own key, `idempotencyKey` comes from a user-supplied function, and the two workflow `executionId`s have no shape the framework guarantees. A narrowing ALTER that fails on existing data during a framework upgrade is a worse outcome than the index-size concern it would fix — and the MariaDB hash long-unique is harmless on those tables anyway, since `_voltro_*` is filtered out of the binlog reader's include list.

  **Migration** — `voltro update` prints it (`0.21.0/01_maxlength-now-migrates`, `manual`, and it fires only for projects that declare a bound). Your source does not change; every `.maxLength(n)` already written keeps compiling. What changes is that the next `voltro db apply` — or a `voltro dev` / `voltro serve` boot with auto-migrate — emits ALTERs it used to skip. Run `voltro db plan` first: it prints exactly which columns would be altered without touching anything, and an empty plan means this does not affect you. Widening is `safe` and can simply run; narrowing is `needs-backfill` and the plan carries the count query to run before it.

  *Why this is `BREAKING` and not `Fixed`: the API is compatible — nothing is removed, renamed or narrowed, and the same call compiles. But an upgrade now performs DDL against YOUR tables that the previous version silently skipped, and on MariaDB `longtext → varchar(n)` is a full table rebuild that locks. The DB-changes-need-no-codemod exemption is written for `_voltro_*` tables riding the differ; this reaches user tables, so the operator deserves the warning at `voltro update` time rather than in a changelog section they may never open.*

### Added

- **@voltro/cli, @voltro/voltro** — **`voltro schedule run <name>`** fires one scheduled job on demand, against `voltro dev` or `voltro serve`.

  Asked for by a consumer whose nightly jobs correct business data and whose workaround was: edit the cron expression to a minute out, wait for the reload, put it back. "Run it once now and watch" is a normal thing to want.

  **Most of it already existed, and that is why it took a measurement to find the gap.** `SchedulerHandle.fireNow` has been there, and so has `POST /_voltro/inspect/schedules/:name/fire`. What was missing was the way in: no CLI verb, and — the part that mattered — **`voltro serve` mounted no inspect surface at all**. Every `/_voltro/inspect/*` route existed only under `voltro dev`, which is the one place a nightly data-correcting job is not running.

  So production now mounts it. Two things make that safe rather than a new attack surface, and both were checked rather than assumed:

  - `handleInspectRequest` is **closed by default**: with no `VOLTRO_INSPECT_TOKEN` every request is 401 carrying the remedy, the compare is constant-time, and the token is never minted outside dev. Opening it is an operator's deliberate act. - Only the handlers production can answer TRUTHFULLY are wired. Everything else stays absent and replies "not configured" — an empty array would claim the app has no procedures. `inspectSchedules` is deliberately still absent: dev computes `nextFiringAt` and the EFFECTIVE coordination from its own boot closure, and reporting a guessed coordination mode to a post-deploy gate is worse than reporting nothing.

  The manifest's `rpc` / `workflows` entries come from a builder both boot paths now share (`inspectEntries.ts`). They are the same facts on both sides, and a second hand-written copy of the descriptor→entry mapping is the shape this repo keeps paying for.

  **A run id of `null` is reported as its own outcome**, not as success: the run was coordinated away — another replica holds the lock, or `onOverlap: 'skip'` found the previous run still going. Printing "ok" would claim work that never started.

  **Also fixed, and it affects every command in the inspect family.** `fetchJson` collapsed a non-2xx into the body's `error` field alone, discarding `message`. The surface answers `{ error: <category>, message: <what happened> }`, so firing an unknown schedule printed "fire failed" while the server had said `scheduler.fireNow: unknown schedule "…"`, and a closed surface printed "unauthorized" while the body named the missing env var. It now prefers `message`, then `reason`, then `error` — fixed in the shared fetch rather than per command.

  Verified end to end against `voltro-starter/apps/v-api-durable`: the fire returns a run id and the job's own output appears in the server log; an unknown name reports the server's reason; `--format json` round-trips.

### Fixed

- **@voltro/database, @voltro/sql-postgres, @voltro/sql-mysql, @voltro/sql-sqlite, @voltro/sql-mssql, @voltro/voltro** — <!-- apiSurface: compatible — reasoned, not rubber-stamped. `attributionFields()` gained an OPTIONAL parameter and `withCapturedAttribution` is new. Both are additive, and it was CHECKED rather than assumed, because this repo has already paid once for a narrowing that read as additive: a zero-arg call still compiles, the value is still assignable to the old `() => …` type, and it still passes as a callback typed with the old shape. All three probed under `--strict`. Nothing was removed. -->

  Write attribution is now CARRIED down the write path instead of re-read from the ambient async-local scope, so a connection-pool handoff can no longer strand a write's `traceId` / `subjectId`.

  `routeEvent` read the identity with `attributionFields()`, and it runs after `await runPromise(...)`. `AsyncLocalStorage` propagates through continuations the current context CREATES; one scheduled by ANOTHER context — exactly what a pool handoff does when an acquisition queues — resumes with that other context's store. So under contention the write landed with the identity absent. Absent is a LEGAL value there meaning "no request behind this write", so the result does not look like a defect: it looks like a schedule. In a compliance trail that asymmetry is the whole problem, and it is why this is closed structurally rather than left as unlikely.

  The value is captured ONCE at each public store method, before any await (`withCapturedAttribution`), and threaded explicitly through every `execute*` and into `routeEvent` — in all four dialect stores, including the transactional view, which is the path every framework mutation takes. The ambient scope is kept as a FALLBACK: a site that has not been threaded behaves exactly as before rather than worse, which is what made the change verifiable site by site.

  **On what is and is not proven.** A pool handoff cannot be reproduced deterministically from a test — the resume context is the driver's choice. Two attempts are worth recording because both produced misleading green: a load-based regression test passed in isolation and failed in the full gate twice (a coin flip that also blocked releases), and a "deterministic" replacement that left the scope before the write finished PASSED against the unthreaded store, because the store re-enters its own scope internally. It proved nothing while looking like proof, so it was deleted.

  What is proven: the pure semantics (`writeAttributionCapture.test.ts` — explicit wins over the ambient scope, explicit wins over ANOTHER request's scope, "no request" stays "no request", keys omitted rather than `undefined`), and the threading itself (`attributionThreadingParity.test.ts` — every store accepts and uses the carried value, no store still calls a bare `attributionFields()`, every transactional view carries it). The guarantee is structural, and it is stated that way rather than dressed up as a reproduction.

  Raised by a consumer who could NOT reproduce the loss across 2700 writes at 96-way concurrency with every core saturated, and who asked for the fix anyway on the right grounds: *"impossible beats unlikely when the failure is invisible."*
- **@voltro/database, @voltro/sql-postgres, @voltro/sql-mysql, @voltro/voltro** — <!-- apiSurface: compatible — `beginLocalWrite`, `endLocalWrite` and `resolveEchoAttribution` are new exports on `@voltro/database`; nothing was removed, renamed or narrowed. Checked rather than assumed, because this repo has already paid once for a narrowing that read as additive. -->

  Under CDC, a plain `store.insert(...)` could deliver its change with `traceId` and `subjectId` ABSENT — not always, and more often the busier the process.

  **The registration was racing the echo, and only winning on a margin.** The write path registers the request identity so the transport echo (a postgres NOTIFY, a mysql binlog row) can be re-united with it, and `pendingAttribution.ts` stated the ordering as a guarantee: the registration happens "BEFORE the transport can possibly echo it, because a NOTIFY fires at COMMIT". That is true INSIDE a transaction, where `routeEvent` runs before COMMIT. It is false for a plain write, whose statement commits ITSELF — the trigger fires while the write path is still awaiting the driver, and the registration lands afterwards. The echo has to make a round trip through the LISTEN/binlog connection, and that round trip was the only thing keeping the order right.

  **Why it stayed hidden.** An unattributed event is a LEGAL event meaning "no request behind this write", so a lost identity is indistinguishable from a background job — there is no error, no warning, and nothing that looks wrong in a trace. It surfaced as one failing assertion in the full test suite, which is the only place this machine is loaded enough to flip the order, and it passed on every isolated re-run.

  **Closed by construction, not by widening the margin.** A store now brackets each non-transactional write (`beginLocalWrite` / `endLocalWrite`) and delivers echoes through `resolveEchoAttribution`. An echo arriving while a write to that table is mid-registration is HELD until that write has had its chance, then answered. Held echoes keep arrival order — a subscriber seeing an update before the insert it updates would be worse off than one missing a `traceId` — and a write that never registers releases the table after a bound rather than parking its stream.

  Three narrowings, each deliberate:

  - **Only under `cdc`.** Nothing is injected in `inline` mode, so there is no echo to order against. - **Only non-transactional writes.** `transactional()` registers before COMMIT already; bracketing it would hold OTHER replicas' echoes for the length of the transaction to fix a race that path does not have. Asserted, so the narrowing is on the record rather than something a later reader "fixes". - **A remote write is still never attributed.** The barrier may delay an answer; it must never invent one. Nothing is registered locally for another replica's write, and that stays true while echoes are held.

  **Both CDC stores had a hand-written copy of the claim-and-emit tail**, which is the shape of the previous three attribution defects in this package. The decision now lives in one function both call, and `echoBarrierParity.test.ts` fails if either store claims for itself, leaves a public write outside the bracket, or brackets the transactional path. The ordering itself is proven against the primitive (`pendingAttribution.test.ts`) — a live suite cannot force it, because the resume point is the driver's choice, and the four barrier tests were verified to go red against the pre-fix behaviour before being trusted.

  Also fixed: `mysql`'s `updateMany` / `deleteMany` route their events after their transaction commits, so they carried the same race despite not going through the per-row write path. They are bracketed too.
- **@voltro/cli, @voltro/voltro** — `voltro build` could fail to build the **start bundle** with `Could not resolve "@voltro/cli/startEntry"`, degrading `voltro start` to the slower per-module boot. The serve bundle carried the identical latent failure.

  Both bundles generate an entry importing a narrow CLI export and build it with `absWorkingDir: <app root>` — so esbuild resolved that bare specifier from the APP's `node_modules`. Under strict pnpm the app has `@voltro/cli` there only if it DECLARES it, and an app normally depends on `voltro` / `@voltro/web` and gets the CLI transitively. The CLI now resolves its own entry from `import.meta.url` and hands esbuild an absolute alias.

  Same shape as the tsx bug (`tsxLoader.ts`): a package that is OUR dependency, resolved from the user's directory, invisible under strict pnpm. Same answer — the CLI knows where it lives, so it stops asking the app.

  **The published export was not the problem.** It is present in the tarball — checked against the real 0.20.1 and 0.20.2 packages, `./startEntry` → `./dist/startEntry.js`, file included. Only resolution failed, which is why re-adding the export would have changed nothing.

  This monorepo hoists everything, so the bare specifier resolves here and the build passes with or without the alias. The test therefore asserts the alias directly rather than inferring it from a green build — the hoisted layout is exactly what hid the strict-pnpm failure in the first place.

  **Also pinned, after a wrong turn worth recording.** A consumer reported that the framework provides no Suspense boundary, so any suspend during SSR throws. The obvious repair — a root `<Suspense>` — was implemented, measured, and REVERTED:

  - With a root boundary, a page that THROWS answered **200** with `<template data-msg="Switched to client rendering">`. React downgrades an errored boundary to client rendering, which silently undid the hard failure shipped moments earlier. - Without one, a suspending page renders fine anyway: `renderToPipeableStream` treats the root as an implicit boundary, so a suspend delays the shell flush rather than failing.

  So the reported problem does not exist on the streaming path, and the obvious fix for it breaks something that does. Both halves are now fixtures with assertions side by side (`ssr-suspends` must render, `ssr-throws` must 500) — adding a root boundary flips the second, and that pair is what makes it visible instead of shipping it. Docs corrected in both languages, including the two places a suspend genuinely is unsupported (`renderToString` behind static prerender, and the client render), neither of which is the framework's choice.
- **@voltro/cli, @voltro/voltro** — <!-- apiSurface: compatible — `CliRuntime` keeps its exported signature and its behaviour (NodeContext + logger); only `runCli`'s internal composition changed. `loggerConfig` is module-private. -->

  Every one-shot `voltro` command printed each log line TWICE. Measured on `voltro agents-md`: 76 lines for 38 events.

  `runCli` installed two loggers. It provided `CliRuntime` (which contains a `LoggerLayer`) to the program, then provided a second `LoggerLayer` around the `matchCauseEffect` wrapping it — so the program ran inside both. Two `LoggerLayer`s in one fiber do not compose the way the name suggests: `LoggerLayer` is `Logger.replace(Logger.defaultLogger, …)`, which removes the DEFAULT logger and adds its own. The second one finds no default left to remove, the removal is a no-op, and the add still happens. Replace composes as replace only against the default — never against another replace.

  The duplicates were distinguishable only because the outer layer was built without the command's `defaultScope`, so half the output carried `scope` and half did not. Had both been configured identically the output would have been byte-identical pairs, which is a good deal harder to notice than a stray field.

  **The failure branch is the half that survives a partial fix**, and it did. Moving the second layer from around `matchCauseEffect` onto the error handler repairs the success path and leaves the failure path doubling, because that branch runs while the program's scope is still open and INHERITS its logger. Verified by measuring three shapes rather than reasoning about scopes: handler-provides → 2 lines, handler-inherits → 1, provide-once-outermost → 1.

  The last is what shipped. The logger is a FiberRef, not a service the program requires, so it is provided ONCE at the outermost boundary and the program gets only `NodeContext`. That covers both branches by construction, rather than by the handler happening to still be inside a scope that has not closed yet. `CliRuntime` is unchanged and still used by `runCliMain`, which provides it once and never had the problem.

  Guarded by `cliRuntime.test.ts`, which asserts the COUNT (any second provision doubles it regardless of what it logs) and that every line carries the command scope. It was checked against the reverted fix in both of its shapes before being trusted. Its capture spies on stdout AND stderr, deliberately: diagnostics route to stderr by level, and a stdout-only capture reported zero lines for the failure path — reading as "nothing was logged" when the truth was "logged on the other stream", which had the test accusing the fix it was written to protect.
- **@voltro/cli, @voltro/voltro** — Three corrections to the `db drift` baseline shipped in 0.20.2, all reported by the consumer who verified the fix — and all of them defects in that fix rather than in older code.

  **1. The first `db drift` after upgrading CRASHED.** `liveFingerprint` is a new column and does not exist until a `db apply` adds it, so naming it in the ledger read died on `SqlError: Failed to execute statement` instead of reaching the "no baseline yet" branch written for exactly that moment. The documented sequence was `0 → apply → clean`; the real one was `crash → apply → clean`, with the crash landing in the first CI run after an upgrade. Proven by dropping and re-adding the column: absent → exit 1 and a driver error, present → exit 0 and the honest message. The read is now `SELECT *`, which cannot go stale against an older ledger.

  **2. A no-op `db apply` established no baseline.** The baseline is written per applied plan row, so an already-current schema produced none — and "cannot compare" then persisted indefinitely rather than for one run, for any app whose schema was current when it upgraded. A no-op apply now backfills the latest row's `liveFingerprint` instead of inserting a history entry for a migration that did not happen.

  **3. "No baseline" gets its own exit code: 3.** Previously it exited 0, so a CI gate could not distinguish "compared and matched" from "did not compare" — and on a stable schema the second could persist forever. The consumer named that as their reason for NOT adding a drift gate: it would pass vacuously, which is the failure this whole thread is about. Now `0` = matched, `3` = no baseline, `4` = diverged.

  **And the SQL-error reporter added in the same release did not work on this path.** It walked `.cause`, and an `Effect` `FiberFailure` has none — its cause hides behind `Symbol(effect/Runtime/FiberFailure/Cause)`, with only `stack`, `message` and `name` as own keys. So the helper returned `undefined` and not even its "no statement attached" fallback fired. The consumer reproduced that against an empty database and checked field by field; all absent.

  The reason the tests missed it is worth recording: every fixture was a hand-built object WITH a `.cause` — the shape assumed, not the shape the runtime produces. The suite now builds a real `FiberFailure` through `Effect.runPromise`, and the walk unwraps the symbol and flattens the `Cause` tree (`Fail`/`Die`/`Sequential`).

  Their note on the irony is fair and is the reason this is one entry rather than two: defect 1 above IS the "next DB-shaped error in your CI" that the reporter existed to make readable, and it arrived as a bare wrapper plus a driver stack. With the statement printed it would have named the missing column immediately.

  **A FOURTH copy of both defects was found in `voltro dev`'s migrations inspect endpoint, and it was the worst one.** It carried the same postgres-only `::text` casts, wrapped in `orElseSucceed(() => [])` — so on every non-postgres dialect the syntax error became an EMPTY history rather than a failure: the devtools migrations panel showed nothing, and with no history row the drift verdict came out `false`. A silent, permanent "no drift" on every mysql/mariadb/mssql/sqlite app. It also compared the declared hash against a live one, exactly like the CLI did.

  Both are fixed there too, and `ledgerReadPortability.test.ts` now fails if any query touching `_voltro_migration_plans` grows a `::type` cast again. Three copies were fixed in one change and the fourth was missed in the same change, which is the argument for the test rather than another paragraph in a maintainer note.
- **@voltro/cli, @voltro/voltro** — `voltro dev` served a client-only shell — with a **200** — whenever a `renderMode: 'ssr'` page failed to render on the server. It now answers 500 with the cause, exactly as `voltro start` does.

  **Reported as "voltro dev does not SSR". It does**, and has since before 0.20.0 — the middleware, its intent stated in a comment ("mirroring what `voltro start` does in production"), is an ancestor of every 0.20.x tag. What the reporter saw was the masking: their pages suspended during the server render (a lazily-loaded i18n catalog above any Suspense boundary), all 225 degraded to Vite's SPA shell, and an empty `<div id="root">` is indistinguishable from a framework that never server-renders. Their conclusion was the only one the evidence supported.

  **The 200 is the part that mattered.** The same render is a hard 500 under `voltro start`, so those pages were down in production while dev reported success — the inverse of the usual "works in dev, breaks in prod", and worse, because nothing prompts you to look. Measured before the fix: `HTTP 200`, no `x-voltro-rendered-by` header at all, and the thrown error present in the dev log but nowhere in the response.

  Three sites did this (both streaming `onShellError` handlers and the outer catch); all three now fail through one helper. The pattern was already in the file — the `isDeferralNotSupported` branch refuses rather than degrades and says why in the same words ("falling through would leave the developer with an unstyled page and a log line, which is exactly the silent degradation the hard error exists to prevent"). One of the removed fallbacks sat directly under a comment stating that falling through would mask the bug.

  Dev puts the cause and stack in the response body; production keeps its bare `server error`, so a stack never reaches a public response. That is a difference in what the failure says, never in whether it fails.

  **Also fixed, found while reproducing it: a page added while `voltro dev` runs was never server-rendered.** The middleware matched against a route table built once at boot, so a new page missed matching entirely and returned before its module was ever loaded — Vite's SPA shell, 200, and *no log line at all*, because the middleware never ran. The page tree the middleware reads is now refreshed by the same regeneration that rewrites the route table (`dirs` too, or a layout added after boot would be invisible to the spa-shell decision).

  Both are covered by `webDevSsrLayoutLoader.test.ts` against a real dev server: `/ssr-throws` must answer 500 + `x-voltro-rendered-by: ssr-dev-failed` + the cause and must never contain the empty shell, and a page written while the server runs must be server-rendered without a restart. Each assertion was verified red against its own reverted fix — separately, because the first failure aborts the test and would have left the second unproven.

  One thing this does NOT change: the framework still provides a Suspense boundary only for its own deferral (`<Await>`), not a blanket one at the root. Code that suspends outside it needs a boundary you mount yourself. That is now documented next to the failure behaviour, since a hard 500 is how you will meet it.

### Internal (no consumer-facing effect)

- **@voltro/cli** — Maintainer notes only — no shipped behaviour changes.

  `mssqlClusterPatch.ts`'s header said the `@effect/cluster` patch covers "two mssql-only bugs" (it is four: the `deliver_at` INT-overflow, the MERGE…OUTPUT with correlated sub-SELECTs, `FOR UPDATE`, and `USING (SELECT * FROM (VALUES …))`) and implied that a version bump needs nothing but a re-key, because 0.59.0 → 0.60.0 happened to apply unchanged. On 0.60.2 the same patch fails on 3 of its 6 files — upstream refactored `SqlMessageStorage` and moved the context the hunks match on. A bump can require REGENERATING the patch.

  The regeneration recipe now lives in `packages/sql-mssql/CLAUDE.md`, together with the two measurements that produced confidently wrong answers while working this out: reading an already-patched `node_modules/.pnpm/*patch_hash=*` copy and concluding upstream had fixed it, and un-patching one of the several installed copies and concluding the patch was not load-bearing. Both look like evidence.

  Also recorded there: verify by BREAKING it. `git apply --check` proves the patch lands, not that it still fixes anything. Un-patched, the mssql cluster suite fails with `Incorrect syntax near ')'`; patched, 5/5 against the live fixture.

---

## [0.20.2] — 2026-07-30

### Fixed

- **@voltro/database, @voltro/cli, @voltro/voltro** — `voltro db drift` could never report clean. It compared the LIVE schema's fingerprint against `_voltro_migration_plans.fingerprint` — which is the **declared** snapshot's hash. The two are not comparable: introspection cannot recover everything a declaration carries (generated expressions, `maxLength`, sensitivity markers), so hashing a live snapshot never equals hashing the declaration it came from. The command was therefore RED on a provably clean database, permanently.

  Fixing the postgres-only cast in the previous release is what made this visible — before that, `db drift` crashed before it could compute a wrong answer.

  A consumer measured it precisely: three different fingerprints for one database (`apply` recorded `b1078b73…`, `drift` computed `8dcc9c5e…`, `plan` computed `0c376108…`), stable across runs, with `db plan` reporting 0 operations in between.

  **The fix is a second, comparable baseline.** `_voltro_migration_plans` gains `liveFingerprint` — the post-apply LIVE fingerprint, taken from the convergence re-plan's `fromFingerprint` (that re-plan runs AFTER the apply, so its "pre-state" is our post-state; it is already computed, so this costs nothing). `db drift` compares against that, and hashes the live snapshot WHOLE, exactly as the applier did.

  It used to strip `_voltro_*` tables before hashing, which sounds reasonable and was half the incomparability. A framework upgrade that adds a `_voltro_*` column will now show as drift until the next `db apply` records a new baseline — honest, since the live schema did change, and it self-heals on the apply an upgrade needs anyway.

  Rows written before the column exists have no baseline. `db drift` says so and exits 0, instead of comparing a live hash against a declared one and calling the difference drift. `_voltro_*` table changes ride the declarative differ, so no codemod.

  **The "Probable causes" list is gone.** It named out-of-band DDL and a missing ledger row, and the consumer hit it with neither being true — the row was right there in `db plans`. A diagnosis that asserts a cause it cannot know is the same defect as the `ALTER TABLE FORCE` repair line retracted in the same release, and it costs more here because it is confident: it sends the reader hunting through somebody's shell history. The command now says what it can actually see — that the schema changed, not what or who — and points at `db plan` for the real difference.

  Worth recording what the pair of defects cost together, in the consumer's framing: `db drift` exists to catch a divergence between declared and live, and the one real divergence they have (`sessions.tokenHash` declared `varchar(64)`, live `longtext`) is invisible to it — while it loudly reported a divergence that did not exist. False negative on the real thing, false positive on nothing. The false negative is the still-open `maxLength`-in-the-snapshot item.

---

## [0.20.1] — 2026-07-30

### Changed

- **@voltro/database, @voltro/runtime, @voltro/plugin-versioning, @voltro/plugin-presence, @voltro/voltro** — Five framework-table indexes were holding GENERIC names in a namespace that is shared with your tables. Index names are unique per SCHEMA on every supported dialect, so `_voltro_row_history.index('byTrace')` reserved `byTrace` for the whole database — and `byTrace` is the first thing anyone reaches for when indexing a `traceId`. A consumer added `traceId` to their own audit table, indexed it the obvious way, and collided with ours; the framework's own error message even suggested renaming the framework's index as the fix.

  Renamed: `_voltro_row_history` `byTrace` → `byRowHistoryTrace`, `bySubject` → `byRowHistorySubject`; `_voltro_api_keys` `byTenant` → `byApiKeyTenant`; `_voltro_presence` `byChannel` → `byPresenceChannel`; `_voltro_connections` `bySubject` → `byConnectionSubject`. These are `_voltro_*` tables, so the rename rides the declarative differ on `voltro db apply` / boot — no codemod. Adopters see a one-time index rebuild.

  A test now enforces the rule that most framework tables already followed: a framework index name must MENTION its own table. Mechanical, so it cannot rot the way a curated list of "generic" names would, and it does not demand the full `_voltro_<table>_<name>` form — which would force renaming ~20 already-safe indexes for no benefit. It also asserts no two framework tables claim the same index name, since installing two such plugins together would fail at migrate time for a reason neither plugin's author could see.

### Fixed

- **@voltro/sql-mysql, @voltro/voltro** — A MariaDB table with a UNIQUE constraint on an UNBOUNDED text column can never be decoded from the binlog. The reader now says so ONCE — with the real cause and a remedy that works — and excludes the table, instead of looping on it forever.

  **The mechanism.** MariaDB backs an unbounded UNIQUE with a **HASH long-unique index**, which adds a hidden `DB_ROW_HASH_n` column to the InnoDB row. That column IS in the binlog row image and is NOT in `information_schema.COLUMNS`, so the reader compares N+1 against N and throws on every write to that table:

  ```text
  Table app.sessions schema changed between binlog event and metadata fetch:
    the event has 9 columns, fetched metadata has 8
  ```

  Nothing is broken; the table is shaped that way, permanently. The previous recovery (skip to the current binlog end) recovered nothing, because the end is exactly where the next failing write appears — a loop a consumer measured at roughly every 9 seconds, re-signalling resync to the whole fleet each pass.

  **The cause we shipped in the previous entry was WRONG, and this retracts it.** It blamed a `DROP COLUMN` that ran as `ALGORITHM=INSTANT` leaving a phantom column, and told people to run `ALTER TABLE … FORCE`. The same consumer measured that: 9 InnoDB columns before the rebuild, 9 after, hidden column still present — the rebuild recreates the index and therefore recreates the hidden column. The repair line sent readers in a circle. They also disproved the version theory, being on the same MariaDB 11.8 we had tested on and failed to reproduce a phantom column with.

  **Now:** affected tables are found at CDC start by a privilege-free probe — the direct evidence in `INNODB_SYS_COLUMNS` needs `PROCESS`, which an app DB user does not have, so the constraint SHAPE is inferred from `information_schema.STATISTICS` + `COLUMNS` instead — reported once as an error naming `text().maxLength(n)` as the remedy and `ALTER TABLE FORCE` as explicitly not one, and EXCLUDED from the reader.

  Excluding is what makes it converge, and that is measured rather than assumed: an excluded table with a hidden hash column produces no reader error at all, while the same table included throws on the first write. Cross-instance change events for such a table are lost until it is bounded; own-node reactivity is unaffected (writes still emit inline).

  Framework `_voltro_*` tables cannot hit this — they are filtered out of the reader's include list before it reaches the replication client, and exclusion demonstrably shields the metadata fetch.

  **Caveat worth reading if you are already affected:** on a table that ALREADY exists, adding `.maxLength(n)` currently changes nothing — the schema differ does not diff text length, so it plans 0 operations and reports "up to date". That is a separate defect, reported in the same round and not yet fixed; until it is, the remedy only applies to newly created tables.
- **@voltro/cli, @voltro/voltro** — `voltro codegen` no longer writes a silently plugin-less `rpcGroup.generated.ts`, and it now reports what it merged.

  `loadApiConfig` swallows every failure into `null`, and `config?.plugins ?? []` turned that into "this app has no plugins". So an `app.config.ts` that threw while importing produced a generated file with **no plugin error union and no plugin routes** — followed by `voltro codegen: wrote rpcGroup.generated.ts`. The file typechecks, so nothing downstream catches it; the only symptom is a client branching on an error tag that never arrives.

  A consumer with ~140 declarative `guards:` measured that file 2781 lines shorter after a version bump, with the `ScopeError` import and the whole `__voltroPluginErrors` union gone. For the record, since they were careful to separate measurement from conclusion: the generator did NOT drop the feature — the plugin-codegen path is byte-identical between 0.19.0 and 0.20.0, and the published `@voltro/cli@0.20.0` does contain the identifier they grepped for. Their `grep` came back empty because the bundled chunk contained a literal NUL byte, which makes a file binary to most search tools (fixed separately, and it had been hiding files from our own audits too). What was real is the artefact diff, and this is the path that produces it without a word.

  Now: a config that EXISTS but fails to load is a refusal with a non-zero exit and the underlying cause, not a quiet downgrade. An app with no `app.config.ts` at all still generates — absence is legitimate, failure is not. And every run prints `(plugins N, error schemas N, plugin routes N)`, because a count that drops from 7 to 0 has to be visible in the success line or the next occurrence is found the same way: by diffing artefacts during a debugging session.

  `loadApiConfigDiagnosed` is the new seam (`{ config, present, error }`); `loadApiConfig` is unchanged for every existing caller.
- **@voltro/cli, @voltro/voltro** — `ssr cold-compile` log lines now carry the compile's duration, and `voltro start` emits them at all.

  The lines had a `start` and an `end` and no timing, which looks readable and is not: cold compiles run concurrently up to `VOLTRO_DEV_SSR_COMPILE_CONCURRENCY`, so the pairs INTERLEAVE. Subtracting adjacent timestamps names the wrong module, and above a limit of two they cannot be paired by eye at all — which is what a user reading a pod log actually hit, with three `start` lines before their `end`s:

  ```
  …:59.704 ssr cold-compile start id=…/layout.tsx
  …:59.704 ssr cold-compile start id=…/(main)/layout.tsx
  …:03.447 ssr cold-compile end 3743ms id=…/layout.tsx
  ```

  The gate had the number for free and threw it away. It is measured INSIDE the concurrency permit, so it is the module's own compile cost rather than the time it spent queued behind the limit — those are different numbers and only one of them is a property of the module. A slow first paint is usually one slow module, and this is the line that names it.

  A failed compile now says `FAILED` instead of `end`. Without that, a 3.7-second line for a module that threw read exactly like a slow but successful compile.

  `voltro start`'s middleware fallback constructed the same gate with NO callbacks, so an on-demand compile there produced no line whatsoever; it is wired now.
- **@voltro/cli, @voltro/voltro** — `voltro db plans`, `db drift` and `db restore-snapshot` worked on postgres only. On mysql/mariadb (and mssql and sqlite) all three died with:

  ```text
  fatal  unhandled cli error  (FiberFailure) SqlError: Failed to execute statement
  ```

  The cause is three `${sql('col')}::text AS ${sql('col')}` casts — POSTGRES syntax, in read paths whose helper is still called `buildPgLayer`. `db apply`, which WRITES the same ledger table, has no cast and worked, which is exactly the split a consumer reported: the commands that read were broken, the one that writes was fine.

  The casts existed to stop a driver handing back a `jsonb` object or a `Date`. Normalising in JS gets the same result and cannot be dialect-specific, since drivers differ in whether a json column arrives parsed and whether a timestamp arrives as a `Date`.

  Worth naming what it cost: `db drift` is the command whose whole job is "alert if live diverged from declared", and the consumer who found this had live divergence at the time. The specific detector and the general one were blind together.

  **And the error now names the failing statement.** Their verdict was the actionable part of the report:

  > *"the error names no statement … the statement text (or even the operation name) > would turn this from a dead end into a bug report. We would have sent you the > failing SQL if the error had contained it."*

  Right twice over — they could not diagnose it, and neither could we from the report; it took reading our own source. `@effect/sql`'s `SqlError` carries the driver error in `cause`, and every supported driver puts the useful part there (mysql2: `code`, `errno`, `sqlState`, `sqlMessage`, usually `sql`; pg: `code`, `detail`, `hint`, `position`). The CLI's fatal reporter printed only the wrapper. It now walks the cause chain and prints the driver message, the codes and the statement — collapsed to one line, and saying `<not attached by the driver>` when there genuinely is none, because that is information too.

  Shape-based rather than `instanceof SqlError`, deliberately: the CLI catches errors that have crossed the serve/start bundle boundary, where two copies of `@effect/sql` make `instanceof` silently false — the failure mode this repo has already paid for elsewhere.
- **@voltro/cli, @voltro/voltro** — `voltro doctor`'s `plaintext-secret` rule no longer flags metadata ABOUT a credential. An audit row denormalising the public facts of an api key — `apiKeyId`, `apiKeyKeyId`, `apiKeyType`, `apiKeyOwnerId`, `apiKeyName` — had three columns already excluded by the `*Id` suffix, while `apiKeyType` and `apiKeyName` fired. Telling a team to encrypt the LABEL of a credential is how a rule earns being ignored.

  The exclusion now covers final words that cannot BE the credential — `Name`, `Type`, `Kind`, `Label`, `Prefix`, `Suffix`, `Status`, `State`, `Scope(s)`, `Version`, `Count`, `Provider`, `Format`, `Note`/`Description`/`Comment`, plus the existing `Id` and the hash family. Deliberately NOT on the list: `Value`, `Secret`, `Token`, `Key`, `Password` — the words that name the thing itself. A false negative from an over-wide list is silent, so that is the failure mode the list is built against, and a test pins the words that must still fire.
- **@voltro/database, @voltro/cli, @voltro/voltro** — `voltro doctor` no longer contradicts itself about the `.serverOnly()` wire audit. The same command on the same tree reported:

  ```
  human: serverOnly: NOT CHECKED | json: {'checked': True, 'leaks': 0}
  ```

  Two causes, both fixed. `registerRelations` refused a re-registration of the IDENTICAL relation object, so a process that executes a module twice looked like two conflicting declarations — it now mirrors `registerTable`'s `existing === table` tolerance (a DIFFERENT block claiming the same name still throws). And doctor loaded the app three times per run; it now loads once, so every report sees the same outcome instead of the first one succeeding and the next failing.

  The consequence was worse than the noise: the throw aborted the wire audit, so the check that a token cannot reach a client had not run since the reporting app adopted the marker — and a CI gate written exactly as we documented (`fail on serverOnly.checked === false`) reported green on an app where the audit provably had not run. That is the "reads as coverage without being coverage" failure the `serverOnly` field was added to remove, reappearing in the field added to prevent it.
- **@voltro/sql-mysql, @voltro/voltro** — `insertIgnore` on MariaDB no longer reports a cause it cannot know, and no longer turns a REJECTED write into a silent "conflict". `INSERT IGNORE` downgrades EVERY error to a warning — foreign key, NOT NULL, CHECK, truncation — so the post-check's premise ("the insert was skipped ⇒ a unique constraint fired") does not hold on this dialect. It asserted a second unique index that did not exist; the real cause was an FK (an auto-stamped `createdBy` with no matching `actors` row), and a consumer spent the diagnosis looking for a phantom index.

  The message now reads the real error from `SHOW WARNINGS` on the same connection — BEFORE the existing-row lookup, since that lookup is itself a statement and resets the warning list. A non-duplicate warning is reported as a rejection and throws, because returning there is data loss presented as a normal outcome: the row is not written and the caller is told it already was. A genuine duplicate on an unnamed constraint now names the constraint that fired. Outside a transaction the warning cannot be attributed to our own statement (each statement acquires from the pool independently), so the message says the constraint is unknown rather than guessing — framework mutations are auto-transactional, so the common path has the cause.
- **@voltro/logger, @voltro/cli, @voltro/database, @voltro/voltro** — `voltro doctor --json` and `voltro capabilities --json` now emit exactly one JSON document on stdout. A `log.warn` from module discovery landed there ahead of it, so:

  ```console
  $ voltro doctor --json 2>/dev/null | python3 -c 'import json,sys; json.load(sys.stdin)'
  JSONDecodeError: Extra data: line 2 column 1
  ```

  Note the `2>/dev/null` in that repro — stderr was already redirected, so there was no shell-side workaround. And it only happened when a warning fired, so a consumer's CI parsed the document correctly until one file out of 368 tripped one. That is the same failure the `serverOnly.checked` field was added to remove — an automat unable to separate the normal case from the special case — one layer out, in the surface added to fix it.

  A command that owns stdout for machine output now calls `claimStdoutForJson()` before doing any work that could log, and every record goes to stderr from then on. The stream decision itself moved into ONE place (`@voltro/logger`'s `stream.ts`, exported as `routeDiagnosticsToStderr`): the Effect surface and the direct surface each carried their own copy of `level === 'error' ? stderr : stdout`, and two copies of one rule is how the rule failed to change.

  **Also fixed, same report:** the warning that started it was itself wrong. A `*.relations.ts` whose `relations(...)` map is EMPTY was reported as *"no relations(...) export found"* — pointing the reader at a missing export that is right there. `isRelationsSpec` rejects an empty map (correctly — there is nothing to register), but the caller could not tell that apart from a module with no export at all. It now says the map is empty and names the export.
- **@voltro/cli, @voltro/voltro** — The SSR bundle build now externalises a bare specifier it cannot resolve instead of aborting, so an uninstalled OPTIONAL peer no longer makes `voltro build` impossible.

  The SSR step runs with `ssr: { noExternal: true }` — inlining everything is what lets a production web image ship without a framework dependency tree — and that left no escape for a package that cannot be resolved at all. The commonest such package is an optional native peer reached through a library's Node entry:

  ```
  Rolldown failed to resolve import "canvas"
    from ".../konva/lib/index-node.js"
  ```

  `konva`'s `main` is its Node build, which requires the optional native `canvas`; its `browser` field points at one that does not. An app that never renders to a canvas server-side has nothing to install.

  A consumer measured that there was no way out from their side either, and each measurement is worth keeping: the import was ALREADY dynamic (rolldown must still resolve it to form the chunk), `renderMode: 'spa'` does not help (`.framework/app.tsx` imports every page statically for the router, so the module is in the SSR graph whatever the render mode), and an `ssr.external` passthrough in `app.config.ts` is not read. So `voltro build` — and with it the production image — was unavailable for that app.

  The api serve bundle and the web start bundle already did exactly this; that plugin is esbuild's and this step is vite/rolldown, so it is the same probe behind a different interface. Framework packages (`@voltro/*`, `@effect/*`, `effect`) are never externalised, so the "needs nothing from node_modules" property still holds.

  Every externalised specifier is NAMED in the `SSR bundle ready` line. Externalising is right for an uninstalled optional peer and wrong for a genuine missing dependency — it trades a loud build failure for a quiet runtime one — and only the reader can tell which, so it is reported rather than swallowed.
- **@voltro/sql-mysql, @voltro/sql-sqlite, @voltro/sql-mssql, @voltro/sql-postgres, @voltro/database, @voltro/voltro** — A typed error thrown inside a mutation now reaches the client TYPED, on every dialect. It arrived as an untagged `Die` defect on mysql/mariadb, sqlite and mssql: `transactional()` settled its program with `runPromise`, which rejects with Effect's `FiberFailure` wrapper, and the wrapper copies `message` and a decorated `name` but nothing else — no `_tag`, no payload, no prototype. So the rpc encoder could not match the failure against the mutation descriptor's `error:` union:

  ```
  └─ ["error"] └─ ["_tag"] └─ is missing
     Expected never, actual (FiberFailure) NotFoundError: …
  ```

  Framework mutations are auto-transactional, so this was EVERY typed mutation error in an app. Nothing failed — `defineMutation({ error: … })` compiled, the client's type still said `NotFoundError`, and the `error._tag === 'NotFoundError'` branch was simply never taken at runtime. Actions, which are not auto-transactional, marshalled correctly the whole time, which is what made the transaction the discriminator. A hand-rolled error class lost its fields and its `instanceof` too; only `message` survived, which is why a workaround built on `error.message` looked like it worked and hid this.

  Postgres already had the unwrap, with a comment describing this exact consequence, and the three sibling dialects kept the broken call — so the fix is now one shared `settleTransactionExit` in `@voltro/database` that all four import, plus a parity test that fails if any store's `transactional()` reaches `runtime.runPromise` again. Reported by a consumer on MariaDB who verified it against 0.19.0 too, so it is not a 0.20.0 regression.

### Internal (no consumer-facing effect)

- **@voltro/runtime, @voltro/database, @voltro/protocol, @voltro/cli, @voltro/plugin-billing, @voltro/plugin-mail, @voltro/plugin-sso-saml, @voltro/plugin-storage** — Fourteen source files carried a LITERAL NUL byte — the house idiom for a composite map key, written as the raw character instead of an escape. That makes the file BINARY to every text tool: `grep` skips it entirely and reports nothing, which is indistinguishable from a clean file. It was found because a new guard test scanning for framework index names came back clean on `runtime/src/connectionVault.ts` — 1020 lines that every previous grep-based audit in this repo had also silently skipped, including the one looking for exactly the index name that file declares.

  Replaced with the JavaScript escape for U+0000. Identical runtime value, files are text again. No behaviour change.

---

## [0.20.0] — 2026-07-29

### ⚠ BREAKING

- **@voltro/plugin-versioning, @voltro/database, @voltro/voltro, @voltro/sql-postgres, @voltro/sql-mysql, @voltro/sql-sqlite, @voltro/sql-mssql** — `versioningPlugin({ timing: 'in-transaction' })` produced a WRONG trail, not merely a slow one. Reported and reproduced against MariaDB 11 by a team that wired both plugins and measured before migrating a single call site.

  **It recorded every change twice.** The two timings are alternatives, but the post-commit change tap stayed wired when the in-transaction recorder was registered, so both ran. One `bookmarks.create` → two history rows.

  **And the trail was mis-ordered, which is worse.** Each path numbered independently: one insert plus one update produced versions `0, 0, 1, 2` across four rows. `selectAsOf`, `sortHistory` and `diffVersionRows` all read `version`, so `rowAsOf` returned the wrong snapshot and `diffVersions` found nothing. A duplicate can be deduped; a wrong order cannot be detected from the data.

  The recorder wrote a constant `version: 0` on purpose, with a design note arguing that ordering could come from `changedAt` and that a read per covered write was too expensive. Both halves were wrong: `changedAt` is millisecond-resolution, so two writes to one row inside one transaction tie routinely, and the number is what every reader consults.

  **BREAKING —** a `WriteRecorder` now receives a PORT (`{ append, maxOf }`) rather than a bare `append`. `maxOf` is one aggregate with an equality filter on the connection the write already holds; it is what lets an append-only trail number its own entries. A recorder still cannot UPDATE, DELETE or open a nested transaction, and a throw from either operation still rolls the caller's write back. Apps that merely ENABLE the timing need no change — only a hand-written recorder does, and `tsc` names every site.

  **Cost, stated rather than avoided:** `'in-transaction'` now takes TWO round-trips per recorded write, roughly doubling this timing's published per-write overhead. Both timings number from 1, so switching `timing` no longer shifts version numbers.

### Fixed

- **@voltro/database, @voltro/sql-postgres, @voltro/sql-mysql, @voltro/sql-sqlite, @voltro/sql-mssql** — The correlation bridge did not survive a transaction, and did not survive CDC. Both are fixed, and both were found by measuring against live databases after a consumer isolated the symptom in a scratch app.

  **Every write a framework mutation makes was unattributed.** `transactional()` is entered from the request's async-local scope, but its callback runs from inside the Effect the store builds — and measured against live postgres AND live mariadb, the scope is active at the call site and EMPTY inside the callback. Framework mutations are auto-transactional, so this was every handler write. Same class as the `bindMutation` defect fixed alongside it: a scope covering the construction of an Effect and not its execution. The caller's attribution is now captured at `transactional()` entry and re-established around the callback, in all four dialect stores.

  **And the CDC transports could not carry it at all.** Under `changeStrategy: 'cdc'` — the DEFAULT — the event a subscriber receives is rebuilt from a postgres NOTIFY payload or a mysql binlog row image, neither of which can hold a request context. `registerPendingAttribution` / `claimPendingAttribution` (`@voltro/database`) let the write path hand its identity to the echo, keyed by `(table, op, id)` and claimed once. A write made on ANOTHER replica has nothing pending and stays unattributed, which is the correct answer rather than a gap.

  **Plus one nobody had reported, found on the way:** on postgres under CDC the write path skipped `routeEvent` entirely, and `runWriteRecorders` lives inside it — so `versioningPlugin({ timing: 'in-transaction' })` with the default `CDC=1` recorded NOTHING. The mode whose entire promise is "if the change committed, the entry is there" wrote an empty trail, silently. `routeEvent` now runs in both modes; only the DELIVERY decision is strategy-dependent.

  New live-dialect suites (`cdcAttribution.integration.test.ts` in `sql-postgres` and `sql-mysql`) pin all of it, and were verified red against the previous code.
- **@voltro/plugin-versioning, @voltro/runtime, @voltro/cli** — `_voltro_row_history.traceId` and `.subjectId` were NULL on every write. Three independent causes, all found from one consumer report whose evidence pinned the diagnosis before we looked: `subjectId` was NULL while `changedBy` on the SAME row carried the acting user — so the identity was known and was not travelling.

  - **The adapter dropped them.** `dataStoreHistoryStore.append` hand-wrote its insert object and listed `changedBy` but not `traceId` / `subjectId`. This is the second time that shape has bitten in this file — the READ side (`rowToVersion`) had drifted identically. A row built by hand in one place and read by hand in another disagree exactly when a field is ADDED, because nothing fails. Both now spread the row. - **An Effect-returning handler was unattributed.** `bindMutation` established the scope around the CALL, which covers an async executor for its whole run — but an Effect-returning one is only CONSTRUCTED there and runs later. It is now forked inside the scope, with interruption and typed failures preserved (both pinned by tests). Verified by measurement, not assumption: an Effect forked inside an ALS scope keeps seeing it across `sleep`, `yieldNow` and a `setTimeout` promise, while the same effect merely constructed inside sees nothing. - **The devtools `/invoke` path never entered the scope at all.** It bypasses the rpc stack by design, and that also bypassed everything `bindMutation` sets up. The audit plugin recorded a traceId (it reads `requestContext.traceId`, which this path does build) while every write underneath carried none — three consumers of one call disagreeing about its trace. It also synthesised `inspect-<8 random chars>`, which no trace consumer can parse; the reporter's framing is the rule worth keeping — *a synthesised id produces a column that looks joinable and is not; NULL at least fails honestly.* It is a real 32-hex id now, and it reaches all three sinks.

  `actingUserId` is imported at the new call site rather than re-derived — one answer to "who is writing", shared with what `audit()` stamps.
- **@voltro/cli** — Three ways a check reported nothing while checking nothing, all found by a consumer verifying the silence instead of trusting it.

  - **`unexercised-row-filter` never fired on a typed registration.** The match was `/\bsetRowFilter\s*\(/`, which demands the paren directly after the name, so `setRowFilter<Ctx>({…})` — the spelling our own generic signature invites — broke it. The rule was blind for exactly the teams that had wired `load`/`predicate` carefully. It counts CALLS now. - **…and its test-side condition was satisfiable by a COMMENT.** It matched `rowFilter:` in raw text. Comments and string literals are stripped, and the suite must both call `makeTestContext` and bind `rowFilter` in real code. - **The same paren-adjacent shape sat in two shipped codemod gates.** `0.7.0/01` (row filter) and `0.7.0/02` (`invoke`) both gate on a generic export, so a typed call made `voltro update` print nothing at all: the upgrade reads as clean and the behaviour change lands unread. Both now use the shared `callPattern`, which allows type arguments including nested ones.

  Two false-positive fixes in the hand-roll detector, from the same report:

  - **A file that WIRES a plugin is no longer told to adopt it.** The `presence` rule reported `app.config.ts` (which calls `presencePlugin()`) to an app that had just deleted its hand-rolled table. Rules that recommend a package now declare it, and a file referencing that package is skipped. - **Generated files and `.d.ts` are out of the scan.** A recommendation aimed at a file the next boot overwrites is never actionable.

  And one more of the first kind, found while checking why a withdrawn report's probe had stayed silent: `raw-fetch` counted only a BARE `fetch(…)` callee, so `globalThis.fetch(url)` / `self.fetch(url)` in a server file read as clean.
- **@voltro/cli** — `voltro doctor`'s `serverOnly: NOT CHECKED` line now names the failure, and the field exists in `--json`.

  The refusal to claim a pass was right. What shipped with it was nothing to act on: the `catch` discarded the error entirely, so there was no reason, no failing module, and — because the field was absent from `--json` — no way for CI to assert "still unchecked" rather than reading silence as a pass.

  A consumer's verdict, which is the useful part: *"The message is honest and that is the problem."* They had already verified that every descriptor, `app.config.ts` and the generated rpc group imported cleanly under `tsx` on their own, so the difference had to be in what `loadDiscovered` does BEYOND importing — and none of that was visible from outside. It matters more than its size because `.serverOnly()` is what guards their `sessions.tokenHash` and `apiKeys.keyHash`, markers they added after finding a query whose output schema shipped a hash over the wire.

  `--json` now carries `serverOnly: { checked, reason?, leaks? }`. Gate CI on `checked === false`.
- **@voltro/plugin-versioning** — A version snapshot no longer copies `.serverOnly()` columns into `_voltro_row_history`. `.encrypted()` columns are KEPT, and that distinction is the whole finding.

  Reported by a team choosing which tables to version: `sessions` holds `.encrypted()` PATs and a `tokenHash`, `apiKeys` holds a `keyHash`, and they could not determine from outside what the snapshot would contain. They excluded both tables — then went and measured it, which corrected their own report:

  ```
  probeItems.secret     enc:v1:a56iziEV9THLhzmJ:Vk0ux+0bECleTLBJkCa0Rg==:3AtMwP…
  _voltro_row_history   {"secret":"enc:v1:a56iziEV9THLhzmJ:Vk0ux+0bECleTLBJkCa0Rg==:…"}
  ```

  **`.encrypted()` lands as ciphertext, byte-identical to the source column**, so versioning such a table widens nothing — the history is exactly as readable as the row it came from. Withholding it would have cost real audit data to prevent an exposure that does not exist.

  **`.serverOnly()` is withheld**, and the reason is not "a second copy under different retention" — that argument is weak on its own, since the hash already sits in the source table. The decisive one: `crud.*` STRIPS `.serverOnly()` columns from every row it returns, and a snapshot would smuggle the same value back past that stripping inside a `json()` blob, where no column-level rule applies.

  Withheld names are listed under `data._omitted`, so a reader can tell "this column was withheld" from "this column did not exist then". Both timings apply the same policy. `.sensitive()` is not involved: it is an export-masking marker for values that are legitimately readable in the app.
- **@voltro/cli** — A page that RE-EXPORTS its component (`export { default, renderMode } from '../page'`) no longer fails the codegen gate with "exports no default". The check required the literal words `as default`, so the one spelling that lets two routes share a screen without copying it was the one spelling it refused — and it refused in `voltro build`, while dev and tests stayed green because nothing prerenders there. `export { default as Screen }` is still correctly rejected: it renames the default away.

  Two follow-ons from the same shape:

  - The refusal message said the file "ends in `.page.tsx`" and offered "drop the `.page` suffix" as a fix. That is the 0.15.0 convention, replaced by directory routing in 0.17.0 — it named a convention that no longer exists and a fix that could not work. It now names `page.tsx` and both real fixes. - `scanRenderProfile` read a forwarded `renderMode` as absent and fell back to `'static'`, so `staticSafe` and the deploy-target classification could call an app CDN-deployable with an `ssr` route in it. The forward is now followed (relative specifiers, depth-capped); an unresolvable one still falls back rather than failing the scan.
- **@voltro/database** — `VOLTRO_SOFT_DROP=1` could never converge. The applier renames the object to `<name>__dropped_<ts>` instead of dropping it, which leaves it undeclared — and the differ read that as one more forgotten table, planning the drop again. The re-plan inside `applyPlan` then found an operation still outstanding and aborted with "the DDL for these operations is a no-op — this is a framework bug", which was a wrong diagnosis of a real defect: the DDL had worked. No fingerprint was recorded, so the migration counted as unapplied and every later `db apply` / boot hit the same wall. The only exit was a hard drop of the snapshot — exactly the recoverability the flag is chosen for.

  The planner now treats `<name>__dropped_<YYYYMMDDHHMMSS>` as framework-managed, alongside `_voltro_*` / `cluster_*`. Deliberately not retention-aware: a planner whose output depends on the clock would produce different plans before and after midnight, and `db gc-snapshots` already owns expiry.

  Reported against tables; the same defect existed one level down for soft-dropped COLUMNS, where it was worse — a re-planned `drop-column` carries no `dropped()` marker and so refuses to plan at all. Both are fixed.

  The convergence message itself no longer asserts a cause it cannot know. It said "the DDL for these operations is a no-op", which was flatly wrong here and sent the reporter looking for dead DDL. It now names both causes — no-op DDL, and a planner that cannot see what the DDL did — and says which one an operation naming a just-renamed object usually is.

### Internal (no consumer-facing effect)

- **The `0.20.0/01_write-recorder-port` codemod gains the gate test its two predecessors have.**

  `codemodRegistry.test.ts` asserts that every `*.codemod.ts` on disk is registered and that ids are unique — registration, not behaviour. What it cannot see is the one way a `manual` codemod fails in practice: an `appliesTo` that is too broad, so the note prints for projects that have nothing to do. That is not a cosmetic problem. A note which fires on every app is how readers learn to skip notes, and the next one carries a boot refusal.

  This codemod is the case where the silent direction matters most. The break is a TYPE error, so `tsc` already names every affected site; the note exists only to explain `maxOf`, which the compiler cannot. Apps that merely ENABLE `timing: 'in-transaction'` need to do nothing — `plugin-versioning` ships the recorder and it is already updated — and they are the large majority.

  Four cases, covering both directions: a project registering its own recorder (note prints, and names `{ append }`, `maxOf`, and the `null`-is-not-zero distinction that a hand-written sequence gets wrong), an app that only enables the timing (silent), the identifier in a comment or a string (silent), and the generic call form `registerWriteRecorder<Row>(…)`, which `callPattern` admits and a naive match would miss.

---

## [0.19.0] — 2026-07-29

### ⚠ BREAKING

- **@voltro/plugin-audit** — **The durable audit sink stops writing credentials to a log table by default.** `redactInput` defaults to `'all'` — the payload becomes `{ __redacted: 'all' }`, which still proves a payload existed. `redactInput: 'none'` restores the previous behaviour, and a function gives field-level control.

  `AuditEvent.input` is the raw mutation input, so an unredacted trail is where a password change, an API key at issuance and a PAT land — the one place nobody thinks to look for a credential. Losing payload detail is visible the first time you read a row; leaking a credential is not visible at all, which is why the default moved rather than staying opt-in.

  **It is deliberately NOT driven by `.serverOnly()` / `.sensitive()`,** which is the design a consumer proposed and the one that cannot work: those markers live on TABLE COLUMNS and this is a mutation's INPUT. `changePassword({ oldPassword, newPassword })` has no column to consult, so a marker-driven default would cover exactly 0% of the motivating case while reading, to whoever configured it, like protection. (`.sensitive()` is also the export axis, not "unsafe to log" — the category error the three-marker table exists to prevent.)

  Also:

  - **`record: 'all' | 'errors' | predicate`** — filters by OUTCOME, where `include`/`exclude` filter by tag. `'errors'` is the forensic core and pairs with `plugin-versioning` for the successful writes. **`'all'` stays the default** on purpose: defaulting to errors would silently stop recording successes on upgrade, and "what did this compromised account touch" is answered by successes. - **`errorTag`** — the typed error's `_tag`, flattened out of the `outcome` json and indexable. `null` for an untagged failure rather than a guess: "this had no tag" and "the tag is 'Error'" are different, and a column that invents the second makes every filter on it quietly wrong. - **Two indices for the questions asked under pressure** — `(subjectId, status, at)` and `(tenantId, status, at)`. "Every denied call by subject X in the last 30 days" and "every failure against tenant Y" were both unindexed; the existing `byAuditTag` / `byAuditTrace` cannot serve either. - **Retention registers itself** — 365 days, `VOLTRO_AUDIT_LOG_TTL_HOURS`, drained by the boot sweep. "Pair it with the governance sweep" was a docs sentence rather than a default, so nobody did. - **Erasure is deliberately NOT auto-registered.** Erasing a subject must not delete the record that they were refused four hundred times — that record *is* the evidence. Anonymise instead; the docs carry the `subjectScopes` entry to paste, and it stays a decision the app makes explicitly.

  **Migration** — `voltro update` prints it (`0.19.0/02_audit-redact-input-default`, `manual`, and it fires only for apps that mount the plugin). Nothing stops compiling and existing rows are untouched; what changes is what the NEXT row records. Keep the new default unless you know your mutation inputs carry no secrets; pass a function for field-level control; or opt back in explicitly with `redactInput: 'none'`. The codemod deliberately does NOT write `'none'` into your config — a transform could do it perfectly, which is exactly why it must not: it would pin every adopter to the behaviour the default moved away from and report the migration as complete.

  *Why this is `BREAKING` and not `Changed`: it is a silent behaviour change on upgrade. Nothing fails, which is the problem — an operator who never reads this section keeps a trail that has quietly lost its payload detail. `BREAKING` is what routes it into `voltro update`.*
- **@voltro/cli, @voltro/database** — **`.serverOnly()` now gates where it said it did.** A wire-reachable query that declares a `.serverOnly()` column of its source table in its `output` fails the boot under `voltro serve`, and makes `voltro doctor` exit non-zero. `voltro dev` still warns.

  It shipped as one `log.warn` and nothing else — in every command — while `ColumnBuilder.serverOnly()`'s own doc comment and the seeded `AGENTS.md` marker table both said "**the boot audit — it FAILS the boot**, it does not warn". A team read the strong sentence, adopted the marker on four credential columns, injected a deliberate leak to check, and watched the server come up serving the leaking query. That is the register this repo keeps meeting from a new angle: *a check that prints instead of gating still reads as coverage* — here on the one marker whose entire job is the enforcement.

  Two smaller things went with it, both of which had misled the reporter:

  - **The message names the command.** It was emitted through a module-level logger scoped `voltro:dev`, so a warning from `voltro serve` announced itself as dev — which is why they concluded, and reported, that the audit does not run in production at all. It did; it just misattributed itself and stopped nothing. - **The audit is computed once, in `loadDiscovered`**, the discovery dev / serve / doctor / check all share — the same reasoning `validateRegisteredRelations()` lives there for. Dev and serve may disagree about what a leak COSTS; they must not disagree about what a leak IS.

  `VOLTRO_SERVER_ONLY` moves the line both ways: `strict` fails `voltro dev` too, `warn` downgrades serve, `off` silences it. The downgrades are documented rather than hidden, because the alternative to a stated escape hatch is deleting the marker, and a check whose only way out is to disable it gets disabled.

  `voltro check` deliberately does NOT run it: it has a live-api mode with no access to your table definitions, and a rule that fires in one of its two modes is worse than one that fires in neither.

  **Migration** — `voltro update` prints it (`0.19.0/01_server-only-gates-the-boot`, `manual`, and it fires only for apps that actually use the marker). Run `voltro doctor` BEFORE you deploy: it reports exactly what `voltro serve` will now refuse, with no deploy involved. Each finding has two honest fixes and only its author can choose — the column is not wire-safe (drop it from the query's `output`, keep the marker), or the marker is wrong (drop the `.serverOnly()`). Do not substitute `.encrypted()`: that is the at-rest axis, the runtime decrypts for the handler, and reading it as "safe to expose" is the category error the three-marker table exists to prevent. To ship while triaging, `VOLTRO_SERVER_ONLY=warn` downgrades serve back to a warning — a bridge, not a setting to keep.

  *Why this is `BREAKING` and not `Changed`: no signature moves and nothing that compiled stops compiling, so the literal type-level test does not catch it. It can still turn a booting production app into one that refuses — which is the point, the boot it refuses is the one shipping the column — and that failure lands at DEPLOY time. Filing it as `Changed` would have kept it out of the one section the stability contract names as the migration path, and out of `voltro update` entirely, because codemods hang off `BREAKING`. Both doc claims were corrected in the same change, so the `.d.ts`, the agent template and the docs site now describe the same behaviour.*

### Added

- **@voltro/plugin-audit** — **`auditByTrace` / `auditBySubject` — the read side of the correlation join.**

  `byAuditTrace` and `byAuditSubjectStatus` shipped in the same release with **no caller**. That is the identical defect the versioning side had and that its own entry points were added to fix, repeated on the other half of the join one file away: an index nobody can enter is a query the app still hand-writes, and the docs then demonstrate a raw select over a framework-internal table.

  It surfaced by checking an adopting team's design document against the code rather than from memory. Their §6 asks for *"a read-side composition joining `_voltro_row_history` × the audit sink (on `traceId`) × `actors`"* — which needs BOTH halves to have an entry point, or neither is usable.

  ```ts
  const calls   = await auditByTrace(ctx.store, traceId)                     // who called, and was it refused
  const changed = await historyByTrace(ctx.store, traceId, tenantId)         // what it changed
  const denied  = await auditBySubject(ctx.store, actorId, { status: 'error' })
  ```

  `auditBySubject` takes `status` as a real argument rather than leaving the caller to filter in JS — the index is `(subjectId, status, at)`, so a filter applied after fetching would not use it. `limit` defaults to 100, because an actor's history is unbounded and an entry point that returns all of it is one you call once in production.

  *They were also not exported from the package index when first written — built, tested, and unreachable. Caught before shipping; worth recording because "it has a test" and "a consumer can call it" are different claims.*
- **@voltro/database, @voltro/runtime, @voltro/protocol, @voltro/cli, @voltro/plugin-versioning, @voltro/plugin-audit, @voltro/voltro** — **`ChangeEvent` carries the calling `traceId` and `subjectId`** — the join key that lets `plugin-versioning` (what changed) and `auditPlugin` (who called, and whether they were refused) be read as one trail.

  Both halves shipped and neither could be joined to the other. A consumer put it exactly right: *"we are not asking you to build our audit feature. We are asking for the join key that lets anyone build one on the three pieces you have already shipped."*

  **The mechanism, and the part that was an empirical question rather than a design one.** Identity is known one layer up (`subject` in the middleware, `traceId` at the rpc boundary) and the event is created several layers down, inside each dialect store's private emit. Threading a context argument through every `DataStore` method to reach it would change the port every driver implements, for metadata that is ambient by nature — so it rides an `AsyncLocalStorage` (`@voltro/database`'s `writeAttribution.ts`), the same shape as the existing trace and replica-routing contexts.

  Whether that survives a SQL store was *not* obvious: the write goes through `ManagedRuntime.runPromise`, so the read happens inside an Effect fiber, and a scheduler draining fibers from a shared loop would run them under the async context of whoever created the drain. If that were true the attribution would be silently ABSENT — a join key that is simply never there, on a trail nobody checks until an incident. Verified against a real sqlite store, including the transactional path (which queues events and flushes them post-commit, *outside* the scope — which is why the stamp goes at event CREATION, not delivery).

  - **One call site, not one per method.** The scope is established at the rpc executor boundary, so it covers every write the handler makes — `ctx.store`, `EffectStore`, the crud helpers, a plugin interceptor's own writes. Wrapping the store middleware instead would have meant wrapping each mutating method and hoping the next one added remembers. - **`subjectId` is the same identity `audit()` stamps** (`actingUserId`, now exported so there is one source). Two answers to "who wrote this row" on one write would be worse than one; which API *key* was used is recoverable from the audit row sharing the `traceId`. - **Absent is a fact, not a gap** — no request behind the write (seed, startup hook, schedule, workflow step), or an event from another replica, where stamping the local ambient trace would attribute a remote write to a local call. The keys are omitted rather than set to `undefined`, so an unattributed event is byte-identical to one from before this existed. - **`dev.ts` and `serveCommand.ts` no longer hand-mirror the plugin fan-out.** They built that object literal separately in two files; a field added to one and not the other gives a plugin the data in dev and silence in production, and both files typecheck alone. Now `toPluginChangeEvent`.

  `_voltro_row_history` gains `traceId` / `subjectId` plus `byTrace` and `bySubject` indices — "what did this call touch" and "what did this actor touch" were previously unanswerable at any speed, since `byRow` requires already knowing which row you are asking about. `changedBy` now prefers the caller over the row's `audit()` stamp, which fixes a reported case for free: the stamp is `null` for every write through the boot store, so a login route produced version rows with no actor at all.

  *`apiSurface: compatible`: every golden line this touches is either a pure addition (the new fields and `actingUserId`) or api-extractor renumbering an import alias — `Subject_2` became `Subject` across `runtime` and the `voltro` re-export because a new import changed the ordering. No declaration changed shape and no call site is affected.*
- **@voltro/cli** — **`voltro doctor`'s hand-roll detector gains six rules and ranks its findings by file count.**

  An adopting team audited four apps by hand against the framework's "reach for instead" table and produced twenty items, each with a count (`1631` files with a manual `rows[0]`, `500` queries with `input.offset`, `306` forms, `368` relations declared against `17` uses). **The detector already reported eleven of those twenty, with counts.** These are rules for what it missed — the findings that made a person do work a scan should have done:

  - **`offset-pagination`** — `.offset(` / `input.offset` / `.skip(` on a list query. `hand-cursor` did not catch this: it wants `hasMore` AND `limit+1`, which is a hand-rolled *keyset* pager. Plain OFFSET is a different smell with a worse ending — O(n) in the page number, so it does not fail, it just stops loading once the table is big, on the tables (audit logs, time entries) that get big first. - **`oauth-token-table`** — an `accessToken`/`refreshToken` column on an app table → `defineConnection({ kind: 'oauth2' })`. The reporting team's version of this leaked: a `json()` column holding a Slack payload with the token inside, readable by every colleague in the tenant. - **`non-incremental-aggregate`** — a `count/sum/avg/min/max` aggregate with no `incremental:`, rescanned in full on every refresh. - **`external-state-library`** — jotai / zustand / mobx / redux → `defineStore`. A second runtime beside the reactive engine, and one that does not participate in the subscription graph. - **`hand-route-module`** — importing a hand-maintained `routes` / `urls` module instead of `.framework/routes.generated`, where a renamed page is a compile error rather than a 404 someone finds in production. - **`hand-permission-check`** — a component reading a permission bag by hand instead of `useCan` / `useResourceCan`.

  **And the findings are now ordered by file count rather than by the order the rules happen to be declared in.** A reader facing twenty findings acts on the top of the list, so an arbitrary order silently decides what gets fixed. The reporting team ranked their own work by exactly that number and had to count it by hand, because this printed the same facts unranked. Effort we cannot know; magnitude we can.

  *Two of these rules were caught being wrong by their own tests before shipping: `offset-pagination`'s first version checked only `input.offset` and did not match its own fixture (a narrow rule reports nothing and reads like a clean codebase), and the ranking test passed with the sort deleted until the fixture was rebuilt so declaration order and count order disagree.*
- **@voltro/plugin-versioning** — **`historyByTrace` / `historyBySubject` — the entry points the new indices existed for.** Plus their Effect twins.

  The correlation bridge added `byTrace` and `bySubject` to `_voltro_row_history` in the same release, and shipped them with no caller: `rowHistory` requires a `rowId` you already have, which is the wrong way round during an incident, when what you have is a trace or an actor. The docs demonstrated a raw `ctx.store.select('_voltro_row_history')`, which is the shape an index is supposed to save you from writing.

  Both are tenant-scoped exactly like `rowHistory` (own-tenant rows plus null-tenant rows from untenanted source tables; `undefined` skips the filter, for system paths only). `historyBySubject` takes a `limit`, default 100, because an actor's history is unbounded and an entry point that returns all of it is one you call once in production and never again.

  **And a bug this surfaced.** The stored-row → `VersionRow` decoder was written inline inside `versionsOf`, before the bridge existed, and silently dropped `traceId` / `subjectId` — so `rowHistory()` returned rows missing the very field the feature exists to carry. Extracted to one `rowToVersion` now shared by all three readers, which is why the drift was possible in the first place.
- **@voltro/cli, @voltro/database** — **`setRowFilter` is now named in the always-loaded agent core**, with the distinction that makes it findable.

  A team migrating eleven hand-written row filters reported missing it **twice** — once in a full framework audit, and once while telling a colleague in writing that the framework has no row-filter primitive. The depth doc covers it well; a depth doc is opened by someone who already suspects the topic. The core's SERVER rubric asked *"a permission check?"* and answered with two BOOLEAN questions, so a reader holding a row-VISIBILITY question took the nearest-fitting answer and wrote the filter by hand. Eleven times.

  The rubric now asks it directly, and the pairing is the point:

  > `guards:` → *may I call this procedure?* → a typed `ScopeError`. > `setRowFilter` → *which rows may I see?* → the rows are simply absent.

  Plus the failure it deletes — a `WHERE ownerId = me` in the list handler covers the query and **not** the subscription — and the trap they lost time to: `load` must read through an UNFILTERED store, because applying the filter to its own loader recurses until the stack blows.

  Also:

  - **`voltro doctor` gains `unexercised-row-filter`.** Their 7466 tests stayed green when the filter landed because not one passed `rowFilter:` to `makeTestContext`; they only noticed because they sabotage their own predicates before believing a pass, and four sabotages ran green. `makeTestContext({ rowFilter })` being opt-in is right — a process-global in a parallel suite would be worse — but the consequence is a visibility rule nothing verifies. Same class as a suite reporting `passed` with no database. - **`MATCHES_NO_ROWS` is exported from `@voltro/database`.** They wrote `eq('id', '')` for "this caller sees nothing", because the natural spelling `inSet(col, [])` is the one that is dangerous in most query builders: an empty `IN ()` gets dropped, and a dropped predicate does not narrow, it WIDENS to the whole tenant. Here it is safe — the SQL compiler emits `FALSE`, the memory evaluator returns false — but that held by accident, with no test enforcing it. Now pinned in both evaluators, including the mirror case: an empty `notIn` matches EVERYTHING.

  *The doctor rule was itself wrong when first written: it read the normal source set, which excludes test files, so it could never observe the passing case and would have fired unconditionally on every app. Caught by writing the negative test.*
- **@voltro/database, @voltro/plugin-versioning, @voltro/sql-postgres, @voltro/sql-mysql, @voltro/sql-mssql, @voltro/sql-sqlite** — **`versioningPlugin({ timing: 'in-transaction' })` — the history row commits or rolls back WITH the change it records.** Default stays `'post-commit'`.

  Post-commit recording is lossy by construction: between COMMIT and the forked history write there is a window, and a process that dies inside it leaves the change permanent and the trail silent. The size of the window is not the point — the direction is. A missing entry cannot be told apart from "nothing happened", so a trail that can lose entries proves nothing. Retry does not close it either; the process that would retry is the one that died.

  This was the sole reason an adopting team could not replace their hand-rolled audit writer — called from **315 of 358 mutation handlers**, with the 43 misses being what happens to any rule that depends on someone remembering.

  **What a recorder receives is an `append`, not a store.** Bound to the caller's transaction, insert-only. It cannot open a nested transaction (which throws by design), cannot read-modify-write its way into a deadlock, and "append-only" stops being a docs claim and becomes the shape of the only thing it is handed.

  **The rejection is the guarantee, not a defect.** When the history insert fails, a transaction offers exactly two outcomes: the mutation fails with it, or the error is swallowed and the change commits without its entry — which is post-commit's hole with the cost already paid. There is no third option, so recorders do not swallow.

  **Why the default did not move.** In-transaction makes the history table a hard dependency of every covered write path: its availability becomes your write path's availability, and every covered write holds locks longer. Post-commit loses at worst one entry; in-transaction can at worst stop writes to the covered tables. Right trade for a compliance trail, wrong one for the undo / time-travel use this plugin also serves.

  **It refuses the in-memory store at boot** rather than silently no-op'ing. `memory` is the default dev store; an option that appears to work where it is cheapest to try and stops where it matters is worse than one that says so. Two limits hold in both timings and are documented: `store.raw()` produces no change event and is absent from the trail, and a write made outside a transaction is recorded immediately after rather than atomically.

  *Two design claims in the plan for this were wrong and are corrected there. "Twelve write sites across three layers, no funnel" was true of `storeMiddleware` and irrelevant — each dialect store funnels every write through one private `routeEvent`, which is where this hooks. "Bulk writes have no per-row post-image" was simply false: `updateMany` already issues `RETURNING *` and emits one event per affected row, so bulk needed no special case at all. Both were found by looking in the middleware instead of the store — twice.*

### Fixed

- **@voltro/cli** — **A `voltro serve` that refuses to boot says why, instead of blaming the serve bundle.** A deliberate refusal — a missing `VOLTRO_SESSION_SECRET`, a `.serverOnly()` leak — came out of the launcher as:

  ```
  [voltro] serve bundle failed to load: VOLTRO_SESSION_SECRET is not set — refusing to serve. …
  [voltro] FATAL: production `voltro serve` requires a precompiled serve bundle at …
  but it is missing or failed to load. Run `voltro build` before serving …
  ```

  The real reason is on the first line, under a wrong headline, followed by a louder and more confident wrong instruction. An operator whose first deploy forgot the session secret is told to rebuild an artefact that is fine — and rebuilding it produces the identical output, so the loop has no exit.

  The cause is a catch that has to exist: `bin/voltro.mjs` imports the precompiled serve bundle inside a `try`, because an unusable bundle must degrade to the tsx path rather than kill the boot. It could not tell "this artefact is broken" from "this app decided not to start". Refusals now carry a marker (`bootRefusal.ts`) and the launcher prints them and exits 1.

  The marker is the error's `name`, a plain string, rather than a class: the serve bundle INLINES the framework, so the thrown Error crosses an instance boundary where `instanceof` does not survive — the same reason the core-table registry is keyed by `Symbol.for`.

  Found by running the new `.serverOnly()` gate against a real fixture rather than by reading it, which is also how the misleading pair came into view: the session-secret case had been shipping that way for a while.
- **@voltro/sql-postgres, @voltro/sql-mysql** — **`insertIgnore` explains a second-unique conflict instead of reporting an internal invariant.** The message was `row conflicted but lookup found nothing`, which tells a caller nothing they can act on.

  It is reachable by ordinary means, and on mariadb it is the *common* path: `INSERT IGNORE` swallows ANY unique violation, so a row with a fresh `id` and a duplicate `slug` is skipped, and the lookup by the named `conflictColumns` then finds nothing. A team with `tenants (id PK, slug UNIQUE)` hits it on the first duplicate slug.

  The message now names the columns that were checked, the table, and the actual cause — a different unique constraint fired, and `insertIgnore` models one conflict target. This is step 1 of `plans/framework-insertignore-any-unique.md` and is deliberately independent of the feature: whether or not `conflictColumns: 'any'` ever ships, this error should have been readable.
- **@voltro/cli** — **`ui/unlinked` and `ui/orphaned` resolve through barrel re-exports.** A `*.component.ui.tsx` reached only via `export { X } from './x'` was reported as unrendered, however many pages actually rendered it.

  On the app that reported it, `PageContent` is imported by 42 pages — every one of them through `@/components/shared` — and doctor said `imported only by: index.ts, its own test`. It was the last false positive standing after the alias fix took that app from 16 findings to 2.

  The rules ask "does anything RENDER this". A barrel is a real importer and renders nothing, so stopping at the first importer answers a different question than the one asked — but only in an app that has an `index.ts`, which is why it survived.

  The walk is narrowed by NAME rather than opened up wholesale: a downstream file counts only if it imports one of the names the barrel republishes from that file (aliases followed, `export *` expanded to the file's own exports, type-only re-exports ignored — they publish nothing at runtime). Without that narrowing, `export *` on a 40-entry barrel would credit its entire readership to every entry and `ui/orphaned` would quietly stop finding anything — trading a visible false positive for a silent false negative. There is a test for exactly that direction: a barrel with a used entry and a dead one must still report the dead one.

### Internal (no consumer-facing effect)

- **@voltro/protocol** — **`PluginHttpRouteRequest.store` names all four absences instead of one.** The docstring said "It is not tenant-scoped" and left soft-delete filtering, audit stamping and row-level security to be inferred from "everything that does not need a Subject".

  A team planning to port 19 raw-SQL sites onto that seam inferred the opposite: they wrote down "a store read adds `deletedAt IS NULL`" as the trap with teeth on their list — a soft-deleted user logging back in would go from "found and revived" to "not found → insert → unique violation on email" — and deferred the whole port partly over it. The store does no such thing; it is the raw store plus the storage codec. Naming exactly one of four absences reads as an exhaustive list.

  The docstring now carries the same table the `AuthStrategyInput.store` docs do, with the soft-delete row called out for anyone porting: a read here returns tombstones the way their SQL did, so a lookup that must see one needs no opt-out. (`.withDeleted()` is the opt-out on `ctx.store`, which *does* apply the filter.) Doc-only; the behaviour is unchanged and was already correct.
- **`ci.yml` gains a `workflow_dispatch` trigger.** The full matrix — 11 database services plus the SQL Server AG init containers — is not reachable from a push to `main`: `paths-ignore` plus the job-level `if: github.event_name != 'push'` mean a main push runs static checks only.

  So the only ways to exercise it were a pull request and the release gate, which meant a change to the workflow itself could sit unrun until it fired for the first time INSIDE a release — where a failure costs a ~40-minute round-trip and blocks the publish. That is precisely the position this repo was in.

---

## [0.18.0] — 2026-07-28

### ⚠ BREAKING

- **@voltro/database** — **`_internalCmp` is no longer exported from `@voltro/database/sql`.**

  It was re-exported from the migration planner with the comment "exported for tests that want a custom column sort". No test ever imported it — not in `@voltro/database`, not in another package, not in any sibling repo. It was published surface that existed for nobody, and it stayed invisible because the API goldens covered only each package's main entry until now.

  `codemod: none` because nothing can plausibly be importing it: a grep of every workspace package and every sibling repo finds the export line and no call site. `cmp` remains the local helper it always was; if a test genuinely needs it, export it again together with the caller that justifies it.
- **@voltro/database, @voltro/runtime** — **`.where(col, 'like', value)` is removed, and `startsWith` takes the job it was pretending to do.**

  `'like'` never behaved like SQL LIKE. It mapped to `contains` — `%…%` around the value, case folded on both sides — so `.where('path', 'like', '/api/%')` matched only rows literally containing the characters `/api/%`, and the wildcard the caller wrote did nothing. The docs advertised `.where('col', 'like', 'abc%')`, which is exactly the shape that silently returns nothing.

  In its place, a real prefix predicate:

  ```ts
  .where('key', 'startsWith', 'awb_')          // ergonomic form
  where(startsWith('key', 'awb_'))             // predicate helper
  jsonField('config', 'ns').startsWith('awb_') // inside a json() column
  ```

  `startsWith` is **case-SENSITIVE**, unlike `contains`, and the asymmetry is the design rather than an oversight. `contains` is a search primitive — a human typing into a box means `hello` to find `Hello`. A prefix is a NAMESPACE: `awb_` and `AWB_` are two different key spaces, and quietly merging them is a bug. It also matches the JS method it is named after.

  It is the one string predicate a database can answer from an index: it lowers to `LIKE 'literal%'`, which a btree can range-scan. `contains` (`%…%`) cannot, which is also why there is no `endsWith` — a second un-indexable operator would only look cheaper than it is. `%` and `_` in the value are escaped, so they match literally, and the ESCAPE clause is stated per dialect (sqlite and mssql have no default escape character; mysql already has backslash *and* treats it as a string escape, so the clause is omitted there).

  **Migration** is deliberately manual. Rewriting `'like'` → `'contains'` would reproduce exactly what runs today, bug included, and report the migration as complete — while every site that used a wildcard keeps returning nothing. Each call site is one of two things and only its author can tell which: a wildcard pattern (→ `startsWith`, and that query has been wrong until now) or a substring search spelled oddly (→ `contains`, no behaviour change).

### Added

- **@voltro/protocol** — **`apiKeyStrategy`'s `resolveKey` receives the strategy input** — the same object a hand-written `AuthStrategy.resolve` gets, as a second argument:

  ```ts
  apiKeyStrategy({
    prefix: 'awb_',
    resolveKey: async (hash, { store }) => {
      const rows = await store?.query(apiKeys.byHash(hash))
      return (rows?.[0] as ApiKeyRecord | undefined) ?? null
    },
  })
  ```

  `AuthStrategyInput.store` shipped two releases ago and this helper was the one auth seam that could not reach it: an app using `apiKeyStrategy` had to wrap it in its own strategy purely to close over a store the framework had already handed over — or keep the second connection path to the same database that the seam exists to delete. Existing one-argument resolvers are unaffected.
- **@voltro/testing** — **`describeIfReachable` — because "the database was not there" must not look like "the database was fine".**

  Every integration and dialect-parity suite probes a TCP port and bails when the service is down. The bail was hand-written each time, and the hand-written form ends a test body with an early `return` — which is a PASS. So with no database at all, a suite reports `Tests 2 passed`. The only trace is a smaller duration, which nobody reads, and vitest swallows the accompanying `console.warn` by default, so even the intended signal never prints.

  Found by running the postgres introspection suite against port 1: `2 passed`, having connected to nothing. That suite guards the fix for a boot hang, so a green run there was load-bearing evidence that meant nothing.

  `describeIfReachable(label, target, suite)` makes the honest outcome the default: an unreachable target produces a vitest SKIP — reported as `skipped`, counted separately, with the missing service named in the suite label. "We did not verify this" and "we verified this and it holds" no longer print the same.

  Same class as the changelog and message-API selftests: a check that has quietly stopped checking still prints green, and green is read as evidence.

  **All 36 suites carrying that shape are converted** — every one was fully gated, so wrapping the suite loses no test. Verified in both directions, because only one of them is obvious:

  | | before | after | |---|---|---| | no services running | 105 tests **passed** | **0 passed, 92 skipped** | | the full stack up | 105 passed | **all pass, 0 undeclared skips** |

  The second row is the point — the sweep did not quietly turn a suite off.

  **It paid for itself immediately, three times.**

  *Twelve replication tests had never run.* postgres streaming replica, mysql GTID replica, mssql Always-On AG — left out of CI on the grounds that starting them OOMs a standard runner, so they reported `passed` in every run without once executing. Replication and failover: precisely the behaviour nobody can verify by reading it. Measured rather than argued: baseline 2091 MiB, `postgres-replica` **82**, the mysql pair **1335**, the AG pair **2035**. The OOM claim is true of the WHOLE compose file (keydb, dragonfly, valkey, redis cluster) and not of these. `ci.yml` now starts them — plus the two one-shot containers that actually FORM the availability group, without which both nodes are healthy and the suite still cannot connect.

  *Eighteen cache tests had tested one engine out of four.* The RESP suite iterates redis / valkey / keydb / dragonfly; only redis was started. The other three cost **27 MiB between them**.

  *Three SQL Server instances were fighting over memory.* With the AG nodes running beside `mssql-test`, two mssql round-trip suites failed — and passed when run alone, which is how they would have been dismissed as flakes. Each instance now declares `MSSQL_MEMORY_LIMIT_MB`, so the stack is the same size on a laptop and on a runner. Under the full gate, with every package's suite running in parallel, sql-mssql is 59/59.

  **What remains skipped is skipped for a reason, and the reason is written down.** Four tests, in `sql-sqlite` and `sql-turso`: `clusterTestSuite` gates cross-process resume on `clusterResume`, which needs the workflow runner's state in SQL, and neither dialect keeps it there. Not a missing service — a capability that does not exist for that dialect. That distinction is the whole content of the allowlist.

### Fixed

- **@voltro/runtime, @voltro/cli, @voltro/protocol** — **The boot store (`AuthStrategyInput.store`, `PluginHttpRouteRequest.store`) applies the storage codec.** `.encrypted()` columns decrypt on read and encrypt on write, and array columns round-trip on dialects with no native array type.

  Both seams are documented as "not tenant-scoped", which is correct and unavoidable: they hand out a store to code that runs BEFORE a Subject exists, so tenant scope, soft-delete filtering, audit stamping and row-level security genuinely cannot apply. What that was silently taken to mean is "the raw driver", and it dropped two things that need no Subject at all.

  The consequence was a silent wrong answer rather than an error. An auth strategy reading an `.encrypted()` column got the literal string `enc:v1:…` back — which compares, concatenates, renders and logs perfectly well, and simply never matches the token it is compared to. The failure surfaces as "wrong credential". On the write side it was worse and unrecoverable: an insert through that store wrote PLAINTEXT into a column the schema declares encrypted.

  The line is now **everything that does not need a Subject**, not "less than `ctx.store`". Wired in `voltro dev` and `voltro serve` in the same change (`wrapStoreWithBootCodec`); `raw()` is deliberately left as a pass-through, since it is the documented escape hatch for hand-written SQL and re-encoding rows a caller asked for verbatim would be its own surprise.

  **Upgrading: a reader that compensated for the missing codec by hand now gets the decoded value.** Two shapes, and they land differently. A hand-rolled `decryptField` on the way out is HARMLESS — it passes a non-`enc:v1:` value through unchanged, so the call simply stops doing anything. A hand-rolled `JSON.parse(row.someJsonColumn)` on a column the codec now deserialises is NOT: it is handed an object and throws. If you read an `.encrypted()` or array column through `AuthStrategyInput.store` / `PluginHttpRouteRequest.store` and unpacked it yourself, grep those call sites — the ones typed as a `string | ReadonlyArray<string>` union already survive, a bare `JSON.parse` does not. (Reported by an adopter whose two such reads happened to be written defensively, which is why their upgrade was silent.)
- **@voltro/cli** — **The remaining file-moving codemods narrow their projects too**, and the shared project is no longer a stale snapshot.

  `0.14.0/03_pages-suffix` is gated on `/src/pages/` throughout — plus each app's `app.config.ts`, which is how it tells a real web app's pages from a library that merely keeps components under `src/pages/`. `0.15.0/01_undo-mistaken-taxonomy-renames` only ever renames `*.component[.ui].ts(x)` and an export-less `*.types.ts(x)`. Both now declare that scope and take the importer closure instead of the workspace.

  `0.14.0/04_file-taxonomy` deliberately keeps the whole project: its `isCandidate` is "any `.ts`/`.tsx` that does not already carry a convention", so there is nothing to narrow and pretending otherwise would only move the cost.

  **And a real bug the equivalence test caught.** The shared project was built once, lazily, at the first whole-project codemod — *before* a narrowed one wrote its renames to disk. So `0.14.0/04` ran against the pre-`03_pages-suffix` tree and classified `alpha.tsx` as a component, where the whole-project run produced `alpha.page.tsx`. The shared project is now dropped whenever a narrowed codemod changes the disk, and each shared codemod saves before the next narrowed one reads it — the disk is the single source of truth in both directions.

  Nothing in either codemod's own output hinted at it: both reported success, with different results. Verified by running the same fixture through the 0.13.0 → 0.16.0 jump both ways and diffing the trees; identical, including an aliased and a relative importer outside every scope.
- **@voltro/cli** — **`voltro update` sizes the codemod pass's heap from the machine.** Node caps the old space near 4 GB regardless of installed RAM, so a large monorepo died on a machine with memory to spare — and died in V8, with no line naming a limit.

  The pass holds one ts-morph project whose cost is roughly linear in the file count: measured at ~97 KB per file on a synthetic fixture (818 MB at 2,320 files, 1,399 MB at 8,320 — median of three runs on an otherwise idle machine). An adopter's repo needed ~30 GB; at node's default it aborted in 83 seconds.

  The re-exec'd child now gets `--max-old-space-size` at 75% of total RAM, leaving room for the OS. An explicit `NODE_OPTIONS` from the caller always wins, and nothing is set when 75% would be *below* node's own default — a lower ceiling than node would pick is a pure regression.

  This does not make an impossible run possible; it stops an arbitrary limit from being the binding one. Where the machine genuinely lacks the memory, the scan's file ceiling reports it in words.

  **Measured, not assumed — and three plausible fixes were measured and rejected first**: replacing the runner's per-codemod full-text bookkeeping, releasing ts-morph's node-wrapper cache, and hoisting the aliased-importer resolution out of the move loop. A fourth, chunking the pass into per-codemod projects, is correct (109/109 codemod tests pass with one file per project) but does not flatten the curve — peak still grows ~97 KB per file either way, because the codemods that move files must see the importers and so keep the whole-project view.
- **@voltro/cli** — **A codemod that moves files no longer loads the whole workspace.** Peak memory now follows what the codemod touches instead of how large the user's repository is.

  A mover has to see its importers — `SourceFile.move()` rewrites the relative specifiers pointing at the moved file and `moveCarryingAliasedImports` does the same for aliased ones, but only for importers that are IN the project. So the movers took everything, and everything is what made the pass cost ~107 KB per file. An adopter's monorepo needed ~30 GB and died in a V8 abort.

  `codemodImporterClosure` answers "who imports these" from TEXT: one streaming pass that extracts module specifiers and discards the source. A codemod declaring `scope` + `needsImporters` then gets a project of its scope plus that closure. `0.17.0/01_pages-are-directories` — the most expensive codemod in a run — is the first to use it; it only ever touches files under `src/pages/`, so the rest of the repo was pure cost.

  Measured on the 0.16.0 → 0.17.0 jump, 300 pages, synthetic fixture:

  | files | narrowed | whole project | |---|---|---| | 2,320 | 554 MB | 720 MB | | 8,320 | **617 MB** | 1,359 MB |

  ~10 KB per file instead of ~107 KB — the curve is flat, which is the point: the run no longer gets harder because the repository grew.

  **The closure's matching rule is the same one `rewriteAliasSpecifier` applies**, so a file it leaves out is one the mover would not have rewritten anyway. Directory names are indexed too, because `import x from './settings'` resolving to `settings/index.page.tsx` names the directory and never the stem — and `move()` rewrites that specifier.

  Verified by equivalence, not by inspection: the same fixture run both ways produces byte-identical trees, including an aliased and a relative importer living OUTSIDE the codemod's scope. The first version of this failed that test — the specifier index was built over the union of the selected scopes, which no longer contains the importers once a codemod narrows its own.
- **@voltro/cli** — **The codemod scan's file ceiling now applies to the git enumeration path**, which is the path every real project takes.

  `enumerateScopedFiles` has two branches: `git ls-files` when the root is a repository, and a glob walk otherwise. The ceiling — with the error that explains what to exclude — sat in the glob branch only. So a git repository walked straight past it, loaded the whole workspace into one ts-morph project, and died in V8 with no line naming a heap. An adopter measured it: 4 GB aborts in 83 s, 24 GB after 14 minutes, 30 GB completes in ~9 minutes. "It died" was the entire diagnosis available to them — from a guard written to prevent exactly that.

  The message now also says WHY the count matters (every matched file is parsed into one project), and names three ways out in order: `.gitignore` for non-source trees, running from the app directory, or `--only <id>` one codemod at a time. `VOLTRO_CODEMOD_MAX_FILES` raises the ceiling for a workspace where the whole set really is source, alongside `NODE_OPTIONS=--max-old-space-size`. The limit is read at call time rather than frozen at import.

  Regression cover asserts BOTH branches, and the git one was verified by removing the call and watching that test go red.
- **@voltro/cli** — **`component/one-per-file` counted things that are not components — and its message asserted they were, which is what made it expensive.**

  The classifier read the first letter of the exported NAME. So two shapes reported clean code as broken, both found by an app adopting the taxonomy across 658 files:

  - `export const DEFAULTS = { a: 1 }` beside a component was reported as `exports 2 unrelated components: DEFAULTS, OnlyOne`. A plain object, named as a component. That app split a five-line constant into its own file to satisfy a rule that was misfiring. - `export default OnlyOne` next to `export const OnlyOne` was reported as two components, the second one named `Default`. One binding, exported twice — the export FORM was being counted, not the component.

  Classification now comes from the DECLARATION: a function, a class, an `FC`-annotated binding, a `memo`/`forwardRef`/`observer` wrapper, a tagged template. An object, an array, a string, a number, a `new` — not components. The `default` entry resolves to the declaration it names and de-duplicates on the node, so the same component cannot be counted once per export form.

  The classifier is deliberately GENEROUS about the unknown, because the two error directions are not symmetric: calling a component "not a component" makes the rule report `exports no component` on a correct file, which is worse than letting one unusual value through.

  The catalogue said a `*.component.tsx` promises "exactly one component **(+ types)**", which reads as "types are the only exception". It never was — the rule counts components, and a `const COLUMNS = […]` beside the table that renders it was always allowed. The docs now say so in both languages.
- **@voltro/cli** — **The seeded `AGENTS.md` / `CLAUDE.md` never mentioned `.serverOnly()` — the one marker that decides leak vs no leak.**

  Reported from a strict-mode pass over five apps: the template documents `.encrypted()`, `audit()`, `tenant()`, `softDelete()`, `.check()`, `validate()` — and not the marker that decides leak vs no leak. *(Corrected after publication: this entry said the boot audit "hard-fails" on it. It did not — it was a single `log.warn`, in every command. The next release makes the claim true; see that entry.)* Depth existed (`database/sensitivity`, `data/crud`, both languages); the always-loaded core simply never pointed at it, so an agent or a human following the template never learned the marker exists.

  The core now carries a short section on the three markers as ORTHOGONAL questions, because the substitution is the real hazard:

  | Marker | Answers | Enforced by | |---|---|---| | `.serverOnly()` | may this leave the server at all? | an audit — see the correction above | | `.sensitive()` / `.safe()` | may it appear in an export? | the masking profile, fail-closed | | `.encrypted()` | is it encrypted at rest? | the store's codec |

  Reading `.encrypted()` as "safe to expose" is a category error and a plausible one — the runtime decrypts for the handler, so an encrypted column reaches a client like any other unless it is ALSO `.serverOnly()`.

  **The doc-drift guard is why this survived, so it grew the missing half.** It asserted the core carries the EXPORT axis (`.sensitive(`, fail-closed, the export endpoint) and said nothing about the wire axis. There is now a matching assertion for `.serverOnly()`, the enforcement, and the orthogonality — verified by deleting the section and watching it go red. *(It asked only for the words "boot audit", which the template supplied while promising a failure nothing performed. The next release makes it name the commands that gate.)*
- **@voltro/cli** — **`voltro dev` could not start at all in a strict-pnpm install: the supervisor respawned with a bare `--import tsx`.**

  Node resolves a bare specifier against the CHILD's working directory. `@voltro/cli` declares tsx; the user's app does not — so under a non-hoisting layout tsx lives inside `node_modules/.pnpm/@voltro+cli@…/node_modules/tsx` and is invisible from the app root. The first respawn died with `Cannot find package 'tsx'`, naming a package the reader never asked for and cannot usefully install.

  `bin/voltro.mjs` had already learned this and resolves an ABSOLUTE URL before spawning. The supervisor then threw that answer away and re-derived a worse one.

  Every spawn site now goes through `tsxLoaderArgs()`, which prefers the loader already present in `process.execArgv` — literally the absolute path the shim computed, and inheriting it also preserves the user's own node flags (`--inspect`, `--max-old-space-size`), which a hand-built flag list silently dropped. Behind that: `VOLTRO_TSX_IMPORT`, now exported by the shim for grandchildren that are not themselves loader-registered; then resolution from `@voltro/cli` itself, the package that depends on tsx. The bare specifier survives only as a last resort, and it now says so instead of failing mutely.

  Three of the five spawn sites were already correct — `envWatch`, the web-dev respawn, and the shim — and nothing said the other two were wrong. A test now fails if any non-test file under `src/` writes the flags by hand.
- **@voltro/cli, @voltro/devtools** — **The in-page devtools overlay had no way to authenticate against a fail-closed inspect surface — and the one channel it documented was disabled two directories away.**

  Since `/_voltro/inspect/*` went fail-closed, the overlay's Traces / Webhooks / Indexes panels needed a bearer token. A browser cannot be given one: the only channel that would reach it is a `VITE_`-prefixed env var, i.e. a live credential compiled into every bundle, which this framework refuses to do anywhere. So the overlay pointed at `VITE_VOLTRO_INSPECT_TOKEN` — which `voltro dev` and `voltro build` both make unreadable on purpose, by setting vite's `envPrefix` to a sentinel that matches no real variable. A reader who followed the docs set the variable, got no header, and had no way to see why.

  Three changes, one shape:

  - **`voltro dev`'s vite proxy attaches the minted bearer server-side** on the `/_voltro/api/<name>` route the panels fetch through. Nothing to configure, and the token never reaches the page. It refuses two cases deliberately: a caller that already sent an `Authorization` header (the dashboard forwards a real one — overwriting it would re-scope somebody else's request), and a non-loopback target (`proxyTarget` is user config, so injecting unconditionally would hand this machine's credential to a host we do not control). - **The dead `VITE_VOLTRO_INSPECT_TOKEN` fallback is gone.** `<VoltroDevtools inspectToken>` remains for reaching an api the proxy does not front, and its doc now says plainly that whatever you pass ships in the bundle. Its test previously admitted, in a comment, that it asserted the null path and called it the fallback — which is how a documented-but-dead channel survived a green suite. - **The empty-state copy said inspect was "open in dev mode".** That stopped being true when the surface went fail-closed, so a reader who hit a 401 was told by the panel itself that it could not have been an auth failure. It now names the remedy.

  Separately: the `indexes` tab-badge count polled with the overlay CLOSED. Its two neighbours (`traces`, `webhooks`) take an `enabled` flag; this one was missed, and it is the expensive one — it holds an `EventSource` open per api for the whole life of the page, plus a fallback poll whenever that stream errors.
- **@voltro/cli** — **`voltro doctor`'s boundary rules could not follow a `@/`-aliased import, so they under-reported — silently.**

  The file-taxonomy walker resolved relative specifiers and nothing else. On an app that imports through a tsconfig `paths` alias — which is most of them — every such edge was invisible, and a rule that cannot see an edge cannot fire on it:

  - `internal/foreign-import` and `fixture/production-import` came back CLEAN on code that violates them. That is the dangerous direction: no findings reads exactly like no problems. - `ui/unlinked` fired on all 16 of one app's presentational components, because each was reached only through `@/components/…`. A rule that fires on correct code teaches people to ignore it.

  The graph now resolves aliases from the NEAREST `tsconfig.json` walking up to the scan root — `voltro doctor` runs at the project root while `@/*` is declared per app, so reading only the root config found no `paths` at all in the layout we scaffold.

  Two more edge forms were missing for the same reason: `export … from` and dynamic `import()`. The re-export one is not a completeness flourish — a barrel is the file most likely to reach across a feature boundary, so `export { x } from './orders.internal'` is precisely the case `internal/foreign-import` exists to catch, and it was the one shape the rule could not see.
- **@voltro/cli** — **The release gate now fails on a skipped test it was not told about.** "All green" has to mean everything RAN, or the total is a number about how little was attempted.

  Locally a skip stays fine — nobody should need six databases to run `pnpm test`, and a suite that fails without them stops being run at all. In CI it is not fine: a skipped test is an unverified claim wearing the same colour as a verified one.

  `scripts/check-no-skipped-tests.mjs` reads the per-package vitest summaries out of the test step and fails on anything skipped that is not declared in its `ALLOWED` map with a reason and an **exact** count. Both directions are enforced, and the second is the one that matters:

  - more skips than declared → something stopped running; - **fewer** skips than declared → the entry is stale, and a stale allowlist silently absorbs the next regression. That is the failure mode an allowlist has instead of the one it removes, and it is only survivable if the list is forced to stay exact.

  It runs `--selftest` first, like the changelog and message-API gates, for the same reason: a check that has quietly stopped detecting anything still prints green. Both of its rules were verified by breaking them and watching the selftest go red — the ANSI stripping and the stale-entry direction.

  **Two kinds of skip exist and only one is a defect.** "The dependency was not there" is a coverage gap — start the service. "This does not apply to this configuration" is correct — declare it. The allowlist holds exactly the second kind: four tests in `sql-sqlite` and `sql-turso` whose dialects keep no workflow-runner state in SQL, so cross-process resume is not a thing they can do. The 12 replication tests that would have been the first entries were the FIRST kind, and cost 3.4 GB on a 16 GB runner — `ci.yml` starts their services instead of declaring them away.

  The check found both of its first three catches on its own first run: 18 cache tests covering one RESP engine of four, and the two dialect suites above. It also caught itself — it passed in 0.2 s on a run whose test step had aborted after 0.5 s, because an empty log has nothing to complain about. A log with no vitest summaries is now a failure, with its own selftest case.
- **@voltro/cli** — **Four `voltro dev` inspect endpoints answered 200 to any caller: `traces`, `webhooks`, `analytics`, `aggregates`.**

  `handleInspectRequest` gates everything that reaches it. The branches in front of it are EARLY RETURNS — they answer and never reach it — so each had to remember to gate itself, and four did not. `traces` is the sharp one: an adopter measured 38 KB of live spans from an unauthenticated `curl`, a request-by-request record of what the process just did. Per the tracing docs those spans also carry `rpc.tag`, `subject.type` and `tenant.id`.

  That is the split `0.12.0` was written to close — *"the absence of a secret is not consent"* — with `metrics` and `logs` gated and `traces`, which is strictly more revealing than either, not.

  **Scope, because the reporter could not test it and asked: `voltro serve` and `voltro start` are NOT affected.** Neither mounts these branches — `serveApi.ts` contains no `/_voltro/inspect` path at all, and `start.ts` goes through the shared, gated `handleInspectRequest`. The leak is dev-only, which lowers the severity without removing it: a dev server on a shared machine or a bind-mounted container is not private either.

  The gate now runs ONCE at the door, before any branch, so a new branch cannot be added without it. CORS preflight stays exempt — a browser sends `OPTIONS` with no `Authorization` header by construction, and the preflight carries no data.

  **The failure mode was already written down one level below.** `inspectLogsEndpoint` carries the comment *"gating per-caller drifted (start was ungated, dev gated nothing, webDev gated disabled-but-not-token)"* and single-sources the gate inside the handler. The lesson was right; it was applied at the wrong depth.

  **One branch changed behaviour beyond the four: `POST /_voltro/inspect/clientLog` on the API.** It is an ingest endpoint, so the risk it carried was injection rather than disclosure — anyone could write lines into the developer's terminal log. Nothing in the framework posts there: `@voltro/web`'s browser bridge ships to its own origin, which the web dev server serves and deliberately leaves ungated (a browser has no token, and that path accepts only log batches). The API's copy is reached by direct callers, which can carry one.

  **The overlay is carried across this**, in the same release: the Vite dev proxy now attaches the minted bearer server-side (see the devtools-overlay entry), so the panels stay live and the browser still never holds the token.
- **@voltro/web** — **`<Link ref>` typechecks, which it always should have — the runtime forwarded it all along.**

  `Link` spreads every prop it does not consume onto its `<a>`, and React 19 hands `ref` to a function component as an ordinary prop, so a ref has always reached the anchor. `LinkProps` extends `AnchorHTMLAttributes`, which carries no `ref` — so the type refused behaviour that worked.

  Not cosmetic: every polymorphic slot that threads a ref through — `<Button component={Link}>` in MUI, Chakra's `as`, any `component=` escape hatch — was a type error. A consumer shimmed `Link` app-wide to get past it.

  The test covers both halves, because neither alone is enough: a `satisfies LinkProps` that stops compiling if `ref` leaves the type, and a jsdom render asserting the ref (object AND callback form — the slots use callbacks) actually lands on the anchor.
- **@voltro/database** — **Postgres FK/PK introspection reads `pg_catalog`, not `information_schema` — this killed a `voltro dev` auto-migrate hang that never finished.**

  This shipped in code without a changelog entry, so nobody upgrading was told either that the hang was fixed or that a new env var exists. Recording it now, with the numbers measured on our own hardware rather than taken from the report.

  On a FK-dense schema, `voltro dev` hung indefinitely at `auto-migrate: planning schema` — pod 0/1, no error, no timeout. The FK query 4-way-joined `information_schema.{table_constraints, key_column_usage, constraint_column_usage, referential_constraints}`. `constraint_column_usage` is a security-barrier view whose `table_name IN (…)` predicate does **not** push down, so every batch re-scanned FK metadata for the whole catalog. The `blocked` refuse-gate sits *after* introspection, so its helpful message never printed.

  Measured against a live 532-table / 2637-FK postgres 17:

  | | one batch of 20 tables | full introspection | |---|---|---| | `information_schema` | **2041 ms** | ~55 s extrapolated over 27 batches | | `pg_catalog` OID join | **8.7 ms** | **404 ms cold, 368–374 ms warm** |

  Both return the identical 90 rows for that batch, so this is a correctness-preserving rewrite, not a narrowing. `PgFkRow` and `mapPgRule` are unchanged — `confdeltype`/`confupdtype` chars map back to the information_schema rule tokens in SQL. Multi-column FKs pair positionally via `unnest(conkey/confkey) WITH ORDINALITY`; composite PKs keep declared (`conkey`) order.

  Batching is kept, not removed: it exists for pooler mis-framing of large responses, and with the OID join the batch filter pushes down, so each batch scans only its own tables.

  **New env var: `VOLTRO_INTROSPECT_TIMEOUT_MS`** (default 30000, `0` disables). The whole postgres introspection runs in one read transaction under `SET LOCAL statement_timeout`, so a query that ever degenerates again dies with an actionable error instead of freezing a pod at 0/1. `SET LOCAL` reverts at commit — no pooler session-state leak.

  MySQL/MariaDB were never affected: their FK query already joins `key_column_usage` to `referential_constraints` with no `constraint_column_usage` cross-join.
- **@voltro/cli** — **`voltro update --only <id>` works in the spelling the tool itself prints**, and an unknown flag is now refused instead of ignored.

  `--only` was read by a separate pass that the positional-argument loop knew nothing about, so the flag was skipped and its VALUE — which does not start with `--` — fell through and was resolved as the app directory. The report named a path the user never typed:

  ```
  $ voltro update --codemods-only --only 0.17.0/01_pages-are-directories
  voltro update: no package.json at …/web/0.17.0/01_pages-are-directories
  ```

  Both the usage text and the dirty-tree hint print `--only <id>`, so the documented form was the broken one. `--only=<id>` and `--only <id>` now both work, on `voltro update` and on the `_apply-codemods` entry it re-execs into.

  Separately, an unrecognised flag now exits 1 with `unknown flag <name>`. A typo'd `--codemod-only` (singular) used to be dropped silently — and the command then ran the FULL update, bump and install included, on a tree the user had asked to touch as little as possible.
- **@voltro/cli** — **`voltro update` dying of memory now says that is what happened.**

  An adopter upgrading 0.14 → 0.17 hit it twice — 4 GB after 83 s, 24 GB after ~14 min, succeeding only at 30 GB — and reported the part that actually cost them: *"in der Ausgabe stand nichts von einem Heap-Limit — der Prozess starb, ohne die Ursache zu nennen."* V8 does not fail an out-of-memory politely; it aborts, so the child was gone by SIGABRT with nothing naming a limit, and the exit code was passed through in silence.

  The cause is separately addressed and unreleased at the time of their report: `withCodemodHeap` sizes the child's `--max-old-space-size` from the machine rather than node's ~4 GB guess, and the codemod pass now builds an importer closure instead of the whole workspace (measured ~10 KB per file against ~107 KB before). Their 30 GB should not be needed again.

  But a cause that is fixed is not the same as a failure that explains itself, so the exit is now read rather than forwarded: a SIGABRT / 134 names the memory, the `--max-old-space-size` knob, and the two ways to narrow the pass (`--only`, `--root`); a SIGKILL is reported as something having stopped it — usually a container's memory cgroup — rather than as a codemod failure, which would send you to debug the wrong thing. An ordinary non-zero exit stays silent, because the child has already explained itself.

### Internal (no consumer-facing effect)

- **The API goldens now cover every published entry point, not just `dist/index.d.ts`.**

  The changelog already states the rule: "The public API of each package is its `publishConfig.exports` entry points." Entry pointS — but every generated `api-extractor.json` pointed at the package's main entry and nothing else, so **87 declared subpaths had no golden at all**: `@voltro/protocol/apikey`, the nine `@voltro/plugin-auth/*`, `@voltro/web/hooks`, `@voltro/database/sql`, and the rest.

  That is the signal that forces a `BREAKING` changelog entry and its codemod, and on a subpath it was simply absent. `ApiKeyStrategyOptions.resolveKey` gained a parameter in this same release and no gate said anything — additive, so harmless, but the repo's own "more precise is still breaking" rule (the one that cost an adopter 102 hand-fixes) would have shipped silently through the same hole.

  `gen-api-extractor.mjs` — already the single source of truth for this wiring — now emits one config per entry point (73 → 160) and one golden each, derived from the exports map so the checked set is BY CONSTRUCTION the published set. It also deletes configs and goldens for entry points a package no longer exports: a golden nothing runs reads exactly like covered surface. The per-package `api:check` chains its configs with `&&` rather than a loop, so the first failure stops and reports — a loop is what let the local gate score a green `api-surface` over two stale goldens.

  Verified by breaking a subpath signature on purpose: `api:check` now exits 1 and names `protocol-apikey.api.md`.

  Regenerating also fixed real drift in the shared path map — `@voltro/cli/serveEntry`, `/startEntry` and `/devActivity` ship but were missing from every package's `tsconfig.api-extractor.json`.
- `pnpm gate` ran each ci.yml `run:` block with `pipefail` but not `-e`, while GitHub Actions' default shell is `bash --noprofile --norc -eo pipefail`. A multi-command block whose MIDDLE command failed carried on, and the step's status became the status of the last command — so the `api-surface` step, a `for` loop over every package's `api:check`, printed two API-drift warnings and still reported `✓`. The gate said green on a tree whose CI job goes red, which is the one thing it exists to prevent.

  Fixed, and it now ships a `--selftest` that runs first (silent unless it finds something), matching the changelog and message-API gates. Its first case is the exact shape that hid this — a failing middle command with a passing last one — and it goes red without the `-e`.
- **The skipped-tests gate could not read the output CI actually produces.**

  It parsed turbo's STREAMING shape, where every line carries a `@voltro/kv:test:` prefix. On a GitHub runner turbo detects Actions and switches to GROUPED output instead: the package name moves into a `::group::@voltro/kv:test` header and the lines inside carry no prefix at all. The summary regex requires the prefix, so it matched nothing.

  The consequence was not a wrong answer — it was no answer. The check found zero summaries and refused to judge a run in which all 110 tasks had passed, which is exactly what it is built to do when it cannot confirm anything. It failed the release it was gating.

  It had never once run in CI. A push to main runs static checks only, so between the commit that introduced it and the release that used it, nothing executed it against real runner output. Its nine selftest cases all passed throughout: they exercise the RULES, and the bug was the INPUT FORMAT.

  Both shapes parse now, group attribution closes on any non-`:test` group boundary so a stray summary is never credited to the wrong package, and a log downloaded with `gh run view --log` (which renders ESC as the two characters `^[`) parses too — that is how a red run gets debugged offline, and it is how this fix was verified: against the failing release run's own log, where the old parser reports "no summaries" and the new one reports the same `4 skipped in 2 packages` the local gate reported.

  Eight selftest cases cover the grouped shape, for the reason the selftest exists at all — a check that has quietly stopped detecting anything still prints green.

---

## [0.17.0] — 2026-07-27

### ⚠ BREAKING

- **@voltro/cli** — **A directory is a route segment, and its route is `page.tsx`.**

  ```
  src/pages/page.tsx                  → /
  src/pages/pricing/page.tsx          → /pricing
  src/pages/users/[id]/page.tsx       → /users/[id]
  src/pages/docs/[...slug]/page.tsx   → /docs/<anything>
  ```

  A parameter is a **directory** name now, never a filename. Beside `page.tsx` sit the other reserved names its segment owns — `layout.tsx`, `error.tsx`, `loading.tsx`, `not-found.tsx` — plus `page.test.tsx` and any co-located components.

  **Why, one release after `*.page.tsx` landed.** The evidence was in the same folder the whole time: `layout.tsx` / `error.tsx` / `loading.tsx` / `not-found.tsx` are reserved names that take their meaning from the DIRECTORY, with no suffix. The page was the only member of that family carrying one — so the convention broke its own rule four times per folder, and nobody could say which form a route should take without a four-row table.

  What the change removes, beyond the inconsistency:

  - **The choice.** `x.page.tsx` and `x/index.page.tsx` routed identically. - **A silent bug class.** `settings.page.tsx` beside `settings/layout.tsx` rendered WITHOUT that layout, because the chain is built from the directories a file physically sits in. Nothing warned; the page just lost its layout. Under the new rule the case cannot be expressed. - **The `page/unsuffixed-in-pages` heuristic** — "a default export that nothing imports is probably an unmigrated route". A `.tsx` in a route folder that is not `page.tsx` is now structurally not a route. A guess replaced by a fact, which also retires the diagnostic promoted to an error last release.

  **The codemod moves every route into its own directory** and carries its co-located test. It **refuses** where two files claim one route (`users.page.tsx` + `users/index.page.tsx` both routed to `/users`): NEITHER is moved, both are named. Picking would delete a route silently — the failure this whole change exists to make impossible.

  Applied to our own five repos: 222 routes moved, no collisions.

  **A note for anyone writing a codemod.** 0.14.0's two codemods imported `PAGE_PATTERN` from the shared registry, and this release changed what it means — so `03_pages-suffix` started appending a second suffix (`index.page.page.tsx`) and `04_file-taxonomy` renamed routes to `*.component.tsx`, silently, for anyone upgrading from 0.13. Both now freeze their own patterns. A codemod describes ONE jump between two conventions that were true at the time; it is the one place a local copy of a shared rule is correct.

---

## [0.16.0] — 2026-07-27

### Added

- **@voltro/protocol, @voltro/runtime, @voltro/cli, @voltro/database** — **A plugin HTTP route reaches the app's DataStore, on `req.store`** — and `voltro db apply` finally honours `VOLTRO_DESTRUCTIVE_OK`.

  ### `PluginHttpRouteRequest.store`

  The same seam as `AuthStrategyInput.store`, one layer over, and the same report produced it. An adopter's `auth/db.ts` has five consumers: two are auth strategies and collapsed onto `input.store` exactly as designed; three are plugin HTTP routes and could not, so the second `ManagedRuntime` + `MysqlClient` stayed for them.

  Login is the sharpest case and it is not exotic: it MUST write (the session row), it cannot be an rpc mutation because it is what mints the cookie, and it is a documented first-class pattern — `@voltro/plugin-auth` ships `handleSignIn` / `handleSignUp` and the reference consumer mounts them on this surface. Every app that does so needed a store the route contract did not give it.

  It is the BOOT store, through the same lazy getter the auth chain reads — one ref, three consumers now — and `undefined` while it is still being built, so a route should answer rather than throw. It is **not tenant-scoped**: a route serves raw HTTP with no resolved Subject, so a route reading tenant-owned rows must derive and apply that scope itself. That is the price of the surface being raw, and the reason an rpc procedure stays the better home for anything that can be one.

  ### `db apply` honours `VOLTRO_DESTRUCTIVE_OK`

  The table-list opt-in shipped on the auto-migrate path only. `voltro db apply` computes its own plan and checked `summary.blocked` directly; its module contained no occurrence of the variable at all. So an app's staging migration Job — running the sanctioned `db apply --plan` form — had **no route through an intentional, declared, `lossy`-classified table drop**, and the release note's own example was a command that ignored the variable it set.

  Both forms now route through the same helper as the boot gate: every blocked op must be `lossy`, a named scope unblocks only the tables it names, anything still blocked refuses the whole plan. The applier receives the UNBLOCKED plan — passing the still-blocked one would have it refuse a second time, which is the exact bug `unblockLossy` was written for.

  Worth naming, because it is a boundary of the message-API gate added last release: the variable exists, is spelled correctly, and IS read — by a different command than the one printing it. *"The named API exists"* and *"the named API is reachable from here"* are different claims, and only the first is checkable from a string.

### Fixed

- **@voltro/cli, @voltro/database** — **`voltro update --codemods-only` crashed on a file deleted but not staged** — a regression introduced by 0.15.0's own git-based enumeration.

  `git ls-files --cached` reads the INDEX, and the index still holds a path that is already gone from disk until the deletion is staged. `addSourceFileAtPath` then threw ENOENT, surfacing as a raw ts-morph stack rather than as anything a reader could act on.

  The state is not exotic — it is what an ordinary mid-work tree looks like, and `--dry-run` is documented as allowed on a dirty tree, which is exactly where an unstaged deletion lives. The command was unusable in the state it explicitly permits. Paths that are gone are now filtered out: there is nothing to rewrite and nothing to report.

  **A failing DDL statement now travels with the error, not only to stderr.** A soft-drop aborted a boot with a bare `SqlError: Failed to execute statement` — no statement, no table, no operation. The applier wrote the detail with `process.stderr.write` inside a `tapError` and re-raised the ORIGINAL error, so the detail was lost whenever the process aborted before the stream flushed, or whenever the caller rendered the error rather than the console. `migrate.ts` already carried it in the error for file-based migrations; the planner-driven applier did not, so **which path failed decided whether you could see what failed.**
- **@voltro/cli** — **`voltro update --codemods-only` refused in the one state it exists for.**

  The clean-tree guard ran before the repair path, so the command whose entire job is finishing an interrupted upgrade refused whenever the tree was dirty. An adopter reported it twice; the second time their tree carried uncommitted work **from the previous upgrade**, so they bumped four manifests by hand and ran the install themselves — the outcome `voltro update` exists to prevent, reached through its own guard.

  `--force` was always available and is the wrong answer: it is documented as "not recommended", so it reads as an escape hatch rather than as the sanctioned route through a state we explicitly support.

  `--codemods-only` now **warns** instead of refusing, saying the codemod diff will be mixed in with the existing changes and pointing at `--dry-run` / `--only`. The full update still refuses — bump, install and rewrite in one pass on top of unrelated changes is a diff nobody can read — and its refusal now names the repair path.

### Internal (no consumer-facing effect)

- **@voltro/cli** — The admin-import rejection cases share ONE booted server, and report their phase timings.

  Two release gates have now failed on that single test line, with two different symptoms and neither an auth defect: first a **400** — this server rejects a request whose body framing broke BEFORE routing, so `gate()` never ran — and then, after the body was removed, a **120-second timeout**. A bare `Test timed out` cannot say whether the boot, the request or the close is what hung, which is why the second failure taught us nothing the first hadn't.

  This file was standing up SIX real `serveApi` instances, each with its own socket and Effect layer, on a machine already running every other package's suite under `turbo --concurrency=2`. The two rejection cases assert nothing about the store, so they now share one boot, and the assertion carries `boot=…ms absent=…ms wrong=…ms` — a future failure names the slow phase instead of just the line number.

  No product code changed.
- **@voltro/cli** — The admin-import rejection tests stop uploading an archive they never needed.

  A release gate failed with `expected 400 to be 401` on `401 without a Bearer token` and did not reproduce in five later runs. It was not an auth defect and not load-flake in the usual sense: measured against a live server on that path, an honest no-auth request answers **401**, a body-less one answers **401**, and one whose `Content-Length` lies — or whose chunked framing ends early — answers **400**, because the HTTP layer rejects broken framing BEFORE routing. `gate()` is the handler's first statement, so when it never runs there is no 401 to give.

  Those two tests were POSTing the full packed bundle to assert an authorization property that is decided without reading the body at all. The archive proved nothing and made a multi-KB upload a precondition of an auth assertion. They now send no body, which is both flake-free and the sharper claim; the assertions carry the response body so a future mismatch names the responder instead of printing a bare status.

  No product code changed — the endpoint's behaviour is unaltered.
- **@voltro/cli** — The admin-import auth test reports WHO answered, not just that the number was wrong.

  It failed once under full-gate load with `expected 400 to be 401`, and did not reproduce in four subsequent runs (the file alone, the integration group alone, two full suites, a second full gate). The bare status made the log useless: `gate()` is the first statement in `handleAdminImport` and always 401s an absent token, so a 400 proves the request never reached that handler — but nothing in the failure said which handler DID answer.

  The assertion now carries the response body and the target URL, so the next occurrence names the responder instead of costing an afternoon. No product code changed; the endpoint's behaviour is unaltered.

---

## [0.15.0] — 2026-07-27

### ⚠ BREAKING

- **@voltro/cli** — **A rename now carries the ALIASED importers, not only the relative ones.**

  An app's web build stopped compiling after `voltro update`: 84 relative imports were rewritten correctly, 163 aliased ones across 88 files were not, and `tsc` reported 249 errors on names that no longer existed. Nothing in the codemod's output hinted that a whole class of import had been skipped.

  Two independent halves, and each alone leaves the imports stale:

  - The codemod's ts-morph project was built with **no `baseUrl` and no `paths`**, so `@/components/link` resolved to nothing. It now gets the compiler's own shape — the raw `paths`, not the pre-resolved Vite table, because a `paths` target is relative to `baseUrl` by definition and an absolute one does not resolve. - `SourceFile.move()` rewrites relative specifiers and **nothing else**, which is correct on its own terms: ts-morph cannot know whether the alias mapping or the file is meant to change. So the codemods now rewrite the aliased ones themselves, narrowly — only specifiers that RESOLVED to the moved file, and only the trailing stem, which needs no alias table and so cannot disagree with one. The run reports how many it rewrote.

  **A file an exact `paths` entry names is left alone and reported.** Renaming `link.tsx` while `"@/link": ["src/components/link.tsx"]` points at it leaves the mapping resolving to nothing — and the same path is usually repeated in a vite/vitest alias table no codemod owns. One app hit this and then saw a taxonomy violation reported on a file the codemod had itself created.

  **`page/unsuffixed-in-pages` is now an error, not a warning.** The comment justifying the warning contradicted the scanner it described: that bucket is already narrowed to a default export nothing imports, which is what an unmigrated page looks like and what a co-located component never does. The failure it names is invisible everywhere else — an unmigrated route simply 404s, with a clean `tsc` and a green suite. One app finished a migration with 51 of them. `codemod: none`: the rename it asks for already ships as `0.14.0/03_pages-suffix`, and nothing about a user's SOURCE changes here — what changes is that `voltro check` now fails on a route that does not route.
- **@voltro/protocol, @voltro/client, @voltro/web** — **`errorTag` moves from `@voltro/client` to `@voltro/protocol`.**

  It reads the `_tag` that `toRpc` writes, so it now lives beside `toRpc` — one file owning both ends of that contract. Where it used to live had a cost invisible from inside the framework: an app's shared error handler sat in a package that pulled only `@voltro/i18n`, and reading a tag would have meant depending on the entire client package for seven lines. They declined, and kept parsing message strings with a regex — the exact outcome the helper exists to prevent.

  `@voltro/web` re-exports the client surface, so it loses the symbol too — the same codemod covers an app that imported it from there.

  Not re-exported from `@voltro/client`: two import paths for one helper is how the next reader learns the wrong one. The transform codemod repoints the import, preserving an alias (`errorTag as tagOf`) and the type-only form, and merges into an existing `@voltro/protocol` import rather than adding a second.

  While moving it, its doc comment gained the thing that matters at the call site and was only implied before: **`instanceof` does not hold on the client.** What arrives there was decoded from JSON and never constructed, so match on the tag, not on the class. One team read the old wording as a promise that `instanceof` works and was right to say so.

### Added

- **@voltro/protocol, @voltro/cli** — **An auth strategy reaches the app's DataStore, on `input.store`.**

  ```ts
  const sessionStrategy: AuthStrategy = {
    id: 'db-session',
    resolve: async ({ headers, store }) => {
      if (store === undefined) return { kind: 'skip' }        // still booting
      const [row] = await store.query(sessions.byToken(headers.authorization))
      return row ? { kind: 'matched', subject: toSubject(row) } : { kind: 'skip' }
    },
  }
  ```

  Without it, a DB-backed strategy — a session row, an API-key record, a PAT table — had to open a SECOND connection path beside the framework's, to the same database the request store opens a moment later. One adopter's `auth/db.ts` is 105 lines of exactly that: a second `ManagedRuntime` plus a `MysqlClient`, load-bearing for their session lookup and their ApiKeyStore. Every DB-backed OIDC / SAML / PAT integration rebuilds it, which is what made this a framework gap rather than an app's problem.

  It is the **same value** `auth.resolveScopes` already receives, through the **same lazy getter** — one ref, two consumers, rather than each caller reaching for the store its own way. That is deliberate: `voltro dev` builds the store AFTER the auth chain and `voltro serve` builds it BEFORE, so a value captured at config time would be `undefined` forever in dev and correct in production. The getter is read ONCE per request, not once per strategy.

  `store` is `undefined` only while the store is still being built, and on an app with no store — a strategy should `skip` rather than throw. It is the BOOT store, not a request-scoped one: strategies resolve before a request store exists.

  **Not narrowed to a read-only surface**, and the reason is worth stating: the narrower type would be the better guarantee, `DataStore` is the driver SPI, and giving strategies a different type from the one `resolveScopes` gets would put two views of one object in the same file. A strategy that writes during subject resolution is a design mistake; the type system is not going to catch it for you. Read users / sessions / keys, do not run domain writes.

### Fixed

- **@voltro/cli** — `voltro dev` terminates when its dev server does, instead of living forever.

  The supervisor never watched its child die. `runChild` was an `Effect.acquireUseRelease` whose `use` was `Effect.never`, so `proc.on('exit')` existed ONLY in the release path — and that path runs when the fiber is interrupted (a restart, a signal), never when the child exits by itself. Two more places assumed the same thing: `runSupervisor` was typed `Effect<never>`, and `dev.ts` returned `new Promise(() => {})` after starting it.

  So a boot that aborted — an unreachable database, a refused migration, a failed env gate — left the child dead and the supervisor waiting for a file change that nobody was there to make. Measured, not inferred: one developer machine carried 15 such `voltro dev` process pairs, `ppid=1`, the oldest 7 days old, every one a boot that had failed against a remote database. They hold a watcher and a terminal-less process each; in CI the same shape keeps a runner busy after the job "finished".

  `use` now awaits the child (`awaitChildExit`) and the supervisor races that against the watch loop, so whichever happens first decides. A restart still does NOT end it — `stopChild` interrupts the fiber, so the deferred is never completed on that path. A child killed by a signal reports `code: null`, which is reported as a failure rather than a clean 0.

  What a self-exit MEANS then depends on whether anyone is watching, because the two failure modes pull in opposite directions:

  - **Interactive** (stdout is a TTY) — a crashed boot is something you are about to fix, so the supervisor says so and keeps watching. The next save restarts it, which is what every other dev server does; stopping would throw away the watcher mid-edit and make you retype the command. - **Non-interactive** — nobody is going to fix anything. `voltro dev` exits with the child's code, so a failed boot is a failed command. This is the case that produced the invisible processes, and the one CI actually waits on.

  A CLEAN exit always stops, watched or not. `VOLTRO_DEV_KEEP_ALIVE=1|0` forces the answer for what the TTY check cannot see — a CI runner with a TTY allocated, or a wrapper that pipes output while a human still watches it — and cannot keep a clean exit alive, which would turn a deliberate shutdown into a hang.

  Pinned against REAL child processes, because the defect was an Effect that never settled — a stubbed `once('exit')` that resolves is exactly what would have passed while the bug shipped.
- **@voltro/database** — **A user-facing message that names an API must have one — now checked in CI.**

  The sibling of the claimed-wiring check. That one asserts a doc comment's claimed caller exists; this one asserts a message's claimed API exists. Same failure shape, worse audience: a doc comment is read by somebody browsing, a refusal by somebody already blocked and looking for the sanctioned way out.

  It exists for a reported bug that nothing could have caught. The drop-table refusal offered, as its FIRST option, *"chain `.dropped()` on it"* — tables have no such marker, only columns do. Doc SAMPLES are typechecked; message strings are not, and cannot be. Three of one release's reported defects lived in that blind spot.

  Three rules, each with an unambiguous answer, because a noisy gate is skipped and then costs more than it saves:

  - a `VOLTRO_*` variable a message tells you to **set** must be read somewhere, - a `` `.method()` `` a message tells you to **chain** must be a callable MEMBER of a published type, - a `--flag` in a `voltro …` instruction must be parsed.

  The member rule is the one that took two attempts. The first version asked "does this name exist in the public surface" and the motivating bug **passed it**: `dropped` is exported, as a free `dropped()` you write as a column's value. A dotted claim is a claim about something chainable, so a free function and a `readonly dropped?: boolean` data property are both correctly rejected now.

  **It immediately found a second instance nobody had reported** — the drop-COLUMN refusal also said "chain `.dropped()`", one level down from the reported one, and the real spelling is `<column>: dropped()` as the field's value. Close enough to guess from, which is why it survived.

  Ships with a `--selftest` that runs first in CI, for the reason the changelog gate has one: a check that has quietly stopped detecting anything still prints green, and green is read as evidence.
- **@voltro/database** — **Two migration refusals sent people the wrong way** — the worst place for a bad hint, because whoever reads one is already blocked and looking for the sanctioned way out.

  **The drop-table refusal recommended an API that does not exist.** Its first option was *"add it to your declared set + chain `.dropped()` on it"*. There is no table-level `dropped()` — only the column marker. The recommendation was also the conceptually RIGHT one, which is what made it expensive: the two options that do work are both worse, so a reader picks the one they cannot follow.

  There is now a real per-table answer: **`VOLTRO_DESTRUCTIVE_OK` accepts a table list**, not just `1`. `VOLTRO_DESTRUCTIVE_OK=old_things` acknowledges the data loss for that table and leaves every other lossy op in the plan blocked. `1` still means all of them — which is rarely what somebody means, and was previously the only way to say anything. A user with one intended drop and three other lossy ops had to acknowledge all four or hand-write a `DROP TABLE` migration, the path 0.14.0's own upgrade note warns against.

  The message also states why there is deliberately no table marker: a dropped COLUMN leaves a slot worth documenting in the declaration; a dropped TABLE leaves nothing, so the marker would be a dead entry you must remember to delete.

  **The drop-column refusal never mentioned `renamedFrom`.** It offered "chain `.dropped()`" or "restore the field" — and followed literally on a rename, the first costs exactly the data the user was trying to keep. When the plan drops AND adds columns on the same table, the message now leads with *"did you rename one?"* and names both sides. The evidence was in the plan the whole time.

  Finding that required fixing a second thing: the footer read only the BLOCKED operations, and an `add-column` is `safe`. The counterpart of a rename was never in the list it was looking at.
- **@voltro/cli** — **0.14.0's taxonomy codemod renamed two kinds of file it should not have, and a repo that already upgraded carries the damage with a green build.** Both were found by adopters running it on real projects; both are silent — the rename succeeds, the imports are rewritten, nothing throws.

  **A framework primitive was treated as an undeclared file.** `health.route.tsx` → `health.route.component.tsx`, five times in one app. The codemod kept its OWN list of "suffixes that already carry a contract" instead of reading `fileConventions.ts`, and `.route.` was not on it — the exact drift that module exists to prevent, reproduced inside a file that imports from it. The list is gone; the registry answers now, and it gained `ROUTE_PATTERN` plus a `carriesFrameworkConvention()` every consumer shares.

  The root cause underneath was worse than a missing entry: `export default defineRestRoute({...})` resolved to the placeholder name `Default`, which starts with a capital, and was counted as a COMPONENT on that basis. A default export is now only evidence of a component when the exported thing is callable — so a convention nobody has registered yet is safe too.

  **A file with no exports was called a type file.** `test-setup.ts` → `test-setup.types.ts`, while `vitest.config.ts` still named `./test-setup.ts` as a **string**. Not an import, so nothing rewrote it and nothing failed: that suite would have run without its setup and stayed green. Five more went the same way — a registry module, two migration runners, a `.register.ts`, and a code generator whose `export` tokens live inside template strings.

  `*.types.ts` promises "zero runtime exports", and that promise only means something for a file that exports TYPES. Zero of everything promises nothing, and renames a module whose whole purpose is being imported for effect — where the filename is often the only reference there is. It now requires at least one exported type.

  **The shipped codemod undoes both**, and can only reach files whose content proves the suffix was wrong: a `.component.` on a name that already carries a framework convention, and a `.types.` on a file that exports nothing at all. It also prints the one thing it cannot fix — references by PATH rather than by import (a vitest `setupFiles`, a tsconfig `include`, a Docker `COPY`) were strings on the way out and are strings on the way back.
- **@voltro/cli** — Three ways `voltro update` failed on a real adopter's host, none of which we could have found ourselves — each needs a machine we do not have.

  **The install could not run here, and the refusal left the tree half-upgraded.** Their install runs in a container against its own store. `voltro update` ran the package manager on the host anyway; pnpm refused (it wanted to remove `node_modules` and had no TTY to ask) and exited — after the version bumps were already written and before any codemod ran. That is the state this command's own documentation calls the worst one to be in, and it was reachable by design.

  `--no-install` now writes the bump and stops, saying plainly that the tree is half-upgraded and naming both remaining steps. The install-failed message points at it too. Note what this is not: a compatibility flag. It is a mode for a host where the install is somebody else's job, and it ends by telling you the job is not done.

  **The codemod scan exhausted a 12 GB heap, and said nothing about why.** The crash was a bare V8 out-of-memory stack. The scan pruned the directories WE know about — `node_modules`, `dist`, `.turbo` — which cannot cover a project's own heavy ignored trees (a build cache, a data dump, a virtualenv).

  Inside a git repository the scan now asks git: `git ls-files --cached --others --exclude-standard` is exactly "files this project considers its own", and a codemod rewrites source — source that git ignores is not source we may rewrite. It also removes the traversal, so there is nothing left to exhaust memory on. Outside a repo the walk remains, now with a ceiling that REPORTS which directory to exclude instead of dying namelessly.

  **"not a Voltro app" was the wrong conclusion.** Said of a directory containing an `app.config.ts`, it sends the reader looking for the wrong problem. Three web apps in a workspace inherited from another tool had their `@voltro/*` dependencies in an ancestor `package.json` — the apps ARE Voltro apps; only the declaration lives elsewhere. With an `app.config.ts` present the message now says that, and names the two ways forward.

  **A monorepo may keep ONE root `package.json`** with its apps carrying only an `app.config.ts`. Running `voltro update` inside such an app used to say "no package.json at <dir>" — true, and useless. It now recognises the layout, says it is supported, and prints the command with the root already filled in.
- **@voltro/cli** — **`voltro update --only <id>`**, and a summary that stops contradicting itself.

  `--only` runs just the named codemods (repeatable or comma-separated; ids are what `--dry-run` prints). The ask behind it: two of three codemods were load-bearing for one app — without them, 52 type errors and 51 routes that 404 — while the third was elective, and the repairing tool refuses on a dirty tree, so there was no way to take the necessary half first.

  It is deliberately NOT a `--required` flag over a REQUIRED/OPTIONAL axis on each codemod. "Required" would have to mean *this app does not run without it*, and that is a property of the app: `03_pages-suffix` is unavoidable for a project with pages and irrelevant to an api-only one. Marking it on the codemod would encode a guess as a contract. An unknown id is an error listing the ids that ARE available — "it did nothing" and "you typed it wrong" otherwise look identical.

  **The summary counted only edited files, and renames vanished from it.** The before-snapshot is keyed by PATH, so a moved file has no entry under its new one: a pass that renamed 238 files and edited 9 importers reported `(9 files)`, understating the change by a factor of 26 in the line a user plans around. Moves are now paired by content and reported separately — `(238 renamed, 9 edited)`.

  **A dry run no longer speaks in the past tense.** It printed `✓ <id> — <title>`, the same line a real run prints. Now `·` and `[would apply]`.

---

## [0.14.0] — 2026-07-26

### ⚠ BREAKING

- **@voltro/cli, @voltro/database** — `voltro dev` no longer applies file-based migrations to a REMOTE database unattended.

  The planner refuses lossy operations without `VOLTRO_DESTRUCTIVE_OK=1`, and its own error text names the way through: "if intentional, set VOLTRO_DESTRUCTIVE_OK=1 OR add a file-based migration". So the documented route around the safety belt had none of its own. Measured, not hypothesised: a file containing `DROP TABLE ... CASCADE` was saved at 15:11; at 15:12:41 the table was gone from a live production database, recorded `appliedBy: boot:dev`. An already-running dev server had picked the file up on its next reboot. Nothing was started, no command was typed, no review happened — writing the file WAS the deployment. What prevented harm was a hand-written guard inside that particular migration; the framework contributed nothing.

  Local counts as: loopback, private LAN (RFC 1918), `host.docker.internal`, a `.local` / `.localhost` name, a `file:` / `sqlite:` URL, or a BARE hostname (`postgres`, `db`, `voltro-test-mariadb`) — only a container network resolves those, so docker-compose setups are untouched. An unparseable `DB_URL` counts as remote on purpose: guessing "local" wrongly writes to production, guessing "remote" wrongly costs one env var.

  Three deliberate non-choices. It does **not** detect destructive SQL — in arbitrary SQL that is not decidable, so the gate would be either leaky or noisy; what it separates is *saving a file* from *applying it to production*. It **refuses** rather than skipping quietly — a skipped migration leaves the database in a shape the app does not expect, and the failures that follow point everywhere except at the cause. And it stays **silent when nothing is pending**, so dev against a remote database is unaffected until the moment a file would actually execute against it.

  `voltro db files`, `voltro db apply` and `voltro serve` are unchanged: an explicit command is already an explicit decision. Escape hatch: `VOLTRO_REMOTE_MIGRATIONS_OK=1`.

  `@voltro/database` gains `pendingFileMigrationIds(sql, projectRoot)` — the ids that would run next, without running them. The gate needs to name what it is refusing, and must not have executed anything to find out.
- **@voltro/cli, @voltro/web** — **Only `*.page.tsx` under `src/pages/` is a route.**

  Pages were the last primitive without a file convention. Every other one carries its type in the name — `*.query.ts`, `*.cron.tsx`, even `*.island.tsx`, which is a *part* of a page — while a page was any `.tsx` that happened to sit under `src/pages/`. Two discovery models, and the positional one made colocation impossible: a component next to its page got a URL.

  The breakage was silent, which is why this is a correction and not a taste argument. Nobody navigates to an accidental URL in dev, so the route existed, was broken, and said nothing. `voltro build`'s prerender is the first thing that ever evaluates the module — one app carried ~600 accidental routes for months and found out at its first production build, with `SSR received a descriptor with no Component`.

  ```
  src/pages/users/index.page.tsx        → /users
  src/pages/users/[id].page.tsx         → /users/[id]
  src/pages/(marketing)/pricing.page.tsx → /pricing
  src/pages/users/UserTable.tsx          → not a route — colocation is now legal
  src/pages/users/index.page.test.tsx    → not a route
  ```

  The suffix sits *behind* the segment, so dynamic params, catch-alls and route groups are unchanged. `layout.tsx` / `error.tsx` / `loading.tsx` / `not-found.tsx` keep their exact names — `layout.layout.tsx` is nonsense — but they are now **scoped to route-bearing directories**: a special file in a directory with no `*.page.tsx` at or below it is inert and is reported at boot rather than silently wired. That scoping is not cosmetic. The change *invites* colocation, so a co-located `error.tsx` (a 404 illustration, say) would otherwise install a real error boundary for the whole subtree.

  **Separately, and independent of the suffix: a page with no default export now fails at codegen.** The suffix declares intent, and intent can be wrong — a `*.page.tsx` with no component is still a broken route. `voltro dev` / `build` / `start` all run the check before serving anything, so the first `voltro dev` says it, naming the file and both fixes (add the export, or drop the suffix). This is the half of the report that actually removes the failure class.

  **`voltro doctor` gained a page-convention scan**, in both directions: a `.tsx` under `src/pages/` that looks like an unmigrated page (default export, imported by nothing — a real page is only ever found by the router), and a `*.page.tsx` *outside* `src/pages/`, which will never route. The second failure class is created by this change, so it ships with its own check. A third, advisory bucket names a page with no `*.page.test.tsx` beside it.

  **Migration** is `git mv` and nothing else — page files are not imported, so no import path moves with them. The codemod renames every file that is a route *today*, which is behaviour-preserving by construction. It deliberately does **not** use the tempting rule "pages are the files nobody imports": that rule is right about pages and wrong about the case that costs you a route — a page with a co-located test *is* imported, by its own test, which our own testing guidance encourages. Such a page would keep its name, stop being a route, and 404 with nothing in any build log. Files that something other than a test imports are renamed anyway and then **reported**, so a human decides whether they should instead lose the suffix and become ordinary co-located code.

  A library that happens to have a `src/pages/` directory (the framework's own `@voltro/devtools-ui` keeps 31 shared page components there) is left alone — the codemod requires an `app.config.ts` at the app root, the same signal the CLI uses.
- **@voltro/database, @voltro/cli, @voltro/ai, @voltro/plugin-webhooks** — **Reactivity is the default. `.reactive()` is gone; `.nonReactive()` opts out.**

  A reactive framework whose reactivity is opt-in has the default backwards. Every table now participates in cross-instance change capture, and you write nothing to get it.

  The old keyword failed in both directions silently, which is why this is a correction rather than a preference:

  - Written on mysql/mariadb/mssql it did **nothing** — those readers tail every table anyway. A team read its doc ("opt the table into the reactive engine"), found 26 tables without it, and reasonably concluded much of their app was never live. It always was; the doc was wrong, and the investigation was the cost. - Omitted on postgres with `changeStrategy: 'cdc'` it meant a write on one instance **never reached another instance's subscribers**. Correct on the writing pod, stale everywhere else, and perfect in single-instance dev.

  **`.nonReactive()` turns reactivity OFF — not "off across instances".** The table emits no change events at all: no local subscriber fires, no cross-instance transport carries it. A version that silenced only the cross-instance half would leave every in-process subscription live on a table whose declaration says it is not reactive, which is not what the name says.

  Implemented at each store's emit, in every dialect — memory, sqlite (and turso, which reuses it), postgres, mysql/mariadb, mssql — with the predicate living once in the table registry. On postgres, mysql/mariadb and mssql it ALSO drops the cross-instance half: no `REPLICA IDENTITY FULL` and no `pg_notify` trigger, excluded from the binlog reader's filter, excluded from the Change Tracking set. The dialect-dependent meaning is gone, not moved.

  The WRITE is unaffected — this is about notification, never persistence.

  Keep it for a genuinely hot, genuinely unsubscribed table — an append-only event log, a metrics sink. `REPLICA IDENTITY FULL` widens every UPDATE/DELETE in the WAL and the trigger fires on every write, so opting one of those out is a real saving. Opting out a table a query still reads is not, and `voltro dev` / `voltro serve` warn when you do.

  The codemod deletes every `.reactive()` call — the behaviour it opted into is now universal, so removing it changes nothing for those tables. Tables that never had it gain the trigger on the next migration; that is the point.

  **sqlite and turso.** Every table is reactive there too — the store emits its committed deltas inline, exactly as on every other dialect. `.nonReactive()` has nothing to suppress on them, because neither has a cross-instance transport to opt out of.

  For sqlite that is honest: a local file is one process. **Turso is not**, and that is now said at boot. It reuses the SQLite store, so it has no cross-instance change capture at all — while `libsql:` / `https:` / `wss:` and the embedded-replica mode exist precisely to point several app instances at one primary. On a shared turso, every table's subscribers see only their own instance's writes, regardless of any flag. Somebody who chose a distributed database and a reactive framework has every reason to assume otherwise, so `voltro dev` / `voltro serve` warn when the URL is a shared one. A local `file:` turso stays silent.

  **Type-surface note.** The `Reactive` type parameter on `Table<…>` now defaults to `true`, so exported table constants in `@voltro/ai` and `@voltro/plugin-webhooks` are typed `Table<…, true, …>` where they read `false` before. Code that only USES those tables is unaffected — the parameter is not part of any call signature. Code that ANNOTATES one by hand (`const t: Table<'x', …, false, never> = …`) has to drop the explicit parameter or write `true`; the framework's own annotations were updated the same way.
- **@voltro/cli** — `auth.resolveScopes` receives the app's DataStore.

  The hook shipped with `(subject, { headers, clientId })`, which is missing the one thing a role-based resolver needs. A role lives in the database — the reporting app's is two joins deep — so reaching it meant opening a SECOND connection path beside the framework's, to the same database the request store opens a moment later. That is why an otherwise willing adopter could not adopt, and it made the hook's stated purpose unreachable by its own signature.

  The second argument now carries `store`. It is the BOOT store, not a request-scoped one, and it arrives through a lazy ref because strategies resolve before a request store exists — `undefined` only while the store is still being built. Wired identically under `voltro dev` (which builds the store after the auth chain) and `voltro serve` (which builds it before).
- **@voltro/client** — **Tracking catalogues are typed against the component's props.**

  ```tsx
  interface ButtonProps { readonly plan: 'free' | 'pro'; readonly onClick: () => void }
  
  const spec = defineTracking<ButtonProps>('Checkout', {
    onClick: (props) => ({ event: 'checkout.started', plan: props.plan }),
  })
  
  const tracked = useTracking(spec, props, sink)
  return <button {...tracked}>Checkout</button>     // now actually typechecks
  ```

  Found by using the primitive on real code for the first time. Two defects, both invisible until then:

  - a payload builder could only reach `props['plan']` as `unknown` and cast it — in the ONE file whose job is to state exactly what leaves the browser. A cast is the last thing that belongs there. - `useTracking` returned `Record<string, unknown>`, so the usage our own docs show — `<button {...tracked}>` — did not typecheck under `strict`. The sample survived because it was never compiled against a typed handler.

  The type parameter defaults to the old untyped bag, so an untyped catalogue is unchanged. What breaks is the **return type narrowing** from `Record<string, unknown>` to the props type: `tracked['notAProp']` was `unknown` and is now an error, and `(tracked['onClick'] as () => void)()` no longer needs its cast. A `manual` codemod, because a transform cannot typecheck and so cannot tell a now-redundant cast from one that was hiding a real disagreement.
- **@voltro/cli** — **The web file taxonomy — seven contract suffixes, each enforced.**

  Every entry had to pass one test: *does another file's correctness depend on this file keeping its promise?* If yes the promise belongs in the name, because a contract you cannot see is one you break without noticing. If no it is a category, and categories are read out of the file.

  | Suffix | Promise | |---|---| | `*.component.tsx` | exactly one component (+ types) | | `*.component.ui.tsx` | one component, **reads only** — never writes | | `*.hook.ts` | exactly one `use*` hook (+ types) | | `*.types.ts` | zero runtime exports | | `*.internal.ts` | only its own directory subtree may import it | | `*.fixture.ts` | no production path may reach it | | `*.tracking.ts` | analytics is called nowhere else |

  `voltro doctor` enforces all of them; `voltro doctor --json` emits every finding.

  **`*.component.ui.tsx` may read and must not write.** `useT`, `useCan`, `usePermissions` stay allowed on purpose — threading translations and permissions through props is prop-drilling, and it makes every call site worse without making the component more portable. Importing a write hook (`useMutation`, `useAction`, `useUpload`, …) is the violation, because a component that can mutate cannot be rendered ten thousand times in a list, reused across features, or prerendered without first reading its source. That property is what its callers rely on. It must also be *reached* from a `*.component.tsx`, another `*.component.ui.tsx`, or a page: an unrendered presentational component is carried, reviewed and refactored forever without reaching a user.

  **`*.types.ts` having no runtime export is a guarantee, not tidiness.** It makes importing the module free in the bundle *and* makes it impossible for it to participate in a runtime import cycle — and in a large codebase the second one is the valuable half, because a cycle is invisible until it throws.

  **`*.tracking.ts` confines the event catalogue.** The rule is global: `defineTracking(...)` may be called from nowhere else, so every event name, property bag and decision about which fields leave the building lives in files you can list. A component wires one up with `useTracking(spec, props, sink)` — it NAMES a spec, it never declares one.

  `useTracking` itself is deliberately not confined: it is a hook, so it must run inside a component and could not be moved into a plain module. A rule nobody can satisfy is a rule everybody disables. The payoff stands either way — "what do we send to third parties" becomes a file listing rather than an archaeology project, which is the only form in which that question can be answered on demand.

  **What deliberately has no suffix.** A generic "one component per file" rule would be worth enforcing everywhere, so tying it to a rename would make it opt-in: less coverage for more cost. The shape rules fire only on files that *declared* the contract. There is also no `*.store.ts` — nothing in the framework depends on a store, so the suffix would promise nobody anything. Suffixes follow primitives, never the reverse.

  **Migration.** The codemod renames what the exports decide unambiguously: one component → `*.component.tsx`, one hook → `*.hook.ts`, no runtime exports → `*.types.ts`. Imports travel with the file. It refuses two cases on purpose — a file exporting a component *and* a hook (the one the taxonomy most wants split, and no codemod can decide which half keeps the name), and `*.component.ui.tsx`, which is never inferred because "presentational" is a promise about what a component *may* do, not an observation that it currently does not.

  **The rules apply to code you AUTHOR, never to vendored code.** A shadcn component arrives via `npx shadcn add`, follows shadcn's conventions, and is overwritten by the next `add` — renaming it breaks their convention, is undone next run, and leaves the directory half-migrated the moment one file fails to classify. Our own devtools dashboard demonstrated all three before this landed.

  A directory is exempt when the app's `components.json` names it (`aliases.ui` only — `aliases.components` is where your own components live too, and honouring it silenced the taxonomy across our whole dashboard) or when it carries a `.voltro-vendored` file whose first line names the source. The marker is a file with a reason rather than a config list on purpose: a config list is invisible from the directory it exempts and quietly becomes where people put their own code to silence a rule. Every honoured exemption is printed, so the escape hatch is never silent.

### Added

- **@voltro/protocol, @voltro/cli** — Actions can declare `source` and `target`, and `orphan/unread-table` stops advising deletion on a conclusion it cannot support.

  An action is non-transactional external I/O, and it very often touches a table on the way — a cache it fills, a job row it stamps. There was no slot to declare that, so every such table was invisible: `voltro check` reported one that five action paths read and wrote as an orphan, and the suggested fix was **remove the table**. A wrong finding is bad; a wrong finding whose remedy is destructive is worse.

  Two halves. `defineAction` now takes the same `source` / `target` a query and a mutation take, so the answer can be declared. And while any action declares neither, the orphan rule says so and asks for the declaration instead of proposing a delete — absence of a declaration is not a declaration of absence, the same distinction the manifest already draws for guards.
- **@voltro/cli** — **`*.client.ts` — declare a shared file browser-safe, and have it checked.**

  `*.server.ts` works because it declares a *permission*, not a fact: "this file may import `node:*`". An import graph cannot derive that — it can tell you what a file imports, never what it is allowed to import. `*.client.ts` is the mirror: *I, and everything I transitively import, am browser-safe.* `voltro dev` walks the claim at boot with the same walker and forbidden list the rpcGroup guard uses, and refuses to start if it is false — and **`voltro check` walks it too**, reporting `client/not-browser-safe` with the import chain.

  The check matters more than it sounds: `voltro dev` only sees the app it boots, so a marker in a WEB app or in a package no api boot touches was a promise nobody ever read. CI runs `voltro check`, which walks every marked file in the project regardless of which app owns it.

  It exists for the trap this framework records as its worst, which is **transitive**: a descriptor imports a shared `lib/` helper, that helper also imports the `database` handle, and the whole server graph lands in the browser bundle. The rpcGroup guard already catches that — but only once some descriptor happens to reach the file, and it reports a forty-module chain you read backwards to find the one shared file that should never have touched the database. The marker moves the failure to that file, at the moment it is written.

  An unmarked file makes no claim, and that is deliberate. This is not a `*.component.tsx`-style label: those describe what a file already obviously is, nothing would enforce them, and a convention nothing enforces gets half-adopted — after which an unmarked file means nothing at all.

  **`voltro check` gained `rbac/unenforced-scope`** — a scope a role *grants* that no handler ever guards on.

  `rbac/unknown-scope` already read the registry the other way (a guard naming a scope nobody grants), and that direction is easy because the guard is a thing you can look at. This one has no artefact at all: you cannot grep for an authorization check that was never written, which is exactly why it survives review. One app modelled `api-keys:write` in its role catalogue, complete and reviewed, and no handler checked it — any member could mint a shared credential, and nothing failed. Tests pass when an authorization check is missing.

  A **warning**, not an error: a plugin route may enforce it internally (the graph cannot see inside a plugin), a REST route carries its own guards, or it may be a UI-affordance scope `useCan` reads to hide a button with no server check by design. All three are legitimate. Not knowing which is not.

  Note what this deliberately is NOT: a `*.guard.ts` file convention. Guards are already declarative and already live on the descriptor, beside the thing they protect. Moving them to a separate file would make them *less* discoverable, and their absence from the filesystem would mean nothing — failing the same test `*.client.ts` passes.
- **@voltro/cli** — **`voltro check` enforces the file conventions — CI, not just the doctor.**

  `voltro doctor` is what a person runs when something feels wrong. `voltro check` is what CI runs on every commit. A contract enforced only by the first is enforced on the days nobody is looking, which is every day — so the taxonomy and the page convention now produce `check` diagnostics and set its exit code.

  What FAILS a build: a `*.types.ts` with a runtime export, a foreign import of an `*.internal.ts`, a fixture reachable from production code, a `*.component.ui.tsx` that writes, a store mirroring server state, two stores in one file, and a `*.page.tsx` outside `src/pages/` (which can never route).

  What is REPORTED and does not fail: a missing test beside a file, an orphaned presentational component, and an unsuffixed `.tsx` under `src/pages/`. The last one fires on a correct co-located component often enough that blocking on it would be wrong, and a gate that blocks on a missing test gets disabled within a week — taking the real rules with it.

  Vendored directories are exempt here exactly as they are in the doctor: the rules apply to code you author.
- **@voltro/client, @voltro/web** — **`defineStore` — client state that is not server state.**

  Server state already had a home: a subscription *is* live server state, and it stays live. What had none was the rest — which rows are selected, which wizard step you are on, the draft you have not submitted. Without a primitive for it a team reaches for zustand or jotai, which is a **parallel runtime** — the exact thing the framework's own guidance tells them not to bring. Shipping nothing here was never neutrality; it was an instruction to import something.

  ```ts
  const wizard = defineStore('wizard', () => ({ step: 0 }))
  
  wizard.use((s) => s.step)                    // the global instance
  wizard.use((s) => s.step, { key: orderId })  // one instance per order
  ```

  **There is no `useStore()` that hands back the whole state**, because it would be used, and a component holding the whole state re-renders on every change to any field. Reads go through a selector or they do not happen — that is the only way "no needless re-renders" is a property rather than an aspiration. Proven against a real renderer, not argued: a component reading `coupon` does not re-render when `note` changes, and one selecting `items.length` does not re-render when `['a']` becomes `['b']`.

  **Scoping is by KEY, not by a Provider.** A Provider re-renders every consumer when its value identity changes, whether or not that consumer reads the field that moved — that *is* the context-hell mechanism, so putting a store behind one would reintroduce the problem in nicer clothing. An instance is addressed by a key the caller already has (an order id, a table id, a URL param), which is also the model the framework uses everywhere else: `useSubscription('orders.list', { orgId })` is keyed by input, not by tree position. One read form, an optional key.

  **SSR seeding adds no new channel.** `seedStore(wizard, { step: 2 }, { key })` is callable anywhere on the server during a render, and the value rides the hydration payload the router already writes — no `dehydrate()` to remember and no `hydrate()` to forget, because a step you can forget is a step somebody will. Calling it on the client **throws**: a silent no-op would leave the store empty in the browser and full on the server, which surfaces as a hydration mismatch that reads like a React bug.

  Request scoping is real, not assumed. Two requests interleave at every `await`, so a module-level "current bag" would put one request's values into another's document; the scope is `AsyncLocalStorage`, installed by the server-only `@voltro/web/ssr` entry (the client package is loaded by browsers and cannot import `node:async_hooks`, so it exposes a resolver and the server supplies the scoping). A test drives two interleaved renders and asserts neither sees the other's seeds.

  `withStoreSeeds` is a member of the shared `SsrHelpers` contract rather than an ad-hoc import, so a new boot path cannot quietly omit it — the type is what `voltro dev`, `voltro start` and `voltro build` all resolve against.

  Three details that are load-bearing rather than incidental: `initial` is a **function**, so keyed instances never alias one object (the bug where editing order A also edits order B, found weeks later); the notify loop iterates a **copy**, so subscribing from inside a listener cannot mutate the set mid-iteration; and a `set` whose value is unchanged wakes **nobody**, because a form re-submitting the same draft is the cheapest needless re-render there is.

  **All three SSR emitters open the scope**, and a lockstep test keeps that true. `voltro dev`, `voltro start` and `voltro build` each call `beginStoreSeeds()` once per render — `enterWith`, not a callback wrap, because `renderPageForRequest` spans hundreds of lines with early returns and a streaming branch that returns from the middle, and restructuring production render code buys the scope nothing. The prerender opens a FRESH scope per artefact: one scope for the whole build would put every page's seeds into every page's document. The seeds are folded into the payload inside `renderRouterStateScript` — one place, which every emitter already calls, so a seed cannot be collected in dev and dropped in production.

  **`*.store.ts` joins the file taxonomy**, now that something depends on it: the seed pass and the devtools address a store by NAME, so a file holding two of them breaks something other than itself. `voltro doctor` enforces one `defineStore` per file, and reports a store that reads a subscription — detected by the CALL, not by field names, because a store legitimately holds an `orderId` and guessing from names would fire on correct code.

  **The server renders the seeded value too** — and that is the correctness of seeding, not a refinement of it. Store instances are module-level and shared by every concurrent request, so a seed must never be written into one (request A's wizard step would appear in request B's document). But a server rendering the UNSEEDED instance while the client applies the seed before hydrating produces a mismatch on every seeded page — the exact failure this feature exists to prevent. So the two sides read different sources and arrive at the same value: the server's `getServerSnapshot` reads through the request-scoped bag, the client reads the instance `mount()` already seeded.

  It took an end-to-end test to see it. Six unit tests were green while React threw `Hydration failed` on every seeded render, because a mismatch is a class no server-side assertion can observe — the same reason `hydrateLoaderData.test.tsx` exists.

  **Computed selectors, memoised, with the footgun turned into a warning.** The re-render promise held only for selectors returning a PRIMITIVE. One returning an object or a derived list — the exact shape a computed value has — hands back a fresh reference every call, so `Object.is` reports "changed" forever and the component re-renders on every change to any field. Measured before the fix: an object selector re-rendered on an unrelated `set`, and so did `items.filter(…)`. A promise that fails precisely in the case it was sold for is worse than no promise.

  `{ equals: shallow }` fixes it, the result is cached so an equal value keeps its PREVIOUS reference (which is what makes React skip the render rather than merely recompute), and the selector is not re-run at all while the state object is unchanged. In dev the framework detects the case — a value that would have compared equal one level deep but is not identical, which is exactly the wasted re-render and nothing else — and warns ONCE, naming the fix.

  **Keyed instances are released with their last subscriber.** They were created on demand and nothing ever removed them: a table keyed by row id accumulated one per row EVER rendered — measured at 1000 live after 1000 keys. The drop is deferred by a macrotask so a remount keeps its state (StrictMode double-invokes, and a route change can unmount and remount the same key within a tick); `{ retain: true }` opts out for state that must survive navigating away.

  **Every write passes through one seam**, which is where inspection and undo come from. Instrumenting `set` rather than shipping a declared `actions:` bag is a coverage decision: a declared API only sees the writes somebody remembered to declare, and the write that causes the bug is the one written in a hurry, inline, in an event handler.

  `storeHistory()` / `subscribeStoreHistory()` expose a bounded feed of `{ store, key, label?, prev, next, at }` — bounded because an unbounded log leaks in exactly the long-lived sessions where it would be useful. `store.set(next, key, 'checkout.applyCoupon')` labels a write for that feed. `store.undo(key?)` / `store.redo(key?)` walk an instance's history, with `canUndo` / `canRedo` for the buttons. Restoring is by IDENTITY rather than a merge (a merge would leave behind fields a later write added — a state nobody ever wrote), an undo never becomes undoable itself, and a NEW write after an undo drops the redo tail, which is what every editor does.

  The first version had no redo, because `undo` SPLICED the entry out of the log: nothing was left to step forward to, and the devtools panel had to invent a second model for the same idea. Both are cursors over intact histories now — the store's per instance, the panel's over the global log.

  **Battle-tested against the days it is used badly**, which is where a state library is actually judged. A hostile-conditions suite covers tearing between two readers in one commit, a write from inside a listener, a write during render, unsubscribing mid-notification, a thousand subscribers, a thousand keyed instances, a listener that throws, NaN fields, and the awkward corners of shallow equality. Two of those found real defects:

  - **`set` compared IDENTITY, so a partial merge never short-circuited.** A merge always builds a new object, which meant `set({ step: 2 })` twice with the same 2 woke every subscriber both times — and a listener that wrote could never converge, producing a stack overflow. `set` now compares one level deep, so setting the same values again is free. - **A non-converging listener blew the stack** with an error naming nothing. A depth guard now reports it at the point the loop is still legible.

  And the piece no jsdom test could reach: the fixture app's layout loader seeds a store, and the dev-SSR boot test asserts the SEEDED value in the server HTML. That proves the seed bag the CLI opens and the `seedStore` the app module calls land on the same `@voltro/client` instance under Vite's dev transform — two copies would throw "outside a server render", green in every unit test and fatal on the first real page. It caught an ORDERING defect immediately: the scope was opened AFTER `buildSegmentChain`, so every seeding layout loader threw and `voltro start` answered `server error` while dev fell through to the SPA shell. The build path happened to be ordered correctly and passed.

  **A Stores tab in the `voltro dev` overlay, with time travel.** Every defined store with its live state (global and per key), a feed of every write — store, key, label, and the fields that actually changed — and `◀ Back` / `Forward ▶` that restore the state as it was before or after each one. The state a component reads moves with it.

  This is what putting client state IN the framework buys: no extension, no connector, no version to match. The panel is just another subscriber to the seam every write already passes through, so it sees writes made by code that never heard of devtools, on any machine.

  Travel is deliberately NOT built on `undo`: undo is a stack that CONSUMES entries, so stepping forward again would be impossible. It moves a position over an intact log instead. The first version tracked "the entry we are parked on" and could not tell "stepped back to the beginning" from "live" — Forward was disabled exactly when it was needed. A count of applied writes has no such ambiguity.

  `storeHistory()` returns a STABLE reference until the log changes, because `useSyncExternalStore` requires a cached snapshot and a fresh array per call sends any subscriber into an infinite render loop. Our own panel hit that within a minute of being written, so the safety lives in the API rather than in a note every consumer has to read.

  **Verified in a real browser**, because three of the store's claims cannot be settled anywhere else. `scripts/browser-client-store.mjs` drives chromium against the fixture app and checks: the seeded value is in the FIRST PAINT with **JavaScript disabled** (the only way to prove the server rendered it rather than the client filling it a tick later); React reported no hydration mismatch (a mismatch is a console error in a browser and nothing anywhere else — which is exactly how the seed once shipped rendering 0 on the server and 7 on the client); and a component reading a DIFFERENT field of the same store does not re-render when the first one moves, counted in the DOM because a render count is not observable from outside a page any other way. Writes, undo, redo and the redo-tail truncation are all exercised through real clicks.

  It caught two fixture defects on its first run, one of them the trap the docs name: the layout seeded the GLOBAL instance while the page read a KEY.
- **@voltro/testing, @voltro/cli** — **`ctx.webhooks` exists in the test harness, and `voltro test` stops collecting `e2e/` specs.** Both were found by running the starter's own suite, which had been red on both counts.

  `ctx.webhooks` is a field PRODUCTION supplies (`makeAppContextBuilder`, when the webhooks plugin is configured) and the harness did not — so a mutation written the documented way, `useWebhooks(ctx).emit(todoCreated, {...})`, threw "`ctx.webhooks` is not set" in every unit test. The only way to test one was to hand-roll a context, and the hand-rolled version in the starter was itself broken: it assigned through a cast onto the OUTER context while the handler runs against the transaction context `invoke` derives, so it recorded an emission the handler never made. Every derived context now shares one `MockWebhooks`:

  ```ts
  await invoke(createTodo, handler, { title: 'hi' }, ctx)
  expect(ctx.webhooks.last('todo.created')?.payload).toMatchObject({ title: 'hi' })
  ```

  It records; it does not deliver, sign, or consult subscriptions — a unit test asks what the handler emitted, and delivery is covered where the plugin lives.

  **`e2e/` belongs to `voltro e2e`.** Its specs drive a browser through tsx against a booted api + web and define no vitest suite, so `voltro test` collected them and reported "No test suite found" — a red run for an app laid out exactly as the framework asks. The exclusion EXTENDS vitest's defaults rather than replacing them (vitest does not merge `exclude`, so a bare glob would silently re-admit `node_modules` and `dist`), and a user-supplied `--exclude` still wins outright.

  `voltro test` REPORTS every spec it skipped, on every run. A project that had real vitest specs under `e2e/` would otherwise just start running fewer tests and still print green — a silent cap is worse than the red run this replaced, because nothing says it happened. The shipped codemod prints the same thing during `voltro update`, and only for a project that actually has such files.
- **@voltro/client** — **`store.batch(label, fn)` — many writes, one meaning.**

  ```ts
  checkout.batch('applyCoupon', () => {
    checkout.set({ coupon })
    checkout.set({ total: recompute(coupon) })
  })
  ```

  One notification, one devtools entry named `applyCoupon`, **one undo step**. Without it that action is three of each: Ctrl-Z walks back through a third of a change at a time, and the feed shows three anonymous writes instead of the thing that happened. React batches the re-*renders* on its own — it cannot batch the meaning, and undo and the devtools feed both read the meaning.

  **A throwing callback rolls back every write it made.** Nothing was announced yet, so an action that fails halfway cannot leave the half-applied state that is the usual reason people reach for a transaction. Writes that cancel each other out record nothing at all. A nested batch joins its parent.

  **An `async` callback is a hard error, not a warning.** Everything after its first `await` would land outside the batch — writes escaping one at a time, a rollback covering only the synchronous head, and a devtools entry that lies about what the action did. The error says what to do instead: await first, then batch the writes.
- **@voltro/client** — *(`apiSurface: compatible` — `defineStore` gained an OPTIONAL third parameter and `StoreHandle` gained a member. Every existing call site compiles unchanged; the handle is only ever obtained from `defineStore`, never constructed.)*

  **`defineStore(..., { persist })` — client state that survives a reload.**

  ```tsx
  export const filters = defineStore(
    'inbox:filters',
    () => ({ status: 'open', sort: 'newest' }),
    { persist: { key: 'inbox:filters', pick: (s) => ({ status: s.status }) } },
  )
  ```

  Every hand-rolled version of this gets the same three things wrong, so the framework version does them and the tests pin them:

  - **The stored value is merged over `initial()`, not substituted for it.** Add a field and every returning user otherwise has state missing it — `undefined` where the type promises a string. - **`migrate` returning `undefined` DISCARDS the value.** A stale draft is an annoyance; a half-migrated one is a bug report nobody can reproduce. - **Every storage touch is guarded and wrapped.** The module is imported by the server render too, and Safari in private mode throws on *reading* `localStorage`. A store that throws at import time takes the page with it.

  Only the **global** instance persists — a keyed instance is per entity, and writing every key into one bucket grows without bound.

  **A persisted store on a server-rendered page hydrates against the SERVER value.** The server has no `localStorage`, so it renders `initial()` and the stored value lands in the commit right after hydration. Without that split, every returning user got a hydration mismatch — a flash plus a console error that reads like a React bug. `get()` is not deferred, only the render.
- **@voltro/database, @voltro/cli** — Three diagnostics, each for a failure that had already happened to somebody.

  **The database disagrees that a table is reactive.** On postgres, reactivity is carried by a per-table trigger, and the schema fingerprint covers columns — not triggers. A restored dump, a hand-run `DROP TRIGGER`, or a table migrated under a release that installed none all leave the schema "up to date" and the trigger absent, with subscriptions silently not reaching other instances. The boot now compares the two and names the tables, including the reverse case: a `.nonReactive()` table still carrying a trigger keeps paying `REPLICA IDENTITY FULL` and a NOTIFY on every write for a subscription nobody receives. Reported, never repaired — `voltro db apply` owns DDL, and a boot that quietly re-created triggers would be a boot doing migrations.

  **`apiKeys: true` with no issuance scope declared anywhere.** The management routes gate on `apikeys:issue:self|org|other`. If no role declares one, the capability is switched on and reachable by nobody: every issue request fails its guard, which reads as a permissions bug in the app rather than a missing declaration. The two halves live apart — the flag in `app.config`, the scopes in a role map — and neither side can see the other.

  **A handler that writes `ctx.request.subject.id` with no guard.** The highest-yield finding from a downstream migration: seven per-user mutations with no authentication check at all, each writing a `string | null` subject id into a NOT NULL column, so an anonymous caller reached the database and got `Failed to execute statement` instead of a typed refusal. Their compiler only surfaced it once the write became typed; `voltro doctor` now finds the shape directly. Narrow on purpose — the subject id must be read, a write must be present, and the file must name no guard at all. It strips comments and strings first, so a reassuring note about a guard that is not there does not clear it.

### Fixed

- **@voltro/cli** — Two more places where the framework wrote or looked in the wrong place.

  **`voltro build` rewrote `.gitignore` even when nothing was missing**, and rebuilt it from `filter(l => l.trim() !== '')` — silently deleting every blank line from a file a human maintains and git tracks. Where the file was not writable, an identical-content rewrite aborted the build. It now checks first, APPENDS rather than re-emitting, and treats a failure as a tidiness miss rather than a reason to fail a production build.

  **`voltro doctor` walked a hand-kept list of directories.** A consumer's `schedules/…cron.tsx` was never scanned and they diagnosed it as the `.tsx` extension; the extension was always handled — `schedules/` simply was not on the list. That is the third whitelist in this codebase to drift, so it is gone: the scan walks the app root recursively with the existing prune list, and reports the directories that actually contributed.
- **@voltro/cli** — The observed-graph check no longer reports "never read" from an empty recording.

  A procedure can be recorded as having RUN while no table access was captured for it, and "ran and touched nothing" is then indistinguishable from "ran and nothing was recorded". A consumer saw `edges: []` with three procedures in `exercised`, and every one was reported as declaring a source it never read — including a handler that demonstrably reads its table.

  This is the distinction the manifest already draws for guards, where omitting the field made "no authorization" indistinguishable from "not reported". For reads it had collapsed again. With zero captured edges nothing is claimed, and the procedure is reported in a third bucket alongside `unexercised`. Coverage excludes it too — counting it would overstate the denominator in precisely the run where the recorder produced nothing.
- **@voltro/database, @voltro/cli** — `.reactive()` says what it does, and the framework says which dialect case you are in.

  The keyword is named for a general capability and implements one dialect's transport detail, and both ways of getting it wrong were silent:

  - **Declared where it does nothing.** On mysql/mariadb the ROW-format binlog reader tails every table; on mssql Change Tracking is configured with the whole app table set; sqlite is single-process. The flag is inert on all of them. Its doc said "opt the table into the reactive engine", which is false — every table is already in it, because the store emits committed deltas inline. A team on MariaDB read that, found 26 tables without the flag and 44 query descriptors reading them, and reasonably concluded a large part of their app was never live. It always was; the investigation was the cost. - **Missing where it is required.** On postgres with `changeStrategy: 'cdc'`, only a `.reactive()` table gets the `pg_notify` trigger the CDC consumer listens on. A table without it never propagates a write to another instance's subscribers — correct on the writing pod, stale everywhere else, and perfect in single-instance dev.

  `voltro dev` and `voltro serve` now name whichever case applies: an info line when the flag is inert on this dialect (including that nothing is missing), and a warning listing the tables a query reads that lack it under postgres+cdc. A warning rather than a refusal, because the framework cannot tell from inside one process whether a second one exists.

  Also corrected: the DSL doc, and a `reactiveTables` variable in the CDC wiring that was actually every app table and consulted no flag at all.
- **@voltro/cli** — `voltro test`'s tsconfig reader destroyed any config carrying a `@/*` alias, so the alias derivation shipped in the previous release was correct and never ran.

  The comment stripper was a regex. It read the `/`+`*` inside the alias key `"@/*"` as an opening block comment and closed it on the `*`+`/` inside an `include` glob like `src/**`, deleting everything between — `paths` included. The parse then failed, and an unparseable tsconfig degrades to "no aliases" by design, so the whole thing was silent. Essentially every real tsconfig has both an `@/*`-style alias and a `**` glob, which made this every real tsconfig.

  It now scans string state in one pass instead of regexing over strings, and the trailing-comma pass does the same — a `,` inside a string is not a trailing comma either. Reported with a five-line comment-free repro, which is exactly what made it obvious that comments were never the trigger.

---

## [0.13.0] — 2026-07-25

### ⚠ BREAKING

- **@voltro/protocol, @voltro/plugin-scim, @voltro/plugin-prometheus** — SCIM was served UNAUTHENTICATED whenever its token was an empty string.

  `checkBearer(headers, expected)` returned `true` when `expected` was unset or empty, documented as "no token configured = open; the caller decided not to gate this surface". Its one production caller had decided the opposite: `scimPlugin` declares `token: string`, and `scimPlugin({ token: process.env.SCIM_TOKEN ?? '' })` — the shape anyone writes — turned the gate off silently. The result was SCIM 2.0 Users and Groups readable with no credentials: a full directory dump plus the provisioning surface that can deactivate accounts. Likeliest exactly where it hurts, too: an env var set in production and missing in a preview environment.

  `checkBearer` is now fail-closed by default, with the permissive behaviour available as an explicit `{ openWhenUnset: true }` — a two-argument helper cannot know its caller's intent, so it must not assume the permissive one. `@voltro/plugin-prometheus` passes it (its token is documented as optional), and `scimPlugin` now throws at construction — i.e. at boot — rather than answering the first anonymous request.
- **@voltro/database, @voltro/cli** — `voltro db apply` and boot auto-migrate could report success while applying nothing, and then record a fingerprint that made every later boot short-circuit on "schema up to date".

  Reported from a live pod: `applied 31 op(s)` on every boot for two releases, with none of the 31 present in the database. Nothing was wrong with the transport, the lock or the transaction — the applier emitted statements that postgres accepted and that changed nothing. Two independent causes:

  - A `ColumnSnapshot` carried no `vector` dimension / `array` element / `enum` name, so the applier's type renderers collapsed all three to `text`. A declared `vector(1536)` over a live `text` column planned an `alter-column-type` that emitted `ALTER COLUMN … TYPE text`. Valid, applied, no-op, re-planned forever. (Also meant an `add-column` for a vector, array, enum or PostGIS column created a plain `text` column.) - The default-clause renderers excluded ARRAYS, returning `null`, and the call site turned that into `SET DEFAULT NULL`. A declared `.default([])` on a `json()` column therefore never landed — thirty columns were stuck this way in the reporting schema.

  Fixed: the snapshot carries the type parameters and the renderers delegate to `migrate.ts`'s canonical `sqlType`, so the applier and the CREATE-TABLE emitter cannot disagree; array defaults render (a real `text[]` literal on a native `array()` column, a jsonb literal otherwise); and a default the renderer cannot express now FAILS instead of degrading to `DEFAULT NULL`.

  And the structural guard, which is the part that matters: **`applyPlan` re-plans against the live schema before it records a fingerprint, and refuses to record one if any operation remains.** DDL that changes nothing succeeds exactly as quietly as DDL that works, so the only evidence a plan applied is that the same planner has nothing left to do. `ApplyPlanCtx` gains a required `replan`; `AppliedMigration` gains `appliedOps` (what EXECUTED, not `plan.operations.length`), and the boot log quotes that.
- **@voltro/plugin-storage** — `storage.share`, `storage.revoke` and `storage.listGrants` performed no authorization at all.

  Each took an object id straight off the wire and passed it to a service method that (correctly, for a trusted server-side API) checks nothing, with nothing in between. Any authenticated caller could grant themselves read or write on any object in the installation, revoke anyone else's grants, and enumerate who an object is shared with.

  All three now require that the caller owns the object, or carries `admin:full`. A missing object and an unowned object report the same 403 — a 404 would let an unauthorized caller probe which ids exist. `GrantStore` gains `getById`, which `revoke` needs to resolve a grant id back to its object.

### Added

- **@voltro/runtime, @voltro/database** — API keys carry app-owned `metadata` — the second ownership axis.

  `tenantId` and `onBehalfOf` are the two relationships the framework models. Plenty of apps have a third that actually authorizes the key: a team, a project, an environment. `ApiKeyRecord` in `@voltro/protocol` has carried a `metadata` slot all along — its doc comment even names `teamId` as the example — but the SERVICE had nowhere to store it and nowhere to return it. So an app with a team axis could authenticate through the built-in strategy and still not authorize, and `apiKeys: true` was unusable for it. Reported as the one thing that stopped an otherwise complete adoption; their alternatives were a second table joined on the hot auth path, or smuggling `team:<id>` into `scopes`, where `hasScope` would then see a scope that is not a scope.

  `IssueInput`, `ApiKeyRow` and `ResolvedApiKey` now carry it, stored as JSON on `_voltro_api_keys`, and it survives `rotate` — a rotated key is the same credential with a new secret, so dropping it would silently de-authorize every rotated key.

  It is app data, never identity. The strategy merges it UNDER the framework's own claims: `provider` and the acting `userId` are written afterwards from `onBehalfOf` and always win, including when the answer is "none". A bag that could set `userId` would let whoever minted the key choose who the request is. Pinned end-to-end, not just at the protocol layer.

  `PublicApiKey` also gains `createdBy` and `onBehalfOf`, so `service.list` can answer the two questions an admin actually asks about a shared credential. Neither is a secret — they are the accountability record, and omitting them hid them from the person responsible for the key.
- **@voltro/protocol, @voltro/cli** — A boot warning when two auth strategies claim the same bearer-token prefix.

  The chain is first-match-wins, so a duplicate claim is not a harmless redundancy: whichever strategy runs first decides the Subject. An app that already has its own `sk_` keys and then sets `apiKeys: true` gets the framework strategy appended on the same prefix — resolving without the app's own team binding — and *which strategy answered* decides whether authorization works. Reported by an app that had to pin a test asserting it never enables the flag.

  `AuthStrategy` gains an optional `claimsBearerPrefix`, set by `apiKeyStrategy` from its `prefix` option. Making the claim declarative is what makes the collision detectable at all — the same "only what is declared can be checked" argument the scope rules run on. Checked in `buildResolveSubject`, which both `voltro dev` and `voltro serve` call, so the two boot paths cannot drift.

  A warning rather than a refusal: two strategies on one prefix can be deliberate (a migration window where old and new keys share a shape). What must not happen is that it goes unmentioned.
- **@voltro/protocol, @voltro/cli** — `auth.resolveScopes` — add scopes to an authenticated Subject from your own data, so ROLE-based authorization becomes declarable.

  An app whose authorization is a database role (`requireCallerAdmin(ctx)` reading an `employees.role` column) is invisible to every static check the framework has: `voltro check`'s `rbac/unguarded-mutation` reports its writes as unguarded, and it is right to — nothing about the decision is declared. But the declarative alternative was unusable for exactly those apps: their subjects come from an external IdP's JWTs and carry no scopes, so `requireScope('employee:admin')` would lock out every real user. One app measured 1566 findings it had no way to act on.

  Lifting the role into `subject.scopes` makes the SAME authorization declarable, visible in the manifest and checkable in CI. Deliberately narrow: the hook returns SCOPES, never a Subject — it cannot change `id` or `tenantId` (identity belongs to the auth strategy), and the result is unioned with the strategy's own scopes, so it can grant but never revoke. It runs per matched request, so cache the lookup yourself; the framework does not, because only the app knows how fast a role change must take effect. Wired identically in `voltro dev` and `voltro serve`.
- **@voltro/cli** — `voltro doctor` reports packages resolved at more than one version.

  A consumer reported type errors inside the GENERATED `rpcGroup.generated.ts` — `Property '[TypeId]' is missing`, `typeof Never is not assignable to All`, an `Rpc<…, Stream<…>, …>` refused where `Any` was expected — and reasonably concluded the framework emits bad types, because the errors land in a file they cannot edit and did not write. That is the signature of two copies of `effect` in one install: Effect's types are nominal, so a Schema built by one copy is not the type the other expects.

  It deserves its own check because the RUNTIME usually stays green — two instances only diverge where identity matters — so an app boots, serves and passes its tests while `tsc` is red, which sends people looking at the compiler instead of the dependency tree. The report names the versions, the paths, and the errors it explains. Only identity-sensitive packages count (`effect`, `@effect/*`, `@voltro/*`, react/react-dom); a duplicated string utility is wasteful, not a bug class.
- **@voltro/cli** — `voltro doctor` flags an executor that never names its own descriptor.

  Descriptor/executor pairing is by FILENAME, which is right — and it means a `*.server.ts` can be a complete, correct executor with no reference at all to the contract it implements. Those are exactly the files where a hand-written input drifts from the wire.

  Reported after a 426-executor migration to `ExecutorInput<typeof descriptor>`: three files were skipped by the app's own codemod for a reason no reviewer would guess — they never imported their descriptor, so there was no `typeof` to point at. In the same codebase, six executors had written `boardPurpose: string` where their descriptor declared `Schema.Literal(...)`, discarding the contract at the executor boundary. Only imports of a SIBLING module clear the finding: an executor importing nothing but `@voltro/*` and `node:*` has still not named its contract.
- **@voltro/database** — `updateManyRow(store, table, patch, { where })` — the last untyped write is now typed against its table.

  `insertRow` and `upsertRow` already were; `ctx.store.updateMany(table, row, { where })` still took a string table name and an untyped row literal. Worth closing because the typed versions were measured: migrating 29 `store.upsert` call sites to `upsertRow` produced 15 `tsc` errors across 8 distinct defects that no test had caught — including seven per-user mutations with no authentication check at all (they wrote `ctx.request.subject.id`, typed `string | null`, into a NOT NULL column, so an anonymous caller reached the database and got a raw statement failure instead of a typed refusal).

### Fixed

- **@voltro/runtime** — A `cache:` declared on a query whose handler returns a COMPUTED value was silently ignored; it now says so.

  The snapshot cache wraps the store read, and a computed query has none — its handler has already run by the time the binding is built. Caching one would mean wrapping the handler invocation, which is a different feature. Until that exists, the honest failure is a loud one: silently ignoring the config is how an author ends up believing a hot query is cached while every subscriber re-runs it. The data stays correct, so nothing else would ever tell them. Warned once per query name, not per subscribe.
- **@voltro/cli** — The minted `.env.local` is handed to the workspace's owner, and an unreadable env file explains itself.

  A dev container running as root with the host workspace bind-mounted wrote `apps/api/.env.local` as `root:root 0600` INTO THE SHARED WORKSPACE. On the host, everything that loads env then died with EACCES — vitest, `voltro doctor`, the editor — and the developer could not even read the file, while the next container boot recreated it. Container-with-bind-mount is the ordinary dev shape, not an edge case.

  `0600` stays (the file holds a real signing key), because loosening it to `0644` would make that key readable by every account on the machine for the far more common single-user case. Ownership was the wrong variable, so that is the one corrected: the mint chowns the file to whoever owns the directory, which root can do — exactly the case that needs it — and reports loudly when it cannot. A plain EACCES while loading an env file now names the owning uid, the mode and the current uid, because that pair IS the diagnosis and none of it appears in node's message.
- **@voltro/cli** — Framework-generated output is handed to the workspace's owner, not left owned by whoever the process happens to be.

  The previous release fixed this for the minted `.env.local`. The report that followed showed the scope was wrong: it is EVERY directory the framework generates. A dev pod running as root with the host monorepo bind-mounted leaves `.framework/` and `app.graph.observed.*` as `root:root` inside the developer's own tree, and on the host:

  ```
  voltro build . → EACCES: permission denied, open '…/apps/display/.framework/index.html'
  ```

  That is the harder failure. `.env.local` broke env loading; this breaks the production build of every web app outright, with no workaround short of chown-ing by hand after each pod boot. One team could only verify their frontends through test suites and live requests against the running pods.

  `voltro dev` and `voltro build` now hand their generated output — `.framework`, `.env.local`, every `*.generated.*` — to the uid that owns the app root, and say so loudly when they cannot. A no-op on every ordinary run and in any container started with `--user <uid>:<gid>`: when the process already owns the root it returns without touching the tree. Only generated state is claimed; the framework never chowns a file a human wrote.
- **@voltro/cli** — The observed app-graph no longer restarts the dev server.

  `app.graph.observed.json` was written into the watched app root every 10 seconds, and the supervisor's watcher fired on each write. A downstream pod measured two restarts before every boot over 2000 log lines — the rule, not an outlier — and paid a ~46 s boot three times per save.

  The watcher excludes `<name>.generated.<ext>`, a substring rule chosen precisely because a per-extension whitelist had already let a generated file slip twice. This file slipped it a third time by not carrying the segment at all. It is now `app.graph.observed.generated.json`, which matches the convention instead of adding a fourth special case to a list that has drifted three times; a stale un-suffixed file from an older dev server is removed on boot so it cannot keep triggering restarts.
- **@voltro/cli** — Four tooling fixes, all from downstream reports:

  - **`voltro check --offline` crashed on any app that declares a workflow.** It built workflow entries as `{ name }` behind an `as never` while `InspectWorkflowEntry` is keyed by `tag`, so the manifest's sort read `undefined` and threw — surfacing as "could not assemble the graph from source" rather than the type error underneath. The cast is what let the two shapes disagree. - **`voltro check --offline` reported plugin tables as `dangling-source`.** It collected only the app's own `*.entity.ts` tables, so a query reading `_voltro_storage_refs` was an `error` — which sets the exit code, failing the CI gate the offline mode exists for. It now uses the same `assembleFrameworkTables` the migrator does. - **`voltro test` now derives `resolve.alias` from the app's tsconfig `paths`.** An app mapping `@/* → ./src/*` could not test any module importing through it (`Cannot find package '@/locales/en'`), and the workaround was a local `vitest.config.ts` restating what tsconfig already said. - **The `raw-fetch` doctor rule follows the import graph.** Keyed on filename conventions it caught 9 of 39 outbound calls on the reporting app; the other 30 were in `lib/*.ts` helpers only server code imports. A file reachable from a server-convention file and from nothing else is server code; one a page also imports is not, and stays unflagged.

---

## [0.12.0] — 2026-07-25

### ⚠ BREAKING

- **@voltro/protocol, @voltro/runtime, @voltro/database** — **API keys: `createdBy` and `onBehalfOf` are now two fields, because they were always two relationships.**

  One field carried both, and its own doc comment gave it away — *"the user this key was minted for **/ by**"*. That slash is the defect: a `null` had to mean BOTH "nobody created it" (never true — somebody pressed the button, org keys included) and "it belongs to no person" (the thing actually being expressed). So the model could not answer *"who created this org key?"*, which is a question you will be asked, and an admin minting a key for a colleague had nowhere to record that the key is the colleague's.

  - **`createdBy`** — WHO MINTED IT. Provenance, present for org keys too. - **`onBehalfOf`** — WHO IT ACTS AS. `null` here, and only here, means an ORG key.

  **Attribution now resolves to the person.** `subject.id` for an API-key request is the CREDENTIAL (`apikey_01H…`), and the audit columns stamped it — so a UI rendering "created by …" either showed a raw key id to a human or paid a join back to the key table per row. A personal key now stamps the person it acts as; an org key keeps the key id, because there the credential *is* the actor and the id is the only thing naming which integration.

  **Issuance is three rights, not one admin gate.** `admin:full` for all minting meant a normal user could never create even a narrowly-scoped credential of their own, and "minting for myself" was indistinguishable from "minting as someone else":

  - `apikeys:issue:self` — a key acting as me - `apikeys:issue:org` — an org key (acts as nobody, outlives my account) - `apikeys:issue:other` — a key acting as another user; never implied by the others

  Plus a scope ceiling: a key can never carry scopes its issuer does not hold, or the narrowest issuance right would be a privilege-escalation primitive. `admin:full` satisfies all three, as it does every scope. Omitting `onBehalfOf` defaults to a key acting as the caller — only an explicit `null` asks for an org key, so a client that forgets the field cannot accidentally mint a credential belonging to nobody.

  **A regression caught while building this, worth recording.** The first version spread `userId` onto the Subject only when `onBehalfOf` was set. That left an app-supplied `metadata.userId` in place on exactly the keys that act as no person — an org key — letting a metadata bag forge the acting user. The framework's claim about who a request is must overwrite, *including overwriting with "none"*. An existing test caught it; a new one pins the hole rather than the symptom.

  `onBehalfOf` is a new column on `_voltro_api_keys` — framework tables ride the declarative differ, so no migration to write. Existing rows read back with `onBehalfOf: null`, i.e. as org keys; if yours were personal keys recorded via `createdBy`, backfill `onBehalfOf` from it.
- **@voltro/runtime** — **`apiKeyService.revoke` / `.rotate` now require the caller's `tenantId`, and refuse a key belonging to anyone else.**

  They took a key id and nothing else. The shipped route guards them with `requireScope('admin:full')`, which establishes *"is an admin"* — never *"an admin of THIS key's tenant"*. So an admin of tenant A could revoke tenant B's key given its id, and ids leak: logs, support tickets, an error message, a `createdBy` column.

  **Rotate was the worse of the two**: it revokes the old key and returns a *usable token* for the same tenant, so an unscoped call handed the caller a working credential for someone else's tenant. It is not exposed on the shipped routes, which is the only reason this was a latent hazard rather than a live one.

  `list` was already tenant-scoped, so key ids could not be enumerated — the gap needed an id from elsewhere. That narrows exploitability; it does not make an authorization check optional.

  Required rather than optional, deliberately: an optional scope on a destructive operation is a scope somebody forgets — the same reasoning that moved the SSRF guard into the HttpClient instead of leaving it a helper you remember to call. A key belonging to another tenant returns `false` / `null`, indistinguishable from "no such key", so the call cannot be used to probe for ids.

  The framework's own `/v1/api-keys/revoke` route now passes the caller's tenant — no action needed if you only use the shipped routes. The `manual` codemod covers direct callers; a transform cannot write this argument, since only the surrounding handler knows whose tenant it is, and a placeholder in an authorization check would look done.
- **@voltro/cli** — **The inspect surface is now fail-closed: no `VOLTRO_INSPECT_TOKEN`, no access.** `envTokenAuthResolver` read the other way — unset token meant *everyone authorised* — which was defensible while `/_voltro/inspect/*` was a local-dev convenience and became indefensible once `voltro start` mounted it. A public web app served its route table, ISR cache keys, metrics and the **whole process log buffer** to anyone who asked, unless the operator happened to set a variable the docs described as merely "recommended". The absence of a secret is not consent, and an authorization check you were configured not to perform is a refusal, not a pass.

  **Most setups see no change.** `voltro dev` MINTS a per-project token before the env gate and delivers it to both legitimate consumers without the developer touching it (the CLI reads the runtime registry, so `voltro logs` works from any cwd; the dashboard proxy injects it server-side, so the browser never holds it). Verified against a live boot: tokenless `/_voltro/inspect/app` already returned 401 before this change, and returns 200 with the minted token. `voltro serve` (api) mounts nothing at all and is unaffected.

  **What DOES change is every path that never minted** — `voltro start` (the production web runtime), `voltro serve`, and any harness that spawned a server with no token. Those were the open ones. If you depend on the inspect surface in production, set `VOLTRO_INSPECT_TOKEN` explicitly; it is deliberately never minted outside dev, because in production a missing secret must stay a boot-time decision rather than an invented value.

  Known consequence, recorded rather than left to be discovered: the `voltro-starter` smoke scripts drive `/_voltro/inspect/invoke` with no Authorization header. They were ALREADY failing against a minted dev token before this change (the harness that runs them, `scripts/test-all.sh`, is itself broken on a stale `voltro-dashboard` path and runs in no CI, so nobody saw it). They need the token threaded through; tracked in `plans/app-graph-and-scope-registry.md`.
- **@voltro/runtime, @voltro/cli** — **The `HttpClient` handlers `yield*` now enforces an SSRF guard, on by default.**

  Refused: loopback, RFC-1918, CGNAT and link-local addresses (including the `169.254.169.254` cloud-metadata endpoint), the hostnames `localhost` / `*.internal` / `*.local`, and any non-http(s) scheme — **on the initial request AND on every redirect hop**.

  Why it belongs in the client rather than in a helper you call: the framework already shipped a perfectly good `assertPublicUrl`, and it was reachable from exactly **two** call sites, both inside plugin-webhooks, one of them wrapped in `if (process.env.NODE_ENV === 'production')` — so a box running with an explicit `NODE_ENV=staging` delivered webhooks with no revalidation at all. A guard that has to be remembered is not a guard. Meanwhile every app with a scraper, a webhook-registration form, an importer, or a "test this connection" button feeds a caller-supplied URL into `yield* HttpClient` and got nothing.

  **The redirect hop is the part a hand-rolled version misses**, and it took two things to get right. The guard is installed with `HttpClient.transform` (which wraps `postprocess`, and `followRedirects` calls `postprocess` once per hop) rather than `mapRequestEffect` (which wraps `preprocess` and would run once, ever); and `redirect: 'manual'` is provided to fetch, or fetch follows the hops itself and the intermediate URLs never surface to be checked. Covered by a test that allows the stub host and blocks its redirect target, so a pass can only come from the hop being revalidated.

  **`http.allowHosts` is the escape hatch AND the test hook — deliberately the same mechanism.** A guard that blocks loopback breaks every test pointing app code at a local stub server; without an official hook, teams mock the guard away wholesale and never exercise the production path (which is exactly how the framework's own webhook delivery ended up `NODE_ENV`-gated). Using `allowHosts: ['127.0.0.1:8787']` in a test keeps it on the real guarded path with a narrow exception.

  ```ts
  export default defineApiConfig({
    http: { allowHosts: ['*.svc.cluster.local', 'billing.internal'] },
  })
  ```

  Entries may be an exact host, a `*.suffix` wildcard (which does NOT match the apex — one that did would silently widen the exception), or `host:port`. There is no boolean off-switch on purpose: "we call one internal service" and "we do not check URLs" are different postures, and a boolean cannot tell them apart later.

  **Breaking, and named as such**: an app doing cluster service-to-service HTTP will start failing until it declares its hosts. `*.svc.cluster.local` is blocked by the `.local` rule. The `manual` codemod prints the migration during `voltro update`.

  Not covered: DNS is not resolved, so a public hostname that RESOLVES to a private address (DNS rebinding) still passes. Said plainly in the docs rather than silently implied — that vector needs network-layer egress control.

  Wired through the SHARED `makeHandlerHttpClientLayer` factory that both boot paths call, reading the same `http` config key, so dev and serve cannot diverge. In `dev` it is built inside `runDevInner` rather than at module scope, because the allowlist comes from `app.config.ts` and a module-level default would have quietly ignored it in dev while serve honoured it.

### Added

- **@voltro/cli** — `voltro check` can now reason about authorization. The capability manifest has always serialised each handler's guards, but nothing translated them into the app graph, so every scope-shaped rule was dead code: `requiresScopes` was never populated and `--diff removeScope:<name>` found nothing. The graph now derives `requiresScopes` from the declared guards, which makes scope blast-radius real, and adds a new rule — `rbac/unguarded-mutation` — that flags a mutation or action declaring **no guard at all**. "Is this write authorized correctly" needs runtime knowledge and stays out of scope; "does this write carry any authorization" is decidable straight from the manifest, and an unguarded mutation is the shape of an accidentally-public write (it warns rather than errors, since a deliberately public mutation — a signup, a webhook receiver — is legitimate). The manifest now always emits a `guards` field, as `[]` when there are none: omitting it made "this handler has no authorization" indistinguishable from "guard information wasn't reported", so no consumer could ever draw the conclusion.
- **@voltro/cli** — `voltro check` no longer needs a running api. It still prefers one — a live manifest is ground truth, including table introspection — but with none reachable it assembles the same graph from source, so it works as a pre-commit hook or a CI gate instead of something that needs a second terminal open. `--offline` forces that path. This is possible because `buildRpcEntry` only ever read a descriptor, never a runtime service; it moved to `src/manifestBuild.ts` so the dev boot and the offline path can't drift into producing different manifests for the same app.

  Two noise sources found by running it against real apps and fixed: framework-owned tables (`_voltro_*` plus the auto-injected `actors` / `tenants`) are no longer reported as orphans — the app neither declared them nor can delete them, so the advice was unactionable — while STAYING in the graph, so a framework query reading `_voltro_undo_log` or a mutation writing `tenants` still resolves rather than reporting as a dangling reference. And `rbac/unguarded-mutation` now covers mutations only: `*.action.ts` is "non-transactional external I/O" per the primitive rubric, which is as often read-like as not, and including actions made every finding on a clean starter app a false positive.
- **@voltro/cli** — **The inspect surface is now authenticated by default, and the diagnostic commands can reach a deployed app.**

  `voltro dev` mints a per-project `VOLTRO_INSPECT_TOKEN` (framework-owned, like the session secret) into `.env.local`. Previously the surface was open whenever the var was unset — and the dev server binds every interface, so any peer on the same network could read the app's DB rows, schema and log buffer, and POST to fire schedules or roll back migrations. Nothing needs to be configured by hand: the CLI reads the token from the runtime registry (so `voltro logs` works from any directory, not only the app's), and the dashboard's server-side proxy injects it for same-machine targets, so the browser never holds it. The registry file is now written 0600 since it carries tokens. An operator-set `VOLTRO_INSPECT_TOKEN` always wins.

  `--url <base>` (plus `--token`, or `VOLTRO_INSPECT_URL` / `VOLTRO_INSPECT_TOKEN`) targets a **deployed** app from `voltro inspect`, `logs`, `traces`, `workflows`, `cluster` and `check`. Until now all six resolved targets exclusively from the local runtime registry, so the entire diagnostic toolchain went blind the moment an app left the machine.
- **@voltro/runtime** — **`crud.list` / `crud.count` gain `scope: (ctx) => ({ … })`** — the caller-derived WHERE, as opposed to `filter`'s request-derived one.

  ```ts
  crud.list('timeEntries', {
    filter: (input) => ({ status: input.status }),            // what the caller ASKED for
    scope:  (ctx)   => ({ ownerId: ctx.request.subject.id }), // what it MAY SEE
  })
  ```

  The gap this closes: tenant scope is applied automatically, but anything narrower — owner, team, role — was **not expressible at all**. Replacing a hand-written handler that carried such a narrowing with `crud.list` therefore widened the result set, silently and with no error anywhere. Reported by an app that lost exactly that across eight list views.

  `scope` is merged LAST, so a request field of the same name cannot widen it (`?ownerId=someone-else` is overridden). Pass the same `scope` to `crud.count`, or the total contradicts the pages.

  **Why a separate option instead of `filter(input, ctx)`.** Two reasons, and the second one is why the first isn't the whole story:

  1. Only one of the two is a security boundary. Kept apart, "does this list declare a `scope`?" is a question a reviewer — or a future boot audit — can ask. Folded into `filter`, it becomes "does this filter happen to read ctx somewhere in its body?", which nothing can check. 2. Adding a parameter to `filter` would have been BREAKING, not additive: a filter stored in a variable and invoked directly (`{ ...base(input), status: … }` — a plausible composition pattern) stops compiling on "Expected 2 arguments". The changelog gate caught that classification; this shape needs no codemod at all.

  `apiSurface: compatible` — the one golden line that changed is `crud.count`'s options widening from `Pick<CrudListOptions, 'filter'>` to `'filter' | 'scope'`. A parameter type that accepts strictly MORE cannot break a caller: every value assignable to the old type is assignable to the new one, and both keys are optional.

  The general lesson for any helper we ship that REPLACES hand-written code: the extension point is what keeps people ON the safe path. Hand-writing the query to obtain the narrowing also forfeits `serverOnly` stripping and the page-size clamp — so a missing hook doesn't merely inconvenience, it pushes users off the secure default at exactly the moment their requirements got stricter.
- **@voltro/cli** — **`voltro doctor` flags raw `fetch()` in server files.**

  The SSRF guard shipped in 0.12.0 lives in the `HttpClient` handlers `yield*`. An app reported the consequence honestly: 36 raw `fetch` calls, **zero** `HttpClient` uses — no breaking change for them, and no protection either. A guard in a client nobody adopted protects nobody, and the apps that never adopted it are usually the ones that secured least elsewhere. The absence is worth naming rather than assuming the default did its job.

  Their ask was a **boot** hint. This is in `doctor` instead, and the reason is structural: boot does not parse source. Doctor already runs a ts-morph pass, the cost is opt-in, and a false positive there is a line of output rather than a stopped server.

  Server conventions only (`*.server.ts`, `*.cron.ts`, `*.subscribe.ts`, …). `fetch` is unremarkable in a browser component, and flagging it there would make the rule noise that gets scrolled past — taking the real findings with it. Real CALL expressions only, so the word in a comment, in a string, or inside `prefetch(` does not trip it; each of those is pinned by a test.

  `RuleContext` gained the file's relative `path` to make this possible — every file is parsed under one in-memory name so a single ts-morph project can be reused, so a rule that needs to tell server code from client code had no other signal.
- **@voltro/runtime, @voltro/cli** — **The observed app graph — `voltro check` now reconciles declared intent against recorded behaviour.**

  A query's `source` and a mutation's `targets` are not documentation: the framework routes optimistic patches and decides which subscriptions a write invalidates from them. A wrong declaration is a live, user-visible bug that nothing type-checks — the mutation succeeds, the write lands, and the wrong list fails to update.

  `voltro dev` now records what each procedure ACTUALLY touched into `app.graph.observed.json` (gitignored automatically), and `check` diffs the two: undeclared reads/writes, a declared table never touched, a target whose `op` disagrees with what happened.

  **Why recorded and not derived.** The tempting version parses the handlers. This repo already made and documented the opposite call once, for index auditing: the schema is constructed code, so a visitor cannot follow the builder pattern, and inspecting the realized value is both more correct and simpler. A handler is strictly more dynamic — shared `lib/` helpers, conditionals, computed table names — so a static pass has a long tail of both false positives and false negatives, and a check that is *sometimes* wrong is one people stop reading.

  **`unexercised` is a distinct third state and never an error.** A procedure no test and no dev session ever ran has no observation, which is NOT "touches nothing". Reporting it as a mismatch would bury the real findings in any repo with partial coverage. Coverage is printed first for the same reason: three findings at 8% and three at 95% are different claims, and hiding the denominator is how a check starts overstating what it knows. Observed diagnostics are always warnings — `check`'s exit code gates CI, and an observation is evidence about the runs that happened, not a proof about the ones that didn't.

  Recording is off unless enabled (`enableGraphObservation()`, or `VOLTRO_OBSERVE_GRAPH=1` for an out-of-process harness), so a production serve pays nothing. The recorder sits on the ONE `DataStore` beneath `wrapStoreWithMixinBehaviour` — instrumenting the middleware's ~20 public methods instead would double-count (`one`/`first`/`maybeOne` all delegate to `query`) and miss any method added later.

  Three things this needed that a first cut would have missed, each pinned by a test: transactional writes go through a DIFFERENT store handle (every mutation's writes are transactional, so missing it would report the busiest procedures as touching nothing); queries are bound under a `subscription.` span prefix, which a naive parser rejects, silently dropping the majority of the surface; and concurrent requests need AsyncLocalStorage rather than a module-level "current tag", or two in-flight procedures attribute each other's tables.

  Also fixed, from the same plan: the declared-scope registry now reaches the OFFLINE manifest. `rbac/unknown-scope` fires only when the app has a scope vocabulary, and the live inspect endpoint passed the plugins while the offline path — the CI gate the rule exists for — did not. So the rule worked next to a running `voltro dev` and did nothing in CI. The written `app.manifest.generated.json` had the same empty-registry gap.
- **@voltro/cli** — **`voltro doctor` now checks every literal predicate column against the table it filters.**

  `eq` / `isNull` / `inSet` are free functions, so the column arrives as a bare `string` and the builder cannot relate it to the table the predicate is later attached to:

  ```ts
  database.teamAppointments.where(isNull('deletedAt'))
  //                               ^ no softDelete() mixin, so no such column. tsc: OK.
  ```

  It type-checks, so review and CI pass, and it fails at runtime as a bare SQL error. A downstream cron failed on every recorded run this way and had to be diagnosed by bisecting which store debug lines were *absent*.

  The check reports the table, the missing column, and the table's real columns, so the fix is in the message. Matching is on the AST rather than on text, so a column name in a comment or an unrelated string cannot trip it — the hand-rolled version of this check produced exactly that false positive. A call site whose table cannot be resolved is skipped silently: unlike the workflow audit, where an unreadable payload is a gap worth naming, an unresolvable receiver here is usually not a table at all.

  **Why a check and not (yet) a type.** Binding the predicate to the row — `where(c => isNull(c.deletedAt))` as the only form — is the right end state. It is also 300+ call sites inside this repo alone plus every downstream app, and the codemod has to rewrite arbitrary predicate expressions; a half-migrated query API is worse than either end state. This check is the half that works **retroactively**, on code that already exists, which a type change never will. The precedent is `database/src/indexAudit.ts`, which checks a table's INDEX declarations against its columns for the same reason — the query path was simply the gap.
- **@voltro/cli, @voltro/plugin-rbac, @voltro/protocol, @voltro/mcp** — `voltro check` now catches a guard that requires a scope no role can grant. Such a handler is not merely misconfigured — it is permanently, silently uncallable: every caller fails the guard, forever, and no test finds it unless someone happens to exercise that exact procedure with the role that should have passed. The rule (`rbac/unknown-scope`) existed but was dead code, because nothing produced the app's declared scope vocabulary. `rbacPlugin({ roles })` already IS that vocabulary, so it now publishes the union of its role map via a new optional `declaredScopes` on `VoltroPlugin`, and the capability manifest exposes the union of every plugin's under `scopes`. The wildcard `'*'` is excluded — it is the admin bypass, not a scope name. Crucially, rbac publishes NOTHING when a custom `resolvePermissions` is configured: that resolver merges extra strings (per-row ACLs, feature flags) which by design live outside the role map, so a partial vocabulary would flag correct code. With one configured the check stays dormant, which is the honest outcome — a check that cries wolf gets ignored.
- **@voltro/runtime, @voltro/cli** — **`ctx.storeForTenant(tenantId)`** — the same store, scoped to one tenant.

  For non-request work whose subject has no tenant: a schedule, a subscriber, a workflow step. There, reads see every tenant and a write to a `tenant()` table fails with `TenantScopeViolation`, so a per-tenant cron has to say which tenant it means. Every fan-out cron was literally a loop doing that by hand — and by hand, each `.where('tenantId', t.id)` and each explicit `tenantId:` is one forgotten call away from reading or writing across tenants.

  ```ts
  for (const t of await ctx.store.select('tenants').all()) {
    const scoped = ctx.storeForTenant(t.id)
    await scoped.insert('digests', { body: summary })   // tenantId stamped, not passed
  }
  ```

  **The implementation detail worth stating, because it nearly shipped wrong.** The scoped view does NOT run as `{ ...systemSubject, tenantId }`. A `system` subject carries `tenantId: null` by construction *and* the tenant read-scope mixin special-cases `type === 'system'` to skip the tenant merge entirely — on a system subject a null tenant means "all tenants". Spreading a tenantId onto one produces a completely unscoped store that claims to be scoped to that tenant: worse than the problem this solves. The type system caught it; the fix is a `serviceAccount` subject, which belongs to exactly one tenant by construction, keeping the caller's scopes and identity. `tenantScopedSubject`'s return type is narrowed to that variant so it cannot regress silently, and a test pins the mechanism rather than only the outcome.

  Assembled in the shared `makeAppContextBuilder`, so it carries the same mixin wrapper and the same row filter as `ctx.store` — a caller that RECEIVES a scoped store cannot forget the row filter, and one that builds its own can.

  Inside a request this is almost always the wrong tool: the subject already carries a tenant, and reaching for another is a cross-tenant access with extra steps. It exists because the system subject has no tenant to infer.
- **@voltro/cli, @voltro/runtime, @voltro/database** — **`voltro doctor` now audits every `workflows.start(name, payload)` call site** against the workflows the app registers — unknown name, missing required fields, unknown fields.

  I argued this was redundant once runtime validation landed, on the grounds that it only bought "~24 hours". That was wrong, and the correction is the interesting part: the argument silently assumed a DAILY cron. A weekly job is seven days per data point and a quarterly one is a quarter — and `voltro inspect schedules --failing` structurally cannot see a job that has never fired, because its roll-up is built from recorded runs. A downstream app found a weekly workflow broken since a port exactly this way, and the same scan *disproved* one of their own earlier bug reports, which is the better argument: a static check is how a report stops being anecdotal.

  **`UNCHECKED` is never folded into a pass.** A payload built with a spread or a computed key has an unknowable key set; those sites are counted and listed rather than passed silently, because `0 issues` must not read as "all verified" — the same rule the observed app graph follows with `unexercised`. The workflow NAME is checked regardless of the payload, since a rename is decidable either way. Required keys come from the live `payloadSchema` via the same function the runtime validation uses, so the two cannot disagree.

  In `doctor`, not at boot: `voltro dev` would pay a full ts-morph parse on every start, and a boot that refuses because it could not understand a spread is worse than the bug.

  ---

  **API-key usage accounting is now buffered, and gained `requestCount`.**

  `resolveByHash` did `await store.patch(id, { lastUsedAt })` on **every** auth check — a synchronous row write in front of every key-authenticated request, for a value nobody reads with per-request precision. A downstream team had hand-rolled a fire-and-forget replacement to avoid paying it, then asked for `requestCount` noting they would not pay a synchronous write for that either. Both correct.

  `makeApiKeyUsageBuffer` accumulates in memory and flushes on a timer (30s) or a pending-key threshold. A crash loses the current window: `lastUsedAt` may be one window stale and `requestCount` may undercount. That is the right trade for "is this key still in use, and roughly how much" — key revocation and runaway- integration questions — and the wrong one for billing, which is why the column doc says it must never become a billing input. Cross-replica flushes are not atomic, for the same reason and stated in the same place.

  Wired in BOTH boot paths. A buffer in only one would have made the hot path — and the counter — differ between dev and production.

  `requestCount` is a new column on `_voltro_api_keys`; framework tables ride the declarative differ on `voltro db apply` / boot, so no codemod is needed.

### Changed

- **@voltro/cli** — `voltro migrate` now applies the schema through the **declarative differ** (it delegates to `voltro db apply`) instead of the create-only emitter. This is a correctness fix: the old behavior was `CREATE TABLE IF NOT EXISTS` with no diffing and no ALTERs, so adding a column, changing a type or adding an index reported success having applied **nothing** — and the declared schema silently drifted from the live database. It was the shortest, most guessable name in the schema family, the docs recommended it for CI/ops, and it was the one that didn't work. `voltro migrate --create-only` keeps the old emitter for the one case it suits: bootstrapping a brand-new database with nothing to diff against. No user code changes shape, so no codemod applies — but a pipeline that relied on `voltro migrate` NOT altering existing tables should switch to `--create-only`.

### Fixed

- **@voltro/cli, @voltro/data-transfer** — The admin-import **storage pull** (`POST /_voltro/admin/import` with `{ bundleKey }`) can no longer wedge a request forever, and no longer leaks the provider's stream. Both were unbounded before: a storage backend that goes silent hands back a body that emits neither data, nor `end`, nor `error` — so the archive parser's read never settled and the import held its temp dir, its socket and, with `x-import-atomic`, an **open transaction** until the process died. Nothing downstream could rescue it (the read was a raw promise, hence uninterruptible, and undici's 300 s `headersTimeout` outlasts most callers). The read is now bounded at both phases — resolving the object, and a per-**chunk** idle budget during the transfer, so an arbitrarily large bundle still streams for as long as it likes provided bytes keep arriving; only silence is fatal — and a trip fails with a named error instead of hanging. Separately, `unpackBundle` now always releases its source iterator: every exit left it suspended (the `KIND_END` return is the NORMAL path, plus every `throw`), so the producer's own cleanup never ran and a storage-backed import leaked the provider's body stream / fd — on s3, a pooled socket — once per import. Only the memory and filesystem providers always settle, which is why no test caught either.
- **@voltro/cli** — `voltro help` is readable again: commands are grouped by job (Start a project / Develop / Database & data / Build & run / Deploy / Observe & debug / Maintain / Meta) and each line shows one short lead sentence — the previous flat list printed 45 entries in registry order with summaries up to 351 characters, so every line wrapped. An unknown command now suggests near misses (`voltro mgirate` → "Did you mean? voltro migrate"), and `voltro help <command>` shows that command's help instead of the whole list. Fixes a regression from the uniform `--help`: 9 commands (cloud, workflows, cluster, traces, static, serverless, package, baseline, update) implement their own richer usage text, which the dispatcher was swallowing. Also fixes `voltro secret generate --bytes=64` silently minting a 32-byte key (the `=` form read as "flag absent"), and `bin.ts` losing a non-zero exit code while draining stdout.
- **@voltro/cli, @voltro/database** — Security follow-ups found by a second review pass. The dashboard proxy's `?target=` guard was only applied to `voltro start` — the `voltro dev` twin (`webDev.ts`, the path developers actually run) was still an open SSRF that forwarded the caller's bearer to any host. Both paths now share one allowlist: same-machine (loopback) **or** an origin the operator configured in `VOLTRO_DASHBOARD_APPS` — which also un-breaks the documented remote-dashboard feature the first loopback-only fix had disabled. All four proxy fetches now use `redirect: 'manual'`, so a loopback target with an open redirect can't bounce the proxy to an arbitrary host. Destructive inspect writes (`/schedules/:name/fire`, `/migrations/rollback`, `/seeds/run`, workflow start/cancel, `/data/rows` writes) gained a CSRF guard: a state-changing request carrying a non-loopback `Origin` is rejected — CORS only stops a hostile page *reading* the response, never *sending* a form POST. Minted secrets (`.env.local`) and `voltro cloud env pull`'s `.env.cloud` are written 0600, and `env pull` warns when the file isn't gitignored. `SqlClient` is now re-exported from `@voltro/database` so a hand-written `*.migration.ts` can import it at all (apps don't depend on `@effect/sql`; the runner only warned on the failed import, so the migration was silently skipped).
- **@voltro/cli** — `voltro test` now accepts filters and flags instead of ignoring them. Previously the first positional was always used as vitest's *root*, so `voltro test src/foo.test.ts` pointed the root at a file, matched zero tests, and — with `passWithNoTests` — **exited 0**: a user or CI running one test file got a green result having executed nothing. Now a positional that is an existing directory selects the root and anything else is a name/path filter, `-t` / `--testNamePattern` and `--watch` are forwarded, and an explicit filter that matches no test fails (exit 1) rather than passing vacuously. A bare `voltro test` in a project with no tests yet still passes.
- **@voltro/cli** — **`/_voltro/inspect/metrics` reported an empty rpc surface under `voltro dev`.** The endpoint read `snapshotMetricsSync()` — @voltro/runtime's process-global metric registry — while every rpc sample is recorded into the per-boot `metricsCollector` the mutation / action / query runners are wired with (`recordMetric`). Nothing in the serve pipeline calls `recordSample`, so the two stores were disjoint and the read side was always empty. The dashboard's metrics panel therefore showed no query/mutation/action activity at all in dev.

  Worth naming because a comment asserted the opposite. The shared mutation runner carries: *"Used by BOTH the rpc WS handler and the `/_voltro/inspect/invoke` endpoint so they observe identical behaviour: same transactional wrap, same plugin interception, **same metrics recording**. Skipping any of those on one path means a mutation triggered via the dashboard would silently bypass audit logging."* The write half of that was true; the read half made it unobservable.

  Found by `smokePluginAudit`, which asserts the `mutation.todos.create` bucket grows by two after two invocations and observed zero — a smoke that had not run in a long time because the harness around it was broken.
- **@voltro/runtime, @voltro/cli** — **`defineExecutor` returns the handler's own type instead of widening it.** It returned `(input, ctx) => ExecutorReturn<D, E, R>` — the four-arm union — so an Effect-returning executor came back typed as the union and `Effect.runPromise(execute(input, ctx))` stopped compiling. The wrapper pinned the success value and widened the return type in the same stroke: for the caller, net negative. A team measured it honestly — 164 executors wrapped, 918 errors, zero output drift, 465 test files broken — and declined to adopt for that reason, not out of convenience. They were right.

  Only the RETURN type is threaded through, not the whole function type: returning `F` outright would inherit its ARITY too, so an executor written as `(input) => …` could no longer be called with `(input, ctx)` — trading one papercut for another. The constraint still does the checking; a wrong output shape still fails, pinned by the existing `@ts-expect-error` cases. No overloads, which would have to enumerate the four arms and drift the moment a fifth appears.

  ---

  **The `.serverOnly()` audit accepts a field typed `Schema.Null`.** A view that keeps a server-only column's KEY for wire compatibility — an importer breaks if it vanishes — while always emitting `null`, with the value behind an admin-gated route and a `hasX` boolean beside it, is a real pattern. The audit rejected it, leaving two remedies: break the importer, or drop the marker. A check whose only remedy is to disable it gets disabled.

  The exemption costs something on purpose: the field must be DECLARED null, so the schema stops claiming a string it never sends. `NullOr(String)` still fails, and should — that can carry a value, and the next edit to the handler is one line from sending one. The message now names all three ways out and states plainly that "my handler always sets it to null" is not one of them, since the audit reads the declared shape rather than the emitted value.

  ---

  **The generated agent guide now carries a "What's new in `<version>`" module**, listed first in its index.

  Reported twice by two different teams, the second time as the request that "amplifies everything else": a feature ships, the docs get a page, and nobody finds it. One team discovered `.serverOnly()` only by **diffing two `.d.ts` files** — it was not in the update text and not in the guide. The feature existed; the path to it did not.

  This is the highest-leverage place to fix that for a behavioural reason rather than an editorial one: agents read the seeded guide on every task and a docs page approximately never, so the section rides along with something already being read. Sourced from the most recent RELEASED changelog section, not the unreleased staging area — that is empty right after a tag, which is exactly when someone installs the version and asks what changed.
- **@voltro/plugin-flags, @voltro/plugin-webhooks** — **`requireFlag(ctx, …)` / `isFlagEnabled(ctx, …)` now actually accept an `AppContext`.** Their `GuardCtx` doc comment promised "the framework `AppContext` (`ctx.request.subject`)" and the type rejected it: `FlagSubject`'s optionals were written `id?: string | null`, which under `exactOptionalPropertyTypes` means *absent or string-or-null* and refuses an explicit `undefined` — exactly what a real `Subject`'s `?: T | undefined` fields are. `metadata` had a second problem, a mutable `Record<string, unknown>` where `Subject.metadata` carries a readonly index signature. Every optional now spells `| undefined`, and `metadata` uses a readonly index signature, so the loose-supertype intent holds instead of being an aspiration.

  **`webhookTables()` returns a concrete tuple instead of `ReadonlyArray<TableLike>`.** Two widenings stacked into one unusable return value: the array type made the documented `const [targets, deliveries] = webhookTables()` yield `TableLike | undefined` under `noUncheckedIndexedAccess`, and the `: TableLike` annotations on the three table constants erased their real types so `databaseHandle` rejected them outright. Feeding either into a handle poisoned the inferred types of the app's OWN tables alongside them — the failure surfaced as `database.orders is possibly 'undefined'` in files that never touched webhooks. The annotations are dropped (inference already had the right answer) and the return is `as const`.

  Both were found by type-checking the shipped templates rather than by reading the source: `api-feature-flags` and `api-webhooks` had been failing `tsc` while their own test suites passed, because `voltro test` transpiles without checking. `voltro-templates` now runs both, so this class cannot accumulate silently again.
- **@voltro/plugin-rbac, @voltro/protocol** — **An rbac resolver that throws SYNCHRONOUSLY no longer escapes its error handling.** `rbacPlugin`'s `resolveRoles`, `resolvePermissions` and `resolveResourceRoles` were invoked eagerly, *outside* the Effect — so only a rejected Promise ever reached the `catchAllCause` around them. A synchronous throw (`subject.metadata.roles.map(…)` on a null, a destructure of a missing field, a bad argument to a membership lookup) blew straight past it, and both of the plugin's documented error postures were wrong in that case: the interceptor's "degrade to the subject's own scopes" became a hard request failure, and the resource resolver's "fail-closed → deny" became an opaque defect instead of a typed `ScopeError`. The resolvers now run inside `Effect.suspend`, so a synchronous throw becomes a defect the cause handlers catch — the degrade and the denial both behave as documented. Neither case ever *granted* access; the impact was availability plus a denial that crossed the wire untyped, so a client branching on `ScopeError` saw an unhandled error instead of a refusal.

  **`checkGuardsEffect` now fails closed for every way a resource-scope or policy resolver can misbehave.** It caught only typed failures (`Effect.catchAll`), so a resolver that threw or died escaped as a defect and surfaced as a 500 rather than the typed `ScopeError` the function promises. It now suspends the resolver call and catches the whole cause. This is defence in depth for the fix above and applies to any resolver registered directly via `setResourceScopeResolver` / `setPolicyGuardResolver`, not just rbac's.

  Covered by the new `enforcement.test.ts` in `@voltro/plugin-rbac` — the first tests to install the real `rbacPlugin` and drive calls through the real interceptor + guard chain — plus three fail-closed cases in `@voltro/protocol`'s `scopes.test.ts`.

  codemod: none
- **@voltro/runtime** — **A failing schedule now reports the DRIVER's error, not `@effect/sql`'s wrapper constant.** Every query failure arrives as a `SqlError` whose message is the useless string `Failed to execute statement`; the actual reason — MariaDB's `Unknown column 'deletedAt' in 'where clause'`, a pg SQLSTATE, an FK constraint name — sits on the driver error a few `cause` levels down. `extractDbCause` already pulls it, and the rpc path and the store's tenant-FK guard already used it. **The schedule path did not.**

  So a cron that failed on a bad column logged exactly `Failed to execute statement` with no table and no column — on the one surface with nobody watching. The reporting app had to diagnose it by bisecting which store debug lines were *absent* before the failure.

  Both halves are fixed: the error log gains `dbMessage` / `dbErrno` / `dbCode` / constraint fields, and the message RECORDED in `_voltro_schedule_runs` appends the driver detail — that row is what `voltro inspect schedules --failing` prints, so without it that diagnostic was useless for exactly the failures it exists to surface. A non-DB failure gains no invented fields.
- **@voltro/plugin-storage, @voltro/cli, @voltro/runtime** — **Admin export/import now uses the storage backend the app actually configured.** The serve path resolved storage with `resolveStorageProvider({})`, which builds the ENV DEFAULT and cannot see the options passed to `storagePlugin(...)`. An app running on `storagePlugin({ provider: s3(…) })` — or any custom `name` / `bucket` / `root` — therefore had its admin export and import silently reading and writing a *different* backend than the rest of the app, surfacing much later as a "no object at key" error. `storagePlugin` now publishes its resolved provider, and the new **`appStorageProvider()`** returns it (falling back to the env default when no storage plugin is installed) — use that, not `resolveStorageProvider({})`, anywhere outside the plugin that needs "the storage this app actually uses".

  **`ServeApiHandle.close()` now releases the rpc/ws layer, not just the socket.** `startRpcServer` handed its launch to `NodeRuntime.runMain`, which registers a fresh SIGINT/SIGTERM listener per call and returns no handle — so every boot leaked a live fiber and its scope, and `close()` was quietly untrue about what it freed. It now forks the launch, returns a `shutdown` alongside the server, and wires the process signals **once** instead of once per boot (verified: twelve boots leave exactly one listener each, where the eleventh previously tripped node's `MaxListenersExceededWarning`). Signal behaviour is deliberately unchanged — a signal still interrupts the launch so finalizers run and in-flight requests finish, then exits. Production boots one server per process, so the leak was invisible there; it is the embedded / multi-boot cases and the API's honesty that this fixes.
- **@voltro/protocol, @voltro/cli, @voltro/testing** — **`ctx.storeForTenant(id)` now exists under test, so handlers that use it can be tested at all.** It was added to `AppContext` — and therefore made required on `TestContext` — without reaching the test harness, so `@voltro/testing` did not type-check and `makeTestContext()` returned a context missing the field. Any cron, workflow step or backfill reaching for the scoped view had no route to a unit test.

  **`tenantScopedSubject` moved from `@voltro/cli` to `@voltro/protocol`**, next to `anonymousSubject` / `systemSubject`. The harness could not import it from the CLI, and the alternative — re-deriving "the same" subject in a second place — is precisely the shape of the worst dev/serve drift this repo has hit: two internally-consistent constructions that disagree, where a schedule read ONE tenant under `voltro dev` and EVERY tenant under `voltro serve`. Both the serve context builder and the harness now call the one function, so the scoped store a handler sees under test is the store it gets in production.

  Covered by four cases asserting the scoping itself rather than the field's presence: a write is stamped with that tenant, reads see only that tenant, the tenant-less `ctx.store` still sees across tenants (which is *why* the scoped view exists), and two views for the same tenant agree.
- **@voltro/cli** — **`voltro test` forwards every vitest flag.** It built an options object from the four things it understood itself — root, filter, `--watch`, `-t` — and silently dropped the rest. So `--coverage`, `--reporter=junit` and `--outputFile` were no-ops: a team had no coverage number and no JUnit report in their merge-request widget, with nothing to say why. The exit code still worked, so CI kept blocking correctly, which is exactly why it went unnoticed.

  Flags now go through **vitest's own `parseCLI`**, not a list this wrapper maintains — a hand-kept allow-list would go stale the next time vitest adds a flag, which is the same bug again on a delay.

  Two details found by testing against the real parser rather than a stub:

  - Parsing an *empty* argv is not empty — vitest fills in `--`, `color` and `run`. Forwarding those would hand `startVitest` values the user never asked for, so the parsed options are diffed against that baseline. This also keeps the allow-list-free property: nothing needs to know which keys are "real". - `--reporter=junit` produces `reporter: ['junit']`, not `reporters`. Worth knowing if you assert on it.

  The framework keeps three decisions and they win over a forwarded flag: the **root** (a positional that is an existing directory, which vitest would read as a filter), `--watch`, and `passWithNoTests` — "an explicit filter matching no file is an error" is a judgement vitest cannot make, because it does not know which positional was treated as a root.

  An unrecognised flag stays ignored rather than becoming fatal — that is a separate decision from making the recognised ones work — and a parse failure degrades to running without the extra flags, with a warning, instead of taking the run down.
- **@voltro/runtime, @voltro/cli, @voltro/database** — **A workflow executor can now reach plugin services, `apiConfig.layers`, and `SubjectService`.** It could not, which made the primitive built for long-running EXTERNAL I/O the one primitive that could not use the framework's mechanism for external I/O: `yield* SomePluginService` inside a workflow died with `Service not found`. Analytics, cache, kv, every plugin's `services`, and the app's own `layers:` were provided on the rpc/handler path only, in BOTH boot paths. Reported from an app where 7 of 12 workflows had been failing in production for months.

  The split matters and is now explicit:

  - **Process-wide** services (plugin `services`, `layers:`, cache, kv, analytics, HttpClient) merge into the workflow `ManagedRuntime` — same set a handler gets. - **Per-execution** services (`SubjectService`, `EffectStore`) are provided per run in `makeWorkflowLayers`. Merging a subject into the runtime would be *wrong*, not merely untidy: that runtime is built ONCE at boot, so every execution would act as whoever ran first.

  `SubjectService` is why a plugin service that resolves the CALLER's credential (an OAuth token, a per-user PAT) worked in a handler and failed in a workflow — `ctx.request.subject` carried the value the whole time; nothing provided the Tag.

  ---

  **`voltro inspect workflows --failing`** — the same roll-up and the same exit-1 semantics as `schedules --failing`.

  It needed to be separate because the two failures are separate facts that look identical from outside: a cron whose only job is to START a workflow **succeeds** the moment the start returns. `_voltro_schedule_runs.status` is `succeeded`, `schedules --failing` reports green, the boot banner is clean — and the workflow fails one table over. That is precisely how the months of breakage above stayed invisible: every operational surface said fine.

  `running` / `suspended` are in-flight, not verdicts — they neither count as failures nor end a streak, or one long execution would mask one. `cancelled` is a human decision, likewise not a verdict.

  ---

  **A predicate value is coerced to its column's type.** `eq('ceremonySummaryDate', epochMs)` against a `timestamp()` column compiled to `WHERE date = 1767916800000` and matched **zero rows** — no error, no warning. Verified both ways against a real row: epoch-ms → 0 rows, `Date` → 2. The write path has always encoded (`insertRow` takes a `Date`), so the asymmetry was the trap: a value shaped for the wire reads as *"no data"* rather than as a mistake, and the UI renders an empty state everybody believes.

  Deliberately narrow — epoch-ms / ISO-string → `Date` for a temporal column, and nothing else. No string→number, no truthy→boolean: a silent numeric coercion would paper over a genuine type confusion, whereas here the value *meant* the right thing and was merely wire-shaped. Applied to equality only (a range comparison on a wire number is at least visibly wrong), never inside a JSON path (no column type to consult), and an unregistered table coerces nothing.

  Same root as the predicate-column audit: predicates are not bound to their table. Binding `where(...)` to the row type makes both a compile error and remains the right end state; this fixes existing code today, which a type change never will.

---

## [0.11.4] — 2026-07-25

### Added

- **@voltro/protocol** — **`ApiKeyRecord.metadata` — an app-defined binding carried onto the Subject.** `tenantId` is ONE level of key ownership. A product whose keys belong to a TEAM, a project or an environment needs a second, and the record had no slot for it — so the only safe way to authorize was a DB lookup on every check, on the auth hot path. Reported from an app whose `requireScope` could not see the team, so a key minted for team A authorized org-wide until they patched it with a per-check query.

  ```ts
  resolveKey: async (hash) => {
    const row = await findKey(hash)
    return row && { ...row, metadata: { keyType: row.keyType, teamId: row.teamId } }
  }
  // a guard then reads subject.metadata.teamId — no second query
  ```

  Merged UNDER the framework's own claims: `provider` and `userId` are the strategy's attribution of the request and are applied last, so an app's bag cannot overwrite who the framework thinks made the call (tested). A record without `metadata` behaves exactly as before.
- **@voltro/cli** — `voltro db generate [name]` now scaffolds a fresh, heavily-commented `migrations/<timestamp>_<name>.migration.ts` (the imperative path for data backfills / transforms the declarative differ can't express — for plain schema shape changes prefer `voltro db apply`). The name is slugified; `--name` overrides the positional, `--root` sets where `migrations/` lives. Replaces the old "not yet implemented" stub, and `db generate` is back in the command summary. `voltro db migrate --dry-run` is implemented too — it lists the pending migrations (querying the ledger for what's applied) without applying them, instead of erroring as unimplemented.
- **@voltro/runtime** — `crud.list(table, { columns })` — SQL column projection, so a wide column a list view never shows is **never read**, not merely dropped at the wire boundary:

  ```ts
  export default crud.list('articles', { columns: ['id', 'title', 'createdAt'] })
  // the large `body` / json blob is never SELECTed, transferred, or decoded
  ```

  This is the performance half of projection. The output schema already strips undeclared columns on encode (so nothing extra ever shipped either way) — `columns` additionally saves the read, the DB→app transfer, and the decode.

  `.serverOnly()` columns are removed from the projection automatically: they are stripped from the response regardless, so SELECTing them is pure waste.

  Trap worth knowing: an eager `include` branch joins on a foreign key, so a projection that omits that FK column breaks the relation — keep the FK in `columns` when you also pass `include`. Additive: a new optional `columns` on `CrudListOptions`; omitting it reads the full row exactly as before.
- **@voltro/runtime** — `crud.list(table, options)` gains the read ergonomics a real list view needs, so a generated list isn't limited to "all rows" (the reason a rich hand-written list couldn't move to `crud.*`):

  ```ts
  export default crud.list('absenceRequests', {
    filter:   (input) => ({ employeeId: input.employeeId, status: input.status }), // → WHERE
    paginate: true,                                        // input.limit / input.offset (100 / 0)
    sort:     [{ column: 'createdAt', direction: 'desc' }], // multi-column
    include:  { employee: { with: { team: true } } },      // eager relations, nested filter/sort
    redact:   ['internalNote'],
  })
  ```

  - **`filter`** maps request input to a `WHERE` — a column→value map; an `undefined` field is ignored (so an absent filter param is a no-op). Applied through the tenant-scoped `.where`. - **`paginate`** reads `input.limit` / `input.offset` (defaults 100 / 0). - **`sort`** is a multi-column `orderBy`, applied in order. - **`include`** is the SAME spec `.with(...)` takes, so nested relations and per-branch `where` / `orderBy` / `limit` (nested filtering + sort) all work. `getById` takes `include` too.

  All optional and additive: `CrudListOptions extends CrudReadOptions`, so a bare `crud.list('t')` or `crud.list('t', { redact })` is unchanged. The descriptor's `input` schema declares the filter/pagination fields, and its `output` schema stays hand-written (deriving the projection from the output schema is a codegen concern — a table value can't enter a browser-loaded descriptor).
- **@voltro/runtime** — `crud.list`'s `paginate` now accepts **page-based** paging beside offset-based, and `crud.count` gives a page-based UI the total it needs:

  ```ts
  // list: ?page=3&pageSize=20   (or ?limit=20&offset=40 — both work)
  export default crud.list('absenceRequests', { paginate: true, filter, sort })
  // total for "page 3 of 12"
  export default crud.count('absenceRequests', { filter })
  ```

  - **`page`** is **1-based** (what a UI shows) and pairs with **`pageSize`** (default 100); `offset` is computed as `(page - 1) * pageSize`. A `page` below 1 clamps to the first page rather than producing a negative OFFSET. - **`limit` / `offset`** still work unchanged. `page` wins when a caller sends both. - **`crud.count(table, { filter })`** is a real `COUNT(*)` aggregate over the same tenant-scoped, filtered set — it ignores paging fields on the input, so the total describes the whole result, not the current page. Pass it the SAME `filter` as the list (share the option object) so the count and the pages can't disagree about which rows they mean.

  Additive: paging style is detected from the request input, so an existing `paginate: true` list is unchanged.
- **@voltro/cli** — **`voltro inspect schedules --failing`** — roll up each schedule's recent runs and report only the broken ones, exiting **1** when anything is failing.

  A schedule fires unattended, so a broken one is discovered by someone going to look — and the only thing to look at was the REGISTRATION (which cron exists, when it fires next), never whether it works. That is how three nightly jobs in a downstream app stayed dead for months after a port: each was registered, each fired on time, each threw.

  The exit code is the point: it makes this usable as a post-deploy gate (`voltro inspect schedules --failing || exit 1`) rather than something a human has to remember. Needed no new endpoint — `/schedules/runs` already returns the recorded firings; the roll-up is a client-side join, which keeps the cost on the diagnostic command instead of on every dashboard poll.

  A trailing success ends a streak (a recovered job is not reported) and `skipped`/`missed` runs are ignored — those are coordination outcomes, not handler verdicts. Pairs with the schedule failure now logging at `error`.
- **@voltro/runtime** — `ctx.store.links(junction, anchor).setRows(rows)` — a diff-based reconcile for a many-to-many junction that carries PER-ROW PAYLOAD (a membership `role`, a `capacity` value), the case `set(targetIds)` couldn't model. Each row is `{ [targetColumn]: id, …payload }`; the diff is on the (source, target) pair — an added row is inserted with its payload, a removed row deleted, and a SURVIVING row whose payload actually changed is UPDATED. A row whose payload is unchanged is left untouched, so a reactive consumer sees a change only where the payload differs — the drop+reinsert replacement for a data-carrying junction. Returns `{ added, removed, updated }`. Payload is compared by strict per-column equality (scalars). Additive: a new `setRows` method on `JunctionLinks`.
- **@voltro/runtime, @voltro/cli** — **A query can now be projected to a public REST endpoint.** `publicApi` on a descriptor was only ever mounted for mutations and actions — a QUERY carrying it produced no route at all, silently. Both boot paths now mount queries too, so offering an API you don't consume from your own frontend is a one-line annotation:

  ```ts
  export default defineQuery({
    name: 'absenceRequests.list',
    input: Schema.Struct({ status: Schema.optional(Schema.String), limit: Schema.optional(Schema.Number) }),
    output: Schema.Array(AbsenceRequest),
    guards: [requireScope('absences:read')],
    publicApi: {},          // → GET /v1/absenceRequests/list?status=open&limit=20
  })
  ```

  A query derives **GET**, and its `input` schema binds to the **query string** (`publicApi.ts` wraps it as `{ query }` for GET) — so filter and pagination parameters work as plain URL params. Eager relations work too: `include` is resolved server-side by the executor, so the transport makes no difference. This pairs with `crud.list`'s `filter` / `paginate` / `sort` / `include`, which is what makes a REST list endpoint a single declaration.

  The one-shot execution is `makeOneShotQueryRunner` (`@voltro/runtime`), built ON TOP of the existing `makeQueryDescriptorProducer` rather than beside it — so the declarative `guards:` gate, the per-request row filter and the tenant/soft-delete scoping are literally the same code the socket path runs. A second implementation would have been an authorization bypass on exactly the reads `publicApi` exposes; the tests pin that a guarded query rejects on the REST path with the executor never running and no read issued. Both handler shapes resolve: a descriptor-returning (reactive) query is finalized and executed to rows, a computed query yields its value. Wired identically in `voltro dev` and `voltro serve` (the dev/serve parity rule) from one shared deps object per path.
- **@voltro/protocol, @voltro/runtime, @voltro/cli** — **`publicApi: { stream: 'sse' }` on a query now streams.** The field was declared but unimplemented — a query annotated with it silently served the first snapshot as JSON. It now mounts a Server-Sent-Events endpoint: the initial `snapshot`, then a `delta` per change, until the client disconnects.

  ```ts
  export default defineQuery({
    name: 'orders.live',
    input: Schema.Struct({ status: Schema.optional(Schema.String) }),
    output: Schema.Array(Order),
    guards: [requireScope('orders:read')],
    publicApi: { stream: 'sse' },   // → GET /v1/orders/live?status=open  (text/event-stream)
  })
  ```

  ```js
  const es = new EventSource('/v1/orders/live?status=open')
  es.addEventListener('snapshot', (e) => setRows(JSON.parse(e.data).data))
  es.addEventListener('delta',    (e) => applyDelta(JSON.parse(e.data)))
  ```

  Each event's `_tag` becomes the SSE `event:` name, so a client listens per kind rather than switch-ing on a payload field. Framing splits embedded newlines across `data:` lines (a raw `\n` would truncate the event), sends `retry: 5000`, and emits a keep-alive comment every 15s so proxies don't drop an idle stream (`cache-control: no-transform` + `x-accel-buffering: no` for the same reason).

  Three things this deliberately does NOT do differently from the socket: authorization (the subscription runs through the same `makeQueryDescriptorProducer`, so the declarative `guards:`, the row filter and tenant scoping are the same code), leak handling (the client's disconnect runs the route's own unsubscribe through the response scope's finalizer, and a disconnect DURING setup tears the late-arriving subscription down), and error reporting (a guard denial arrives as one `error` EVENT — by then the response headers are on the wire, so throwing is not available).

  New building blocks, useful beyond publicApi: `PluginHttpRouteResult.stream` lets ANY plugin HTTP route stream, with `sse()` / `sseFrame()` helpers in `@voltro/protocol/rest` for a hand-written `defineRestRoute`; `makeQuerySubscriber` (`@voltro/runtime`) is the shared dispatcher binding both boot paths use. Wired identically in `voltro dev` and `voltro serve`. A `stream: 'sse'` annotation on a mutation/action is ignored (nothing to subscribe to), and a query whose boot layer supplies no subscribe binding falls back to the snapshot response rather than mounting a route that never emits.
- **@voltro/runtime, @voltro/cli** — Boot audit for the `.serverOnly()` marker (#22): `voltro dev` now warns, naming the query + column, when a wire-reachable query's `output` schema DECLARES a `.serverOnly()` column of its `source` table. The `crud.*` read helpers strip these automatically, but a hand-written output can only be caught here — this is what would have flagged the reported dead `apiKeys.getByKeyId` query that shipped a `keyHash`.

  Backed by pure, unit-tested helpers exported from `@voltro/runtime`: `serverOnlyLeaks` (a query's output vs its source's serverOnly columns) and `schemaPropertyNames` (best-effort field introspection over the output Schema AST — Struct / Array-of-Struct / NullOr / nested). Detection under-covers rather than false-positives: a shape it can't read yields no field names. The fix it points you at is to omit the column from the output schema (a runtime strip would fail the encode against a schema that still declares the field — the reason this is an audit, not a strip).
- **@voltro/database, @voltro/runtime** — `.serverOnly()` column marker + `serverOnlyColumns(table)` (#22) — the wire-EXPOSURE axis, declared explicitly at the schema and distinct from `.encrypted()` (storage at rest) and `.sensitive()` (export masking). A `.serverOnly()` column is read normally by server code but must never be serialized to a client:

  ```ts
  keyHash: text().serverOnly(),          // an auth middleware verifies it; a client never sees it
  apiToken: text().encrypted().serverOnly(), // a column can carry both axes, or either
  ```

  The [`crud.*` read helpers](#) strip `.serverOnly()` columns from every returned row AUTOMATICALLY — declare the exposure policy once at the schema and every crud read respects it, so you can't forget it on a handler (the single-source form of the per-call `redact` option, which still works for anything not worth a marker).

  Why a NEW axis rather than reusing `.encrypted()`: encryption at rest says nothing about who may receive the plaintext — decrypting a private note *for its owner* is a valid case, so treating "encrypted" as "never to a client" would be wrong. Exposure is stated explicitly. Default stays exposed to both server and client; `.serverOnly()` opts a column out of the client.

  Additive: a `serverOnly()` builder method + a `serverOnly?` flag on `ColumnDefinition` + the `serverOnlyColumns` helper. Enforcement beyond the `crud.*` path — a runtime strip at the rpc wire boundary and a `serverOnly: true` whole-query primitive — is a planned follow-on (see `plans/framework-serverOnly-exposure.md`).

### Fixed

- **@voltro/cli** — Machine output is now consistent across the CLI. `--json` works everywhere it should: the inspect-family commands (inspect/logs/traces/cluster/check) accept `--json` (and `--format=json`) as an alias for `--format json` — `voltro inspect app --json` silently produced pretty output before; and cloud/capabilities/doctor accept both spellings too. All `--json` payloads route through one `printJson` helper, and `bin.ts` now drains stdout before `process.exit`, so a large payload piped to a file / `| jq` is no longer truncated (only `voltro db --json` was safe before). A user-invocation mistake (no app.config here, a memory dialect where a real one is needed) now prints a clean one-line error instead of a stack trace (new `CliError`, rendered by bin.ts).
- **@voltro/cli** — `voltro version` now prints the real installed version (was a hardcoded `v0.0.0 (scaffold)` placeholder); usage / unknown-command / version output say "voltro" instead of "framework". `voltro <command> --help` (and `-h`) now prints the command summary and returns 0 for EVERY command instead of, in several cases, running the command — `voltro env --help` used to run the env check and could exit 1. Commands with their own richer help (inspect/logs/doctor/secret) still show it. Honest command summaries: `db` now lists its declarative-workflow subcommands (plan/apply/plans/drift/squash/restore-snapshot/files/…) and drops the never-implemented `generate`; `serverless` lists `dev`/`serve` and the `node` default target; `workflows` lists `start`. `voltro cloud deploy`/`rollback`, which are not yet implemented, now fail (non-zero, stderr) instead of printing a message and returning success, and are marked "coming soon" in help.
- **@voltro/cli** — Security: the inspect API's CORS now reflects the request `Origin` and allows credentials ONLY for loopback origins (localhost / 127.0.0.0/8 / ::1). Previously it echoed ANY origin with `access-control-allow-credentials: true`, so a website a developer visited could issue a credentialed cross-origin `fetch` and read the (default-open) inspect surface — DB rows, logs, schema, drift — and POST to fire schedules / start workflows. A non-loopback origin now gets no `access-control-allow-origin`, so the browser blocks the read; curl / same-origin / the local dashboard are unaffected. Also: the dashboard proxy (`/api/dashboard/proxy?target=`) now requires a loopback target (it forwards the caller's bearer, so an arbitrary target was an SSRF + bearer-harvest), and `~/.voltro/credentials.json` is written 0600 in a 0700 dir instead of world-readable.
- **@voltro/cli** — `voltro serverless serve`/`dev` now exits non-zero when the server fails to bind or crashes at boot (it returned 0 = success, so a CI/deploy wrapper read a dead server as up). The production `voltro serve` shutdown chain gained a terminal `.catch` + a 10s force-exit safety net, so a rejected/hung drain step (plugin deactivate, pool close) no longer strands the process until the container's SIGKILL grace timer. `voltro e2e` cleanup now escalates SIGTERM→SIGKILL for a child that ignores SIGTERM (was lingering on :4000/:5190 and EADDRINUSE-ing the next run). `voltro migrate` now warns when it falls back to the default `localhost:5432` DB with no DB_URL/DB_HOST set.
- **@voltro/cli** — CLI flag parsing is now shared (`src/cliArgs.ts`: `flagValue` / `takeFlag` / `hasFlag` / `positionals`) instead of each command hand-rolling its own `indexOf('--x'); args[i+1]`. Adopted across data, serve, cloud, db (14 sites), baseline, and secret — so every command accepts `--name value` AND `--name=value` uniformly. Group-command exit codes are standardized too: an unknown subcommand exits 2, a missing subcommand exits 1 (was a mix of 0/1/1-vs-1 including a dead `? 1 : 1` ternary in storage).
- **@voltro/runtime** — **`crud.list` now caps the page size (default 1000).** The page size is CALLER-controlled — `input.limit` / `input.pageSize` — and nothing anywhere clamped it, so `?limit=1000000000` was a one-request read of the whole table. That was already unwelcome over the WebSocket; it became a genuine exposure the moment a query could be projected to a public REST endpoint (`publicApi`), where the caller is anyone who can reach the URL.

  - `limit` / `pageSize` above the cap are **clamped, not rejected** (a caller asking for too much gets the maximum page, not a 400). - `maxPageSize` raises it deliberately for an export-style endpoint. - A zero/negative `limit` clamps to 1 row, and a negative `offset` / `page < 1` clamps to 0 rather than producing a negative OFFSET (which dialects reject or treat oddly). Non-integers are floored.

  Found by auditing the paging code introduced in this same series — the cap was missing from the start, so this is a fix, not a behaviour change anyone relied on.
- **@voltro/plugin-ai-flows** — **An AI-flow `MediaGenerator` can now require services.** Its type pinned the Effect requirement channel to `never`, so the common implementation — generate, then persist through `StorageService` — was untypeable while working perfectly at runtime. A type that forbids what the program does is a type lying about the program; the host was left carrying a documented cast.

  `MediaGenerator` and its `EngineDeps` siblings (`resolveAgent`, `onEvent`, `memoryPrefix`) now declare `R = unknown`, the same shape `@voltro/ai` uses for tool bodies (`execute?: (input) => Effect<O, never, unknown>`). The engine runs inside the host's runtime, which HAS those services, and states that fact once in a `callDep` bridge rather than at every call site — mirroring `callBody` in `@voltro/ai`.

  `apiSurface: compatible` — the change WIDENS the requirement channel on callbacks the app IMPLEMENTS. An existing implementation that requires nothing (`Effect<A, E, never>`) stays assignable to the widened type, so no downstream implementation breaks; the only consumer of the narrow form was the engine itself, which now bridges it. Verified by a full repo typecheck.
- **@voltro/cli** — **Four discovered conventions were missing from the serve bundle — a prod-only boot crash waiting for an app to use them.** `voltro build` bundles the modules matching `API_ENTRY_PATTERN`; `voltro serve` resolves every app module from that bundle, and a convention the pattern misses falls back to loading `.ts` SOURCE, which a plain-node boot cannot do. `*.outbox.ts`, `*.connection.ts`, `*.email.tsx` and `*.migration.ts` were all absent — and all four are loaded at serve time (`serveCommand` filters outbox handlers explicitly, noting that without them the transactional outbox enqueues rows in production that nothing delivers). Fixed, and the lockstep is now a TEST (`apiEntryPatternLockstep.test.ts`) that asserts a representative filename for every convention matches — it found these four the moment it was written.

  **The file conventions are now single-sourced** (`fileConventions.ts`). They were declared in five modules — dev discovery, plugin codegen, framework-table assembly, migrate, the db command — and copies of a rule that IS the rule drift silently, because each copy is internally consistent. `WORKFLOW_PATTERN` had already drifted into two shapes: strict `\.workflow\.tsx$` in discovery, loose `\.workflow\.tsx?$` in table assembly. So a file named `orders.workflow.ts` got `_voltro_workflow_*` TABLES (the loose copies counted it) but was never registered as a workflow (the strict copy skipped it) — no error, no warning, just a workflow that did nothing.

  The two copies were answering DIFFERENT questions, and conflating them is what made the bug invisible, so both are now named: `WORKFLOW_DESCRIPTOR_PATTERN` (strict — the executor is paired by rewriting that exact suffix, so `.ts` could never work) and `WORKFLOW_PRESENCE_PATTERN` (loose on purpose — over-provisioning a table is harmless, missing one breaks a boot). `voltro dev` now WARNS on the gap between them, naming the file and the one-character fix, instead of skipping in silence.
- **@voltro/database** — **A `.sensitive()` / `.safe()` marker no longer moves the schema fingerprint.** They emit no DDL and introspection never reads them back, yet they fed the hash — so adding a classification flipped the boot fast-path to "the declaration changed" and forced a full diff. A classification sweep over a few hundred tables therefore triggered a migration run, and any drift that had accumulated silently since the last real change surfaced THERE, triggered by an edit with nothing to do with it. Reported from a boot that then REFUSED and crash-looped on 31 pending ops, none of which came from the change that moved the hash.

  They join `idScheme` in `stripFingerprintHints`, which already existed for exactly this class. The rule, now written down where the next field gets added: **if introspection cannot read it back, it does not belong in the fingerprint** — the hash answers "does the database match the declaration?", not "did any character of the declaration change?".

  Tested including the control case (a real DDL change must still move the hash, or the test would pass against a hash that ignores everything).
- **@voltro/i18n** — `createTypedMessages` (#16) now collects a REAL var nested inside a plural/select branch (#21). Previously `'{count, plural, one {# blocker in {discipline}} other {# blockers in {discipline}}}'` required only `count` — `discipline`, which lives inside the branches, was not extracted, so `t('key', { count })` compiled and then threw `The intl string context variable "discipline" was not provided` when it rendered. Now both `count` AND `discipline` are required at the call site.

  `ICUVars` was rewritten to SCAN every `{` and classify what follows it (an arg name / a bare placeholder / a structural branch to skip) rather than match a "body up to the matching `}`", which a nested `{var}` broke. It handles arbitrary nesting, and a branch's literal text (`# days`) is still never mistaken for a var.

  `apiSurface: compatible` — the `ICUVars<S>` signature is unchanged (only its body). The stricter extraction cannot break WORKING code: a call that omitted the nested var was already throwing at render (that is the bug this fixes); code that passed it — the documented workaround — keeps compiling.
- **@voltro/protocol, @voltro/plugin-openapi** — **A streaming route is now visible on the descriptor, and OpenAPI documents it as one.** When `publicApi: { stream: 'sse' }` shipped, the streaming-ness lived inside the handler closure — so anything that INSPECTS a route without running it couldn't tell a stream from a buffered response. The OpenAPI generator therefore emitted `content: { 'application/json': <output schema> }` for an SSE endpoint: a spec that generates clients which try to parse the whole event stream as one JSON value.

  `RestRouteDescriptor` gains `streaming?: boolean`, `publicApiRoute` sets it for a `stream: 'sse'` query, and the generator emits `text/event-stream` (with no JSON response schema) for such a route.

  Worth stating as a rule, since it is the second time this shape has cost something: a fact that tooling must act on belongs on the DESCRIPTOR, not in the closure that implements it. A wrong spec is worse than a missing one — clients are generated from it.
- **@voltro/runtime, @voltro/cli** — **A failing schedule handler is now an ERROR, not a warn.** A schedule fires unattended — there is no user watching a request fail — so the log line IS the discovery channel. It was `warn`, which `voltro logs --level error` does not show, and the only other surface is a dashboard nobody has open on staging.

  The root cause was structural rather than a bad choice: `SchedulerLogger` had no error channel at all (`info` / `warn` / optional `debug`), so the level was not expressible. It now has one — optional, so an embedder passing a two-method logger still compiles, falling back to `warn` — and BOTH boot paths (`voltro dev`, `voltro serve`) wire it, since a channel nothing supplies would have changed nothing.

  Reported from an app where three nightly jobs had been dead since a port — one of them an entire feature that never wrote a single row — each failing on EVERY firing, for months, at `warn`. The failure was already recorded in `_voltro_schedule_runs` (`status: 'failed'`) and published on the server-error bus; the log level was the one place that disagreed with both.
- **@voltro/runtime** — **`ctx.workflows.start` now validates the payload against the workflow's schema, and says so when it doesn't match.** The signature is `(workflowName: string, payload: unknown)` — the name was not checked against the registry and the payload was not checked against anything — so a caller that drifted from the workflow's schema failed DEEP inside the engine, where the message reads like the workflow itself misbehaved.

  A mismatch now throws a `WorkflowPayloadError` carrying `workflowName`, `missingFields`, and the formatted parse error, and an unknown name lists the workflows that ARE registered (so a rename reads differently from a deletion). `run()` and `executionId()` validate too — `start` is not the only entry point.

  Validation is on the DECODED side (`Schema.validate`, not `decodeUnknown`): a `Schema.Date` payload accepts a `Date`. Using `decodeUnknown` here would have rejected valid calls — the check meant to protect the caller breaking them.

  Reported from an app where a cron fired such a start on every tick: three nightly jobs dead since a port, one of them an entire feature that never wrote a row. The payload was the bug and nothing in the failure said so. Pairs with the schedule failure now logging at `error`.

### Internal (no consumer-facing effect)

- **@voltro/cli** — Test-only: cover the migrationRunner SQL apply/rollback path (the report's top coverage gap). `migrationRunnerApply.test.ts` runs real defineMigration steps against Postgres — CREATE/DROP via the SqlClient, asserting the DDL ran, the `_voltro_migrations` ledger recorded it, re-run skips, and rollback reverts — gated on a reachable Postgres (docker-compose `postgres-test` on :55432, or DB_URL) and skipped when down, per the dialect-parity convention. (The runner's ledger upsert uses `now()` + `ON CONFLICT`, so it is genuinely not sqlite-testable.)

---

## [0.11.3] — 2026-07-24

### Added

- **@voltro/runtime** — `crud.*` secure-default CRUD handler helpers + `redactColumns` (A1 core). Each returns an executor you export as a `*.query.server.ts` / `*.mutation.server.ts` default — the descriptor (schemas + `guards`) stays hand-written and browser-safe:

  ```ts
  // accounts.list.query.server.ts
  import { crud } from '@voltro/runtime'
  export default crud.list('accounts', { redact: ['apiSecret'] })
  ```

  They bake in the invariants a hand-rolled CRUD generator kept getting wrong (the leak class was in the HANDLERS, not the schemas):

  - **Tenant scope** — `list` / `getById` read through `ctx.store`, which auto-scopes a `tenant()` table; they never `.unscoped()`, so a cross-tenant read is impossible. - **Redaction** — `redact` columns are stripped from every returned row (a credential / secret / salary a read must never ship), on reads AND on the row a `create` / `update` echoes. `redactColumns(rows, cols)` is exported standalone for a hand-written handler that isn't plain CRUD. - **`getById` returns `null`, never throws** — a reactive getter that throws stalls its shared-WS siblings (pairs with the per-subscription error isolation).

  What they deliberately DON'T do is authorize: a guard runs before the executor, so gating stays on the DESCRIPTOR (`guards: [...]`) — an executor can't gate itself. Keep write descriptors guarded.

  Scope note: this is the browser-safe, codegen-free core. Deriving the descriptor SCHEMAS from a table (to drop the hand-written `Schema.Struct`) is structurally a codegen concern — a table VALUE can't be imported into a browser-loaded descriptor (it drags the store into the bundle; `rowSchema` is server-only for exactly this reason) — so full schema-derivation + a `.crud()` boot audit for the scope/gating discipline are a separate, planned pass. See `plans/framework-a1-defineCrud.md`.
- **@voltro/runtime** — `ctx.store.links(junctionTable, anchor)` — a diff-based writer for a many-to-many JUNCTION table (A2). It reconciles the links from one anchor row against a target-id list by writing only the DIFFERENCE:

  ```ts
  await ctx.store.links('post_tags', { postId: post.id }).set(tagIds) // add missing, remove surplus
  await ctx.store.links('post_tags', { postId: post.id }).add([tagId]) // idempotent
  await ctx.store.links('post_tags', { postId: post.id }).remove([tagId])
  await ctx.store.links('post_tags', { postId: post.id }).list()      // current target ids
  ```

  Why it belongs in the framework rather than every app: a drop-all-then-reinsert `setLinks` loses data when two writers overlap and makes a reactive subscription on the junction churn every row (flicker) even when nothing changed. `links().set()` touches only the rows that actually differ — the added are inserted, the removed deleted, the unchanged left in place — so a reactive consumer sees a change only for what changed, and `set()` returns `{ added, removed }`. `add`/`remove` are likewise idempotent (they read first and act only on the genuine delta).

  `anchor` names the source column and its id (`{ postId: 'p1' }`); the target column is the junction's OTHER `reference()` column, auto-detected. A junction with anything but exactly two reference columns is refused with a message naming what it found — use plain `insertMany`/`deleteMany` for a non-standard junction. The writes go through the normal stamped/tenant-scoped store path, so tenant and audit columns are filled as usual. Additive: a new `links` method on `FluentStore` + the `JunctionLinks` interface.
- **@voltro/client, @voltro/web** — `useSubscription(..., { initialSnapshot })` — the last mile of "SSR-correct first paint, then live" (A5). Pass the value an SSR loader already fetched with `ctx.query` (read it in the component with `useLoaderData()`) and the subscription shows it at the first paint with `loading: false` — it IS real server data — then swaps to the live stream the instant its first snapshot arrives:

  ```tsx
  const seed = useLoaderData<Employee>()
  const { data } = useSubscription('app', 'employees.me', {}, { initialSnapshot: seed })
  ```

  The SSR markup and the hydration render read the same loader value, so they match (no hydration flicker), and the app no longer hand-builds a seed store to bridge loader data into the first render. This is the difference from `fallback`, whose value never came from the server and so keeps `loading: true`; use exactly one of the two. Like `fallback`, `initialSnapshot` guarantees `data` is present, so the call gets the non-union result and needs no `loading` branch. Additive: a new `initialSnapshot` field on `SubscriptionOptions` + an overload; `@voltro/web` re-exports the client surface.
- **@voltro/cli** — `apis.<name>.authHeaders` in a web `app.config.ts` — a declarative per-reconnect auth-header resolver, so an authenticated split-origin web app no longer hand-mounts `VoltroRuntimeProvider` just to inject a rotating-token thunk (A4). The framework owns the client mount, the reconnect re-resolve, and the SSR-null case (the resolver runs browser-only — it never fires on the server):

  ```ts
  // app.config.ts
  apis: {
    api: {
      package: '@app/api',
      authHeaders: async () => ({ authorization: `Bearer ${await getToken()}` }),
    },
  }
  ```

  Because it's a FUNCTION, the codegen imports it from `app.config.ts` into the client bundle rather than serializing it — so a config that declares `authHeaders` must stay browser-safe (no `node:*` / server-only value imports; a pure env schema is fine, and tree-shakes out). It supersedes a static `headers` on the same api. The provider already resolved a `ResolvableHeaders` thunk fresh per connection generation; this just lets you declare it in config instead of hand-writing a `mount()` call.

---

## [0.11.2] — 2026-07-24

### Added

- **@voltro/i18n** — Two escapes for adopting typed messages (`createTypedMessages`, #16) app-wide (#19):

  - **`t.dynamic(runtimeKey, values?)`** — a first-class escape for a genuinely runtime-computed key, on both `useT` and the `useTFn()` result. It takes a plain string with NO forced ICU args, so it doesn't fight the strict literal-key surface. Until now the natural escape — casting a computed key to the catalog key union — made things WORSE: that union spans placeholder-bearing keys, so the call then demanded a spurious 2nd ICU arg. `t.dynamic` is the documented, discoverable alternative. - **`LooseTFunction`** — the widened `(id: string, values?) => string` signature to type a `t` pass-through across a package boundary that can't import the app catalog, instead of falling back to `(...args: any[]) => string`. A strict `TypedTFunction` is deliberately NOT assignable to it (a narrowed key param can't satisfy a wider one — that would erase the checking); pass `t.dynamic` at the boundary, which IS a `LooseTFunction`.

  Additive: `TypedTFunction<C>` gains a `.dynamic` member (the callable surface is unchanged, so `Parameters<TypedTFunction<C>>[0]` and existing typed call sites still resolve). `createTypedMessages` attaches `.dynamic` in place on the two translate functions — no new per-render closure, so a captured `t`'s identity stays stable.
- **@voltro/testing, @voltro/database** — `fixtureRow(table, overrides)` (`@voltro/testing`) completes a partial test row so it satisfies the 0.11.1 required-column insert validation — WITHOUT disabling the check. It fills every NOT-NULL, no-default, non-auto-stamped column the payload omits with a schema-typed placeholder (a `oneOf` column takes its first allowed value; a `unique` column gets a distinct value per call so two fixtures don't collide; `timestamp`/`date` get a fixed epoch), then merges your overrides on top (an explicit value always wins). It leaves out exactly what a caller may omit — nullable, defaulted, and framework auto-stamped columns (id / tenant / audit) — and refuses to guess a structured type (`json` / `bytes` / `vector` / `array` / `interval` / `raw`), throwing a message that names the column and says to pass it explicitly.

  The motivating case: 0.11.1 made the in-memory/test store reject the same partial inserts real Postgres always would (correct — it surfaced a latent prod bug), which turned lean fixtures (`insert(users, { id })`, an omitted required FK) into `TableValidationFailed`. The wrong fix is a `validateInserts: false` knob — it re-hides that bug class, and a test store laxer than production is a fake testing itself. `fixtureRow` is the right one: it makes the fixture COMPLETE.

  ```ts
  await ctx.store.insert('journal_entries', fixtureRow(journalEntries, {
    tenantId, amount: '100.00',   // the columns THIS test cares about
  }))                             // entryNumber, postedAt, … auto-filled + unique
  ```

  It is a runtime filler for the loose `store.insert(name, row)` path (what fixtures use). For COMPILE-time payload typing, use `insertRow` / `upsertRow` from `@voltro/database`. The auto-stamped column set it skips is now exported as `AUTO_FILLED_COLUMNS` from `@voltro/database` — the same list `InferInsertRow` derives its optional columns from, single-sourced so the two can't drift.

### Changed

- **@voltro/cli** — `voltro build` now emits **directly-executable** boot bundles for BOTH app kinds: the web start bundle (`.framework/dist-web/startBundle/startEntry.js`) and the api serve bundle (`.framework/dist-api/serveBundle/serveEntry.js`) each carry a main-guard that boots the app when run as `node <entry>.js`, and stays inert when imported (the `voltro start` / `voltro serve` dev fast paths are unchanged). Production containers can now use `CMD ["node", "…/startEntry.js"]` (or `serveEntry.js`) instead of `pnpm voltro start` / `pnpm voltro serve` — no pnpm process, no `@voltro/cli` bin at runtime — which is what makes `voltro prune-runtime` safe to enable on both: with the self-contained bundle as the real entrypoint, the @vercel/nft trace roots there and legitimately drops `@voltro/cli` and the whole inlined framework tree (a static site's `node_modules` collapses to ~0; a memory api's 146 MB → 11 MB). `prune-runtime` now also roots the trace at the serve bundle. The serve entry chdir's to the app root BEFORE its app-module registry keys are computed from cwd, preserving relocation-safety. Existing `pnpm voltro start` / `pnpm voltro serve` entrypoints keep working. The standalone Dockerfiles gain a build-time boot smoke that fails the build unless the pruned tree reaches ready.

### Fixed

- **@voltro/i18n** — `createTypedMessages` (#16) no longer extracts phantom required vars from a nested plural/select message (#19). For `'{count, plural, one {# day} other {# days total duration}}'`, the type-level `ICUVars` parse was reading a branch's TEXT (`"# days total duration"`) as a bogus required arg name, so `useT('key', { count })` failed to typecheck even though it renders perfectly — and a real var nested inside a branch was dropped. `ICUArgName` now resolves to `never` for any candidate that isn't a valid ICU identifier (`^[A-Za-z0-9_]+$`), so branch text — which contains spaces / `#` / `—` — is never mistaken for a var. Only the top-level arg (`count`) is required, matching what the message actually needs.

  Scope note: a REAL var nested inside a plural branch (`other {# — {discipline}}`) is still not collected, so it reads as not-required rather than wrongly-required — the safe direction. Apps that pluralise in JS over simple `{count}` messages (the Voltro idiom) were already fully typed and are unaffected.
- **@voltro/database, @voltro/runtime** — `InferInsertRow` (and thus `insertRow` / `upsertRow`, #15) no longer requires a non-nullable DB-generated (`generatedAs`) column (#20). A stored/virtual generated column declared without `.nullable()` and without a default was typed **required**, but MariaDB/Postgres REJECT an explicit value for a generated column — so the type forced the caller to pass a value the database refuses at runtime. `.generatedAs()` now marks the column optional-for-insert exactly like a `.default()` column (the DB supplies it), so it may be omitted; the whole payload guard on every real column stays intact.

  Two runtime halves complete it, so the loose `store.insert(name, row)` path agrees: the required-column validation (`missingRequiredColumns`) skips generated columns — omitting one is correct, never a missing-column error — and the store write path now STRIPS any value a caller supplied for a generated column before the INSERT reaches the dialect (tracked on the schema registry as `generatedColumns`), so a value from an untyped insert can't blow up on MariaDB. A generated column is never caller-supplied; the framework and the DB own it end to end.

  The `.generatedAs()` return type narrows from `this` to `ColumnBuilder<…, true>` (the HasDefault flag) — a purely more-permissive refinement: it only makes the column omittable, so no existing code stops compiling.

---

## [0.11.1] — 2026-07-23

### Added

- **@voltro/runtime** — `defineExecutor(descriptor, fn)` type-checks a query/mutation/action handler's return against the descriptor's `output` schema Type — so returning a `number` where `output` is `timestampMs` (Type = `Date`) is a COMPILE error at the handler, not a runtime encode failure that Dies the subscription. The gap it closes: the executor is a separate default export whose return was never tied to `output`, so a handler that builds a plain object with a leftover `.getTime()` compiled green and only threw `Expected DateFromSelf, actual 1784…` at encode time — invisible while a nullable date was null, exploding the instant it became non-null.

  Opt-in and zero-cost: it's a runtime identity (returns `fn` unchanged, so the codegen wires it exactly as the bare default export), and the compile check is the whole value. Wrap the handler and import the descriptor into its `*.server.ts`:

  ```ts
  export default defineExecutor(getRoadmapsByYear, (input, ctx) => …)
  ```

  The Effect error and requirement channels stay inferred from the handler; only the success value is constrained. A reactive query that returns a `{ descriptor }` builder is allowed through unchecked — the store produces its rows, so a value-level return type can't express that row-vs-output check.
- **@voltro/database, @voltro/runtime** — An `.encrypted()` column that can't be decrypted with the active key now fails as a typed, readable `FieldDecryptionError` naming the `table.column`, instead of a raw `Error: field cipher: malformed ciphertext` with no context. It carries a `_tag` (the same tagged shape `storeErrors` uses, so `Effect.catchTag` matches), and never includes the ciphertext. The common trigger is restoring a prod/staging snapshot into a dev DB whose `VOLTRO_FIELD_ENCRYPTION_KEY` differs.

  New dev/migration escape hatch: `VOLTRO_FIELD_DECRYPT_ON_ERROR=null` degrades an undecryptable column to `null` (with one deduped warning per `table.column`, scope `store.fieldEncryption`) instead of letting one bad row nuke the whole read — its readable siblings still decrypt. Default stays `'throw'`; never set `null` in production, where a key mismatch must fail loud. `decryptFieldsOnRead` gains an optional `{ onError, warn }` argument (additive); the raw throw is replaced by the typed one, which existing `catch (e: Error)` handlers still catch.
- **@voltro/i18n** — `assertCatalogParity({ en, de })` checks every locale uses the SAME ICU `{var}` set per key. `defineLocale` enforces KEY parity but not PLACEHOLDER parity — a translation that drops or renames a `{var}` (`'Published on {date}'` → `'Veröffentlicht'`) compiles and boots, then throws `The intl string context variable "date" was not provided` only in that locale, only when the message renders. Call it in a test or at boot; it throws listing every drift (or warns with `onMismatch: 'warn'`). Plural argument names are extracted; a plural's inner `{# item}` branches are not mistaken for placeholders.
- **@voltro/i18n** — `createTypedMessages<typeof en>()` binds a catalog's LITERAL message types to `useT` / `useTFn` / `<T>`, so a missing ICU placeholder is a COMPILE error instead of a runtime throw at format time. Until now `defineCatalog` / `defineLocale` enforced key PARITY across locales, but the call site `t('key', values)` was untyped — a message like `'Published on {date}'` called as `t('roadmap.publishedAt')` (or via the `t('key').replace('{{date}}', …)` idiom from other i18n systems) threw `The intl string context variable "date" was not provided` only when it rendered. Now `useT('roadmap.publishedAt')` demands `{ date }` at compile time, and a wrong/missing key is caught too.

  Opt-in and purely additive: call `createTypedMessages` once with your base catalog (`as const`) and re-export the returned `useT`/`useTFn`/`T`; the bare hooks keep their existing loose signatures. Scope: simple `{name}` and single-argument `{count, number}` forms are extracted; messages with nested inline ICU (`{n, plural, one {…} other {…}}` / `select`) accept a loose values bag rather than a wrong strict one — apps that pluralise in JS over simple `{count}` messages stay fully typed. `<T>` gets a typed key with loose values, because its rich-text `<tag>` renderers can't be modelled by `{var}` extraction.
- **@voltro/plugin-ai-flows** — An AI-flow `MediaGenerator` (and the `makeMediaGenerator` persistence seam) now receives the run it executes within — `run: { runId, tenantId }` — resolved from the durable run row rather than the caller subject. This is what a resume needs: a BOOTSTRAP/crash resume runs under a tenant-less `SYSTEM_SUBJECT`, so a host that persists artifacts per tenant could not read the tenant from `ctx.request.subject` (it isn't there) and would either fail closed or, under an old anonymous fallback, write into the wrong tenant. The engine already loads the run row (for `ownerId`); it now reads `tenantId` from the same row and hands it down to both the deterministic and agentic media steps, and `MediaPersist.put` / `ingestUrl` forward it to `storage.put` / `ingestUrl` so persistence continues in the run's own tenant on replay.

  Additive: `run` is appended to the generator/persist arguments, so a host that ignores it keeps compiling; the tenant is simply available when it doesn't. No `@voltro/web` change — the engine is server-only and not re-exported to the browser surface.
- **@voltro/client, @voltro/web** — A sequence step's `undo` now receives the accumulated context as a second argument — `undo: (result, ctx) => …` — alongside the step's own result. An inverse usually needs an id from an EARLIER step as well as this one's (`deleteJiraDraftTicket({ jiraKey: created.key, draftId: ctx.draft.id })`), and until now the only way to reach it was to re-return that id from the step purely so the undo could read it back. `ctx` is typed as of the step's definition — the steps before it, the same context `covers` and `when` already see — so a later step's result is deliberately not visible (it is rolled back before this one).

  Additive, not breaking: `StepUndo<Result>` became `StepUndo<Result, Ctx = Record<string, unknown>>` with the context parameter defaulted and appended, so a named `StepUndo<T>` still resolves and an existing single-argument `undo: (r) => …` stays assignable. `@voltro/web` re-exports the client surface, which is why it moves too.
- **@voltro/cli** — SSR `ctx.query` now has a first-class, server-only api origin for split web/api deployments (#18). Previously, a `renderMode:'ssr'` page reloaded in a split deployment 500'd: the web pod's `POST <origin>/rpc` fell back to the DEV proxy target (`http://localhost:4000`), which nothing serves in production → `ECONNREFUSED` buried in a render error. Browser-reachability and SSR-reachability were conflated into the one `url` field.

  New `apis.<name>.serverUrl` (and env overrides `VOLTRO_API_ORIGIN_<NAME>` / `VOLTRO_API_ORIGIN`) set the origin the WEB POD uses for SSR — the api's internal cluster DNS (`http://api.<ns>.svc.cluster.local`) — distinct from the browser's relative wsPath, and NEVER emitted into the browser bundle. Resolution: env > `serverUrl` > (dev only) the vite proxy target > an external api's absolute url. Under `voltro start` a package api with none resolves to `undefined` and the loader query FAILS LOUD naming the api and the config to set — it never dials the dev localhost port. A transport failure is wrapped naming the api and the origin attempted, instead of a bare `fetch failed`.
- **@voltro/database, @voltro/runtime** — `store.insert` / `upsert` / `insertIgnore` now raise a clear, typed `TableValidationFailed` naming the column when the payload omits one that is NOT NULL, has no default, and isn't auto-stamped — instead of a raw dialect `SqlError: Failed to execute statement` (`Field '…' doesn't have a default value`) surfaced only on the INSERT path (so it lay dormant until the first row with no existing cache entry). An upsert / insertIgnore whose payload is missing one of its own `conflictColumns` is likewise named at the call (an absent conflict key can't match its target). The check runs AFTER stamping, so auto-id / tenant / audit columns never trip it, and skips nullable, defaulted, and id (`idScheme`) columns — exactly the ones a caller may legitimately omit.

  Two pure helpers back it — `missingRequiredColumns(table, row)` and `missingConflictColumns(conflictColumns, row)` (exported from `@voltro/database`). This is the runtime half of the "handler data silently disagrees with the schema" class; a compile-time payload type needs the column DSL to track `hasDefault` at the type level, which is a separate change.

  **Migration impact — behaviour-breaking for lenient test fixtures.** The in-memory/test store now rejects the same partial inserts a real Postgres always would, so it stops being laxer than production — which is the point (it surfaced at least one latent prod bug where a NOT-NULL `text().unique()` column was written without a value). But a fixture that inserted a partial row (`{ id }` parents, an omitted required FK) and passed against the old lenient memory store now throws `TableValidationFailed`. There is no code-level codemod — the fix is fixture DATA: fill the required columns. Use the new `fixtureRow(table, overrides)` helper in `@voltro/testing`, which fills every NOT-NULL-no-default column with a schema-typed placeholder and merges your overrides on top, so a fixture complies without disabling the check. There is deliberately no opt-out to turn the validation off: a test store that accepts rows production rejects is a fake testing itself.
- **@voltro/database, @voltro/plugin-ai-flows, @voltro/plugin-audit, @voltro/plugin-deactivation, @voltro/plugin-soft-delete** — Compile-time payload typing for writes (#15) — the type-level half that the runtime `TableValidationFailed` guard flagged as a separate change. `insertRow` / `upsertRow` take the TABLE OBJECT (not a string name), so the payload is checked against `InferInsertRow<T>`: every column is required EXCEPT nullable ones, columns with a default, and the framework-filled id/tenant/audit columns. A missing NOT-NULL-no-default column — the exact `lastRefreshedAt` / `teamId` omission from the report — is now a COMPILE error at the call, not a runtime SqlError only on the INSERT path; `upsertRow`'s `conflictColumns` are constrained to the table's own columns too.

  import { insertRow } from '@voltro/database' await insertRow(ctx.store, roadmapEpicStats, { teamId, lastRefreshedAt: new Date() })

  Enabled by a type-level default flag: `ColumnDefinition` / `ColumnBuilder` gained a third `HasDefault` parameter that `.default()` narrows to `true`. It defaults to `boolean`, so every existing `ColumnDefinition<unknown>` (mixins, query builder, migrate, plugins) is unaffected — the only golden churn is the additive third parameter rendering (e.g. an audit mixin's defaulted `createdAt` now shows `ColumnDefinition<Date, "timestamp", true>`). The string-keyed `store.insert` / `upsert` are unchanged; the typed seam is opt-in.
- **@voltro/runtime, @voltro/cli** — `voltro dev` now warns, once per tenant-scoped table, when a request reads it with the empty-string tenant sentinel — an authenticated subject that has no resolved org (`tenantId === ''`). The auto-merged tenant filter becomes `eq('tenantId', '')`, which matches no real row, so every such read returns empty WITH NO ERROR — indistinguishable, from the response alone, between "no such row", "filtered by an empty tenant", and "auth half-resolved". The warning names the cause and the fix. `voltro serve` deliberately stays silent (a prod diagnostic on every scoped read is noise). The decision is a pure predicate, `isEmptyTenantScopedRead`, exported from `@voltro/runtime` so it is unit-tested apart from the 6k-line dev boot; `applyTenantScope` itself stays pure.
- **@voltro/runtime, @voltro/cli** — `voltro dev` now warns once, the first time an authenticated subject resolves with NO active org (a `user` carrying the empty-string tenant sentinel) — "authenticated, but no active org → all tenant-scoped reads will be empty". Broader and earlier than the per-table empty-tenant read warning: it catches the whole class at the door instead of on a specific read. Backed by the pure `isOrglessUserSubject` predicate in `@voltro/runtime`; dev owns the one-time log.
- **@voltro/cli** — `voltro start` (web) now reports boot timing, matching `voltro serve`. It always logs a structured `start: ready in <n>ms` line (with `bootMs`), counted from PROCESS start so the module-graph load — the phase that dominates a scale-to-zero cold start — is included instead of being missed by a mid-boot baseline; the banner's `bootMs` uses the same total. Under `VOLTRO_BOOT_TIMING=1` the line also carries a per-phase `phases` breakdown (`modules`, `config`, `scan`, `provider`, `routes`, `cdc`, `ready`). Because the total is a structured log record, it is retrievable from the container's `/_voltro/inspect/logs` endpoint, not only from stdout.
- **@voltro/cli** — Production web images are now self-contained and dramatically smaller. `voltro build` makes every runtime artefact framework-inlined + tree-shaken — the SSR bundle (vite `noExternal: true`), the start bundle, and the precompiled `appConfig` — so a booted `voltro start` needs from `node_modules` only the runtime-external NATIVE leaves it actually reaches (a SQL driver an ISR/config path touches). A new hidden `voltro prune-runtime <deploy-dir>` command (`@vercel/nft`, a new build-time optionalDependency) traces the real reachable set from those bundles + the app's installed native leaves and drops the rest — the whole `@voltro`/effect/react tree that's now dead weight. The standalone web Dockerfiles run it after `pnpm --prod deploy`; for a static/SSR site with no native runtime dep, `node_modules` collapses to nothing. Measured on a real marketing app: the app tree drops from ~210 MB to ~65 MB (node_modules ~145 MB → 0) and still boots + renders. Fully automatic — a used native driver is traced + kept, an unused one dropped, no per-app allow-list — and non-fatal: any trace failure keeps the fuller tree (a bigger image, never a broken one). The appConfig `@voltro/*` imports are now inlined (were external); this is safe because `runEnvGate` consumes the env schema structurally (`isEnvContract` duck-types `{ vars }`, no `instanceof`/Symbol), so a schema built by the config's inlined `@voltro/env` still validates.
- **@voltro/cli** — `voltro build` now precompiles the web START runtime into a single bundle (`.framework/dist-web/startBundle/startEntry.js`, framework inlined) — the web counterpart to the api serve bundle. `bin/voltro.mjs`'s `voltro start` fast path prefers it, so a cold scale-from-zero web boot loads ONE artefact instead of resolving the whole `@voltro`/effect module graph. Measured on the web-spa-shell fixture: the `modules` boot phase collapses from ~1130 ms to ~67 ms (~17×), directly cutting the phase measured to dominate a scale-to-zero web cold start. Non-fatal — if the bundle build or its import fails, `voltro start` falls back to the per-module CLI entry (correct, just slower). No app or Dockerfile change is needed; the bundle rides in `.framework`, which `pnpm deploy` already copies.

### Fixed

- **@voltro/runtime** — An undeclared infra error no longer reaches the browser as an `ExitEncoded` schema-tree dump (a wall of text that leaks internals and no client can pattern-match). `FieldDecryptionError` — a `.encrypted()` column that can't be decrypted with the active key — now collapses to a generic `InternalError` on both the unary and the subscription paths (it was previously only `SqlError` / `ResultLengthMismatch`, and only on the unary path). Its message names an internal `table.column`, so this also stops that leak; the real cause is logged server-side with the traceId. `TableValidationFailed` is deliberately left through — its summary/issues are meant to be shown to a user. Typed app errors are untouched.
- **@voltro/cli** — Security: the `/_voltro/inspect/logs` endpoint no longer bypasses the inspect gate. It dumps the process LogBuffer (request URLs + error payloads), but it returned early — above the `isInspectDisabled` / `VOLTRO_INSPECT_TOKEN` checks — so `VOLTRO_INSPECT=off` closed the manifest and metrics (503) while leaving the log buffer publicly readable, and a configured `VOLTRO_INSPECT_TOKEN` was ignored for it. The gate now lives inside `handleLogsRequest`, single-sourced across the three call sites (`voltro start` / `voltro dev` / the web-dev server) that each drifted (start ungated, dev gated nothing, web-dev gated disabled-but-not-token): disabled → 503, missing/bad bearer → 401.

---

## [0.11.0] — 2026-07-22

### ⚠ BREAKING

- **@voltro/client, @voltro/web** — **@voltro/client** — `useSequence` steps gain **`covers`** and **`when`**, and the undo contract that was implicit is now written down.

  **`covers` — overlapping undos.** Every succeeded step's undo runs, and the runner has no idea whether two of them reverse the same thing. Reported from a Jira rollback: `deleteJiraDraftTicket` deletes the issue *and* discards the draft, so the earlier `discardDraft` undo ran on something already gone. It worked only because discarding is idempotent — and **nothing said that was load-bearing**. For a refund or a cancellation email the double-run is a defect, not a nuisance.

  ```tsx
  .step('draft', createDraft, { undo: discardDraft })
  .step('jira',  createTicket, { undo: deleteJiraDraftTicket, covers: ['draft'] })
  ```

  `covers` is static rather than a runtime signal on purpose: "this reverse also reverses that one" is a property of the operation, legible where it is defined and checkable against the step names in scope.

  **When the covering undo FAILS**, the covered steps are neither run nor claimed. Whether the cascade got that far is genuinely unknown — running the covered undo risks the double reverse, skipping it risks an orphan — so both guesses are refused and the steps come back in `compensationUncertain` with the step that was supposed to cover them. Same principle as never compensating the step that failed: surface the ambiguity, don't resolve it by assumption.

  **`when` — one optional step.** 13 multi-await blocks in one app; only 5 could migrate. Several of the rest were linear *except* for one conditional step ("schedule the summary only if the set changed", `if (assigneeKey) assign else unassign`) and fell back to `try/catch` entirely, though 80% of the flow was a clean pipeline. An optional step is a different shape from a loop, and the scoping was treating them the same.

  ```tsx
  .step('summary', (c) => scheduleSummary.run({ id: c.save.id }), { when: (c) => c.save.changed })
  ```

  A skipped step contributes `undefined` to the context — the overload says so, so a later step has to acknowledge it — and gets no undo, since it had no effect to reverse. Loops and real branches still keep their `try/catch`; this deliberately does not widen to them.

  **The break:** `StepOptions` gained a type parameter — `StepOptions<Result>` became `StepOptions<Ctx, Result>`, because `covers` and `when` both need to see the accumulated context (`covers` is checked against the step names in scope; `when` receives it). Call sites that pass an object literal to `.step()` are unaffected — the type is inferred — but anyone who *named* the type explicitly gets a compile error. Filed BREAKING rather than Added for the same reason a widened union is: the test is "can this turn code that compiled into code that does not", and it can. `@voltro/web` is listed because it re-exports the client surface — the third time in this round that coupling has decided where a change lands.

### Added

- **@voltro/cli** — **Repo gate** — a new `Claimed-wiring check` (`scripts/check-claimed-wirings.mjs`, wired into CI and therefore into `pnpm gate`): a doc comment that says something wires a symbol up must be telling the truth.

  `setSystemStoreHandle`'s comment read *"Process-wide handle, registered by the runtime boot (dev.ts / start.ts)"*. Nothing registered it, in either path, through an entire release — so `runAsSystem` threw for every consumer, and the comment was the only evidence anyone had that it should work. The shape is not rare: a comment gets written when the wiring is planned, the wiring gets deferred, and the comment never finds out. It then reads as documentation of behaviour rather than of intention, and the more confidently it is phrased the less likely anyone is to check it.

  Two things it does that the obvious version does not, both learned by watching it report the bug as clean:

  - **it counts real call expressions, not text.** The first version matched regexes and found `setSystemStoreHandle({ … })` inside `runAsSystem`'s own error-message string — a text match cannot tell a call from a sentence about a call. (Precisely the defect fixed in the hand-roll detector one commit earlier, repeated one file later.) - **it checks the NAMED caller, not any caller.** The second version asked "does anything call this"; `@voltro/testing` calls it from a test harness, so the bug read clean again. A claim that the runtime boot registers something is not satisfied by a test helper registering it.

  Verified the only way this kind of check can be: by removing the wiring and confirming it fails, with the diagnosis that would have saved the original investigation — `called by: packages/testing/src/testContext.ts ← none of these is the boot`.

### Fixed

- **@voltro/database, @voltro/runtime, @voltro/sql-postgres, @voltro/sql-mysql, @voltro/sql-sqlite, @voltro/sql-mssql** — **@voltro/database + every store** — a row inserted into a table that declares an `id()` scheme now gets one **at the store**, not only when the caller happened to go through `wrapStoreWithMixinBehaviour`.

  Id generation was sitting one layer too high. It is the single stamped field that needs no subject — the scheme is a property of the declared table — yet it lived in the subject-aware wrapper. So every insert through an unwrapped store reached the database with `id: null`:

  ```
  null value in column "id" of relation "_voltro_seeds" violates not-null constraint
  ```

  That was a real shipped bug in the seed ledger (the runner holds the raw store), and a survey found the same shape waiting elsewhere: `plugin-rbac/userRoleStore.ts`, `plugin-governance/consent.ts`, and `plugin-sso-saml/saml-cache.ts` all insert without an id into tables that declare one. Whether any of them broke came down to how their caller happened to obtain its store — and *"depends on how the caller obtained its store"* is not a contract, it is a coin flip with a NOT NULL constraint on the other side.

  `stampGeneratedId` now runs in every store's insert path (all four dialects plus the in-memory store, at the private `executeInsert` / `executeInsertMany` / `executeInsertIgnore` choke points that every public and namespace-view path funnels through). It preserves the semantics the wrapper had: an explicit id is never overwritten, a `numeric` scheme deletes the key so the dialect's SERIAL fires, and an unregistered table is passed through untouched rather than guessed at.

  The wrapper still stamps — it holds the schema registry and does the subject-derived fields in the same pass — and now finds the id already set. That is a floor, not a second implementation of a rule: the invariant is "a row that reaches the database has an id when its table declares a generating scheme", and only the store can promise that for *every* caller, including a `DataStore` someone implemented themselves.
- **@voltro/cli** — **Repo tests** — the liveness backstop for real-listener / real-child-process suites goes from 60s to 120s, in `vitest.config.ts` and the CI flag that overrides it.

  Worth stating plainly what this number is, because raising a timeout is the classic way to bury a problem: it asserts nothing about performance. A real `serveApi` boot is ~0.2s in isolation. The value is pure headroom against residual starvation that the four existing mitigations — the unit/integration project split, group ordering, `fileParallelism: false`, per-package mssql databases — cannot reach, because the remaining contention is turbo running two *packages* concurrently alongside the docker stack.

  60s tripped twice in one session, on two different files (`connectionServe.test.ts`, then `serveApi.test.ts`), each passing in ~2s alone. Different file each time, always in the same family, never reproducible in isolation: that is the signature of starvation rather than of a slow test, and it cost two full gate runs.

  What would not be honest is treating a green run afterwards as evidence the contention is gone. It is not. If a third file trips this, the answer is to stop running two heavy packages concurrently — not to raise it again.
- **@voltro/cli, @voltro/database** — **@voltro/cli, @voltro/database** — two fixes to the 0.10.0 seed ledger, both reported from its first real use, both mine.

  **The ledger never wrote.** `store.insert('_voltro_seeds', …)` ran against the RAW store, and auto-id lives in `wrapStoreWithMixinBehaviour` — so the row reached postgres with `id: null` and died on the NOT NULL constraint. Every boot reported `ran=1 skipped=0` regardless of fingerprint: the feature shipped doing nothing. The ledger now stamps its own id, derived from `_voltroSeedsTable`'s declared scheme rather than a hardcoded prefix, so it no longer depends on how a caller happens to have wrapped its store.

  **Worse than the bug was the logging.** I put the failure on `debug` and swallowed it, reasoning that "it degrades to re-running, which is only a performance regression". That reasoning is exactly what made it undiagnosable: a silently unwritten ledger looks identical to a working one whose seeds all changed — no symptom, nothing to grep, and the reporter had to read the error out of a debug stream to find it. Both the write and the read path now warn, naming the table and the consequence.

  **And the reason it shipped:** the test fake *invented an id* when the row lacked one, making it more permissive than any database. A fake that supplies what the subject under test forgot is not a test — it is the subject testing itself. It now rejects an id-less insert with the real constraint's message; reintroducing the bug fails five cases.

  **A seed step could not reach a usable store.** `SeedStore` exposed only `query`/`insert`/`update`/`delete`, and `query` took just `{ table, predicate }`. A restore of 1361 rows across 167 tables, multi-pass for FK order, needs an idempotent insert and a read it can page — and `upsertByUnique` is neither: a read per row, and it overwrites what it finds, which is wrong whenever the live row is newer than the snapshot. `SeedStore` now carries **`insertIgnore`** (one statement per row, native on every dialect) and a full descriptor read (`order` / `take` / `skip` / `projection`).

  Seed reads are unscoped and include soft-deleted rows *by construction* — a seed runs at boot with no request and therefore no subject, so nothing applies a tenant filter or the `deletedAt IS NULL` predicate. That is now documented on the type rather than left to be discovered, since the absence of `.unscoped()` / `.withDeleted()` reads as a missing feature until you know why they cannot exist here.

  *(`apiSurface: compatible`: the golden churn in `@voltro/database` is two `(undocumented)` markers disappearing because `SeedStore` and its new member gained TSDoc — a comment cannot break a caller. The added `insertIgnore` member is additive for consumers, which is everyone: a `SeedStore` is what `ctx.store` IS, handed to you by the runner. Nobody constructs one, so nobody can be missing a member.)*
- **@voltro/cli** — **@voltro/cli** — `runAsSystem` now works. The process-wide system store is registered at boot by **both** `voltro dev` and `voltro serve`; until now neither did, so every call threw `no data store available — register one at boot via setSystemStoreHandle`.

  `setSystemStoreHandle`'s own doc comment reads "registered by the runtime boot (dev.ts / start.ts)". It describes wiring that was never written: the only callers in the repo were its unit test and a note in `@voltro/testing`. So the failure was not a lifecycle-ordering subtlety — the handle was never set at any point, in any command, and `runAsSystem` was unusable for every consumer.

  Surfaced by someone reporting it as "not registered *yet* at seed time", which implied it worked later. Checking that framing rather than the symptom is what turned a scheduling question into a missing-wiring one. It is registered before the seed runner in dev, since seeds are the earliest thing that can plausibly want it.

  Same class as the seed lifecycle table and the `@voltro/web` re-export: a documented behaviour with nothing behind it, where the doc is the only evidence anyone has.

---

## [0.10.0] — 2026-07-22

### ⚠ BREAKING

- **@voltro/web, @voltro/cli** — **@voltro/web, @voltro/cli** — the SSR → hydration payload now carries **`path`**, the pathname the server rendered that document for, and the client refuses to hydrate a document whose payload names a different route. `mount()` used to decide between `hydrateRoot` and `createRoot` on the mere PRESENCE of the `__voltro_state__` tag; the payload contained no server pathname at all, so "the server rendered my route" and "someone handed me another route's document" were indistinguishable.

  They are distinguishable in exactly one deploy shape, and it is a real one: a static host answers every URL it has no file for with `index.html`. Once the ROOT route is prerendered — which `renderMode:'spa'` pages under a layout became in 0.9.0 — that file is a genuine server render of the root: valid markup, valid payload, everything a hydrating client looks for. A deep link to any other URL therefore adopted the root's layout chain while the client rendered a different route: React hydration mismatch (#418), then a silent full client re-render of the page. Refusing to adopt is the only correct reading of "the server rendered something else", and the fresh render it falls back to is what the visitor would have got anyway — minus the error.

  Host-shaped differences are normalised before the comparison (a trailing slash, a trailing `/index.html`, percent-encoding), because refusing those would send every legitimately prerendered page down the client-render path — correct output, slower, and completely invisible. That half is pinned by its own test for the same reason.

  **The break:** `renderRouterStateScript` (and `encodeRouterState`) require `path`. No application code calls them — `voltro dev`, `voltro serve` and `voltro build` are the only emitters and all three now pass the pathname they hand the renderer, taken from the same value so the payload cannot disagree with the markup. It is filed BREAKING rather than Fixed because the docs point anyone building their own server at `@voltro/web/ssr`, and for them this is a compile error. The codemod is `manual`: the right value is the request pathname a custom server holds in a variable, and a transform could only guess — a plausible-looking wrong path would silently disable hydration for that route instead of failing.

  Pass the request pathname, not the route pattern (`/posts/hello`, not `/posts/[slug]`): it is compared against `window.location.pathname`.
- **@voltro/cli, @voltro/web** — **@voltro/cli, @voltro/web** — `renderMode` is now a **closed set**: `'static' | 'spa' | 'ssr' | 'isr'`. Anything else fails `voltro dev` / `voltro build` / `voltro start` at CODEGEN — before a page module is loaded, before vite runs — with an error naming the page, the value it declared, and the valid set. Previously `resolveRenderMode` returned any string verbatim (its return type was bare `string`), so an invented value silently fell into whichever else-branch each gate happened to have.

  **This is breaking because it rejects a value that used to be accepted.** The one that mattered in practice is `'client'`. It was never in the `RenderMode` type, yet it behaved *almost* like `'spa'` — `voltro build` skipped it in the prerender (`renderMode !== 'static'`), `voltro dev` and `voltro start` fell through to the SPA shell (`!== 'ssr' && !== 'isr'`), the SSR bundle listed it like every other page, the client router ignored the field, and `defer()` rejected it. Two places disagreed, which is the bug this closes: the new SSR-layout-shell gate matches the literal `'spa'`, so a semantically client-only page written as `'client'` silently did NOT get its layout server-rendered; and the static-deploy scan (`renderProfile`) used a value-restricted regex that could not match `'client'` at all and therefore counted those pages as `'static'` — which put every dynamic one into `dynamicMissing` and reported an app that is perfectly static-safe as `staticSafe: false`. A real 33-page app went from `{static: 33}` / `staticSafe: false` / 28 `dynamicMissing` to `{spa: 33}` / `staticSafe: true` / none.

  **Migration: `'client'` → `'spa'`** — applied automatically by the `transform` codemod, which rewrites the literal wherever `renderMode` is assigned (the `export const`, the `as const` form, the annotated form, and an inline descriptor's `renderMode:` property) and leaves every unrelated `'client'` string alone. `'spa'` is the truthful target rather than a guess: it is the mode those pages already behaved as. `'static'` would be actively wrong — it would start pre-rendering pages that have never been pre-rendered. Nothing that was pre-rendered before is pre-rendered differently after; what those pages GAIN is exactly what every `'spa'` page gained in this release, the SSR layout shell, and `0.9.0/01_ssr-layout-shell-for-spa` prints that review step (its predicate matches `'client'` too, so it fires for precisely these projects).

  Alongside: the valid set is single-sourced (`RENDER_MODES` in `@voltro/web`'s router, mirrored in the CLI's `routeMeta` and pinned against it by a test) so the type and the validator cannot drift; `resolveRenderMode` / `RouteMetadata.renderMode` are typed `RenderMode` instead of `string`; and `voltro dev`'s two inline `mod.renderMode as string ?? 'static'` reads now go through that one resolver. A `renderMode` exported from a `layout.tsx` / `error.tsx` / `loading.tsx` has never been read by the framework and still is not — the mode is a property of the page.
- **@voltro/runtime, @voltro/cli** — **@voltro/runtime, @voltro/cli** — a schedule and a bootstrap workflow run now execute as **`SYSTEM_SUBJECT`** (`tenantId: null`, i.e. unscoped) on **both** boot paths, and the ENGINE hands that subject to `buildContext` instead of each boot path inventing one.

  **The bug:** `voltro dev` and `voltro serve` disagreed about who non-request work runs as, and neither errored.

  | | `voltro dev` | `voltro serve` | |---|---|---| | schedule subject | `anonymousSubject($TENANT ?? 'acme')` | `{ tenantId: null }` | | workflow bootstrap subject | `anonymousSubject($TENANT ?? 'acme')` | `{ tenantId: null }` | | workflow run-row `subject` | that same anonymous subject | `null` | | dormancy wakeup key (schedule) | `$TENANT ?? 'acme'` | `'default'` | | dormancy wakeup key (workflow) | `$TENANT ?? 'acme'` | `'default'` |

  `applyTenantScope` treats `tenantId == null` as "system, nothing to scope to" and any other value as a filter. So the SAME cron read exactly one tenant's rows under `voltro dev` and every tenant's rows under `voltro serve` — silently, with no error on either side. A nightly cross-tenant backfill worked in production and quietly did a fraction of its job in development; the reverse reading is worse, since a developer testing tenant isolation in dev saw an isolation that production does not have. The value dev used was the fallback for a login-less dev HTTP REQUEST, which a schedule never is: it leaked from the request path into a place that has no request.

  A dev/prod difference is usually a nuisance. A dev/prod difference in what the tenant filter resolves to is a security surface, which is why the scoping RULE already lives in exactly one module (`tenantScope.ts`). This closes the caller side of the same hole: the INPUT to that rule now has one definition too.

  **Why the engine owns it.** Each caller was internally consistent, so nothing at either call site could catch the drift — that is why it survived. `startScheduler`'s `buildContext` now receives `subject` in its input, and `makeWorkflowLayers` normalises the caller context before `buildContext` sees it. A `buildContext` that RECEIVES the subject cannot make this mistake; one that invents it can, and did twice.

  The wakeup keys are bookkeeping rather than security, but they drift into the same failure: a dormant schedule or workflow wait registered under one boot path's key was invisible to a waker looking under the other's. All four now use `DORMANCY_WAKEUP_TENANT`.

  **Behaviour change to expect:** under `voltro dev`, a schedule or resumed workflow that reads a `tenant()` table now sees **every** tenant, matching what it already did in production. If a cron of yours relied on the dev scoping, it was relying on a value derived from `$TENANT` — make the tenant explicit (`.unscoped().where('tenantId', id)`), which is what the multi-tenancy docs already recommend for cross-tenant reads. Conversely, `.unscoped()` workarounds added to survive the dev scoping are now no-ops rather than necessities, and can go.

  **The break** is the type of `WorkflowLayerOptions.buildContext`'s first parameter (`WorkflowCallerContext | undefined` → `ResolvedWorkflowCallerContext`, whose `subject` and `traceId` are never absent) and, following from it, `serveApi`'s `buildContextForWorkflow` option. No application code touches either — they are framework wiring — but an embedder building a custom server on `serveApi` does, and for them it is a compile error plus a now-dead fallback branch. The codemod is `manual`: the correct edit is deleting a `??` fallback, and which of the two shapes an embedder wrote is not mechanically predictable. `SubscribeContext`, `SchedulerDeps.buildContext`'s input, and every user-facing primitive are additive.
- **@voltro/cli, @voltro/devtools-ui** — **@voltro/cli** — boot-lifecycle seeds now **skip themselves when nothing changed**. The runner reads and writes `_voltro_seeds`, comparing each seed's source fingerprint against the last successful run.

  This closes a documentation gap rather than adding a new idea. The seeds page has always stated that a `boot` seed "only re-runs when the fingerprint changes", the `_voltro_seeds` table has always carried a `fingerprint` column, and the runner computed the fingerprint on every boot — and then only logged it. So every boot seed re-ran on every boot. Nothing was WRONG (a boot seed is idempotent by contract), but "idempotent" means every row is re-checked: a consumer restoring a reference catalogue re-scanned it on every `voltro dev` restart and reasonably concluded the feature had never shipped.

  Two rules, because both failure modes are silent:

  - **Only a succeeded run counts.** A failed run records its status but never satisfies the skip, so one bad boot cannot turn into a permanently skipped seed. - **An unreadable ledger means RUN.** A missing table, an unmigrated database, a memory store — anything that stops us from reading `_voltro_seeds` makes the runner execute every boot seed. Re-running idempotent work costs time; skipping data restoration on a database we could not inspect costs data.

  A skipped seed is **reported**, not omitted: `runBootSeeds` returns it with `status: 'skipped'`, and the inspect payload / dashboards carry that through as its own state. Dropping it from the results would have made an applied seed render as never-run in the devtools and cloud seed panels — and the cloud proxy validates the status with `Schema.Literal`, so an unannounced value there is a decode failure, not a cosmetic one. Both dashboards render `skipped` distinctly from "never run".

  `voltro db seed` is unchanged and still **forced** — it ignores the fingerprint, as documented. Someone typing the command is asking for the seed to run, and quietly doing nothing would be the wrong answer.

  **The break is the status union**, and it is worth spelling out because it looks additive and is not. `SeedRunResult['status']` and `SeedSnapshot['lastRunStatus']` gain `'skipped'`. Nothing was removed — but a consumer that switched exhaustively over `'succeeded' | 'failed'`, or assigned the value to a variable of that narrower type, stops compiling. That is the test the changelog gate applies ("can this turn code that compiled into code that does not?"), and it is the same shape as the `store.query()` narrowing that cost one app 102 hand-fixes. Our own cloud proxy validates the value with `Schema.Literal`, so an unannounced third state would have been a decode FAILURE there, not a rendering oddity.

  A `manual` codemod: adding the case is a two-line edit, but only the author knows whether a skipped seed should render as applied, as pending, or be filtered out of that particular view.

  **Also corrected, because the same audit turned it up:** the lifecycle table claimed `onTenantCreate`, `onSchemaChange`, and `cron` seeds fire. They do not. All three validate at definition time and get registered so the dashboard can list them, and then nothing ever triggers them — a seed declared with one runs never, silently. The docs (both languages) and `seed.ts` now say so, and point at the primitives that do run. Wiring them is a feature, not a fix; promising them meanwhile is the failure mode this whole entry is about.
- **@voltro/client** — **@voltro/client** — a skipped subscription now reports **`idle: true`, `loading: false`**. It used to report `loading: true` forever, since nothing was ever going to arrive.

  That collided head-on with the pattern this hook's own documentation blesses:

  ```tsx
  if (loading) return <Skeleton/>
  ```

  On a skipping call site that renders a skeleton for a query the app deliberately switched off. `skip: !currentUser?.id` and `skip: !open` are the two commonest forms, and they only survived because components happened to carry a redundant `if (!currentUser) return null` in front — the very check `loading` was introduced to replace. `loading` was answering two different questions with one boolean, and at exactly the feature where the difference matters.

  We had it six times in our own `useWorkflow.ts`, every one a `skip: someId === undefined`: `useWorkflowRun(api, undefined)` reported `loading: true` indefinitely, so any consumer following the blessed pattern rendered a permanent skeleton whenever nothing was selected.

  **The reported fix was not the right one.** A third state on every call site — `{ status: 'idle', loading: false, data: undefined }` — would have destroyed the property the union exists for: `!loading` would stop proving `data` is present, silently un-narrowing every call site and reintroducing the `?? []` / `!` this type was built to delete. So the idle state is **scoped by overload** instead:

  | call | result | |---|---| | no `skip`, or a literal `{ skip: false }` | `SubscriptionState<T>` — two states, narrowing unchanged | | a dynamic `{ skip: <boolean> }` | `SubscriptionState<T> \| SubscriptionIdle` — must be handled | | any `fallback` | `SubscriptionStateWithFallback<T>` — `data` always present, `idle` reports why |

  The cost lands precisely on the callers who use the feature; everyone else compiles untouched. A `.test-d.ts` pins all four cases, including a `@ts-expect-error` asserting that handling only `loading` on a skipping site still fails to compile — so a regression in the overload ordering fails the typecheck rather than quietly making every call site longer again.

  **One behaviour change beyond the types:** a subscription that was live and is then skipped goes idle instead of continuing to serve the snapshot it still holds in cache. Otherwise `skip: !open` would show last time's data the instant a dialog reopens. If you relied on the stale value, hold it in your own state.

### Added

- **@voltro/cli** — **@voltro/cli** — `voltro build` now prerenders the SSR LAYOUT SHELL for a `renderMode:'spa'` page — the layout chain server-rendered around an empty page slot (`<div data-voltro-page-slot>`), with `pageClientOnly: true` in the inlined state, i.e. byte-for-byte the shell `voltro start` already produced on demand. It is written to `dist/<route>/index.html`, so a static host paints the layout immediately instead of an empty `#root` (and serves the route at all, instead of falling back to the raw shell). This closes the "build-time prerender of the spa layout shell" follow-up left open by 0.9.0.

  **It is gated, and the gate is the point: the shell is prerendered ONLY when no layout in the page's chain exports a `loader`.** Baking layout-loader output into a file served to every visitor is safe only if that output is request-INDEPENDENT — a layout loader that resolves the signed-in user or the tenant would freeze ONE visitor's data into the artefact, which is a cross-user data leak, not a stale value. There is no way to prove request-independence by inspection, so a chain with any layout loader is left to `voltro start`, which runs the loader per request and is correct by construction; the build logs which route it skipped and which layout caused it. The gate reads the layout module's REAL `loader` export rather than the source-scan heuristic used elsewhere, because a false negative there is exactly the direction that leaks. Dynamic spa patterns are also skipped — `getStaticPaths` is a static-rendering contract, so there is no build-time path to write. A skipped spa page behaves exactly as before.

  `voltro start` is unchanged: it still renders every spa-with-layout route on demand, so the prerendered file is a static-hosting artefact and adds no new serving path. One thing to know if your spa page IS the root route: its shell then becomes `dist/index.html`, which is also the SPA fallback a static host serves for unmatched URLs (the pristine template stays available at `dist/_voltro/shell.html`, which is what `voltro start` reads). Covered by a new `e2e-fixtures/web-spa-shell` + `buildSpaShellPrerender.test.ts`, whose load-bearing assertion is the NEGATIVE one: a spa page under a loader-bearing layout produces no file at all.
- **@voltro/cli** — **@voltro/cli** — `voltro doctor`'s hand-roll detector now parses the files it scans instead of matching text against them, and gains a rule that needs that: **`subscription-undefined-check`**, which flags `data === undefined` / `!data` on a `useSubscription` result and points at `loading` (and `idle`).

  The rule is the reason for the parser. A consumer migrating exactly these call sites wrote a regex codemod for the job, and it rewrote a `summary === undefined` check inside a child component where `summary` was a **prop**. Their compiler caught it only because that particular name was out of scope there; with matching names it would have shipped a silent behaviour change. Text cannot tell you which declaration an identifier refers to, so a rule about identifiers cannot be written in text — the new rule resolves each candidate to its declaration, which makes a prop, a loop variable, and a same-named import simply not be the binding.

  Two existing rules were imprecise for the same reason and are now counted on the AST: the form detector counted the *word* `useState` (including in comments, in strings, and inside `useStateMachine(`), and the N+1 detector counted mentions of `store.query(` rather than calls — a file documenting the N+1 pattern in a comment block was a false positive. Every other rule keeps its exact predicate; the migration was verified behaviour-neutral against the existing suite before anything was upgraded.

  Deliberately NOT built: a full `ts.Program` over the app's tsconfig. Resolving a binding to its declaration is a per-file question, and keeping it per-file means the doctor stays fast and keeps working on a project that does not currently typecheck — which is precisely when someone runs it.
- **@voltro/runtime, @voltro/cli** — **@voltro/runtime, @voltro/cli** — a `*.subscribe.ts` handler's `ctx` now carries **`store`**, so a subscriber can read and write in reaction to the commit it just observed.

  It could not before. `SubscribeContext` was `{ log, id }` while the runner held the store one scope up in the very same function — so a subscriber could observe a change and do nothing about it. The documented workaround was no workaround at all: people fell back to a `*.startup.tsx` that called `store.onChange` itself, i.e. re-implemented the runner in app code to get a store back, losing the file-convention discovery and the per-subscriber error scoping in the process.

  **`ctx.store` runs as `SYSTEM_SUBJECT` — it is NOT tenant-scoped.** This is the part worth reading before using it. A subscriber fires from the change stream, not from a request, so there is no subject to scope to and no tenant to infer. Queries see every tenant's rows, and a write to a `tenant()` table without an explicit `tenantId` fails with `TenantScopeViolation` rather than silently landing in some arbitrary tenant. When the reaction is per-tenant, take the tenant from the row that changed:

  ```ts
  export default defineSubscriber({
    table: 'orders',
    on: 'insert',
    handler: async (event, ctx) => {
      await ctx.store.insert('order_audit', {
        orderId:  String(event.new?.['id']),
        tenantId: String(event.new?.['tenantId']),   // explicit — nothing infers it
      })
    },
  })
  ```

  Writes are mixin-stamped exactly as a request-path write is (id scheme, timestamps, audit columns), so this is the same store semantics handlers have, minus the request.

  Still absent, still deliberate: there is no workflow handle here. A subscriber is best-effort and non-durable, so "a row changed → start a workflow" still belongs in a `*.reaction.tsx`, whose `act` is crash-safe. That distinction is the reason subscribers exist as a separate primitive, and adding a store does not blur it.
- **@voltro/client** — **@voltro/client** — `useSequence` / `sequence()`: multi-step writes as one unit, with one `onError` and per-step compensation.

  `useMutation` and `useAction` deleted the `try/catch` from single writes, and the docs correctly said sequences keep theirs. That left multi-step writes — `upload → createAttachment`, `createDraft → createTicket → startThread → linkThread` — as the only verbose thing on the write path, and they are the hardest case, not the easiest. One consumer counted them as the dominant remaining group.

  ```tsx
  const seq = useSequence({ onError: (e) => toast.error(readError(e)) })
  
  const result = await seq.run(
    sequence()
      .step('upload', () => upload.run({ file }), {
        undo: (created) => removeObject.run({ id: created.id }),
      })
      .step('attach', (c) => createAttachment.run({ refId: c.upload.id })),
  )
  ```

  The step context is typed and accumulates, so a later step reads an earlier result by name. `run` resolves with a discriminated result instead of rejecting — the same "handled" semantics the other write hooks use. **`undo` receives its own step's result**, which is the whole requirement: the id you just created is what you need to delete it again.

  **It is not a transaction, and the API is shaped so nobody can mistake it for one.** After `createTicket` returns, the ticket exists in Jira — nothing the browser does un-creates it, it can only issue a delete and hope. The compensation also runs *in the tab*: close it mid-rollback and the remaining undos never happen. Two rules follow, both enforced and both tested with the failure injected:

  - the step that **failed** is never compensated. It may or may not have had an effect, so undoing it is a guess — and a wrong guess deletes something that was never created, or someone else's row if ids get reused; - a failing `undo` never replaces the original error and never aborts the remaining undos. Rethrowing there would tell the user the wrong thing went wrong AND turn one orphan into several. Cleanup failures come back in `compensationFailures`, because a silently swallowed one is how an orphan becomes permanent.

  For rollback that must survive a closed tab, the docs point at a workflow rather than pretending this covers it. The engine (`runSequence`) is React-free and separately tested, so the ordering and precedence rules do not need a renderer to exercise.

### Fixed

- **@voltro/plugin-duckdb, @voltro/plugin-clickhouse, @voltro/plugin-analytics-postgres** — An analytics range with no `to` no longer hides events written in the same instant as the query. All three sinks built the predicate as `occurred_at < (range.to ?? new Date())` — a STRICT upper bound defaulted to the moment the query is built. Since `track()` stamps `occurred_at = now`, a read landing in that same millisecond compared `T < T` and excluded the event, so the freshest data — the data a dashboard actually shows — was the most likely to vanish.

  Measured, not inferred: a 40-iteration track-then-immediately-aggregate loop against the duckdb sink lost **7 of 40** before the fix and 0 after.

  An absent `to` now means what it says: no upper bound at all. A caller-SUPPLIED `to` keeps the strict `<`, so an explicit range stays half-open `[from, to)` and two adjacent windows cannot both count a boundary event — which is why the strict comparison exists in the first place.

  It surfaced as an intermittent `expected 0 to be 1` in a duckdb dispose-lifecycle test. The clickhouse suite had an assertion pinning the defect (it required `occurred_at < {to:DateTime64(3)}` for a range with no `to`); that now asserts the bound is ABSENT, with a new case proving an explicit `to` still binds one.

  **`@voltro/plugin-tinybird` has the identical construction and is deliberately unchanged.** It passes `to` to a user-authored Tinybird *pipe* whose SQL lives in your own account, so dropping the parameter would break every pipe that declares it required. If you author such a pipe, make its upper bound inclusive or omit it — the client can only fix its half.
- **@voltro/cli** — **@voltro/cli** — `voltro build`'s static prerender now runs LAYOUT loaders, not just the page loader. A CMS-backed nav, footer or site-settings `loader` in a `layout.tsx` previously never ran at build time, so every prerendered page shipped with that layout rendering its no-data fallback and only filled in after hydration — an empty shell in the HTML a crawler (or a JS-less first paint) sees. The justification in the code was that "layouts have no per-request data here", which confused per-REQUEST data with per-BUILD data: a build-time loader is exactly what fetches CMS content for a static page, and a layout loader is no different from the page loader that has always run there. The prerender now assembles the chain through the SAME shared `buildSegmentChain` that `voltro dev` and `voltro start` use — so the three SSR paths cannot drift on loader-argument shape, parallelism or index keying — and passes the resulting `segmentLoaderData` into both `renderPageToHtml` and the single-sourced `renderRouterStateScript`. The prerendered file therefore carries the layout's build-time data in its markup AND in its `__voltro_state__` payload, so the client's hydration render reproduces the same markup and the layout loader does not re-run. Build-time loaders get the build-time context only — `params`, `pathname`, `signal`; there is no `headers` and no `query`, because there is no request. A layout loader throwing `RedirectError` fails the build (a static artefact cannot redirect per-request — switch the page to `ssr`); `NotFoundError` skips that path's artifact; any other throw degrades exactly as a failing page loader does, with a warning and no layout data, rather than failing the whole build. `defer()` inside a layout loader is rejected on a static page for the same reason it already was for a page loader. Verified end-to-end: `webDevSsrLayoutLoader.test.ts` asserts the bytes of a REAL `voltro build`'s prerendered file, and `@voltro/web`'s `hydrateLoaderData.test.tsx` hydrates that document shape for real (jsdom `hydrateRoot`) with zero console errors and no layout-loader re-run.
- **@voltro/database, @voltro/runtime, @voltro/sql-postgres, @voltro/sql-mysql, @voltro/sql-sqlite, @voltro/sql-mssql** — **@voltro/database + `InMemoryDataStore` + the SQL dialects** — the store's change bus no longer prints `MaxListenersExceededWarning: Possible EventEmitter memory leak detected. 11 change listeners added` on a healthy boot. Every dialect store raises its emitter's limit to `CHANGE_LISTENER_CEILING` (512) before any listener binds.

  Node's default of 10 assumes listeners accumulate per request — the classic leak. This bus does not work that way: listeners are bound once at boot, one per declared artefact, and detached at shutdown. The framework itself takes about six; the rest is one per `*.subscribe.ts`, one per `*.reaction.tsx`, and one or two per aggregate. An app crosses 10 by declaring a normal number of things, and the warning it then gets points at nothing it can act on.

  Note what does **not** drive the count, since the obvious guess is wrong: table count. Every listener filters by table inside its own callback, so 500 reactive tables still bind one listener per artefact. A ceiling derived from table count would scale with the wrong number.

  The ceiling is finite on purpose. `setMaxListeners(0)` would silence the warning and also silence a real leak — an aggregate or reaction that re-registers without detaching — forever. 512 sits far above any plausible declaration count and far below a runaway loop, so the warning keeps the meaning it was designed to have.

  `InMemoryDataStore` is included, and was very nearly not — the first pass fixed the four dialect stores and left it alone. That would have been backwards: memory is the DEFAULT store and the one `voltro dev` picks with no database configured, so the warning would have survived precisely where a new project meets it, while the packages that got the fix are ones a beginner has not reached yet.
- **@voltro/runtime** — **@voltro/runtime** — a COMPUTED subscription's delivery no longer reports a hardcoded `rowCount: 1`. It reports the real count: `0` for `null`/`undefined`, the length of an array, `1` for a single value.

  That number is not decoration. It is the `rows` attribute on the `subscription.<tag>.snapshot` span and the Prometheus row histogram — the two places a human looks to answer "did the server actually produce data?". With a constant, a computed query that resolved to `null` traced `rows=1`, identical to one that returned a row.

  This is filed as a fix rather than a chore because we know what it cost. A downstream consumer debugging an SSR loader that returned `null` for `employees.me` read `subscription.employees.me.snapshot rows=1` in their trace, correctly concluded from it that the server had produced a value, and reported a framework bug in the loader's response drain. The drain was fine; the trace was lying. They spent the investigation on the wrong layer, and so did we until the constant turned up. Observability that lies is worse than none, because it is trusted.

  Row-set subscriptions were always correct (`rows.length`) — only the two computed paths, snapshot and recompute-delta, carried the constant.
- **@voltro/cli** — **@voltro/cli** — `voltro dev` no longer throws away client state when you edit a page-local value export. Route Fast Refresh forced a full page reload whenever ANY non-component export of a page/layout changed by value — so a `export const COLUMNS = [...]` edited in the same save as the JSX that renders it reloaded the page, losing form input, scroll position and every `useState`, even though the JSX edit alone would have hot-updated. That was broader than the reason for the reload: an edit can only defeat HMR when the export is one the SERVER already read to produce the HTML in front of you. The forced-reload set is now exactly those exports — `loader`, `renderMode`, `dynamic`, `meta`, `getStaticPaths`, `revalidate`, `staleWhileRevalidate`, `cacheInvalidatesOn`, `interactive`, `tenantAware` — derived from `computeRouteMetadata`'s own parameter type (`src/routeServerExports.ts`), so it cannot drift from the readers in `build.ts` (prerender), `webDev.ts` (dev SSR) and `start.ts`. Everything else a route module exports now hot-updates: the module re-evaluates, the component renders the new value, and any other importer is Vite's normal graph propagation to resolve. A `loader` edit still full-reloads — correct, and unchanged — and the console line now names the changed export plus the server step that consumed it instead of always claiming "a loader runs on the server too". Unchanged as well: every non-component export is still reported to `@vitejs/plugin-react`'s ignored-exports hook (narrowing THAT list would make react-refresh refuse the boundary outright), and the transform stays client-only and append-only. One residual caveat, documented: ADDING or REMOVING a non-component export still reloads once, because Fast Refresh sees a name that was not yet on the ignore list. Verified in a real browser (`scripts/browser-deferred-stream.mjs --only=hmr`): a constant edit, and a constant + JSX edit in one save, both apply as hot updates with a live `useState` counter surviving and the new constant value on screen, while a `loader` edit still resets it.
- **@voltro/cli** — **@voltro/cli** — the dev boot-health handle now reports the port it **actually** bound, not the one it was asked for, so `startDevHealthServer({ port: 0 })` is usable: bind ephemerally, read the real port back off `handle.port`.

  Echoing the request was fine for `voltro dev` itself (it names a concrete port, so the two always matched) and wrong for anyone binding `0` — the handle would report `0`, leaving the caller unable to find its own listener. `port: null` still means the bind failed, unchanged.

  Found via a flaky test rather than a report, and the flake is the more useful half of the story. `devHealthServer.test.ts` picked a free port by binding `0`, reading the port, closing, and re-binding it — a window in which another process can take it. Under a full `pnpm gate` (dozens of parallel test processes plus the docker stack) that window gets hit: the server correctly degraded to `port: null`, the test then fetched a port now owned by something that never answers HTTP, and the run hung to the 60-second timeout. Green locally, red in CI, for nothing. The tests now bind `0` and use the reported port, so there is no window at all.
- **@voltro/cli, @voltro/web** — **@voltro/cli, @voltro/web** — a `defer()` in a LAYOUT loader now works on the SSR-layout-shell path (a `renderMode:'spa'` page under an SSR layout chain). It used to be **silently ignored**: `voltro dev` / `voltro serve` skipped deferral preparation entirely for that path, so the loader's deferred bucket never reached the renderer, `<Await>` in the layout got a promise nobody was streaming, and no error said so. That silence was the defect — every other unsupported combination in this feature (`static`, `isr`, `interactive:'none'`, `interactive:'islands'`) fails loudly, by name, with the fix in the message.

  The shell now STREAMS when a layout defers: the layout chain plus the EMPTY page slot flush immediately — byte-for-byte the shell a non-deferring page has always produced, which is what keeps the client's first render (`pageClientOnly` → the empty slot) hydrating without a mismatch — and the deferred layout value arrives in a later chunk behind its `<Await>` boundary, with the settle script that publishes it to the client registry. A shell whose layouts do NOT defer is untouched: still one buffered write, still no registry script. Both boot paths go through one shared preparer (`prepareSpaLayoutShell` in `ssrHelpers.ts`), so dev and serve cannot drift on the seam flags; `voltro serve` reports the streamed shell as `x-voltro-rendered-by: spa`, the same as the buffered one.

  Two behaviour changes worth naming even though neither breaks code that compiled. (1) `assertDeferralSupported` no longer decides on `renderMode` alone — a `renderMode:'spa'` page is accepted when its shell is delivered as a streamed per-request response, and still rejected when it is not. (2) The build-time prerender of a spa shell passes `layoutShell: 'artefact'`, so a deferring layout there is now a hard error naming the page instead of an `<Await>` fallback frozen into a static file forever. In practice that error is unreachable through `voltro build`: the existing safety gate already sends any chain with a layout `loader` to the on-demand renderer, and a `defer()` can only come from a loader — it is the second line of defence if that gate is ever loosened.

  Verified as ORDERING rather than final bytes (a buffered implementation passes any final-HTML diff): the shell chunk must arrive first and must NOT contain the deferred value, asserted against both a real `voltro dev` and a real `voltro serve` in `webDevSsrLayoutLoader.test.ts`, in a jsdom hydration harness that streams real chunks into a real `hydrateRoot` (`deferredHydration.test.tsx`, with a control that breaks the `pageClientOnly` seam and must mismatch), and in a real chromium (`scripts/browser-spa-layout-shell.mjs --only=defer`, which also runs the seam-broken control against the streamed shell).
- **@voltro/plugin-storage** — **@voltro/plugin-storage** — `storage.listRefs` no longer dies when one ref's `tags` cell is not an array of strings. An unreadable cell reads as `null`; every other row is unaffected.

  The reported failure: a tenant whose `_voltro_storage_refs.tags` column held a jsonb `{}` lost the **entire** query. The row set encodes against `Schema.NullOr(Schema.Array(Schema.String))` as one value, so a single bad cell fails all of it — and it surfaced as an Effect defect (`Die`), not the typed `StorageError` a caller can handle. The plugin's own media-library read was unusable for that tenant.

  `toRef` coerced every other column at the DB boundary (`Number(...)` for size, a ternary for visibility, Date-or-string for createdAt) and `tags` alone was a bare cast — a promise the database never made.

  Where the `{}` came from, for anyone finding one: before json columns were JSON-encoded on write, postgres bound a JS array to a `jsonb` column as a native ARRAY literal, so `tags: []` persisted as `{}`. That write path was fixed in 0.9.0. This read stays total anyway — a built-in query must not die on one cell when the correct answer for that cell is "no readable tags", and the bytes remain untouched in the column either way.
- **@voltro/cli** — **@voltro/cli** — the outgoing-webhook trigger context is now built by one shared function for `voltro dev` and `voltro serve`. Production was missing `access` and `load`/`loadMany` — both **required** on `AppContext` — so `ctx.access.has(...)` and `ctx.load(...)` worked in development and were `undefined` under `voltro serve`.

  It compiled because the literal was cast `as never`. That cast is the whole story: the type system had the answer the entire time and was told not to give it. The shared builder returns a real `AppContext`, so a field added to that interface now breaks at one place instead of silently existing in one boot path.

  Worth stating why this context is hand-assembled at all, since "just use `makeAppContextBuilder`" is the obvious question: that builder takes the webhooks service as a dependency and the service is built FROM this context. Something has to go first, and the trigger context is the right one — it is the single context that must NOT carry `ctx.webhooks`, because a service reaching back into itself is a loop, not a feature. So the fix is a second shared builder, not a deletion.

  The context also now runs as `SYSTEM_SUBJECT` rather than a locally-constructed anonymous subject, matching schedules and subscribers: a trigger fires outside any request, so there is no tenant to infer.

  Reach: the context is handed to `buildDeliverWebhookExecute`, i.e. it is what the durable delivery workflow runs under — not only the service. Nothing in the plugin reads the missing fields today, which is why this was latent rather than a live crash; the `as never` guaranteed it would stay invisible until something did.

---

## [0.9.0] — 2026-07-21

### ⚠ BREAKING

- **@voltro/web, @voltro/cli** — A `renderMode: 'spa'` page whose route has an SSR layout chain now renders that LAYOUT on the server — an SSR shell — instead of rendering nothing server-side. This decouples layout SSR from page render mode: the layout (nav shell, sidebar, auth gate) is server-rendered for an instant first paint and SEO, while the page itself stays client-only. Concretely, on both `voltro dev` and `voltro start`, a spa page under a layout used to ship the empty client shell (`<div id="root"></div>`) and mount the whole tree — layout included — in the browser. Now the server renders the layout chain around an empty page slot (`<div data-voltro-page-slot>`), runs the LAYOUT loaders, and inlines their data + a `pageClientOnly` flag; the browser hydrates that shell and mounts the client-only page into the slot after hydration. The page's OWN loader still runs in the browser, exactly as before. A spa page with NO layout is unchanged (still a pure client mount); `static`/`ssr`/`isr` pages are unchanged. Why this is BREAKING: a layout component AND its `loader` now execute during the server render for spa routes. Layouts shared with any `static`/`ssr`/`isr` page already ran server-side (and `static` is the default), so they are unaffected — the only newly-server-rendered layout is one whose ENTIRE page subtree is `'spa'`. Such a layout must be SSR-safe: no unguarded `window`/`document` at render time or in its `loader`. The `voltro update` codemod (`0.9.0/01`) prints this review step, and only for projects that actually have both a spa page and a layout. Out of scope this release (follow-ups): build-time prerender of the spa layout shell (`voltro build` still skips spa pages, so `voltro start` renders the shell on demand rather than from a prerendered file), `defer()`/streaming inside a spa-shell layout (the shell is buffered), and Fast-Refresh coverage of the new shell path.

### Added

- **@voltro/protocol** — `jwtBearerStrategy` (and `extractBearerOrCookie`) accept an optional `cookieToToken` hook (`CookieTokenExtractor`) that transforms the request's cookie transport into the JWT to verify. It receives a `getCookie(name)` accessor (so a strategy can read sibling / chunked cookies) plus the configured cookie name. Applied to the cookie path only — the Bearer header stays a raw token — and defaulting to a verbatim read, so the raw-JWT cookie path for WorkOS / Kinde / Clerk / Auth0 / OIDC is byte-identical. `supabaseStrategy` supplies one to unwrap the `@supabase/ssr` session envelope. Additive: existing `jwtBearerStrategy` configs and 2-arg `extractBearerOrCookie` calls are unaffected.
- **@voltro/cli** — `voltro doctor` now flags pages that are safe `renderMode:'spa'` candidates. A page is listed when ALL hold: it is a page file (not `layout`/`loading`/`error`/`not-found`), it exports no `loader`, its `renderMode` is `'ssr'` or unset/default (not already `'spa'`/`'static'`/`'isr'`), AND a `layout.tsx` sits somewhere in its directory chain (root, an ancestor, or the page's own dir). That last condition is load-bearing: only with a layout does switching to `'spa'` keep a server-rendered shell (the layout SSRs while the page body goes client-only) — a page with no layout would, as `'spa'`, ship no server HTML at all, so it is never flagged. The hint is ADVISORY and names the tradeoff: `renderMode:'spa'` skips the page's per-page SSR compile while its layout shell still renders server-side — adopt it for internal/authenticated pages whose body needs no SSR; keep `'ssr'` when the page content needs SEO or server first-paint. We deliberately ship NO codemod to auto-flip pages, because dropping a page body's server render is a per-page product decision, not a mechanically-safe transform. The scan reuses the framework's own page discovery (`walkPagesTree`), so it can't drift from what dev/build classify as a page. The full list is retrievable via `voltro doctor --json` (a new `spaCandidates` array); the human view caps at 10 with a "+N more" pointer.

### Fixed

- **@voltro/cli** — `voltro dev` no longer OOM-kills itself when many `renderMode:'ssr'` pages compile at once. The dev SSR renderer compiles each page (and every layout in its chain) on demand via Vite's `ssrLoadModule`, and that call was unbounded: two browser tabs on the same cold route, or a health-check sweep hitting hundreds of distinct routes, each started its OWN esbuild module tree with no dedupe and no concurrency cap, so the transient heaps added up and the process was killed (a downstream app with ~224 SSR pages reached >14 GB in seconds; `--max-old-space-size=8192` died after ~5 concurrent cold pages). The fix is a cold-compile gate (`coldCompileGate.ts`) around every `ssrLoadModule` in the dev SSR handler — the page, each layout/error/loading segment, and the shared `@voltro/web/ssr` helpers, so a sweep can't fan out on layouts either. It does two things: (1) DEDUPES concurrent requests for the same module — N tabs on one cold route trigger ONE compile they all await; (2) BOUNDS how many DISTINCT cold compiles run at once (default 4). A WARM module (already in Vite's graph) bypasses the gate entirely, so a hot app stays fully concurrent — the gate only tames the cold stampede, it never serializes warm serving. Verified: a concurrent sweep of 100 distinct cold routes runs exactly 4 concurrent esbuild trees under the default instead of 100, and 8 concurrent requests to one cold route compile the page once. The bound is overridable with `VOLTRO_DEV_SSR_COMPILE_CONCURRENCY` — drop it to `1`/`2` on a low-memory box, raise it on a beefy one. Default 4 balances memory against cold-sweep throughput (a lower value is safer but serializes first-paint of freshly-hit routes). Production `voltro start` is unaffected: it renders from a precompiled bundle and refuses to boot without one, so it never compiles on demand. The dev-like `voltro start` middleware FALLBACK (used only when `NODE_ENV!=='production'` and no bundle exists) shares the same `ssrLoadModule` path and now goes through the same gate. Covered by `coldCompileGate.test.ts` (dedupe, bound, warm bypass, failure-clears-and-retries, env parsing); the heavy end-to-end reproduction is a scratchpad (`scripts/measure-ssr-compile-oom.mjs`), not a CI gate.
- **@voltro/cli** — `voltro update` (and `voltro update --codemods-only`) no longer aborts with `ELOOP: too many symbolic links` when the app tree contains a circular symlink. The codemod file scan built its ts-morph `Project` with `addSourceFilesAtPaths([...globs])`, whose underlying glob FOLLOWS symbolic links and applies the `!**/node_modules/**` negations only to the RESULTS — so a self-referential symlink anywhere under the app root (pnpm's package layout inside `node_modules`, or any stray symlinked scratch dir) made the walk descend forever and throw before any negation could apply. The scan now enumerates source files itself with `followSymbolicLinks: false` and prunes heavy directories (`node_modules`, `.git`, `dist`, `build`, `.framework`, `.turbo`, `.next`, `.cache`, `.output`, `.voltro-*`, …) at the traversal level rather than by post-filtering — the crawler never descends into them, and no symlink is followed, so a cycle put there by anything is harmless. App source a codemod rewrites is always real files on disk, so files reachable only through a symlink are deliberately not scanned (and never rewritten). A codemod's explicit `scope` still narrows the file set exactly as before.
- **@voltro/plugin-auth-supabase** — `supabaseStrategy({ cookieName: 'sb-<ref>-auth-token' })` now reads `@supabase/ssr` session cookies. Those cookies do not hold a raw JWT — the SDK stores the GoTrue session as a JSON envelope, optionally `base64-`-encoded and split across `sb-<ref>-auth-token.0`, `.1`, … chunk cookies. The strategy previously handed that envelope straight to the JWT verifier, so every cookie-mode request failed with `malformed jwt` and fell back to anonymous (including `ctx.query` from a `type:'web'` loader, which forwards the browser cookie to the api). The strategy now unwraps the envelope — URL-decode, strip the `base64-` prefix and base64url-decode when present, concatenate chunks in order, then lift the inner `access_token` — before verification. The `Authorization: Bearer` path is unchanged (always a raw token). Hostile / malformed / oversized cookies resolve to anonymous (skip), never throw. Auth strategies belong on the `type:'api'` app, not the web app — the api verifies the forwarded cookie.

---

## [0.8.0] — 2026-07-20

### ⚠ BREAKING

- **@voltro/web, @voltro/cli** — **`defer()` + `<Await>`: stream one slow part of a page instead of blocking the whole response.**

  A loader blocks the entire response, so one slow field costs every byte of the page. A loader may now return `defer(eager, deferred)` — two explicit buckets. The eager half is awaited and renders into the shell; the deferred half is handed to the renderer as promises, read through the new `<Await>` component, and **flushed into the same response as each promise settles**.

  ```tsx
  export const renderMode = 'ssr' as const
  export const loader = async ({ query }) => defer(
    { user: await query('users.me') },        // in the shell
    { report: query('reports.quarterly') },   // streamed after it
  )
  
  const { user, report } = useLoaderData<Awaited<ReturnType<typeof loader>>>()
  <Await value={report} fallback={<ReportSkeleton />}>{(r) => <ReportTable rows={r.rows} />}</Await>
  ```

  Two buckets rather than "any promise-valued field is deferred": deferral is then something the author wrote down, not something inferred from a value's runtime shape, and `useLoaderData()` can type it — eager fields come back as values, deferred fields as `Promise<T>`, so the compiler says which ones need an `<Await>`.

  **Streaming is now wired for `renderMode: 'ssr'` on BOTH boot paths.** `voltro dev` and `voltro start` share one shell splitter + stream driver (`cli/src/ssrShell.ts`) rather than hand-mirroring the sequence — the same reason `buildSegmentChain` is shared. `renderPageToStream` was previously present but deliberately unwired, on the (correct, measured) grounds that streaming a Suspense-FREE tree changes event-loop blocking by zero. `defer()` is what makes the tree no longer Suspense-free, so that reasoning no longer applies and the doc comment saying so has been rewritten instead of left contradicting the code.

  Measured against a real `voltro start` (`scripts/measure-deferred-stream.mjs`, one 400ms deferred field): first body byte at 7ms, deferred chunk at 408ms, and a probe firing every 10ms for the duration of the streamed request was served 30 times at a 3ms median / 7ms max. The event loop stays free while the deferred value is pending — that, not TTFB alone, is the point.

  **`defer()` is a hard error, naming the page, on every mode where it provably cannot work** — each verified against React 19.2.7 rather than assumed: `renderMode: 'static'` (`renderToString` does not support Suspense; it emits an errored boundary plus a "switched to client rendering" template with no warning, so the artefact would ship a permanent fallback), `renderMode: 'isr'` (caches a completed HTML string), `interactive: 'none'` (no JS to run React's reveal scripts), `interactive: 'islands'` (the root never hydrates, so nothing consumes the streamed value). Awaiting-and-inlining on the artefact modes was considered and rejected: it makes `defer()` a silent no-op that still reads like it streams, and it needs a second wire format and a second `<Await>` path for no user-visible gain.

  **Why BREAKING.** Two things can turn code that compiled into code that does not, or change deployed behaviour:

  - `useLoaderData<T>()` now returns `LoaderData<T>`. For every concrete `T` that IS `T`, so ordinary call sites are unaffected — but a helper that passes an unresolved generic through (`<D,>(): D => useLoaderData<D>()`) stops compiling and must propagate `LoaderData<D>` instead. A transform cannot tell those apart without typechecking, hence a `manual` codemod. - `ssr` responses are chunked and carry no `content-length`, and `voltro start` needs an SSR bundle built by this version — so a deploy must re-run `voltro build`, and a buffering reverse proxy in front of the app will absorb the win until buffering is disabled for those routes.

  `<Await>` owns the `<Suspense>` boundary, the `use()`, and the settle `<script>` that publishes the resolved value to the client, so the server render and the hydration render are identical by construction rather than by a `typeof document` guard or `suppressHydrationWarning`. A rejected deferred value travels as a resolved error envelope and renders `errorFallback` in place on both sides — Fizz errors the whole boundary on a rejected `use()`, which would otherwise blow past `errorFallback` entirely.

  Coverage: `deferredStream.test.tsx` asserts chunk ordering + the concurrent-probe event-loop claim, `deferredHydration.test.tsx` hydrates a real streamed document in jsdom with zero console errors, `deferred.test.ts` pins all four hard errors, `ssrShell.test.ts` covers the shell split and the three stream-driver ordering hazards, and `webDevSsrLayoutLoader.test.ts` runs the SAME streaming assertions against a real `voltro dev` and a real `voltro start`. Non-deferring pages emit a byte-identical `__voltro_state__` payload, asserted against the exact strings a real build and a real serve produce.
- **@voltro/plugin-auth** — `UserRecord.passwordHash` is now optional (`string | null | undefined`), and the shipped `users` table's `passwordHash` column is nullable to match. It was required, which asserted that every identity has a password. SSO-only, magic-link-only, passkey-only and provider-PAT apps have none, so the only way they could call `subjectFromUser(...)` was to invent a fake hash — strictly worse than storing nothing, because a fake hash is a real value sitting in the column a verifier compares against. The alternative several apps took was to hand-build the `Subject` and lose the helper. The built `Subject` never needed the hash in the first place. **Why this is BREAKING even though nothing was removed:** widening is additive for code that WRITES a `UserRecord` and breaking for code that READS one. `const h: string = user.passwordHash`, `user.passwordHash.length` and `verifyPassword(pw, user.passwordHash)` all compiled before and do not now. The codemod is `manual` because a transform cannot typecheck and therefore cannot tell those sites apart — and both mechanical edits available to it are dangerous. `!` re-asserts the invariant that just stopped holding; `?? ''` manufactures a hash-shaped value and feeds it to a comparison, which is precisely the "a user with no password signs in with any password" failure this change exists to make impossible. An absent hash must be a refusal decision, never a default value. **The security half, which is where the work went.** `handleSignIn` treats an absent hash exactly as it treats an unknown email: it still burns a decoy scrypt, then returns the identical 401 `{ error: 'invalid credentials' }` with no `Set-Cookie`. So a password-less account can never be signed into with the password strategy, and the response reveals neither that the account exists nor that it lacks a password — a divergence there would be an oracle for which accounts are worth attacking through a different strategy. `null`, `undefined` and `''` are all treated as "no password". `passwordlessSignIn.test.ts` pins this across every shape of absence against every shape of submitted password (arbitrary, empty, `undefined`), plus the two indistinguishability properties. It was the only verification site in the package; nothing else reads the field to make a decision. Password RESET is unaffected and still promotes a password-less user: `updatePassword` assigns a hash to a user that had none, after which sign-in works normally and an arbitrary password still fails. `createdAt` stays required — every store can supply it (`insert` takes `Omit<UserRecord, 'createdAt'>`, so callers never pass one), so there is nothing to relax. The nullable column needs no migration work from you — it rides the declarative differ on `voltro db apply` / next boot.

### Added

- **@voltro/testing, @voltro/runtime** — `makeTestContext` now makes its seeded store available to `runAsSystem`, so a row filter over a SHARED resource can be tested. `RowFilter.load` is `Effect<Ctx, unknown>` with `R = never`, so it cannot `yield*` an EffectStore service. A filter over rows the user OWNS needs no store (the subject carries the id), but a filter over rows SHARED with the user — "you see a list you are a member of" — must read a membership table, and `runAsSystem` is its only route there. Under `makeTestContext` that threw `runAsSystem: no data store available`, so the one rule most worth a test ("user B cannot see the row shared with user A") could not be asserted in-repo at all. The reported consequence was an app declining to ship a process-global filter over shared data it had no way to verify. `makeTestContext` registers its RAW `dataStore` + `schemaRegistry` via `setSystemStoreHandle` — never `ctx.store`, since `runAsSystem` applies `wrapStoreWithMixinBehaviour` for the system subject itself and a wrapped store would be wrapped twice (the system subject also bypasses row filters in production, so raw is the semantically correct layer). It is the outer store, not a transactional view: a `runAsSystem` block inside a mutation reads outside that mutation's transaction in production, and does here too. **Isolation, since the handle is a process global.** Registration is last-wins and never auto-cleared, so a bare `await runAsSystem(...)` written directly in a test resolves. Last-wins alone is not enough: two `makeTestContext` calls in one test allocate two separate `InMemoryDataStore`s even from one seed object, so a filter `load` on the first context would otherwise read the second's rows. The harness therefore re-points the handle at its own context for the span of that context's `load` and restores the previous value, making filter resolution independent of build order. Cross-file leakage does not arise — vitest isolates each test file's module registry — and within a file a test that wants the refusal back calls `clearSystemStoreHandle()`. Production is untouched: only the harness registers, so an app that registered no handle still gets the throw. This scopes an exception to the harness rather than relaxing the refusal. `@voltro/runtime` gains `getSystemStoreHandle()` (read the current handle so a caller can save/restore around a bounded span) and exports the `SystemStoreHandle` type. Both are additive. One behavioural caveat worth knowing: a test that built a `makeTestContext` and then asserted `runAsSystem` refuses in the same file will now see it succeed. Call `clearSystemStoreHandle()` first.

### Fixed

- **@voltro/database, @voltro/cli** — `timestampMs` / `timestampMsOrNull` are now importable from a descriptor. 0.6.0 shipped them — and `rowSchema(table)` — documented for a `*.query.ts` `output`, but the browser-safety guard rejects `@voltro/database` and every subpath under it, so the documented usage aborted `voltro dev` with an import-chain error. A downstream app hit exactly that. The field schemas now ship from a new browser-safe entry, **`@voltro/database/wire`**, which contains plain `effect/Schema` values and imports `effect` and nothing else; the guard permits that one subpath. It is not a blanket allowlist entry — the guard resolves a workspace package to its `./src/*.ts` source and keeps walking, so a server import added to that module is still caught and still aborts boot. Both directions are covered by tests (`browserSafetyGuard.test.ts`): the subpath passes, bare `@voltro/database` still fails even with the subpath present, and a deliberately regressed wire module is caught. The package root keeps exporting the same values, so server-side code that already imports the root needs no second import. `rowSchema(table)` / `columnSchema(def)` are corrected rather than changed: they take the table as a VALUE, and reaching a table means importing an app's `database/schema.ts`, which imports `@voltro/database` — so a descriptor can never use them, no matter where they are packaged. That is a consequence of the browser/server boundary, not a packaging accident, and it is now stated where it was previously mis-stated. They remain the row CODEC for server-only code (`*.server.ts`, `*.seed.ts`, jobs, scripts): encoding rows for a file export or queue payload, decoding seed / import data against the real table shape. The doc comments, the docs site (en + de), the seeded agent guide, and the `voltro doctor` `hand-serialized-date` rule — which recommended `rowSchema(table)` in a descriptor's `output` and was therefore actively steering users into the boot crash — all now point at the field schemas instead. No API was removed or narrowed, and no user code that compiled stops compiling: the previously-recommended usage never got as far as a boot, so there is nothing to migrate. Apps that worked around this with hand-written `Date → epoch` converters can delete them and declare `timestampMs` on the field.
- **@voltro/cli** — `voltro dev` now runs LAYOUT loaders during SSR, like `voltro start` always did. A `layout.tsx` exporting a `loader` had it skipped entirely on the dev server's SSR pass: `useLoaderData()` inside that layout was `undefined` on the server-rendered first paint and only populated after hydration, while the identical code rendered correctly in production. The dev log gave the one visible tell — the `loadChain` phase reported `0ms`, because the phase only imported layout modules and never awaited a loader. This is dev/production parity drift, so the fix is structural rather than a second copy of the loop. Chain assembly plus the layout-loader run now live in ONE `buildSegmentChain`, called by both SSR paths; only module loading differs between them (`voltro start` resolves through its SSR module provider, `voltro dev` through Vite's `ssrLoadModule`) and that is injected. Everything a user can observe is single-sourced: the loader argument shape (`params`, `pathname`, `signal`, `headers`, `query` — identical to what a page loader receives, including the server-side rpc `query()` bound to the request's session cookie), the parallel run across chain segments, and the keying of each result by CHAIN index so a layout can never be handed a sibling's data. Dev also now shares ONE `AbortController` across the page loader and every layout loader on a request, wired to the response `close` event — navigating away cancels all in-flight loader fetches, not just the page's. A throwing layout loader is surfaced exactly as a throwing page loader already was on each path: `voltro start` maps `NotFoundError` / `RedirectError` onto a 404 / 3xx and lets anything else propagate, `voltro dev` logs the failure and falls back to the SPA shell. It is never swallowed into a silently empty layout. No documented behavior changes — layout-level loaders were already specified to run server-side, and dev was the outlier. Apps that worked around this by refetching in the layout after hydration can drop the workaround; nothing needs to change to pick up the fix.
- **@voltro/web** — The router no longer discards loader data it already holds while a route's loaders are still in flight. Its `pending` branch rendered the page under `<LoaderDataContext.Provider value={undefined}>` unconditionally, so `useLoaderData()` returned `undefined` for that window even when the router was holding that exact route's data. Where it actually bit: a `renderMode: 'static'` page. The prerender runs the PAGE loader and inlines its result into `__voltro_state__`, but it runs no LAYOUT loaders — so after hydration the client re-runs them and the router sits in `pending` until they settle, with the page's own data committed the whole time. During that window the page re-rendered with `undefined`, and an unguarded page (`data.title`, exactly what the docs tell you to write) threw into its `RouteErrorBoundary`. A fast layout loader hides this — it settles in the same microtask checkpoint and React batches the bad render away — so it only surfaces for real when a layout loader is slow, which is the case a real network call produces. The reuse is GATED, because the inverse is a worse bug: on a client-side navigation the committed data belongs to the route being left, and handing it to the incoming page would typecheck (both sides are `unknown` at that seam) and often look plausible on screen. The committed chain data now carries the pathname it was produced for, and the page-level provider reuses it only when that pathname is the one being rendered. The two diverge in exactly one situation — a route that opts into a `Pending` skeleton is displayed before its loaders settle — and that is the situation the gate exists for. Per-layout data got the same treatment one level down, with a finer key: during that same skeleton window a layout's committed value is reused only if the SAME `Layout` component still occupies that chain position. A shared shell therefore stays populated behind the incoming route's skeleton (the point of opting into one), while a DIFFERENT layout at that chain index now gets nothing instead of inheriting its predecessor's value by index. No public API changed (the api-extractor golden is unchanged) and no code that compiled stops compiling; a page that previously flashed `undefined` mid-pending now simply keeps its data. Covered end-to-end in `hydrateLoaderData.test.tsx` — a real SSR render → real state script → real `mount()` → real `hydrateRoot`, with a deliberately slow layout loader, plus both navigation directions and empty-console assertions.
- **@voltro/cli** — React Fast Refresh now works for pages and layouts in `voltro dev`. Until now **nothing** in a Voltro web app hot-updated: every edit — a page component, a layout component, a stylesheet-adjacent TSX tweak — forced a FULL PAGE RELOAD, discarding form input, scroll position, open dialogs and all client state. Measured in a real browser across both the streamed and the buffered SSR paths, so it was boot-path-agnostic and long-standing. Editing a page component now applies as a hot update with `useState` intact; editing a `loader` still reloads, deliberately. Two causes, both fixed. **(1)** react-refresh only accepts a module whose exports are all components, and a page exports `loader` / `renderMode` / `meta` beside its component, so every route module rejected itself and invalidated upward. **(2)** The rejection then reached the generated `.framework/app.tsx`, which was ALSO ineligible because it exported `preloadCurrentRoute` beside `App`, so it bubbled on to `main.tsx` — which accepts nothing, i.e. a full reload. The obvious fix — strip server-only exports from the client bundle, the Remix approach — is not available here: Voltro's loaders are **isomorphic** (`router.tsx` runs a route's `loader` in the browser on client-side navigation), so the loader must stay in the client graph. Instead, `voltro dev` registers each route module's non-component export names with `@vitejs/plugin-react`'s ignored-exports hook, so react-refresh judges only the components, and the framework decides the reload itself by comparing those exports' VALUES across the update — a function by its source text, everything else by its JSON form. A JSX-only edit recreates the `loader` function object but not its text, so it correctly reads as unchanged and hot-updates; an actual loader edit reads as changed and forces a reload. That reload is the correct outcome, not a limitation: a `loader` also runs server-side, the visible page was rendered from the old one, and the router caches loader results per route + params, so a silent hot swap would leave stale data on screen. The transform is **client-only and append-only** — it never removes an export, and it bails on the SSR environment entirely, because the dev renderer imports each page/layout through `ssrLoadModule` and reads `loader` / `renderMode` straight off the namespace. The generated dev entry is now split in three so the boundaries are clean: `app.tsx` exports `App` and nothing else (a valid refresh boundary), the route table + the mutable module registry HMR patches move to a new `.framework/routeTable.ts` — a plain `.ts` module that is deliberately NOT a boundary, so refreshing `app.tsx` cannot drop already-loaded page modules — and `main.tsx` stays side-effect-only. These are generated files, rewritten on every `voltro dev` boot; nothing user-authored changes. Covered by `routeFastRefresh.test.ts` (client transform injects, SSR transform is a no-op, and the reload decision — including the "re-evaluated but unedited loader is not a change" case that the whole thing turns on) and `webDevEntrySplit.test.ts` (app.tsx has exactly one export). The behaviour itself is browser-only and is driven by `scripts/browser-deferred-stream.mjs`, which now bumps a live `useState` counter before each edit and asserts it survives a component edit and resets on a loader edit — the only assertion that distinguishes a hot update from a reload.
- **@voltro/web, @voltro/cli** — Server-rendered loader data now reaches the client's FIRST render. Every `renderMode: 'ssr'` page with a loader — and every `layout.tsx` with one — previously hydrated with `useLoaderData()` returning `undefined`, because the inlined `__voltro_state__` payload was written by the SSR pipeline and read by nobody: `mount()` used the script tag as a boolean to pick `hydrateRoot` over `createRoot` and never parsed its contents, and `<Router>` started with no committed loader data, re-running every loader in an effect. Layout (chain-segment) data was not inlined in any shape at all. The consequences were not cosmetic. An ordinary SSR page that dereferences its own loader data (`data.value`) threw `TypeError: Cannot read properties of undefined` on the hydration render and fell into `RouteErrorBoundary`; a layout rendering its loader's value produced a genuine React hydration mismatch (server `ROOT_LAYOUT_LOADER_RAN`, client `ROOT_LAYOUT_LOADER_MISSING`), after which the tree was regenerated from scratch. Both are now measured in a real browser against `e2e-fixtures/web-layout-loader`, with zero console errors and zero page errors, and reproduced in the unit suite through a real `hydrateRoot`. The payload is one coherent object — `{ page, segments: { <chainIndex>: … }, ran }` — defined once in `@voltro/web`'s `routerState` module and emitted through a single `renderRouterStateScript()` helper that `voltro dev`, `voltro start` and `voltro build` all call. That is deliberate: the dev and serve SSR paths are independent assemblies, and hand-mirroring the shape into each is exactly the drift that let layout loaders go missing in dev in the first place. `ran` exists because JSON cannot express `undefined` — without it a loader that resolved to `undefined` is indistinguishable from one that never ran, and the client would re-run it. Values are keyed by the same chain index the renderer wraps layouts with, so a layout can never be handed its neighbour's data; escaping is unchanged (`<` is escaped, so a `</script>` inside loader data cannot break out of the tag). **Two behaviour changes worth knowing about, neither of which stops any code compiling.** First, `useLoaderData()` now returns the server's value on the initial render of a server-rendered page instead of `undefined`; components that branched on `undefined` to show a skeleton will simply stop showing it on first paint. Second, loaders no longer re-run on initial hydration — that re-run was the source of the post-hydration flash. A loader whose client-side re-execution an app was relying on (to refresh data or to trigger a side effect after mount) will no longer fire on first load; move that work into an effect. Client-side navigation is unaffected and runs loaders exactly as before, as does a fresh client mount with no server markup. Static prerender (`voltro build`) inlines the page loader's result the same way. It does not run layout loaders — it never did — so a static page's layouts continue to resolve their data on the client after mount.

---

## [0.7.0] — 2026-07-20

### ⚠ BREAKING

- **@voltro/runtime** — Row-level security: a `load` failure is now retried, and then surfaces as an **error** instead of an empty result. `setRowFilter({ load, predicate })` resolves `load` once per request; previously ANY failure was answered with a predicate matching zero rows. A downstream app reported the consequence correctly — for shared/team visibility `load` must read the store, so one transient DB blip denied every constrained table for that request and the whole UI rendered empty. Two distinct defects, fixed separately, because conflating them was the original mistake. **A transient failure should never reach the decision:** `load` had no retry at all, so a reaped connection was answered as if it were an authorization fact. It now runs under a bounded `retry` schedule — `DEFAULT_ROW_FILTER_RETRY`, three attempts backing off exponentially from 20ms, so ~60ms worst case — configurable per filter with your own `Schedule`, or `retry: false` for one attempt. **And refusal was expressed as an empty result, which is a lie:** "we could not determine your visibility" and "you may see nothing" are different facts, and only one of them is a fact. An empty list is byte-identical to legitimate emptiness, so the user reads "you have no tickets", the operator reads a healthy 200, and the outage is invisible to both — the most misleading outcome available. The module header used to defend this ("an empty result rather than a 500 on every page"); every constrained page IS broken, and saying so is the correct behavior. `onLoadError` now defaults to `'fail'`, raising the exported typed `RowFilterUnavailable`; `onLoadError: 'deny'` keeps the old degradation for apps that have looked at the screen and genuinely prefer an empty list to an error state, with the `onError` reporter still firing so the choice is not silent. The app asked for `onLoadError: 'fallthrough'` — fail OPEN to the handler's own check. Deliberately not implemented, and not planned. Serving unfiltered rows when the authorization filter is unavailable leaks data precisely when the system is under stress and nobody is reading dashboards, and it is only safe if every handler still carries the check that row filters exist to replace. Fail-closed is retained under both policies; a test asserts neither branch can ever yield an unfiltered read. **Migration.** `resolveRowFilterScope` gains an error channel — `Effect<RowFilterScope, RowFilterUnavailable>` instead of an infallible Effect — so direct callers (a custom entrypoint, a test harness) must handle it; TypeScript points at each one. Apps that want the previous behavior add `onLoadError: 'deny'`, but should decide that rather than default into it. Tests asserting "a broken load yields an empty result" now fail with `RowFilterUnavailable`, which is the fix working. Subscriptions changed shape too: a re-resolve that fails mid-delivery now REVOKES the subscription and emits a typed error frame — following the existing withdrawn-guard precedent — because an empty snapshot on a live subscription reads to a client as "every row you could see was just deleted". The same failure at subscribe time aborts the subscribe instead of opening a stream on a fabricated snapshot, and unwinds the matcher + dependent-table registration it had already made (previously leaked on any subscribe-time throw).
- **@voltro/testing, @voltro/runtime** — `@voltro/testing` now mirrors the runtime store's POLICY path, not only its DATA path. An adopting app found three places where the harness diverged, each of which made a class of rule untestable in-repo while leaving the suite green. **`invoke` runs Effect-mode handlers.** The framework's contract is "async OR Effect, your choice per handler", and every production runner honours it (`Effect.isEffect(result) ? … : …`). `invoke` only awaited the executor's return value, so an Effect-mode handler handed back its own un-run `EffectPrimitive`: nothing executed, nothing was written, and a test asserting on the "result" asserted on a description of work. The Effect now runs through `runProvidedEffect` — the same function the serve entrypoints use — so a handler failing with a typed error REJECTS WITH THAT ERROR rather than an opaque FiberFailure, and `EffectStore` + `SubjectService` are provided over the context the handler is actually given. Guards, the input decode, the transactional wrap, the deadlock replay, `afterCommit` and the plugin interceptor chain apply identically to both modes. **`makeTestContext({ relations: [spec] })`** registers `relations()` specs. `relations()` is pure — it returns a spec, it does not register one — and production registration is a boot step (`voltro dev` → `registerDiscoveredRelations`), so under the harness an eager load failed with "no relations registered" no matter what the test imported. The option REPLACES the process-global registry with exactly the specs given (the registry is a `Symbol.for` global; additive registration would throw `duplicate relation` on the second `makeTestContext` in a file and leak the first test's relations into the second). Omitting it touches the registry not at all. **The harness store applies row-level security.** After `setRowFilter(...)` a `makeTestContext` read of a constrained table returned the unfiltered set, so "user A cannot see user B's row" could not be asserted at all. `ctx.store` now resolves the filter for its subject and AND-merges it into every read — fluent builders and descriptor reads alike, not bypassed by `.unscoped()`, bypassed for a `system` subject. A new `rowFilter:` option passes a filter directly for tests that would rather not write to a process global. Resolution goes through the runtime's new `resolveRowFilterScopeFor` (`resolveRowFilterScope` is now that function applied to the registered filter), so the retry schedule, the system bypass and the `onLoadError` policy are the runtime's single definition rather than a second copy in the harness. **Breaking, and how to migrate** (`voltro update` prints this): delete any `Effect.isEffect(out) ? await Effect.runPromise(out) : out` shim around an `invoke` call — it is dead code that also destroyed your typed errors. `ProcedureExecutor<Input, Output>` gained an `Effect<Output, E>` arm and an optional third parameter `E` (default `never`); `invoke` now infers from the executor's whole return type instead of taking `Output` as its second type parameter, so an explicit `invoke<typeof d, Note>(…)` type-argument list must be dropped in favour of inference. Handlers themselves keep compiling — what changes is the type of a CALL.

### Added

- **@voltro/plugin-auth** — `subjectFromUser(user, { metadata })` now carries arbitrary app metadata onto the Subject. Previously the helper set the metadata slot ONLY when `memberships` were supplied, so an app whose Subject must carry a provider credential — an Atlassian PAT captured at login and read back as `subject.metadata.jiraToken` by `@voltro/plugin-atlassian`'s `credentialsResolver`, say — could not adopt the helper at all: calling it silently dropped the credential, and building the Subject by hand was the only way to keep it. The framework shipped both halves of that gap itself. `metadata` MERGES with the memberships projection rather than replacing it. **Precedence when a `memberships` key appears in both:** the dedicated `memberships` option wins — it is the specific, typed input, and it is the one projected into the `{ tenantId, role }` shape `subjectMemberships()` and the switch-tenant menu read, so letting a free-form bag shadow it would break tenant switching in a way nothing type-checks. Without the option, a `memberships` key inside `metadata` passes through unchanged. A Subject built with neither option still has NO `metadata` key (not an empty object). The later stamps compose unchanged: the sign-in / sign-up / magic-link / passkey handlers spread the existing slot before adding `sessionId`, and the password strategy does the same for `provider`, so keys set through this option survive every login path — unless a caller names a key `sessionId` or `provider`, which those merges overwrite by design. **`handleSwitchTenant` now carries that metadata across a switch**, via a new optional `metadata` on `SwitchTenantInput` (the built-in `/switch-tenant` route passes the caller's `subject.metadata` for you). Without this the option above would have been a trap rather than a feature: a switch rebuilds the Subject from the user record, so an app parking a provider credential in the slot would lose it the first time a user changed tenant — staying authenticated while every call to the provider began failing, with no signal at the point that caused it. `memberships` is deliberately NOT carried: it is re-derived for the target tenant, and a carried copy would report a role the user does not hold there. Covered by a test that fails on the exact assertion when the carry is removed. `SwitchTenantInput` is now exported too — every sibling handler input (`SignInInput`, `MfaVerifyInput`, …) already was, and this one had simply been forgotten, so an app calling `handleSwitchTenant` directly could not name its argument type. Additive: the option is optional and callers that pass neither get byte-identical Subjects. `packages/plugin-auth/etc/plugin-auth.api.md` gains one line and changes none.
- **@voltro/database** — `timestampMs` and `timestampMsOrNull` — the timestamp wire mapping as STANDALONE field schemas, for hand-written `Schema.Struct` outputs. `Date` in the handler, epoch-ms `number` on the wire; `timestampMsOrNull` is the `.nullable()` column's variant, so a "never archived" row stays `null` instead of becoming `new Date(null)` (1970-01-01, which renders as a plausible date rather than as nothing).

  ```ts
  output: Schema.Struct({
    id: Schema.String,
    addedAt: timestampMs,          // Date in the handler, epoch ms on the wire
    archivedAt: timestampMsOrNull,
    seenAt: Schema.optional(timestampMs),
  })
  ```

  This closes the half of the problem `rowSchema(table)` left open. `rowSchema` only helps a handler returning a RAW FULL TABLE ROW, and real handlers overwhelmingly return a COMPUTED struct assembled across several tables — `{ id, name, slug, addedAt, jiraProjectKey }` — where there is no single table to derive from. That is exactly where the hand-written `Date → epoch` converters accumulate: the app that reported the original gap has ~228 of them, all in shaped outputs, and found zero clean applications for whole-row `rowSchema`.

  Single-sourced, not a parallel declaration: `columnSchema` now READS `timestampMs` for `timestamp()` / `date()` columns, so the derived-row and hand-written-struct paths are the same schema by identity and cannot drift into different wire representations. `rowSchema.test.ts` asserts that identity rather than asserting both merely produce a number.

  There is deliberately no `timestampMsOptional` — an absent field is `Schema.optional(timestampMs)`, which composes without a third export. Both exports live in the browser-safe `@voltro/database` main entry (pure `effect/Schema`, no driver, no `node:*`), which is what a descriptor's `output` needs.

  Additive: two new exports, no existing declaration changed. `packages/database/etc/database.api.md` gains two entries and changes none.

### Fixed

- **@voltro/sql-turso** — Turso (local Rust engine): pooled connections now WAIT for a held lock instead of failing instantly with `database is locked`. The engine keeps SQLite's default of `PRAGMA busy_timeout = 0`, so any statement that met a lock held by another connection failed on the spot — and with the default pool of 4 connections on one file, two concurrent writers are enough to reach it. `makeConnection` now issues `busy_timeout` for every pooled connection, beside the mandatory MVCC and foreign-key pragmas. MVCC did not cover this and was the reason it was missed: `journal_mode=experimental_mvcc` resolves write-write conflicts BETWEEN transactions, while DDL and the schema lock stay exclusive, so the failure lands on statements the concurrency design appears to have handled. It also only reproduces under CPU contention — green on an idle machine, sporadic under load — which is the worst shape for a defect to have. It surfaced as a flaky `CREATE TABLE` in the MVCC keystone test during a full local gate run, where 78 packages build in parallel; a user would see it as an intermittent `database is locked` under production traffic with no obvious trigger. The default is 5000ms, matching better-sqlite3's own default — which is why the sibling `@voltro/sql-sqlite` never needed this: that driver sets the timeout for us, and the turso NAPI driver does not. Tunable via `busyTimeoutMs` on `makeTursoSqlLayer` / `TursoClientConfig`, beside `maxConnections`; `busyTimeoutMs: 0` explicitly restores the fail-immediately behavior (asserted by a test, so the default can never be implemented as a floor that silently ignores 0). It is deliberately NOT on the cross-dialect `ConnectionConfig` — that shape stays free of engine-specific knobs, the same reason the Turso auth token is env-sourced rather than threaded through it.
- **@voltro/cli** — `voltro update` now honors the project's actual package manager instead of defaulting to npm. It resolves the manager by walking from the app directory **up to the repo root**, preferring the corepack `packageManager` field over a lockfile (`pnpm-lock.yaml` / `yarn.lock` / `bun.lock` / `bun.lockb` / `package-lock.json`), and only falls back to npm when nothing declares one. Walking up fixes the workspace case: a scaffolded project keeps its lockfile at the monorepo root, so running `voltro update` from `apps/api` previously found no lockfile and ran `npm install` against a pnpm/yarn workspace — writing a stray lockfile and a nested `node_modules`. The resolved manager is also used for the latest-version registry lookup (`pnpm view` / `yarn` / `bun pm view`, with `npm view` as a last-resort fallback), so a private or scoped registry configured in `.npmrc` / `.yarnrc.yml` is honored. The yarn query dispatches on the installed yarn MAJOR version rather than probing berry syntax first: on yarn classic, `yarn npm info …` parses as `yarn run npm` and **executes a `npm` script from the project's package.json** if one exists — verified against yarn 1.22.22. Resolving a version number must never run user code, so classic gets `yarn info … --silent` and only berry (>=2) gets `yarn npm info`. All three managers are verified against real binaries in throwaway Docker containers — yarn classic 1.22.22, yarn berry 4.6.0, bun 1.3.14 — each asserting both that the query resolves a version and that it does not execute a same-named script. Re-run with `node scripts/smoke-package-managers.mjs`.
- **@voltro/cli** — Three fixes to `voltro update`, all reported by an app upgrading a pnpm workspace. **The bump is now LOCKSTEP across the whole workspace.** `voltro update` in `apps/api` bumped only that `package.json`, leaving the sibling web app and shared `packages/*` on the previous version — an api on 0.6.0 and a web client on 0.5.0 disagree about the generated rpcGroup types and the session cookie shape, and that disagreement surfaces as a runtime decode error, not a build error. When the app sits inside a workspace (`pnpm-workspace.yaml`, or a `workspaces` field in an ancestor `package.json`, found by the same bounded upward walk that resolves the package manager and stops at the first `.git`), every member `package.json` that declares `@voltro/*` is bumped to the target together, and the install runs **once at the workspace root** — running it inside `apps/api` corrupts a pnpm/yarn workspace's layout. Each file that will be bumped is listed in the plan output and in `--dry-run`. A standalone (non-workspace) project is unchanged: its own `package.json`, its own install, in place. The codemod re-exec now also looks for the installed `voltro` bin at the workspace root, since npm and yarn hoist it there. **A failed install now says that the codemods were skipped.** It previously printed only "install failed — package.json was bumped; fix the install and re-run", never mentioning codemods, so a user could boot on target-version code with source shaped for the old one and no signal as to why. The codemods for a jump ship *inside* the target version, which a failed install did not put on disk, so running them is impossible rather than merely undesirable — the fix is the message. It now states plainly that no codemods were applied, why, and prints the exact copy-pasteable recovery command with the concrete versions: `voltro update --codemods-only --from <from> --to <to>`. **`--help` / `-h` is answered before every guard.** `voltro update --help` on a dirty tree printed "working tree is not clean" — at precisely the moment the user was trying to discover `--dry-run` and `--codemods-only`. Help is documentation, not an operation, so it is now handled first, ahead of the `package.json` check, the `@voltro/*`-deps check and the clean-tree guard, and lists every flag (`--to`, `--from`, `--root`, `--dry-run`, `--force`, `--exact`, `--codemods-only`). `voltro doctor --help` had the same shape — it fell through to the preflight and reported on the tree instead — and gets the same treatment. `voltro help`'s `update` line now names `--from` and `--codemods-only` too.

---

## [0.6.0] — 2026-07-19

### ⚠ BREAKING

- **@voltro/plugin-atlassian, @voltro/cli** — The Atlassian avatar proxy gained a **per-user auth mode**, and its config is now a discriminated union on a required `mode`.

  ```ts
  // service mode — one shared token, public route (what the old shape meant)
  avatar: { mode: 'service', resolveCredentials: () => creds }
  
  // per-user mode — the fetch runs with the VIEWING subject's own PAT
  avatar: {
    mode: 'perUser',
    resolveSubject: (req) => verifySession(readCookie(req.headers['cookie'], 'voltro:session'), secret),
    resolveCredentials: (subject) => credsFor(subject),
  }
  ```

  Why a required discriminant rather than overloading `resolveCredentials` on its arity: for a deployment whose hard constraint is that a service PAT must not exist, the difference between the two modes is the whole security posture, and it must be readable at the config site rather than inferred from a callback's signature. In `perUser` mode an unauthenticated caller is refused with 401 — never served from cache — a subject with no PAT on file gets 403, and there is **no fallback to a service credential**.

  `PluginHttpRouteRequest` carries no subject (the framework does not authenticate plugin HTTP routes), so `resolveSubject` is app-supplied; it is the same seam `@voltro/plugin-storage`'s serve route uses.

  An optional byte cache (`avatar.cache`) now serves resolved avatars without a second upstream call. Its key includes the **viewer** in per-user mode (`type:id:tenant:ref`) — keying by avatar owner alone would let one user's fetch populate an entry served to another whose PAT was never checked. Service mode keys by ref alone, which is correct there.

  Migration: `voltro update` adds `mode: 'service'` to every existing `avatar` config (the old shape had exactly that meaning). Opting into `perUser` is a deliberate follow-up — it needs a `resolveSubject` only the app can write.
- **@voltro/plugin-billing** — Billing runs on the official Stripe SDK, and everything Stripe already does is now Stripe's. The change that matters most is not stylistic: **`changeSeats` and `changePlan` never reached Stripe.** They patched a local row and returned a proration figure nothing ever charged. A customer could be granted forty seats while Stripe billed for one. Both now call `subscriptions.update` with a `proration_behavior`, and the local row is written FROM Stripe's answer so the two cannot drift. Removed, because Stripe does them properly: the whole `proration.ts` module (Stripe computes AND invoices proration), and the entire dunning subsystem — `dunning.ts`, `DunningStore`, the `_voltro_billing_dunning` table, the `dunningSchedule` option, and `recordPaymentFailure` / `recordPaymentSuccess` / `runDunningCycle`. Stripe Smart Retries runs the schedule and reports the outcome as a subscription status; our fixed `[1,3,5,7]`-day machine could only ever drift from it. Three defects the SDK's types surfaced immediately: the hand-rolled client sent no `Stripe-Version`, so it read `subscription.current_period_start/end` — a field Stripe has moved onto the subscription ITEM — and stored `null`, which silently disabled every period-dependent behaviour. Seat quantity was read from `items.data[0]` only, undercounting the common base-plan-plus-seat-item layout. And a subscription created in the Stripe Dashboard was dropped entirely for lacking our own `metadata.plan`; it now resolves through the price id. New: `billing.previewChange` (Stripe's invoice preview — quote this, never a local estimate), `billing.invoices` (hosted + PDF links), `startCheckout({ quantity })`, and `billingPlugin({ checkout: { adjustableQuantity, trialDays } })` so Stripe renders the seat stepper and runs trials. Checkout now enables Stripe automatic tax and promotion codes. Webhook signatures verify through `stripe.webhooks.constructEvent`; usage goes to Stripe Billing Meters with an `identifier`, whose 24h dedupe is what makes a replayed flush free. Set `STRIPE_WEBHOOK_SECRET` and a `priceId` per paid plan before upgrading, and reconcile existing subscriptions against Stripe first — see the codemod.
- **@voltro/protocol, @voltro/plugin-auth, @voltro/env, @voltro/cli** — The framework no longer ships a session secret, and no longer invents one. `VOLTRO_DEV_SESSION_SECRET` is removed from `@voltro/protocol/session` and `@voltro/plugin-auth`. It was a fixed string in the framework source applied automatically whenever `VOLTRO_SESSION_SECRET` was unset — so any deployment that reached it signed cookies with a value published in the source, and anyone could forge any user's session. The guard against it ran only when `NODE_ENV` was exactly `production`, which meant staging boxes and a bare `voltro serve` skipped it entirely. `resolveSessionSecret()` now throws when the variable is unset, and `assertProductionSessionSecret()` checks on every `voltro serve` boot regardless of `NODE_ENV`. Development stays zero-config by MINTING instead of sharing: declare a secret with the new `envVar.secret({ generate })` and `voltro dev` writes a unique per-project value into a gitignored `.env.local` on first boot. `generate` is opt-in per variable so third-party credentials (a WorkOS API key) still fail the boot gate rather than being invented. Templates therefore ship no secret values at all, and every template `.gitignore` now covers `.env` / `.env.local`. Also in this change: `AuthConfig.secret` is now optional on `authRoutesPlugin` / `voltroPasswordStrategy` — omit it and the key is resolved at request time, which removes the config-time `process.env` read that pushed apps into writing hardcoded fallbacks. The CLI's default session strategy now reads the cookie before resolving the secret, so an app without sessions no longer depends on one. Migration: see the codemod. In short — drop any import of the removed constant, review (do not blindly delete) hardcoded `?? '…'` fallbacks in case one is signing live sessions, stop passing `secret:` explicitly, and set a real `VOLTRO_SESSION_SECRET` in every deployed environment (`voltro secret generate session`).
- **@voltro/database, @voltro/cli** — A relation whose key options cannot resolve is now refused at boot, and `one` enforces its own cardinality at query time. An app declared `parent: one(() => teams, { foreignKey: 'parentTeamId' })` meaning "the parent whose id my column holds". `foreignKey` names a column on the TARGET; the source-side option is `sourceKey`. Nothing checked the column existed, so for that self-referencing table the walker emitted valid SQL — `WHERE parentTeamId IN (<parent ids>)` — which loads the CHILDREN. It typechecked, it booted, it matched a hand-written SQL join the author wrote to double-check, and it returned `null`. A row that HAD children would have resolved to the wrong row entirely; the reporter's test row was a leaf, which is why it read as "no parent" rather than as a bug. `validateRegisteredRelations()` now runs from the shared discovery both `voltro dev` and `voltro serve` use, rejecting a `sourceKey`/`foreignKey`/junction key that names no such column — and naming the option that was meant whenever the column exists on the other side. Passing both keys is refused too, because a resolvable `sourceKey` selects the source-side shape and `foreignKey` is then never read: the declaration would silently mean less than it says. Static validation provably cannot decide the SELF-REFERENCING case, where the column exists on both sides by definition. That half is caught by the `one` contract: the walker used to keep the last of several matching rows, so a mis-declared relation handed back an arbitrary child instead of a parent. It now fails, and when the relation is self-referential the message explains which side each option names. Same defect class as a `.one()` that returned the first of several rows, one layer down.
- **@voltro/client, @voltro/web** — `SubscriptionState<T>` is a discriminated union on `loading`, so `loading` narrows `data`. `if (loading) return <Skeleton/>` now leaves `data` typed as `T` — no `?? []`, no `!`. The flag shipped in 0.4.0 as a plain boolean beside `data: T | undefined` with no type-level relationship between them, which meant adopting it required KEEPING the `data === undefined` check redundantly beside it: the ergonomic helper made call sites longer. The reported shape that proved it: `const isLoading = !currentUser || loading` fails to compile where `!currentUser || data === undefined` narrowed fine, so the working "fix" was to re-add the very check the flag was meant to delete. A boolean that does not narrow is not an improvement over the check it replaces. Passing `fallback` returns `SubscriptionStateWithFallback<T>`, where `data` is always present and there is nothing to narrow. Call sites need no change — existing `data === undefined` guards still compile, merely redundantly. Two shapes do: an `interface X extends SubscriptionState<…>` becomes a type intersection (TS cannot extend a union), and hand-built state literals must have `loading` and `data` agree. Fixes a bug in the same stroke: `loading` is now derived from whether data arrived rather than from the revision counter. The in-band per-subscription error path bumps the revision while the base stays undefined, so a subscription whose COLD START failed reported `loading: false` with `isEmpty: true` — it rendered "no results" for a failed stream, the exact flash-of-empty-state class these flags exist to prevent. It now stays pending with `error` set.
- **@voltro/ui** — `UiStrings` gained a required `connectAccount` section (labels for the new `<ConnectAccount>` control). Every section of `UiStrings` is required, so a value annotated as the FULL interface — `const strings: UiStrings = { … }` — stops compiling with TS2741 until the section is added. `<UiStringsProvider strings={…}>` takes `PartialUiStrings` and is unaffected, which is the overwhelmingly common usage. Migration: add a `connectAccount` section with copy for your locale (the codemod prints the English defaults to translate), or switch the annotation to `PartialUiStrings` if the object was only ever an override bag. The defaults are deliberately NOT injected automatically — a localised app would then silently ship English copy, which is harder to notice than a compile error.

### Added

- **@voltro/client** — `useAction().run(input, options)` takes the same per-call `onSuccess` / `onError` / `notify` bag as `useMutation`'s `mutate`, including the load-bearing semantic: supplying an error handler marks the failure HANDLED, so `run` resolves with `undefined` instead of rejecting — which is what actually deletes the try/catch. With no options it rejects exactly as before, so unhandled failures stay loud. The asymmetry was arbitrary and expensive: roughly half of a real app's write sites are actions (sends, captures, invites), and they were the half that could not use the ergonomics, so they kept the try/catch. Documented alongside it: the callback form is for SINGLE-SHOT writes. A `for`-loop or a multi-step sequence relies on the throw to stop; once the failure is handled the promise resolves and the loop keeps going. Those want the bare `run(input)` and a real try/catch.
- **@voltro/protocol, @voltro/cli** — Boot now reports procedures whose `guards:` declare a per-resource check that nothing will enforce. `guards: [{ scope: 'teams:write', resource: (i) => i.teamId }]` reads as "may you write THIS team", but without a registered `setResourceScopeResolver` the extractor is advisory and the check runs against the caller's GLOBAL scopes — so an app whose authorization is per-resource (roles held on a membership row, subjects carrying no global scopes) gets a guard that passes callers it should refuse. The declaration looking stricter than the enforcement is the shape of every security bug that survives review. A production app hit exactly this, concluded the declarative path could not express per-team authorization, and reached for the far heavier policy machinery instead — nothing had told it the guard was being downgraded. A report rather than a refusal: a single-tenant app declaring `resource` for documentation value is not broken, and a boot that dies over an authorization nuance the app may enforce elsewhere would be its own kind of wrong. Silent is the one thing it must not be. Relationship guards are never flagged — they resolve through the tuple source and deny when unanswerable, so they cannot degrade to a global check, and flagging them would be the cry-wolf failure that gets a warning ignored. Wired into `voltro dev` and `voltro serve` through one shared helper.
- **@voltro/ui** — `<ConnectAccountFor api connectionId />` resolves the connection handle through `useConnection` itself, so the common case no longer requires wiring the hook by hand. The prop form stays for a custom connection source, a fixture, or a test. The absence of a bound variant was defended as "@voltro/ui is prop-driven", which is only half the rule. The kit's actual convention is that it does not depend on a PLUGIN — which is why `<PresenceAvatars>` takes a roster rather than calling `usePresence` from `@voltro/plugin-presence/web`. Depending on `@voltro/client` is routine and already the norm here: `<AgentChat>` calls `useAgentChat`, `<AsyncSelect>` calls `useQueryField`, `<AutoForm>` calls `useFormBinding`. `useConnection` is core, so the prop-only shape made this one component the exception rather than the rule. A separate component rather than an overload, because a hook cannot be called conditionally: one component calling `useConnection` only when `api` was present would break the rules of hooks the moment a caller switched between forms.
- **@voltro/runtime, @voltro/protocol, @voltro/cli, @voltro/client, @voltro/ui, @voltro/plugin-atlassian** — **Connections — a per-user credentials vault (`defineConnection`).** One declaration in a `*.connection.ts` yields the whole connected-accounts layer an app previously hand-rolled: an encrypted per-subject token store, the OAuth 2.0 authorize/callback pair (PKCE + single-use anti-CSRF state), refresh-before-use, a resolver for handlers and plugins, and a connect UI.

  ```ts
  // api/connections/jira.connection.ts
  export default defineConnection({
    id: 'jira', kind: 'oauth2', label: 'Jira',
    authorizeUrl: '…', tokenUrl: '…',
    clientId: serverEnv.JIRA_CLIENT_ID, clientSecret: serverEnv.JIRA_CLIENT_SECRET,
    scopes: ['read:jira-work', 'offline_access'],
  })
  
  // in any handler
  const jira = await ctx.connections.get('jira')   // the CALLING subject's, refreshed
  ```

  - **Per-subject by construction.** Every vault read and write folds `subjectId` into its predicate, and the built-in procedures take the subject from the resolved request — their input schemas have no subject field at all. There is no code path that returns a credential given only a connection id. - **Encrypted at rest via the existing cipher.** Tokens go through `encryptField` (the same `FieldCipher` `.encrypted()` columns use). An app that declares a connection with no cipher configured **refuses to boot**, naming the connections — there is no plaintext fallback. - **Refresh before use, without stampeding.** Renewal happens 60s ahead of expiry, guarded by an in-process single-flight AND a compare-and-set lease on the row, so two replicas cannot both spend a rotating refresh token. A 4xx from the token endpoint is classified `revoked` (tokens cleared, re-consent needed); a 5xx/network failure is `error` (tokens retained, next use retries). - **Client + UI.** `useConnection` / `useConnections` (`@voltro/client`) and `<ConnectAccount>` (`@voltro/ui`). The wire shape carries status, account and expiry — never a token. - **Plugin injection.** `@voltro/plugin-atlassian/connection` exports `connectionCredentials({ connectionId, baseUrl })`, a drop-in `credentialsResolver` backed by the vault. The plugin's existing `credentialsResolver` option is unchanged and keeps working.

  Two framework tables (`_voltro_connections`, `_voltro_connection_grants`) are created only when the app declares a connection; they ride the declarative differ like every other `_voltro_*` table. The callback endpoint (`GET /_voltro/connections/<id>/callback`) is mounted in both `voltro dev` and `voltro serve` from one shared builder.
- **@voltro/cli** — `voltro doctor` flags a handler that converts a `Date` by hand on the way out — `.getTime()` / `.toISOString()` inside a result mapping — and names `rowSchema(table)` in the descriptor's `output` instead. The advice states the part that was actually misunderstood: `output` IS the serializer (it is the rpc success schema), so a Date column declared as a Date crosses as epoch ms on its own, while declaring `Schema.Number` describes the WIRE type and leaves nothing to convert. Deliberately narrow: a lone `.getTime()` is ordinary arithmetic — a duration, a comparison — and only the mapping shape says "this is being shaped for the wire". A test pins that a duration calculation does NOT fire, because a rule that cries wolf is the one people stop reading.
- **@voltro/cli** — `voltro doctor --json` prints the complete hand-roll scan — every file path per finding, plus the scanned file count and directories — as machine-readable output with no preflight prose interleaved. The human view shows three paths per finding, which is fine as a summary and useless as a work list: those paths ARE the actionable part of a finding, and the matching rule lives inside the CLI, so nobody could re-derive the remainder with their own grep. A finding worth printing at all has a file list worth retrieving. The human view now also states how many paths it withheld and where to get them — a cap that does not announce itself reads as completeness. This is the opposite of the narrowing applied to `voltro capabilities`: that one cut which findings are reported (fewer false positives); this one stops hiding detail of findings already judged worth reporting. Mirrors `voltro capabilities --json`.
- **@voltro/testing** — `invoke(descriptor, executor, rawInput, ctx)` now enforces the descriptor's `guards:` against `ctx.request.subject` before decoding the input, rejecting with the typed `ScopeError` exactly as the server does. Scope guards, resource-scoped guards (through a registered `setResourceScopeResolver`), and relationship guards are all covered — the last FAIL CLOSED when no tuple source is registered, matching production, because a harness that quietly allowed them would train tests to pass on precisely the configuration that denies in prod. The previous version declined this and explained why: authorization was rpc middleware wired at dispatch in the CLI, not carried on the descriptor, so no util here could reach it without a layering inversion. That was true when written and became obsolete when `guards:` moved onto the descriptor and `checkGuardsEffect` landed in `@voltro/protocol`. The blocker dissolved; the comment outlived it, and an app read it as "the framework cannot test this". Guards run BEFORE the decode, mirroring the dispatch spine — an unauthorized caller must not be able to distinguish a malformed payload from a well-formed one. Still not covered, deliberately: transport concerns (connection info, rate limiting, the tenant header) are properties of the HTTP hop rather than the procedure, and a mutation invoked this way does not open a transaction.
- **@voltro/database** — `manyToMany` eager loads can return the junction's own columns: `with({ teams: { junction: ['role', 'addedAt'] } })` puts them under `_junction` on each target row, and `junction: true` takes all of them. A junction carrying meaningful columns — a role, a joined-at stamp, a permission tier — is the rule rather than the exception, and until now those columns could be FILTERED on (`onJunction`) but never returned, so any relation with real membership data had to stay a hand-written join. The junction rows were already being fetched in full to resolve the target ids; this stops discarding them. Nested rather than merged onto the target row: a junction and its target routinely share column names (`createdAt` is the obvious one) and merging would silently overwrite real target data with membership data. Each link gets its own clone, because one target row object is shared across every parent linking to it — attaching junction data in place would hand every parent the last writer's membership row. Without `junction` the shared-reference path is unchanged, so no existing m2m load pays for this. A branch requesting junction columns is served by the walker rather than the single-query JSON path. Compiling it would mean hand-writing a nested JSON object per dialect to save one round trip — four chances to get a dialect subtly wrong against a documented correctness reference that already works. Worth revisiting as an optimisation; not worth leading with.
- **@voltro/runtime, @voltro/cli** — The transactional outbox now keeps a queryable per-attempt delivery history, and can be resent on demand. `ctx.outbox` was a fire-and-deliver driver: `_voltro_outbox` holds one row per intent and mutates it in place, so it answers "is this still owed" but not "what did the remote say on attempt 3", "how long did it take", "who resent it" — the questions a delivery-history UI exists to answer. Apps were keeping their own per-attempt audit table alongside it. **`_voltro_outbox_attempts`** is that table, framework-owned. One append-only row per delivery attempt: `outboxId`, `effect` (denormalised so the history survives the entry's purge), `attempt` (1-indexed, monotonic across the entry's whole life), `trigger` (`automatic` | `manual`), `triggeredBy` / `reason`, `outcome` (`delivered` | `failed` | `dead`), `startedAt` / `finishedAt` / `durationMs`, `error`, `response`, and the entry's `subjectId` / `tenantId` / `traceId`. `response` is a JSON snapshot of whatever the handler RETURNED — that is how an HTTP-shaped handler records `{ status, body }` without the framework pretending to model HTTP. Reading it is a normal store read (`ctx.store.query({ table: '_voltro_outbox_attempts', … })`); no new query API was added because none is needed. Attempt recording is best-effort: a failed log write never fails the delivery it observes. **`ctx.outbox.resend(outboxId, { reason?, attempts? })`** re-arms one entry for immediate delivery, including one that already reached `dead`. Legitimate because delivery is at-least-once and handlers are already required to be idempotent — but never invisible: the resulting attempt is recorded as `trigger: 'manual'` with the requesting subject and reason, and the entry carries a `resendCount`. It re-arms the existing row rather than enqueuing a copy (a copy would carry the same `idempotencyKey` and split one entry's history across two ids), grants a small fresh budget (default 1 attempt, since a dead row has already spent its allowance), and refuses an entry whose attempt is in flight. **Retention** is bounded twice: a per-entry cap of 50 attempts trimmed on write (dialect-neutral; the automatic path can never reach it, so it bounds the repeatedly-resent entry) and a 30-day sweep over `startedAt` (`VOLTRO_OUTBOX_ATTEMPTS_TTL_HOURS`). The boot sweep now also ages out **delivered** `_voltro_outbox` rows (`VOLTRO_OUTBOX_TTL_HOURS`) — never `dead` or `pending` ones, which are an unresolved incident and an outstanding debt respectively. No codemod: `_voltro_outbox_attempts` and the three new `_voltro_outbox` columns ride the declarative differ, so `voltro db apply` / `voltro dev` boot reconcile them, and every existing call site keeps compiling.
- **@voltro/runtime, @voltro/cli** — Row-level security: `setRowFilter({ load, predicate })` registers a subject-derived predicate that is AND-merged into every read, so "tickets on teams I hold a role on" stops being a filter each list handler and each subscription must remember to write. Two halves on purpose. `load(subject)` is async and runs ONCE per request — read your membership tables there. `predicate(ctx, table)` is pure and sync and runs per read. A single `(subject, table) => Promise<Predicate>` would be simpler to declare and much worse to run: the obvious implementation re-queries memberships once per query in a handler that touches five tables. The filter narrows, never replaces, so it cannot grant access. It applies to BOTH read paths — `ctx.store.query(descriptor)` and the fluent `select(...)` builder — because a filter present on one of them is a detour rather than a boundary; a test asserts the fluent path specifically, and it caught that path being unwired during development. Fail-closed where it counts. A `load` that fails denies every constrained read instead of degrading to "no filter", because a row filter that evaporates under load failure is worse than none: the system keeps serving and nothing looks wrong. The cause is reported rather than swallowed. `.unscoped()` / `crossTenant` do NOT bypass it — those opt out of tenant ISOLATION for admin reads, and letting them drop row visibility too would convert an isolation escape into an authorization one. Only a `system` subject bypasses, because that is the framework acting as itself. Subscriptions re-resolve it before every delivery, derived from the descriptor WITHOUT the filter so successive deliveries cannot accumulate stale predicates and so visibility GAINED mid-subscription actually appears. Same reasoning as the per-delivery guard re-check: a membership can end while the socket stays open, and a filter frozen at subscribe keeps serving rows the subject has lost.
- **@voltro/database** — `rowSchema(table)` builds an effect/Schema for a table's rows with the WIRE representation already correct — `timestamp()` columns cross as epoch ms and arrive back as `Date`, `bigint()` crosses as a decimal string.

  ```ts
  export const listNotes = defineQuery({ output: Schema.Array(rowSchema(notes)) })
  ```

  This was reported as "the `output` schema is not used as a serializer", after an app wrote ~228 hand `Date → epoch` converters at its handler tails. That diagnosis was wrong and the verification is worth recording: `output` is passed straight to `Rpc.make` as `success`, so a handler's result IS encoded through it and decoded on the client. Declaring `Schema.Number` for a `timestamp()` column describes the WIRE type rather than the domain type, which leaves the schema nothing to convert and the handler doing it by hand.

  What was actually missing is the schema worth declaring — nobody wants to work out the right Encoded/Type split for thirty columns per table. `bigint` crosses as a string rather than a number because a number silently rounds past 2^53. Columns whose shape the declaration does not pin down (`json()`, `vector()`, `raw()`) map to `Schema.Unknown` rather than a guess: a wrong schema rejects valid rows at the wire boundary and reports it far from the column responsible.

  `omit` keeps a column off the wire. It is a convenience, not a security boundary — an omitted column is simply absent from THIS schema.
- **@voltro/testing** — `makeTestContext` now provides `ctx.outbox`, and `invoke` drains the handler's `afterCommit` callbacks after a mutation's transaction COMMITS — never after a rollback. This was previously listed as "not covered", alongside plugin interceptors and the deadlock replay. That was the wrong company for it: those two are serve entrypoint wiring a unit harness has no equivalent for, while this was a hole. `ctx.outbox.enqueue` schedules its delivery nudge through `afterCommit`, and the harness had neither the facade nor the hook — so the framework shipped a transactional-outbox primitive whose test story was "you cannot". The facade is the same `makeOutboxFacade` production builds, over the same store, so an enqueue inside a mutation is atomic with the domain write here too and a test exercises the real idempotency path rather than a stand-in. A test asserts that a throwing handler loses BOTH the row and the outbox entry. The ordering is the property worth having, not the presence: a drain that ran on the way out regardless would pass every naive test and be exactly wrong. Building it surfaced a real defect on the first run — the transactional context is derived, so callbacks queued inside the transaction landed in an array nobody drained. Derived contexts (the transaction, `withSubject`, `withTenant`) now share one collector, which is what production does by passing a single `afterCommit` into `buildContext`.
- **@voltro/testing** — `invoke()` now runs a MUTATION inside a real store transaction, matching the serve pipeline: everything the handler writes through `ctx.store` commits together, and a handler that throws leaves nothing written. This makes "the mutation failed, therefore nothing was written" a testable assertion — before, a half-applied mutation looked correct under test and only came apart in production, where `makeMutationRunner`'s `store.transactional(...)` is real. The kind comes off the descriptor (`kind: 'mutation'`), so nothing is declared at the call site. Queries and actions are deliberately NOT wrapped — actions run outside a transaction in production because their external I/O cannot be rolled back, and the harness reproduces that rather than being uniform. The rollback is the store's own transactional view discarding its overlay, not a copy the harness restores. Also adds `runInStoreTransaction(ctx, work)` for tests that want the same guarantee around a block of their own: `work` receives a full `TestContext` re-derived over the transaction — its store, loader, and `withSubject` / `withTenant` re-scopers all read and write through it, so a handler cannot escape the transaction mid-mutation.
- **@voltro/runtime** — `ctx.store.one(...)` / `.first(...)` / `.maybeOne(...)` return the ROW TYPE. They take the typed builder itself or its `.descriptor`, and the row type is inferred — no explicit type argument, no cast:

  ```ts
  const user = await ctx.store.one(database.users.where(eq('id', id)))
  user.name   // string
  ```

  The two things we recommend did not compose. The typed read path lives on the typed builder, while the single-row terminals lived only on the string-keyed `select('users')` builder, which yields untyped `Row` — so adopting `.one()` re-introduced exactly the casts the typed path had just removed. You could have typed rows or the terminal, not both. That is not a last-mile gap for sophisticated apps: both shipped in the same release and we never tried composing our own two recommendations.

  `one()` probes with LIMIT 2 and fails with `NoRowFound` on zero AND on two or more, matching the existing terminal. Scoping is inherited from `query()` rather than re-implemented — a second scoping path is how one of them quietly stops filtering by tenant.

### Fixed

- **@voltro/database** — `VOLTRO_DESTRUCTIVE_OK=1` can now actually drop a table. It never could: the boot gate decided the plan may proceed but left every operation still marked `blocked`, and `applyPlan` carries its own unconditional refusal on exactly that flag — so the documented escape hatch, which the planner's own fix hint tells users to set, was refused a second time one call later. The boot path now clears the flag on the lossy operations it has approved (`unblockLossy`). Non-lossy blocked ops — a rename without a marker, a NOT NULL without a backfill — stay blocked regardless of the opt-in, because those lose data whether or not you meant it.
- **@voltro/cli** — `voltro doctor`'s hand-roll scan now reports what it covered — the file count and the directories — and says so explicitly when it found no source directories at all. It previously printed nothing in both the "scanned nothing" and the "scanned everything, found nothing" cases, which are not the same result. A downstream user read that silence as "this command has no hand-roll detector" and kept hand-rolling primitives that ship, then reported the detector as missing. Absence of output is not evidence of absence of findings; a check that can be silent about having done nothing will eventually be believed.
- **@voltro/cli** — Two `voltro doctor` rules were wrong often enough to train people to ignore them. The credential-column rule matched any name CONTAINING a credential word, so it flagged `jiraSecretId` (a Vault identifier), `apiKeyHash` (a hash — which IS the protection, and in that app a unique lookup column, so encrypting it would break authentication), and vault handles. It now skips names ending in `Id`/`_id` or `Hash`/`_hash` and names beginning with `vault`, and says why in its advice. The sequential-reads rule told the reader to convert to `relations()` + `.with()`. Measured against a real 74-hit codebase, about two handlers were clean full-parity conversions; the other ~72 are multi-source assemblies — ids collected from several sources, JSON-array references, junctions with meaningful columns — where `.with()` covers only part or shifts behaviour. The smell is real; the prescribed remedy was wrong roughly 97% of the time. The advice now names both levers with the criterion: `relations()` when the reads are a parent→child walk on ONE key, `Effect.all` over the independent leading reads when the handler collects ids from several sources — the same queries and results, only concurrent. The rule is deliberately NOT split on a heuristic: a parent→child walk also collects ids from its parent result, so "later query uses an earlier id" does not separate the two shapes, and a confidently-wrong split would be worse than one honest finding.
- **@voltro/database, @voltro/sql-postgres, @voltro/sql-mysql, @voltro/sql-mssql, @voltro/sql-sqlite** — The single-query JSON eager-load path now serves the two shapes it used to hand back to the row-by-row walker, so `.with()` stays one round trip in more cases — and one of them stops being silently wrong on SQL. **Junction-column projection.** `manyToMany` branches asking for `junction: ['role', 'addedAt']` / `junction: true` used to decline the whole descriptor, costing extra queries on exactly the relations that carry real membership data. Each dialect now builds the `_junction` object natively — postgres routes the projection out of the correlated join as one `jsonb` carrier and strips it back off `to_jsonb(t.*)`, sqlite / mysql / MariaDB carry `__j_`-aliased scalars, mssql uses `FOR JSON PATH`'s dotted aliases. Values are computed inside the per-parent join, so a target row under two parents carries each parent's own junction row; a requested column the through table lacks still reads `undefined`, never raises. **`one` cardinality on the fast path.** A `one` whose foreign key sits on the TARGET can match a second row when the relation is mis-declared. The JSON path emitted `LIMIT 1` / `TOP 1` and handed back the first of them — the same defect the walker raises `EagerCardinalityError` for, so which path ran decided whether you got an error or an arbitrary row. Those branches now over-fetch two rows and the decoder raises on the second, with the walker's message verbatim (both call the same `oneCardinalityMessage`). Cost is a two-row cap instead of one, only on target-side `one` relations; correct data still returns a single row. The stores re-throw `EagerCardinalityError` instead of retrying on the walker. Also fixed while verifying against live engines: mssql wrapped the WHOLE result set in one `FOR JSON PATH … WITHOUT_ARRAY_WRAPPER`, so any eager read matching more than one row produced concatenated documents that failed `JSON.parse` and degraded to the walker — it now emits one document per row; and MariaDB's paginated `manyToMany` referenced the target alias from outside the derived table that hid it.
- **@voltro/database** — The single-query JSON path decoded dates by dialect convention instead of one blanket rule, fixing an mssql divergence where the same row came back with a different instant depending on which path served it. Measured against the live engines rather than reasoned about. A value stored as `12:34:56Z` returned correctly from the walker and as `11:34:56Z` from the JSON path on mssql, because `FOR JSON` emits a `DATETIME2` as its UTC wall-clock with NO zone marker and `new Date(string)` resolves a zone-less date-time as LOCAL. The error tracks the DST offset OF THE DATE BEING READ — 60 minutes for a March date read in July, 120 for a July one — so a single table came back with rows shifted by different amounts, which is close to undiagnosable from the symptom. The obvious fix is wrong: decoding everything as UTC repairs mssql and BREAKS mysql, whose `DATETIME` holds a local wall-clock that the driver also writes and reads as local, and which therefore round-trips correctly today. Verified both directions against live engines before choosing. Postgres carries its own offset (`TIMESTAMPTZ`) and needed nothing. The parity suites previously used an ISO `text()` column for the junction timestamp specifically to route around this. They now use a real `timestamp()`, because a workaround that outlives its bug hides the regression it was invented to dodge — and removing the per-dialect rule turns exactly the two junction parity tests on mssql red while the other five stay green.
- **@voltro/database** — Postgres introspection reads a composite primary key's columns in DECLARED order. The pg_catalog query joined `pg_attribute` on `attnum = ANY (con.conkey)` with no `ORDER BY`, so Postgres returned the members in physical column order while `conkey` stores them in the order the key was declared. The assembly folds those rows into an insertion-ordered set that becomes the synthetic `<table>_pkey` index, and the planner compares index columns order-sensitively — so a table declared `.primaryKey(['userId','orgId'])` whose `orgId` sits earlier physically introspected as `['orgId','userId']` and emitted a phantom `_pkey` diff on every plan, forever, never converging. Ordering by `array_position(con.conkey, att.attnum)` restores the declared order. Verified against live Postgres with a declare → apply → introspect → re-plan round-trip that yields zero operations; the same round-trip reproduces the phantom diff when the ordering is removed.
- **@voltro/testing, @voltro/runtime** — `invoke` now reproduces the last two hops of the dispatch spine it used to declare out of scope: **plugin interceptors** and the **deadlock replay**. - `makeTestContext({ plugins: [myPlugin] })` wires a plugin's   `interceptMutation` / `interceptQuery` / `interceptAction` into every   `invoke` on that context — kind-selected and composed with the same   `composeRpcInterceptors` the serve entrypoints use (first plugin outermost),   running OUTSIDE the transaction and AROUND the guards, exactly as in   production. `rpcInterceptorFor(ctx, kind)` exposes the composed chain. - A mutation whose transaction fails with a deadlock-shaped error is replayed,   via the runtime's own `runWithDeadlockRetry` (now exported) rather than a   second copy of the transient-error classification. Each attempt starts clean:   `resetAfterCommit(ctx)` drops the rolled-back attempt's queued post-commit   work, so a replayed mutation no longer fires outbox nudges for writes that   never landed. Backoff is zero under test — jitter exists to de-correlate   concurrent lock victims, which a unit harness has none of. - Ordering fix: `invoke` ran the descriptor's `guards:` BEFORE the input   decode, describing that as mirroring the serve pipeline. It does not — the   wire (`@effect/rpc`) decodes before any runner is reached, and guards run   inside the plugin-interceptor chain so an rbac-style plugin can publish role   scopes first. `invoke` now runs `decode → interceptor(guards → txn →   afterCommit)`. A test that asserted a `ScopeError` for a payload that ALSO   fails to decode now sees the parse failure, which is what a client gets.
- **@voltro/cli** — The 0.5.0 typed-`store.query()` change is reclassified as BREAKING and now ships the `0.5.0/02_typed-store-rows` manual codemod. It was released under `Added` with the claim that it "never types less than before, so it is additive: existing code keeps compiling" — the premise is true, the conclusion is not. Making a type more precise breaks every cast that previously widened from `unknown`: `rows[0]['meta'] as AppMeta` was `unknown → AppMeta` and always legal; `rows[0].meta as AppMeta` converts between two known types and fails with TS2352 when they do not overlap. One app upgrading from 0.3.0 hit 102 of these by hand, with no note and no codemod, because the entry was not typed BREAKING and the changelog gate only enforces codemod-or-`none` on entries that are. The gate now also flags a public-API surface change whose golden `etc/*.api.md` lines are MODIFIED or REMOVED (as opposed to purely added) when no entry classifies it, so a narrowing filed under `Added` cannot slip through again.
- **@voltro/cli, @voltro/plugin-auth** — - **A production web image no longer needs the tsx loader.** `voltro start` imported the app's `app.config.ts` at boot — the last TypeScript file on the serve path — so every web image had to ship tsx and, with it, @voltro/cli's whole optional build toolchain. `voltro build` now precompiles the config to `.framework/dist/server/appConfig.js` beside the SSR bundle (bare specifiers stay external, so `@voltro/env` and friends remain a single instance), `loadConfig` prefers that artefact, and `bin/voltro.mjs` runs `start` IN-PROCESS from the precompiled pair — no child process, no loader hook. A production start without those artefacts now fails loud instead of silently transpiling, and the config precompile is fatal like the SSR bundle build. Verified: with `app.config.ts` removed entirely, `voltro start` still boots and serves from the compiled config. - **The SSR bundle build is fatal.** It previously swallowed its own failure with a warning, and `voltro start` then silently booted Vite middleware mode, compiling TSX on demand, per request, in production — the slow path the bundle exists to avoid, entered unnoticed. - **`@voltro/plugin-auth`** — the pre-wired session strategy omits `secret` when unconfigured instead of passing `undefined`, so it typechecks under `exactOptionalPropertyTypes` and keeps the documented default (`resolveSessionSecret()`) rather than meaning "no secret".

### Internal (no consumer-facing effect)

- **@voltro/cli** — The doc-sample checker now resolves free framework primitives instead of letting them type as `any`. Samples are fragments, so TS2304 stays unreported — but an unresolved identifier is `any`, which meant a block using `useSubscription` without importing it typechecked vacuously: every member access unchecked, no drift code able to fire. Measured over the EN corpus, 292 blocks use a `use*` / `define*` primitive and 122 of them — 41% — were in exactly that state, reported as covered while checking nothing. The checker now runs two passes: the first asks the compiler which names are genuinely unresolved, the second appends an import for each one that a single `@voltro/*` entry point uniquely exports. Restricted to `use[A-Z]` / `define[A-Z]` names, because a docs page builds a scene across fences (`const view = …` in one block, `view.html()` in the next) and an unrestricted index imported `@voltro/database`'s `view()` over such a name and invented a failure. Imports are APPENDED, so every existing line number — which the diagnostic-to-doc mapping depends on — stays put. It exposed seven real drift findings on first run, all fixed in the docs.
- **@voltro/runtime** — Regenerate the `@voltro/runtime` api-extractor golden. `ApiKeyServiceShape`'s `verify` / `resolveByHash` returns were extracted into a named `ResolvedApiKey` interface (a14ce089) without the golden being regenerated, so the pre-push api-report check blocked every push. Consumer-compatible: the anonymous return type became a named one with the same fields plus `createdBy`, and a read of the previous fields is unaffected — a returned object gaining a field gives callers more, not less. Hence `apiSurface: compatible` rather than a BREAKING classification.

---

## [0.5.0] — 2026-07-18

### ⚠ BREAKING

- **@voltro/runtime** — `.one()` now fails when a query matches MORE than one row, not only when it matches none. It previously probed with `LIMIT 1`, so a filter that quietly stopped being unique returned an arbitrary row while the thrown error still claimed "expected exactly one row". It now probes with `LIMIT 2` and fails with `NoRowFound({ found: 2 })`. Migration: a `.one()` whose filter is not unique and that meant "any match" becomes `.first()` / `.maybeOne()`; one that meant "the unique row" stays as-is and now fails loudly when that assumption breaks. `NoRowFound` and `OptimisticLockError` are now `Schema.TaggedError`s, so they can be declared directly in a descriptor's `error:` union and caught with `Effect.catchTag`. Previously they carried a `_tag` field that looked declarable but did not typecheck there, forcing every caller to catch and re-wrap them in a hand-written tagged error — that wrapper can be deleted. Constructing one directly now takes an object: `new NoRowFound({ table, found })`, `new OptimisticLockError({ table, expected })`. `instanceof` and `_tag` checks are unaffected.
- **@voltro/database, @voltro/runtime** — `ctx.store.query()` now returns the row type instead of `Readonly<Record<string, unknown>>`. `QueryDescriptor<R>` carries a phantom row type, so the shape the typed builder already knew survives `.descriptor` into the store:

  ```ts
  const rows = await ctx.store.query(database.notes.where(eq('id', id)).descriptor)
  rows[0].title   // string — previously `rows[0]['title'] as string`
  ```

  The type was always available; it was dropped at exactly this boundary, which is why reading a field meant casting. One downstream app accumulated 2,032 of those casts against a surface that could have typed them all along. `EffectStore.query` is typed the same way, so an Effect-form handler keeps both the row type and the `StoreError` channel. The driver-level `DataStore.query` stays untyped deliberately. It is the SPI every dialect store and transactional view implements, and those genuinely do return untyped rows off the wire; the type is re-applied one layer up, at the handler-facing `FluentStore`. **Corrected after release — this entry originally appeared under `Added` with the claim that it "never types less than before, so it is additive: existing code keeps compiling." The premise is true and the conclusion does not follow.** Making a type more precise is source-breaking for every cast that previously widened from `unknown`: `rows[0]['meta'] as AppMeta` was `unknown → AppMeta` and always legal, while `rows[0].meta as AppMeta` is a conversion between two known types and fails with TS2352 when they do not overlap. One app upgrading from 0.3.0 hit 102 of these by hand. Migration (now shipped as the `0.5.0/02_typed-store-rows` manual codemod): delete the cast — most are simply redundant now; where the types genuinely differ, fix the column type rather than the call site (a `json<T>()` whose runtime shape is not `T` is a modelling bug the old `unknown` was hiding); reach for `as unknown as T` only when the divergence is real and intended. A hand-built descriptor still resolves to `Row`, so untyped call sites are unaffected.

### Added

- **@voltro/protocol, @voltro/runtime, @voltro/cli, @voltro/mcp** — Relationship (ReBAC) authorization is now declarative. A descriptor can carry `guards: [{ action, resourceType, resource: (input) => input.id }]` alongside its scope guards; the framework resolves it before the executor (for a mutation, before the transaction opens) and fails with a typed `ScopeError` naming `<resourceType>:<action>`. `defineResourcePolicy` previously enforced nothing on its own. Making it bite required hand-building a map from rpc tag to policy rule and installing an interceptor — undocumented plumbing that nobody wired, and **fail-open by omission**: an rpc missing from the map passed unchecked, with no type error and no boot warning. A guard on the descriptor cannot be forgotten for an rpc that exists, because it is part of the rpc. `setTupleSource` registers where relations are read from — a real registry, not a per-call parameter. Both entrypoints register a default reading `_voltro_rebac_tuples`; an app whose relations already live in its own tables (a `teamMembers` row) registers its own instead of copying data into a framework table. Every unanswerable case DENIES: no tuple source, no policy for the type, an input that does not identify a resource, or a tuple source that throws. An authorization question nobody can answer is a refusal. The capability manifest now carries the declared guards, so a client gates UI on the same declaration the server enforces instead of a hand-kept copy. Only the data crosses the wire — the pure `resource` extractor stays server-side and is reported as `resourceScoped: true`, so a client knows the real answer is per-row and asks rather than assuming. Because guards are re-checked on every subscription delivery, a relationship revoked mid-session now ends the stream rather than continuing to serve it.
- **@voltro/database, @voltro/runtime, @voltro/cli** — `paginateBy(descriptor, column, cursor, limit, direction?)` generalises `paginateById` to any orderable column. `paginateById` hardcoded `id`, which is right for a sortable key and useless for what feeds actually need — "the next page by `createdAt`" — so apps fell back to hand-rolled `limit + 1` / slice / `hasMore` triples on cursors the helper could not express. `paginateById` is now literally `paginateBy(descriptor, 'id', …)`, so the two cannot drift. `direction` flips the comparison as well as the sort: a `desc` feed pages with `<`. Mismatching those is the classic keyset bug — an ascending comparison under a descending sort returns the same first page forever. The docs example previously demonstrated a related trap (ordering by `createdAt desc` and then calling `paginateById`, which silently re-orders by `id asc`); it now shows the correct form and names the trap. `ctx.load` / `ctx.loadMany` add request-scoped batching. Same-tick reads of one table coalesce into a single `WHERE id IN (...)`, so a breadth-first walk costs one query per LEVEL rather than per node — the assembly shape `relations()` + `.with()` cannot express, because each level's ids come from the level above. Misses are cached too (a repeated dangling reference is fetched once) and a failed batch rejects its waiters without poisoning the cache, so a transient store error does not become "these rows do not exist" for the rest of the request. The cache is request-scoped as a correctness requirement, not a tuning choice: anything longer-lived would serve one subject's rows to another. Workflow steps can now `yield* EffectStore`. Handlers always could; workflow executors could not, so a step reading the store had to lift `ctx.store` with `Effect.tryPromise` — the idiom the rest of the framework tells you to avoid, because it discards the typed `StoreError` channel. The asymmetry was an oversight; the layer is now provided from the workflow context's own store.
- **@voltro/cli** — `voltro capabilities [--json]` enumerates the framework's export surface by reading the `.d.ts` files in the project's own `node_modules`, so the answer to "what does this framework export" can be verified rather than recalled. The `--json` form is locale-independent and byte-stable, so it can be diffed across upgrades. Symbols that ship but appear nowhere in the project's seeded agent guide are flagged — limited to primitives and hooks, because counting every undocumented export on a real tree gave 1,088 (mostly types and internal Layers), a number too large to act on. `voltro doctor`'s hand-roll detector gained server rules: a hand-written not-found branch on `rows[0]` (→ `.one()`), several sequential `store.query` calls assembling related data (→ `relations()` + `.with()`), `ctx.store` lifted with `Effect.promise` (→ `EffectStore`), an imperative scope check at the top of an executor (→ `guards:`), a credential-shaped column with no `.encrypted()`, a notify/webhook helper at a mutation's tail (→ `defineSubscriber` / `defineReaction`), and hand-rolled cursor pagination (→ `paginateById`). Its scan roots now include the API app directories (`queries/`, `mutations/`, `database/`, …) — without that the server rules could never have fired. The always-loaded agent core now carries a "Pick the SERVER primitive" rubric alongside the client one, and a doc-coverage gate keeps the server surface from drifting out of it.
- **@voltro/runtime, @voltro/cli** — `ctx.outbox.enqueue(effect, payload, options?)` — a reliable external side effect from a mutation. The enqueue writes through `ctx.store`, which inside a mutation IS the transactional view, so the intent to deliver commits in the same transaction as the domain write or not at all. That closes the window a post-commit tap cannot: `@voltro/plugin-cdc-out` is at-least-once *from enqueue* (its own docs say so — a crash between commit and tap loses the event). Here the enqueue cannot be lost, because losing it means the domain write rolled back too. Delivery after commit remains at-least-once, so handlers must be idempotent; `idempotencyKey` makes that easy to honour. Delivery is declared per effect in a `*.outbox.ts` via `defineOutboxHandler`. The worker is nudged on commit for the fast path and polls every 5s — the poll is the contract, not the optimisation: it recovers rows whose nudge was lost to a crash, rows from another replica, and rows waiting out a backoff. Exponential backoff capped at 5 minutes, configurable `maxAttempts` (default 8), and a dead-letter that stays queryable in `_voltro_outbox` with its `lastError`. An effect with no registered handler is left PENDING rather than dead-lettered — the usual cause is a deploy where the enqueuing code shipped ahead of its handler, and discarding those would turn a rollout ordering detail into permanent loss of a side effect the app believes happened. Two handlers claiming one effect are refused at boot with both filenames. `_voltro_outbox` rides the declarative differ and is created only when the app declares at least one handler. Wired in both `voltro dev` and `voltro serve`.

### Fixed

- **@voltro/runtime, @voltro/cli** — Declarative `guards:` are now re-checked before EVERY subscription delivery, not only when the subscription is opened. A subscription is a long-lived authorization grant, and the scopes that justified it can be withdrawn while it is still open — a role revoked, a resource un-shared, a membership ended. Previously the gate ran once at subscribe and every later delivery re-ran the query and pushed rows out without re-asking, so a revoked subject kept receiving live updates until their socket happened to drop. A denial now ends the subscription with the typed `ScopeError` rather than silently freezing the subscriber on its last authorized value, and the re-check runs BEFORE the read, so a revoked subject's rows are never materialised. Both the descriptor and computed-query paths are covered, in both `voltro dev` and `voltro serve`. Non-authorization failures (a bad predicate, a dropped connection) still leave the subscriber on its last good snapshot as before.
- **@voltro/testing, @voltro/cli** — - **`@voltro/testing`'s context now provides `ctx.load` / `ctx.loadMany`.** The request-scoped batching helpers were added to the handler context but not to the test harness, so `makeTestContext` no longer satisfied `AppContext` — any suite building a context failed to typecheck, and a handler calling `ctx.load` could not be tested at all. The harness now builds a loader over the same underlying store (one per context, so `withSubject` / `withTenant` can't serve one subject's cached rows to another), mirroring the real builder. - **The `.one()` codemod is declared for 0.5.0, not 0.4.0.** It was authored while 0.4.0 was unreleased; since codemods are selected with `from < version <= to`, leaving it at 0.4.0 would have silently skipped it for everyone upgrading from 0.4.0 — exactly the users who need it.

---

## [0.4.0] — 2026-07-18

### ⚠ BREAKING

- **@voltro/protocol, @voltro/plugin-rbac, @voltro/runtime** — **Resource-aware declarative guards + one denial tag.** `guards: [{ scope, resource }]` now actually scopes to a resource. Register a resolver — either `setResourceScopeResolver((req) => Effect<boolean>)` from `@voltro/protocol`, or `rbacPlugin({ resolveResourceRoles: (subject, resourceId) => roleSlugs })` — and the framework asks it, per request, whether the caller holds the scope **on the extracted resource** (a team, workspace, document), before the executor runs (mutations: before the transaction opens). A globally-held scope or `admin:full` still short-circuits without a resolver call; a resolver error fails **closed**. With no resolver registered, `resource` stays advisory (global check) exactly as before, so single-tenant apps are unaffected. **BREAKING — `@voltro/plugin-rbac` no longer exports `Forbidden`.** rbac's `permission()` / `assertPermission()` / `anyPermission()` now fail with protocol's `ScopeError` — the SAME typed error the declarative `guards:` enforcement already raised. One denial tag across the framework: a client that matches `_tag === 'ScopeError'` (or `errorTag(err)`) recognizes both a descriptor-guard denial and a `permission()` denial with one branch. The `@voltro/plugin-rbac/errors` subpath is removed. Migration (the `voltro update` codemod rewrites the mechanical part): - `import { Forbidden } from '@voltro/plugin-rbac/errors'` →   `import { ScopeError } from '@voltro/protocol'`; `import { Forbidden } from '@voltro/plugin-rbac'` →   `import { ScopeError } from '@voltro/plugin-rbac'` (re-exported from the root). - Every `Forbidden` identifier → `ScopeError` (incl. `error: Forbidden` on a descriptor). - By hand: a constructed `new Forbidden({ required, reason })` becomes   `new ScopeError({ required, message })` (the field is `message` and is   REQUIRED); and any `Effect.catchTag('Forbidden', …)` / `err._tag === 'Forbidden'`   string becomes `'ScopeError'`. Guards are normally caught, not constructed, so   most apps only need the import rewrite the codemod does. Also note: `requireScope` / declarative `guards:` enforcement is now async-capable (it awaits the resource resolver when one is registered) — no change to handler code, which never called the internal enforcement directly.
- **@voltro/client, @voltro/plugin-rbac** — **`@voltro/plugin-rbac/web` is removed — the UI permission gate lives in `@voltro/client`.** The framework shipped `useCan` TWICE, with the same name and the same semantics, over two different React contexts. An app that mounted `<PermissionProvider>` and imported the plugin's `useCan` got an empty scope set and silently hid every gated affordance — no error, just a UI where nothing is permitted. One of the two had to go. `@voltro/client` survives because scopes are a FRAMEWORK concept: `@voltro/protocol` owns `ScopeError`, `guards: [{ scope }]` and `ctx.access`, and rbac is only one way to PRODUCE scopes — an app can register its own `setResourceScopeResolver` over its own tables and never install the plugin. The hook consumes scopes, which is generic; producing them is rbac-specific. Keeping the hook behind the plugin would force an rbac dependency on apps that deliberately don't use rbac, purely to gate a button. `@voltro/plugin-rbac` keeps everything that IS rbac: the roles map, role→scope compilation, the `permission()` / `assertPermission()` / `anyPermission()` server guards, the role tables, and `resolveResourceRoles`. The codemod rewrites every mechanical site: `RbacScopeProvider` → `PermissionProvider`, `canFromScopes` → `canCall`, `canAnyFromScopes` → `canCallAny`, and repoints `useCan` / `useCanAny` / `ADMIN_SCOPE` to `@voltro/client`. A raw `useContext(RbacScopeContext)` is ANNOTATED rather than rewritten — the client equivalent is `usePermissions()`, which returns `{ scopes }` instead of the bare array, so a silent rewrite would compile and then hand back the wrong shape.

### Added

- **@voltro/runtime, @voltro/cli** — **`read({ where })` on aggregates.** Aggregate reads were all-or-nothing, so every consumer that wanted one slice pulled the whole materialised set over the wire and filtered in the component — the cost scaling with the aggregate, not the slice. `where` entries are ANDed; a scalar means strict equality, an array means IN; filtering runs before `orderBy`/`limit`. Deliberately a data filter over already materialised rows, not a predicate language — aggregates stay aggregates.
- **@voltro/client** — **Two ergonomics that remove the two most-repeated shapes in a Voltro frontend.** - **`mutate(input, options)` — `onSuccess` / `onError` / `notify`.** Every write   site used to re-type `try { await mutate() } catch { toast.error(…) } finally   { setSubmitting(false) }`. `pending` already replaced the `finally`; these   replace the rest. Load-bearing semantic: supplying an error handler (`onError`   or `notify.error`) marks the failure HANDLED — `mutate` resolves with   `undefined` instead of rejecting, which is what actually deletes the try/catch.   With no handler it rejects exactly as before, so unhandled failures stay loud.   `notify` routes to an app-registered sink (`setMutationNotifier`) — the   framework stays unbound to any toast library. - **`useSubscription` now returns `loading` and `isEmpty`, and accepts   `fallback`.** Call sites branched on `data === undefined`, conflating "no   snapshot yet" with "zero rows" — the cause of a flash of empty-state before the   first snapshot. They are now distinct, derived once. `fallback` fills `data`   while loading without lying about `loading`. Also documented: `error` carries a   COLD-START stream failure (check it to avoid an infinite skeleton); a failure   after data arrived deliberately does not blank good data — those reach the   error bus (`useOnRpcError`).
- **@voltro/cli** — **Make the client hook surface discoverable — and keep it that way.** A downstream app built its whole frontend on three transport hooks (`useSubscription` / `useMutation` / `useAction`) and hand-rolled forms, tables, upload, permission gates, debounce and pagination, because the agent guide documented ~none of the ~40 client hooks that already ship. Three changes: - **The always-loaded core now carries a "Pick the CLIENT primitive" rubric** —   the same decision-rubric treatment the backend primitives get, plus a   "you're about to write X → reach for Y" table (per-field `useState` →   `useFormBinding`, hand-rolled table → `useDataTable`, `FileReader` → `useUpload`,   `useMemo` fan-in → `useDerived`, …). - **The hook reference is complete.** `reference/hooks-overview.md` presented   itself as "the client-side hook surface" while documenting 13 of 44 hooks; it   now enumerates the schema-driven-UI, files/permissions/utilities, and AI   families (en + de). - **A coverage GATE** (`agentsMdTemplate.test.ts`): every exported `use*` hook of   `@voltro/client` must appear in the composed agent-docs corpus, or the test   fails with the list. Docs can no longer silently fall behind the export surface. Plus **`voltro doctor` now flags hand-rolls**: it scans an app's source for the patterns a shipped primitive covers (hand-rolled form/table, `FileReader` upload, `setTimeout` debounce, `useMemo` fan-in, local Next.js compat shims, hand-rolled presence) and names the primitive to use. Advisory, never fatal — a false-positive lecture must not fail a build.
- **@voltro/cli** — **Generated `matchError` / `AppError` / `AppErrorTag`.** Codegen now emits a per-app exhaustive error matcher into `rpcGroup.generated.ts`, derived BY REFERENCE from every descriptor's `error:` schema plus the cross-cutting plugin errors. `matchError(err, { [tag]: handler }, fallback?)` dispatches on `_tag` with the handler keys constrained to the app's ACTUAL error tags — so a hand-maintained tag list that silently drifts (dead/renamed tags) is gone; a stale tag is a compile error. Browser-safe (reads `_tag` structurally, no runtime dep). Workflows keep their existing `WorkflowErrors`.
- **@voltro/cli** — **`voltro doctor` + `voltro update --codemods-only`/`--from`** — two upgrade-path ergonomics from AWB adoption feedback: - **`voltro doctor [app]`** (and **`voltro serve --preflight`**) preflights an API   app for production serve: it verifies the precompiled serve bundle exists and,   if not, prints the exact fix and exits 1 — so a Dockerfile / CI step catches   "prod `voltro serve` with no `voltro build`" at BUILD time instead of at   cold-start (0.3.0 made an unbuilt production serve fatal). - **`voltro update --codemods-only` (alias `--run-codemods`) + `--from <version>`**   — re-apply the codemods + manual notes for an explicit `[from, to]` delta   WITHOUT bumping `package.json` or installing. The recovery path for a hand-edited   version bump (`bump package.json` + install first), which otherwise makes   `voltro update` report "already on X — nothing to do" and silently skip the   codemods. `--from` also overrides the auto-detected source version on a normal   update.
- **@voltro/runtime** — **`encryptField` / `decryptField` — standalone field cipher for raw-SQL paths.** `.encrypted()` columns encrypt/decrypt transparently inside the `ctx.store` middleware, so a code path that reaches the DB by RAW SQL (an auth strategy with no store handle reading/writing a session token, a one-off backfill) bypasses it. `@voltro/runtime` now exposes the SAME registered cipher standalone: `encryptField` encrypts a value; `decryptField` decrypts an `enc:v1:…` value and passes a non-ciphertext value through unchanged (so a raw-SQL path can adopt encryption while pre-existing plaintext rows keep working). Both throw a clear error when no cipher is registered. Encryption stops being all-or-nothing tied to going through `ctx.store`.
- **@voltro/testing** — **`makeVoltroTestClient` — the missing half of the test story.** The framework shipped a strong BACKEND test story (`makeTestContext`, `mockStore`, `mockAi`, the workflow runner) and nothing for the client. A Voltro frontend is a reactive-data app: with no way to render a component against mocked `useSubscription` / `useMutation` — and to inject a loading state, a stream error, or a failing write — it is structurally untestable, which is why real apps end up with zero frontend tests. `import { makeVoltroTestClient } from '@voltro/testing/client'` returns a `Provider` plus `setSubscription` / `failSubscription` / `resetSubscription` and a `calls` log of every write with its input. A tag ABSENT from the fixture stays in the loading state — the distinction from an empty result is exactly what you want to assert. Deliberately NOT a renderer: it hands you a Provider, so it works with `react-dom/client` + `act`, testing-library, or your own harness, and locks you into none of them.
- **@voltro/i18n** — **Plurals, Intl formatters, and catalogs a bundler can actually split.** - **`plural(locale, count, forms)` + `usePlural`** — real CLDR categories via   `Intl.PluralRules`. Replaces the hardcoded `"{count} epic(s)"` pattern, whose   literal `(s)` is simply wrong outside English, and which cannot express   languages that distinguish few from many (Polish 2–4 vs 5+). An explicit `zero`   form is honoured for exactly 0; ordinals via `{ type: 'ordinal' }`. - **`useFormatDate` / `useRelativeTime` / `useFormatNumber` / `useFormatCurrency`   / `useFormatters`** — locale-bound `Intl` wrappers, so an app stops carrying   several divergent hand-rolled "X minutes ago" helpers that each round   differently. - **`defineCatalogs` + `LazyI18nProvider`** — code-split catalogs.   `pickCatalog({ en, de })` is a STATIC import map: every locale is a value-level   import, so the bundler must emit them in one chunk and a German-only visitor   downloads English too, growing linearly per locale. A map of `() => import()`   loaders is the only shape a bundler treats as a chunk boundary. `preload(locale)`   before hydration keeps first paint synchronous via `peek()`; the provider's   `fallback` is for a locale SWITCH, not first paint. Unknown locales degrade to   the base catalog, and a failed chunk load stays retryable.
- **@voltro/protocol, @voltro/client** — **Nested optimistic: `shapeItem` + bulk (multi-item) targets.** Two additive improvements to path-targeted auto-optimistic: - **`shapeItem`** — the nested counterpart of `shape`, typed to the ITEM of the   nested array (not the mutation's output), so a path-target patch reads/returns   the item without casting `current`. (Previously the nested shaper was the flat   `shape`, pinned to the output row — every adopter had to cast. `shape` stays   bound to the output for flat targets; a single field can't be both, so the   nested shaper is its own.) - **Bulk `identify`** — a target's `identify` may now return `string[]` to   patch/delete MANY items (or top-level rows) in one mutation — the group-drag /   batch-edit case where per-item parallel writes used to race. Each patched item   keeps its own key. Runtime-compatible: a nested target that still uses `shape` falls back transparently. No codemod — additive.
- **@voltro/web, @voltro/client** — **Two hooks apps kept hand-rolling, because nothing shipped them.** - **`useTheme` (`@voltro/web`)** — returns `{ theme, resolvedTheme, setTheme }`   over the framework's own `voltro:theme` cookie and the `html.dark` class. The   bug it removes is specific: an app that hand-rolls a second theme store ends up   with two writers on one class, so the toggle's state and the rendered theme can   disagree, and the pre-paint script flashes the wrong one. `'system'` resolves   through `matchMedia` and tracks live OS changes. - **`useConnectionStatus` (`@voltro/client`)** — `'connected' | 'degraded' |   'offline'`, derived from the only two signals the client honestly has:   `navigator.onLine` (browser says the network is gone) and the rpc error bus (a   call actually failed). `degraded` means "we saw a failure and no success since";   coming back online clears it, because the failures counted during an offline   window ARE that window. Deliberately no polling ping just to colour an   indicator.
- **@voltro/database** — **`.uniqueActive([cols])` now works on mysql / mariadb** (it previously failed loud at migrate — those engines have no partial index). It lowers automatically to a STORED generated column per key column — `CASE WHEN <predicate> THEN CAST(<col> AS CHAR(255)) ELSE NULL END` — plus a UNIQUE over them. NULL-distinct uniqueness means a soft-deleted row's generated columns are all NULL and never collide, so re-creating the key just works — the same resurrection-safe semantics the partial index gives on postgres/sqlite/mssql. The lowering is applied identically on the emit side and the declared-snapshot side, so it round-trips through the declarative differ (verified live on MariaDB: declare→migrate→introspect→re-plan is a no-diff, and a duplicate among active rows is rejected while soft-deleting one frees the key). You write the same `.uniqueActive([...])` on every dialect.

### Fixed

- **@voltro/cli** — **Standalone `voltro codegen` now emits the SAME `rpcGroup.generated.ts` as `voltro dev` / `voltro build`.** Previously the standalone command skipped the app's plugins, so it dropped every plugin's cross-cutting error union AND its client RPC routes — an inconsistent generated file (typed plugin errors gone, plugin route tags unresolved) vs the dev/serve boot path. The command now loads `app.config` and gathers the plugins' codegen inputs through the SAME `gatherCodegenPluginImports` the boot path uses (single-sourced, so the three paths can't drift).
- **@voltro/cli** — **The hook-coverage gate was too weak, and it let dead documentation through.** It asked only whether a hook's NAME appeared in the agent-docs corpus — which a one-line row in a link table satisfies. Ten client hooks had exactly that, and six of those rows linked to a page that never mentions the hook, so a reader following the link learned nothing. Two sharper checks replace it: - **Substance** (`agentsMdTemplate.test.ts`): a hook must appear at least once   OUTSIDE a table row, so a bare row no longer counts as documentation. - **Dead references** (`gen-agent-docs.mjs`): a row promising `[`useX`](/docs/…)`   must point at a page that actually mentions `useX`. This lives in the generator   because that is the only thing which reads the docs tree — and it walks BOTH   language trees, since an en-only check leaves every German dead link invisible.
- **@voltro/cli** — - **Serve bundle: inline the pure-JS SQL drivers (`pg`/`mysql2`/`tedious`) instead of runtime-shimming them.** A production `voltro serve` with a postgres/mysql/mssql store crashed at boot with `Pg.Pool is not a constructor` (and the mysql/mssql equivalents): the driver's `@effect/sql-*` consumer links its leaf as a namespace (`import * as Pg from 'pg'; new Pg.Pool()`), but the native-leaf CJS shim (`module.exports = <leaf>`) did not surface the named members through esbuild's `import * as` interop, so `Pg.Pool` was `undefined`. These drivers are pure JS (no `.node`), so they are now inlined into the serve bundle like the rest of the framework — esbuild links the real module and the namespace resolves correctly. Only genuinely-native leaves (`better-sqlite3`, the turso/libsql addons, `pg-native`) and the dynamically-imported `ioredis`/`nodemailer` still resolve via the runtime shim. Verified end-to-end from a relocated prod tree (a real `POST /rpc` through the inlined `pg`).

---

## [0.3.0] — 2026-07-18

### ⚠ BREAKING

- **@voltro/cli** — `voltro build` precompiles the whole serve path (framework + effect + `@voltro` inlined, app modules as lazy chunks) into a single **serve bundle**, and `voltro serve` boots from it in-process — cutting `serve: ready` from ~1000 ms to ~180 ms (5–6×; the win is larger on a cold scale-to-zero container). The app's declared SQL driver is inlined (only the native leaf like `pg` stays external, resolved at runtime so it survives the deploy relocation). **Production now serves ONLY from the bundle and NEVER transpiles on demand:** the bundle build externalises unresolvable optional peers (e.g. `@react-email/render` behind `@voltro/plugin-mail`) so it always builds; a bundle-build failure is **fatal** (`voltro build` exits non-zero); and an unbuilt production `voltro serve` fails loud instead of falling back to tsx. The build toolchain (`tsx`, `esbuild`, `vite`, `@vitejs/plugin-react`, `@tailwindcss/vite` + their native tree: rolldown/lightningcss/postcss/jiti) moves to **`optionalDependencies`** of `@voltro/cli`, so `pnpm --prod --no-optional deploy` yields a serve image with none of it — a prod API image's `node_modules` drops ~305 MB → ~131 MB, structurally, with no fragile prune list. `voltro dev` and a non-production local `voltro serve` are unchanged (still tsx). **Migration:** in production (`NODE_ENV=production`) run `voltro build` before `voltro serve`. The generated Dockerfiles already do; a custom Dockerfile / start script adds a `voltro build .` step before `voltro serve .` (`voltro update` prints this — see the 0.3.0 codemod note).

### Added

- **@voltro/protocol, @voltro/runtime, @voltro/plugin-rbac** — Declarative authorization `guards:` on `defineMutation` / `defineQuery` / `defineAction`. The framework enforces the declared scope(s) in the dispatch spine BEFORE the executor (for a mutation, before the transaction opens), fails with a typed `ScopeError`, and auto-merges `ScopeError` into the wire error union so the client decodes the denial typed. Guards are browser-safe DATA (scope strings + a pure `resource: (input) => id` extractor). Checks run against the caller's EFFECTIVE scope set — raw subject scopes ∪ `@voltro/plugin-rbac` role-derived scopes — via a new canonical effective-scope seam in `@voltro/protocol` (`effectiveScopes` / `setEffectiveScopes` / `checkGuards`), which rbac now publishes to (so a role-granted scope satisfies a `guards:` entry and the in-handler `permission()` identically). Adds `ctx.access` (`has` / `hasAny` / `require` / `scopes`) — the cast-free typed authorization slice on every handler context. Enforcement is single-sourced in the shared serve pipeline, so `voltro dev` and `voltro serve` can't drift.
- **@voltro/protocol, @voltro/client** — Nested / path-targeted auto-optimistic. A mutation `target` can now patch a nested array INSIDE a query's value — a JSON array column (`snapshot.projects`) or a computed/shaped result — at item granularity, via `path` (dot-path to the array), `by` (item key, default `id`), and `match` (a pure predicate that scopes the patch to the entries whose current value satisfies it, preventing a patch bleeding across sibling subscriptions that share a source table). Previously auto-optimistic only patched the flat top-level row array keyed by `id`; nested values needed a hand-written `.withOptimistic` reducer. `path`/`by`/`match` are browser-safe descriptor data (a dot-path string + pure predicate), same discipline as `identify`/`shape`. A path insert is applied even on a computed entry (it targets a known document, not a blind top-level add).
- **@voltro/runtime** — `ctx.store.applyDefined(input, keys)` (and a standalone `applyDefined` export from `@voltro/runtime`) — builds a partial-update patch keeping only the listed keys whose value the caller actually provided (`!== undefined`; a defined falsy value like `0`/`''`/`false` is kept). Collapses the per-field `if (input.x !== undefined) patch.x = input.x` idiom every partial-update mutation hand-writes.
- **@voltro/database** — `.uniqueActive([cols], opts?)` on the table builder — a portable partial-UNIQUE constraint that holds only among the rows matching a predicate (default `"deletedAt" IS NULL`, pairing with `.softDelete()`). Emits `CREATE UNIQUE INDEX … WHERE` on postgres / sqlite / mssql, so a soft-deleted row leaves the active set and a NEW row with the same key inserts cleanly — no hand-written `generatedAs("CASE WHEN …")` column and no resurrection footgun. On mysql / mariadb (no partial-index support) it FAILS LOUDLY at migrate time rather than silently emitting a full unique index that would forbid re-creating a soft-deleted key — the generated-STORED-column lowering for those dialects is a follow-up. Kept out of the declarative index snapshot (the incremental planner is predicate-blind and would misclassify a unique+partial index as a full constraint), so the fresh-schema DDL path is its sole emitter and there is no re-diff churn. Live-verified against postgres.
- **@voltro/cli** — `voltro update` upgrades an app to the latest framework: it bumps every `@voltro/*` dependency, installs with the detected package manager, and runs the codemods shipped with the target version. Codemods are authored with `defineCodemod` + an import-scoped ts-morph helper toolkit (`renameImport`, `renameModuleSpecifier`, `renameJsxProp`, `renameObjectKey`, `add`/`removeImport`, structural `changeCallArgs`/`wrapCall`, `annotate`) and run against the app source; a `manual` kind surfaces written steps for changes that can't be automated. Breaking public-API changes now ship a codemod (or an explicit `codemod: none`), enforced by the changelog gate. Framework-owned `_voltro_*` table changes continue to ride the declarative differ on `voltro db apply` / `voltro dev` boot — `update` does not touch the database.

### Fixed

- **@voltro/database** — The core-table registry (`registerCoreTables` / `requireActors` / `requireTenants`) now stores its state on a process-global `Symbol.for` singleton, the same mechanism the main table registry already uses — instead of module-local `let` bindings. Module-local state splits when the `@voltro/database` module is duplicated in a process (e.g. resolved through both the `.` and `./sql` entry points, or a bundled framework copy alongside an externally-resolved one): one instance's `registerCoreTables` becomes invisible to the instance that reads it, surfacing as a spurious `core 'actors' table not registered` at store construction. Pinning it to `globalThis` makes every copy share one store, matching the table registry's already-global behaviour.

---

## [0.2.2] — 2026-07-17

### Added

- **@voltro/cli** — `voltro build` now precompiles the whole serve path (framework + effect + `@voltro` inlined, app modules as lazy chunks) into a single **serve bundle** (`.framework/dist-api/serveBundle/serveEntry.js`), and `voltro serve` boots from it in-process — no child `node --import tsx`, no CLI command graph, no per-module resolution of the ~2700-module framework graph. This cuts `serve: ready` from ~1000 ms to ~180 ms (~5–6×) on both driverless (memory) and driver-backed (postgres) apps; the win is larger on a cold scale-to-zero container where module resolution dominates. The app's declared SQL driver is inlined into the bundle so it shares the framework's single effect instance (only the native leaf like `pg` stays external, resolved at runtime so it survives the deploy relocation). Fully fallback-safe: a missing, stale, or corrupt bundle degrades to the standard tsx serve path, so it can never stop `voltro serve` from booting. Nothing to configure — building an API app produces the bundle and serving prefers it automatically.

### Fixed

- **@voltro/database** — The core-table registry (`registerCoreTables` / `requireActors` / `requireTenants`) now stores its state on a process-global `Symbol.for` singleton, the same mechanism the main table registry already uses — instead of module-local `let` bindings. Module-local state splits when the `@voltro/database` module is duplicated in a process (e.g. resolved through both the `.` and `./sql` entry points, or a bundled framework copy alongside an externally-resolved one): one instance's `registerCoreTables` becomes invisible to the instance that reads it, surfacing as a spurious `core 'actors' table not registered` at store construction. Pinning it to `globalThis` makes every copy share one store, matching the table registry's already-global behaviour.

### Internal (no consumer-facing effect)

- **@voltro/cli** — `appModuleLoader` now accepts lazy `() => import()` loaders alongside eager module namespaces (the eager path — today's `apiEntry.js` bundle — is unchanged). Groundwork for the serve bundle: app modules registered as lazy loaders evaluate on first `importAppModule` (during `runServe`, after `registerCoreTables`) rather than eagerly at bundle-import time. No consumer-facing effect on its own.

---

## [0.2.1] — 2026-07-17

### Fixed

- **@voltro/cli, @voltro/database** — `voltro codegen` / `dev` / `serve` / `build` no longer crash with `Cannot find package '@voltro/sql-mysql'` on an app that doesn't declare that driver (e.g. a memory-store app, or a postgres app for the mysql/mssql drivers). The CLI's framework-table assembly statically imported the CDC-offsets table schema from `@voltro/sql-mysql` / `@voltro/sql-mssql` (`import { _voltroCdcOffsetsTable } from '@voltro/sql-mysql'`), so merely LOADING the CLI resolved those driver packages — failing whenever one wasn't installed. This surfaced only in a production install where the drivers aren't present (0.2.0's app-declared-driver change), not in the workspace where all drivers are devDependencies. The two CDC-offsets table schemas (pure schema, no driver runtime) move to `@voltro/database` — the driver-agnostic package both the CLI and the drivers already depend on; the drivers re-export them so their public API + CDC readers are unchanged. The CLI now references a SQL driver only through the runtime `importDriver()` lookup (lazy, and only for the dialect actually in use). The lazily-imported driver modules are also typed with local structural interfaces instead of `typeof import('@voltro/sql-*')`, so no driver package is referenced in a type position either.

---

## [0.2.0] — 2026-07-17

### ⚠ BREAKING

- **@voltro/cli** — The SQL dialect drivers (`@voltro/sql-postgres`, `@voltro/sql-mysql`, `@voltro/sql-mssql`, `@voltro/sql-sqlite`, `@voltro/sql-turso`) are no longer dependencies of `@voltro/cli`. Each app now declares the driver for its own DB dialect as a dependency, and `voltro dev`/`serve`/`migrate` resolve it from the app root. This keeps every driver — including turso's ~90 MB native binary — out of the production image of an app that doesn't use it: a `store: 'memory'` app ships no SQL driver at all, and a postgres app ships only `@voltro/sql-postgres`. Combined with a `pnpm --prod deploy`-based Dockerfile (the standalone/template Dockerfiles now do this), a memory-API image drops its `node_modules` from ~477 MB to ~330 MB. **Migration:** add the driver for your dialect to the app that uses it — `pnpm add @voltro/sql-postgres` (or `-mysql` for mysql/mariadb, `-mssql`, `-sqlite`, `-turso`). A memory store needs none. Scaffolded projects already declare it, and `voltro add mssql` adds `@voltro/sql-mssql` automatically. If the driver is missing at boot, `voltro serve` now fails with a message naming the exact package to install instead of an opaque module-not-found.

---

## [0.1.6] — 2026-07-17

### Added

- **@voltro/cli** — `voltro serve` now logs how long it took to become ready (`serve: ready in <n>ms`), and with `VOLTRO_BOOT_TIMING=1` breaks that total into per-phase timings — `modules` (node init + module-graph load/compile), `config`, `discover`, `store`, `plugins`, `workflow`, `ready`. This is the diagnostic for cold-start latency on scale-to-zero containers: read it from the container's own logs at its real CPU allotment to see which phase dominates. On a cold process the `modules` phase (evaluating the dependency graph) is almost always the largest, and no app-level change — precompiling the API with `voltro build` included — shrinks it; precompiling removes only the transpile of the app's own source files. The feature is a handful of `performance.now()` calls and one log line, safe to leave on in production.

---

## [0.1.5] — 2026-07-16

### Added

- **@voltro/cli** — `voltro build` now precompiles an API app (previously it only handled web apps). It bundles the whole handler closure — every procedure/executor, workflow, subscriber, reaction, aggregate, agent, tool, webhook, cron, startup, `app.config`, and their shared `database`/`lib` deps — into a single `.framework/dist-api/apiEntry.js` (framework/npm kept external), via a two-pass esbuild build (pass 1 discovers the full module closure so shared side-effectful modules like the schema are covered; pass 2 emits the bundle + a module map). `voltro serve` loads that one bundle at boot and resolves every app module from it — no `node --import tsx` runtime transpilation, and a SINGLE instance of each module (so side-effectful modules like the table registry aren't evaluated twice). Without a build — `voltro dev`, or `voltro serve` on an unbuilt app — every module still loads from source exactly as before, and a per-module miss falls back to source too, so a stale/partial bundle degrades safely. Note: an API's boot is dominated by the framework/Effect module-graph evaluation, not app-module transpilation, so the bundle is primarily a correctness/hygiene win (no source transpilation in production) rather than a large cold-start reduction.
- **@voltro/i18n** — Two message accessors that react-intl parity was missing. `useMessages()` returns the active locale's RAW (unformatted ICU) catalog — `useMessages()['some.id']` gives the template, not the formatted output — for when you need the raw string. `pickCatalog(catalogs, locale, defaultLocale)` resolves a catalog for an ARBITRARY locale OUTSIDE React (for `meta({ locale })` and other non-hook call sites where `useT` can't run); it returns the concrete catalog type, so a known-key lookup is `string` (not `string | undefined`) — the exact shape `PageMeta.title` needs, replacing the hand-rolled `getCatalog(locale)` helper apps kept copying.
- **@voltro/cli, @voltro/workflow** — `VOLTRO_WORKFLOW_RUNNER_STORAGE` (`memory` | `sql`) forces the workflow-engine runner storage instead of always deriving it from the store dialect. The load-bearing case is `memory` on a real SQL dialect (postgres / mysql / mariadb / mssql): it runs the single-process durable engine — workflow run state stays SQL-backed via `@effect/cluster`'s `SqlMessageStorage` — but SKIPS `SqlRunnerStorage` entirely, so there is no `cluster_runners` / `cluster_locks` table and none of its `GET_LOCK` advisory-lock acquisition. That unblocks a managed MySQL / MariaDB reached through a connection-load-balancing Service or a non-session-pinned pooler, where the advisory-lock connection can't be pinned to one backend and the runner-storage bootstrap wedges before it ever creates its table (the pod stays un-Ready while a shard-lock refresher errors forever). The tradeoff is no cross-pod shard handoff. An invalid value — or `sql` on sqlite / turso — fails boot loudly rather than silently selecting a broken engine. The `voltro cluster status` snapshot and the boot log both report the resolved storage.
- **@voltro/cli, @voltro/workflow** — Durable-workflow clustering now keeps **cross-pod handoff on Galera / Percona XtraDB (multi-primary) clusters**. `@effect/cluster`'s default shard-ownership coordination uses session advisory locks (`GET_LOCK` / `pg_advisory_lock`), which are node-local and can't coordinate a fleet whose connections span cluster nodes — so on a Galera cluster behind a load-balancing Service, pods split-brain shard ownership and the runner-storage bootstrap can wedge (pod never becomes Ready). The new **`VOLTRO_WORKFLOW_SHARD_LOCK`** (`auto` | `row` | `advisory`, default `auto`) fixes this: `auto` probes the live connection (`@@wsrep_on`) and, on a wsrep cluster, switches `SqlRunnerStorage` to a certified row-lease on the `cluster_locks` table (`INSERT … ON DUPLICATE KEY UPDATE … WHERE acquired_at < expiry`) instead of advisory locks — Galera certifies that write across all nodes, so shard ownership and dead-pod handoff stay correct without a single-writer proxy. A single-primary server keeps the faster advisory path. The resolved mode is reported in the boot log and in `voltro cluster status` (`shard-lock=row`). Non-wsrep multi-primary topologies (e.g. MySQL Group Replication) can force it with `VOLTRO_WORKFLOW_SHARD_LOCK=row`; an unrecognized value fails boot loudly.

### Fixed

- **@voltro/cli** — `voltro start` no longer loads every page module at boot, so a large mostly-static site (e.g. a docs site with hundreds of prerendered routes) boots with memory proportional to its ssr/isr routes instead of its total routes — it used to OOM a modest container even though only a handful of routes ever need a runtime module. The SSR bundle (`voltro build`) now emits page/layout modules as lazy `() => import()` loaders plus a build-time `pageMeta` manifest; `voltro start` reads render mode / tenant-awareness / revalidate from the manifest and imports a page's module (and its content chunk) only when that ssr/isr route is actually rendered. A 500+ route docs site that OOMed a 512 MB container now boots at ~190 MB. Bundles without a `pageMeta` manifest (older builds) fall back to the previous load-every-module behaviour, and `voltro dev` (Vite middleware mode) is unchanged.
- **@voltro/web** — Static prerendering (`voltro build`) no longer crashes with "Router hooks must be used inside <Router>" on pages whose layout reads router/i18n context (e.g. URL-prefix locale). The framework's React contexts are now process-wide singletons pinned on the global symbol registry, so the SSG renderer (loaded via Vite's `ssrLoadModule('@voltro/web/ssr')`) and the app's page modules (with `@voltro/web` externalised to Node) always resolve the SAME context instance even though the prerender loads the package through two module instances. This only bit consumers building against the published npm package; the framework's own workspace build resolves `@voltro/web`'s source and masked it.

### Internal (no consumer-facing effect)

- **@voltro/voltro** — Package READMEs no longer carry a relative `[Changelog](./CHANGELOG.md)` link — npm resolved it to a 404 (`npmjs.com/package/@voltro/CHANGELOG.md`) because relative README links don't resolve for scoped packages, and there is no public changelog URL (the repo is private). The `CHANGELOG.md` is still bundled in each package tarball. The README link row is now Documentation · voltro.dev · Voltro Cloud.

---

## [0.1.1] — 2026-07-15

### Fixed

- **@voltro/ai** — `AgentDescriptor` / `AgentExecutor` no longer carry a `unique
  symbol` type brand (only the portable string `_brand`). A d.ts bundler
  duplicates a `unique symbol` per entry point, so a descriptor built with
  `defineAgent` (from the browser-safe `@voltro/ai/agent` subpath) was not
  assignable to `defineAgentExecutor` (from the main entry) when the package is
  consumed from npm — the canonical agent pattern failed to typecheck for every
  published-package consumer. Type-only; the runtime brand was never read.
- **@voltro/cli** — the `voltro` bin resolves the `tsx` loader relative to
  `@voltro/cli` (via `import.meta.resolve`) instead of a bare `--import tsx`
  resolved from the consuming app's CWD, so a standalone (non-monorepo) install
  no longer fails with "Cannot find package 'tsx'" at `voltro dev` / `voltro
  build`.

---

## [0.1.0]

Initial pre-release baseline. This is the starting point the changelog tracks
from; it is not an exhaustive history of prior development. The framework ships
as `@voltro/*` packages spanning the runtime, database/query layer, web client
and router, durable workflows, the CLI, and the plugin ecosystem
(auth, storage, mail, billing, observability, and more). Not published to a
public registry yet.

Subsequent releases record their deltas from here under dated headings, with
`⚠ BREAKING` first.
