@voltro/cli 0.22.0 → 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/CHANGELOG.md +383 -0
  2. package/dist/apiBuild-CD-4JLLA.js +2 -0
  3. package/dist/{apiBuild-2zRxj2hJ.js → apiBuild-JQtIhZPy.js} +2 -2
  4. package/dist/bin.js +3 -3
  5. package/dist/{commands-DGaeTn2f.js → commands-Bsu9Buln.js} +1764 -1492
  6. package/dist/dbCommand-CSguuUQn.js +2 -0
  7. package/dist/{dbCommand-DVASmZj2.js → dbCommand-GI7-BVZt.js} +440 -259
  8. package/dist/{dev-BEYBSGJ7.js → dev-DRxF_qOB.js} +1 -1
  9. package/dist/{dev-DeHVUxQx.js → dev-jM07kq_D.js} +1479 -1479
  10. package/dist/index.js +1 -1
  11. package/dist/{inspectMetrics-4AVPn6IS.js → inspectMetrics-dcC7gnxu.js} +603 -560
  12. package/dist/{serveCommand-DEVTtnLy.js → serveCommand-Cl8sicD5.js} +230 -229
  13. package/dist/serveEntry.js +2 -2
  14. package/dist/{start-BjGUIIvj.js → start-C_4VUDT8.js} +327 -325
  15. package/dist/startEntry.js +2 -2
  16. package/package.json +17 -17
  17. package/templates/AGENTS.md +1 -1
  18. package/templates/agent-docs/_index.md +1 -1
  19. package/templates/agent-docs/_manifest.json +1 -1
  20. package/templates/agent-docs/caching.md +21 -5
  21. package/templates/agent-docs/cli.md +44 -0
  22. package/templates/agent-docs/data.md +23 -5
  23. package/templates/agent-docs/database/migrations.md +94 -0
  24. package/templates/agent-docs/plugins.md +42 -0
  25. package/templates/agent-docs/routing.md +23 -1
  26. package/templates/agent-docs/templates/apibackends.md +2 -2
  27. package/templates/agent-docs/whats-new.md +203 -136
  28. package/templates/apps/api-ai/package.json +7 -7
  29. package/templates/apps/api-auth/package.json +8 -8
  30. package/templates/apps/api-backend/package.json +7 -7
  31. package/templates/apps/api-backend-deactivation/package.json +7 -7
  32. package/templates/apps/api-backend-mail/package.json +8 -8
  33. package/templates/apps/api-backend-mariadb/package.json +9 -9
  34. package/templates/apps/api-backend-storage/package.json +8 -8
  35. package/templates/apps/api-data-advanced/package.json +8 -8
  36. package/templates/apps/api-durable/package.json +8 -8
  37. package/templates/apps/api-feature-flags/package.json +9 -9
  38. package/templates/apps/api-governance/package.json +8 -8
  39. package/templates/apps/api-kv/package.json +8 -8
  40. package/templates/apps/api-moderation/package.json +8 -8
  41. package/templates/apps/api-observability/package.json +8 -8
  42. package/templates/apps/api-ratelimit/package.json +8 -8
  43. package/templates/apps/api-rbac/package.json +8 -8
  44. package/templates/apps/api-rest/package.json +7 -7
  45. package/templates/apps/api-saas/package.json +11 -11
  46. package/templates/apps/api-search/package.json +8 -8
  47. package/templates/apps/api-versioning/app.config.ts +6 -4
  48. package/templates/apps/api-versioning/package.json +8 -8
  49. package/templates/apps/api-webhooks/package.json +9 -9
  50. package/templates/apps/changelog/package.json +6 -6
  51. package/templates/apps/edge-functions/package.json +2 -2
  52. package/templates/apps/frontend-admin/package.json +8 -8
  53. package/templates/apps/frontend-app/package.json +8 -8
  54. package/templates/apps/frontend-blank/package.json +7 -7
  55. package/templates/apps/frontend-contact/package.json +7 -7
  56. package/templates/apps/frontend-dashboard/package.json +7 -7
  57. package/templates/apps/frontend-docs/package.json +7 -7
  58. package/templates/apps/frontend-i18n/package.json +6 -6
  59. package/templates/apps/frontend-landing/package.json +7 -7
  60. package/templates/apps/frontend-spa/package.json +7 -7
  61. package/templates/apps/frontend-ssr/package.json +7 -7
  62. package/templates/apps/frontend-ssr-api/package.json +8 -8
  63. package/templates/apps/frontend-static-blog/package.json +6 -6
  64. package/dist/apiBuild-sqNmWmY5.js +0 -2
  65. package/dist/dbCommand-CO3eSAZR.js +0 -2
@@ -1,4 +1,4 @@
1
- # What's new in 0.22.0
1
+ # What's new in 0.23.0
2
2
 
3
3
  Read this FIRST when a task touches an area you have not worked in recently.
4
4
  It is the cheapest way to notice that the framework grew the thing you were
@@ -9,245 +9,312 @@ BREAKING entries name a codemod; run `voltro update` to apply it.
9
9
 
10
10
  ### ⚠ BREAKING
11
11
 
12
- - **@voltro/protocol, @voltro/runtime, @voltro/cli** — **`cache: { scope: 'tenant' }` one entry per org, none shared across orgs.**
12
+ - **@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.**
13
13
 
14
- 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.
14
+ ```ts
15
+ versioningPlugin({}) // every app table
16
+ versioningPlugin({ exclude: [domainEvents] }) // opt one out — by value
17
+ versioningPlugin({ include: [aiFlowsTable] }) // add a PLUGIN's table
18
+ ```
15
19
 
16
- `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:
20
+ The old shape had two failure modes and both were silent:
17
21
 
18
- > `'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.
22
+ - 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.
19
23
 
20
- 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.
24
+ 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)`).
21
25
 
22
- ```ts
23
- export const last12Months = defineQuery({
24
- name: 'globalStatistics.last12Months',
25
- input: Schema.Struct({}),
26
- output: Stats,
27
- source: ['invoices', 'employees'],
28
- cache: { ttl: '5m', scope: 'tenant' },
29
- })
30
- ```
26
+ **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.
31
27
 
32
- Rubric, now three-way: does the resolved predicate depend on the caller? On the PERSON `subject`; on their ORG only `tenant`; on neither `global`.
28
+ **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.
33
29
 
34
- **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.
30
+ A table named in BOTH `include` and `exclude` throws at construction rather than picking one only the author knows which was the mistake.
35
31
 
36
- **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.
32
+ 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.
37
33
 
38
- **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.
39
- - **@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.**
34
+ **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.
40
35
 
41
- `/_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.
36
+ `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.
42
37
 
43
- 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.
38
+ ### Added
44
39
 
45
- **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.*
40
+ - **@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.
46
41
 
47
- **`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.
42
+ `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:
48
43
 
49
- **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).
44
+ - **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.**
50
45
 
51
- 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.
52
- - **@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`.
46
+ 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.
53
47
 
54
- 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".
48
+ 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.
55
49
 
56
- 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.
50
+ 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.
57
51
 
58
- **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.
52
+ **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.
59
53
 
60
- **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.
61
- - **@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.**
54
+ The ordering is the product, not the SQL, and every step is there because skipping it loses data you find out about later:
62
55
 
63
- `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.
56
+ 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.
64
57
 
65
- **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.
58
+ 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.
66
59
 
67
- **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:
60
+ 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.
68
61
 
69
- ```ts
70
- table('_voltro_notes', { id: id({ prefix: 'note' }), body: text() }).renamedFrom('notes')
71
- ```
62
+ **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.
72
63
 
73
- `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:
64
+ 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.
65
+ - **@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.
74
66
 
75
- - **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.
67
+ 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.
76
68
 
77
- A marker whose old table is simply absent is a quiet no-op, so it survives a staged rollout.
69
+ Three findings, all exact:
78
70
 
79
- **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 UNIQUEwhich 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.
71
+ - **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.
80
72
 
81
- 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.
73
+ 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.
82
74
 
83
- 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.
75
+ 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.
76
+ - **@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.
84
77
 
85
- **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).
78
+ | 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) |
86
79
 
87
- **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.
80
+ 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.
88
81
 
89
- ### Added
82
+ 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.
90
83
 
91
- - **@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.**
84
+ **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.
85
+ - **@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.
92
86
 
93
- 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.
87
+ `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**.
94
88
 
95
- 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.
89
+ ```ts
90
+ const mode = new URLSearchParams(ctx.search).get('mode')
91
+ throw new RedirectError(`/?error=${code}${mode ? `&mode=${mode}` : ''}`)
92
+ ```
96
93
 
97
- `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.
94
+ 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.
98
95
 
99
- **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.
96
+ **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.
100
97
 
101
- 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.
98
+ 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 `''`.
99
+ - **@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.**
102
100
 
103
- **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`).
101
+ ```ts
102
+ import { aiFlowsTable } from '@voltro/plugin-ai-flows'
103
+
104
+ export const flowFavourites = table('flow_favourites', {
105
+ id: id({ prefix: 'fav' }),
106
+ flowId: reference(() => aiFlowsTable, { onDelete: 'cascade' }),
107
+ })
108
+ ```
109
+
110
+ 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."*
104
111
 
105
- **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.
112
+ **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.
106
113
 
107
- Two related asks from the same report are NOT in this change, deliberately:
114
+ 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.
108
115
 
109
- - **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.
110
- - **@voltro/cli** — **`voltro doctor` gained an authz scan, and the check it replaces was measured wrong in both directions.**
116
+ **Two corrections that came out of building it**, because designing on the stated model would have produced the wrong thing:
111
117
 
112
- 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.
118
+ - **`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.
113
119
 
114
- Three things were wrong with the old rule, and each is answered:
120
+ `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.
121
+ - **@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.
115
122
 
116
- **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".
123
+ **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.
117
124
 
118
- **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.
125
+ 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.
119
126
 
120
- **`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.
127
+ **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.
121
128
 
122
- **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.
129
+ 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.
123
130
 
124
- **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.
131
+ Measured after: **0 failures in 25 consecutive runs** of the suite that previously failed about one run in nine.
125
132
 
126
- 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.
133
+ Production is untouched: the wildcard is still the default, and nothing here runs outside a test process.
127
134
 
128
- **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.
129
- - **@voltro/protocol, @voltro/cli** — **`internal: true` keeps a procedure off the wire. There was no way to say that.**
135
+ ### Fixed
130
136
 
131
- `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.
137
+ - **@voltro/database, @voltro/sql-postgres** **`cdcChannel` was an option that did nothing.** Setting it produced zero change events and zero errors.
132
138
 
133
- 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:
139
+ 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:
134
140
 
135
141
  ```
136
- auditLogInternal.createFromAction
137
- input: { actorId, actorType, actorEmail, actorName, resourceType,
138
- resourceId, eventType, before, after, teamId, … }
142
+ cdcChannel=(default) → events received: 1
143
+ cdcChannel=my_own_channel events received: 0
139
144
  ```
140
145
 
141
- 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.
146
+ 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.
142
147
 
143
- ```ts
144
- export const createFromAction = defineMutation({
145
- name: 'auditLog.createFromAction',
146
- input: Schema.Struct({ /* … */ }),
147
- output: Schema.Void,
148
- internal: true, // no client-group entry, no route in dev or serve
149
- })
150
- ```
148
+ `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.
151
149
 
152
- Server code calls it by importing its executor directly, which is what a server-to-server caller already does.
150
+ **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`.
153
151
 
154
- **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.
152
+ 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.
153
+ - **@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.
155
154
 
156
- Available on queries, mutations, actions **and streams**. A stream without it would have been a hole in the same boundary; `tsc` caught that omission.
155
+ A consumer mapped it exactly while working out how a months-old dump lands on today's schema:
157
156
 
158
- **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.
157
+ | 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) |
159
158
 
160
- `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.
159
+ 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.
161
160
 
162
- ### Fixed
161
+ **Two different answers, because the two paths are not the same problem.**
162
+
163
+ - **`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.
164
+
165
+ ### Also from the same report
166
+
167
+ **`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.
168
+
169
+ **`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.
170
+
171
+ **`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.
163
172
 
164
- - **@voltro/workflow** **The cross-dialect cluster-engine suite poisoned the database it tests against, and got less reliable the more you ran it.**
173
+ **`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.
174
+ - **@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.
165
175
 
166
- `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`.
176
+ 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.
167
177
 
168
- Measured on mysql, one file, back to back:
178
+ 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.
169
179
 
170
- | after | `cluster_messages` rows | |---|---| | run 1 | 15 | | run 2, with the purge | 15 | | run 2, purge disabled | 30 |
180
+ | | before | after two names | |---|---|---| | no access check | 447 | 246 | | vocabulary | 91 | 294 |
171
181
 
172
- 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.
182
+ 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.
173
183
 
174
- 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.
184
+ **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.
175
185
 
176
- 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.
177
- - **@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".
186
+ 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.
187
+ - **@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.
178
188
 
179
- **Two independent missing deadlines, on the same path.**
189
+ 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.
180
190
 
181
- 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.
191
+ 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.
182
192
 
183
- 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.
193
+ **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.
184
194
 
185
- **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.
195
+ 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.
196
+ - **@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
186
197
 
187
- 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.
198
+ TypeError: Cannot read properties of undefined (reading 'key')
188
199
 
189
- **`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.
190
- - **@voltro/database, @voltro/cli** — **A framework table that changed SHAPE never reached an existing database, and what `voltro dev` did depended on your dialect.**
200
+ 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.
191
201
 
192
- `_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.
202
+ 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.
193
203
 
194
- 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.
204
+ `serveApi.ts` already carried a comment describing that exact crash in the opposite directiona 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.
195
205
 
196
- **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.
206
+ Three further holes came out with it:
197
207
 
198
- **Nothing to run.** A boot applies framework-table changes wherever it applies your own. No `voltro db apply` step, no dialect-specific instruction.
208
+ - **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.
199
209
 
200
- Two things worth knowing, because they are how a half-finished version of this looked correct:
210
+ **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.
211
+ - **@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>)`.
201
212
 
202
- - 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.
213
+ Boot one decided correctly and said so:
203
214
 
204
- **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.
215
+ ```txt
216
+ ✓ CREATE TABLE _voltro_ai_flows (27 cols)
217
+ # .renamedFrom('ai_flows') NOT applied — 'ai_flows' is still declared by this
218
+ # schema … that is the intended outcome when an app owns a table of the same name.
219
+ ```
220
+
221
+ Boot two, over exactly that state, refused:
222
+
223
+ ```txt
224
+ auto-migrate: REFUSED — 2 blocked operation(s)
225
+ - rename-table : both 'ai_flows' and '_voltro_ai_flows' exist in the database
226
+ ```
227
+
228
+ **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.
229
+
230
+ 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.
231
+
232
+ 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.
233
+
234
+ 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.
235
+ - **@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:
236
+
237
+ 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
238
+
239
+ 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.
240
+
241
+ `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.
242
+
243
+ Three rules keep it from doing damage:
244
+
245
+ - **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.
205
246
 
206
- 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.
207
- - **@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.**
247
+ 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.
248
+ - **@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.
208
249
 
209
- `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`.
250
+ 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.
210
251
 
211
- `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.
252
+ 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.
212
253
 
213
- 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.
254
+ 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.
214
255
 
215
- 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.
256
+ ### Internal (no consumer-facing effect)
216
257
 
217
- **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.
218
- - **@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.
258
+ - **The four 0.22.0 codemods gain the gate tests the convention asks for.**
219
259
 
220
- 0.21.0's own change log said the opposite *"narrowing is deliberately NOT blockedblocking 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.
260
+ `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.
221
261
 
222
- **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.
262
+ 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.
223
263
 
224
- 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.
264
+ 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.
225
265
 
226
- Reported by a consumer who had run all four of their check queries first2900 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.
266
+ **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.
267
+ - **The test harness sends ANY hostless bind to the loopback, not just `port: 0`.**
227
268
 
228
- 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.
229
- - **@voltro/database, @voltro/runtime, @voltro/cli** — **Two places where the framework had documented a shortcoming instead of removing it.**
269
+ 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.
270
+
271
+ `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`.
272
+
273
+ 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.
274
+
275
+ Re-verified after: `devHealthServer` 5 passed, `mcp` 13, `protocol` 301, `runtime` 1038, and an instrumented run still reports `bound 127.0.0.1:<port>`.
276
+ - **The net harness is shared across every package that binds a listener, and a derived guard keeps it that way.**
277
+
278
+ 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.
279
+
280
+ `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.
281
+
282
+ `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.
283
+
284
+ 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.
285
+ - **Thirty-two tests reported `passed` when their service was absent. They skip now, and a derived check keeps it that way.**
286
+
287
+ 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.
288
+
289
+ Measured, not inferred:
290
+
291
+ ```
292
+ $ PG_PORT=1 vitest run plugin-ratelimit/src/postgresStore.test.ts
293
+ Tests 5 passed (5)
294
+ ```
230
295
 
231
- ### The framework bootstrap is one statement kind again
296
+ 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.
232
297
 
233
- `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.
298
+ 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.
234
299
 
235
- 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.
300
+ 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.
236
301
 
237
- 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.
302
+ `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.
303
+ - **@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.
238
304
 
239
- 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.
305
+ 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`.
240
306
 
241
- ### A guard and its executor share one query
307
+ **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.
242
308
 
243
- 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.
309
+ 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.
244
310
 
245
- 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.
311
+ **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.
246
312
 
247
- `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.
313
+ 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.
248
314
 
249
- `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.
315
+ 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.
316
+ - **The 0.23.0 versioning codemod gains its gate test.**
250
317
 
251
- 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.
318
+ 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.
252
319
 
253
- Not breaking: a `TupleSource` implementation that destructures the fields it already used keeps compiling.
320
+ Four cases, both directions. Verified by breaking the gate: widening `TOUCHES_VERSIONING` to match everything fails exactly the two silent-direction cases.