@voltro/cli 0.29.0 → 0.30.1

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 (169) hide show
  1. package/CHANGELOG.md +448 -0
  2. package/THIRD-PARTY-NOTICES.md +2 -2
  3. package/dist/apiBuild-Ca6Sq5jy.js +2 -0
  4. package/dist/{apiBuild-BESZbTjA.js → apiBuild-DmkzRyv7.js} +2 -2
  5. package/dist/bin.js +3 -3
  6. package/dist/{checkCommand-BPAtyWs8.js → checkCommand-C4konUy9.js} +435 -304
  7. package/dist/checkCommand-DHD0aBeR.js +2 -0
  8. package/dist/{commands-82BDEktg.js → commands-BkJft9K2.js} +2413 -1814
  9. package/dist/{dbCommand-bCBbk6tz.js → dbCommand-qn__kLEt.js} +344 -265
  10. package/dist/dbCommand-wfGi3FgG.js +2 -0
  11. package/dist/{dev-Cg4BKLmi.js → dev-C_NPcLDp.js} +2716 -1782
  12. package/dist/{dev-DeEJj5T4.js → dev-DXhhZ7-f.js} +1 -1
  13. package/dist/frameworkTableAssembly-Bn_c4Mnl.js +2 -0
  14. package/dist/{frameworkTableAssembly-4Db40V23.js → frameworkTableAssembly-Cpgn2e88.js} +155 -141
  15. package/dist/index.d.ts +7 -2
  16. package/dist/index.js +1 -1
  17. package/dist/{inspect-BMvQpwqy.js → inspect-BUUjt773.js} +150 -14
  18. package/dist/inspect-mmBuRXmy.js +2 -0
  19. package/dist/{inspectMetrics-DHYygE4s.js → inspectMetrics-CkB2J0Zn.js} +566 -512
  20. package/dist/{manifestBuild-C4R1EdHn.js → manifestBuild-BLrVuSlM.js} +1 -1
  21. package/dist/manifestBuild-Dj8Jjoto.js +2 -0
  22. package/dist/{sdkgen-CW5NbYF0.js → sdkgen-wsWLrq4l.js} +1 -1
  23. package/dist/serveCommand-HtI4X5kj.js +1577 -0
  24. package/dist/serveEntry.js +2 -2
  25. package/dist/{start-CsCIaW4-.js → start-egkbpugc.js} +177 -175
  26. package/dist/startEntry.js +2 -2
  27. package/package.json +17 -17
  28. package/templates/AGENTS.core.md +50 -0
  29. package/templates/AGENTS.md +53 -2
  30. package/templates/agent-docs/_index.md +3 -2
  31. package/templates/agent-docs/_manifest.json +16 -4
  32. package/templates/agent-docs/ai.md +56 -0
  33. package/templates/agent-docs/cli.md +46 -0
  34. package/templates/agent-docs/internationalization.md +8 -7
  35. package/templates/agent-docs/local-first-mobile.md +18 -6
  36. package/templates/agent-docs/routing.md +15 -1
  37. package/templates/agent-docs/security.md +17 -0
  38. package/templates/agent-docs/templates/apibackends.md +209 -0
  39. package/templates/agent-docs/templates/appshells.md +144 -1
  40. package/templates/agent-docs/templates/mobile.md +60 -0
  41. package/templates/agent-docs/templates/overview.md +17 -7
  42. package/templates/agent-docs/whats-new.md +36 -243
  43. package/templates/agent-docs/workflows.md +367 -0
  44. package/templates/apps/api-ai/package.json +7 -7
  45. package/templates/apps/api-auth/package.json +8 -8
  46. package/templates/apps/api-backend/package.json +7 -7
  47. package/templates/apps/api-backend-deactivation/package.json +7 -7
  48. package/templates/apps/api-backend-mail/package.json +8 -8
  49. package/templates/apps/api-backend-mariadb/package.json +9 -9
  50. package/templates/apps/api-backend-sqlite/package.json +8 -8
  51. package/templates/apps/api-backend-storage/package.json +8 -8
  52. package/templates/apps/api-cms/package.json +10 -10
  53. package/templates/apps/api-collab/README.md +70 -0
  54. package/templates/apps/api-collab/app.config.ts +28 -0
  55. package/templates/apps/api-collab/database/schema.ts +63 -0
  56. package/templates/apps/api-collab/mutations/documents.create.mutation.server.ts +19 -0
  57. package/templates/apps/api-collab/mutations/documents.create.mutation.ts +36 -0
  58. package/templates/apps/api-collab/mutations/documents.setBody.mutation.server.ts +20 -0
  59. package/templates/apps/api-collab/mutations/documents.setBody.mutation.ts +30 -0
  60. package/templates/apps/api-collab/package.json +30 -0
  61. package/templates/apps/api-collab/queries/documents.query.server.ts +16 -0
  62. package/templates/apps/api-collab/queries/documents.query.ts +23 -0
  63. package/templates/apps/api-collab/template.json +6 -0
  64. package/templates/apps/api-collab/tests/documents.setBody.test.ts +89 -0
  65. package/templates/apps/api-collab/tsconfig.json +5 -0
  66. package/templates/apps/api-data-advanced/package.json +8 -8
  67. package/templates/apps/api-durable/package.json +8 -8
  68. package/templates/apps/api-feature-flags/package.json +9 -9
  69. package/templates/apps/api-governance/package.json +8 -8
  70. package/templates/apps/api-kv/package.json +8 -8
  71. package/templates/apps/api-moderation/package.json +8 -8
  72. package/templates/apps/api-observability/package.json +8 -8
  73. package/templates/apps/api-ratelimit/package.json +8 -8
  74. package/templates/apps/api-rbac/package.json +8 -8
  75. package/templates/apps/api-rest/package.json +7 -7
  76. package/templates/apps/api-saas/package.json +11 -11
  77. package/templates/apps/api-saas-starter/package.json +10 -10
  78. package/templates/apps/api-search/package.json +8 -8
  79. package/templates/apps/api-status/package.json +8 -8
  80. package/templates/apps/api-versioning/package.json +8 -8
  81. package/templates/apps/api-webhooks/package.json +9 -9
  82. package/templates/apps/changelog/package.json +6 -6
  83. package/templates/apps/edge-functions/package.json +2 -2
  84. package/templates/apps/frontend-admin/app.config.ts +1 -1
  85. package/templates/apps/frontend-admin/package.json +8 -8
  86. package/templates/apps/frontend-admin/src/locales/index.ts +1 -1
  87. package/templates/apps/frontend-app/app.config.ts +1 -1
  88. package/templates/apps/frontend-app/package.json +8 -8
  89. package/templates/apps/frontend-app/src/locales/index.ts +1 -1
  90. package/templates/apps/frontend-app/src/pages/layout.tsx +1 -1
  91. package/templates/apps/frontend-app/src/pages/page.tsx +1 -1
  92. package/templates/apps/frontend-auth/package.json +8 -8
  93. package/templates/apps/frontend-auth/src/locales/index.ts +1 -1
  94. package/templates/apps/frontend-blank/app.config.ts +1 -1
  95. package/templates/apps/frontend-blank/package.json +7 -7
  96. package/templates/apps/frontend-blank/src/locales/index.ts +1 -1
  97. package/templates/apps/frontend-blank/src/pages/layout.tsx +1 -1
  98. package/templates/apps/frontend-blank/src/pages/page.tsx +1 -1
  99. package/templates/apps/frontend-cms/package.json +9 -9
  100. package/templates/apps/frontend-cms/src/locales/index.ts +1 -1
  101. package/templates/apps/frontend-collab/README.md +80 -0
  102. package/templates/apps/frontend-collab/app.config.ts +38 -0
  103. package/templates/apps/frontend-collab/package.json +33 -0
  104. package/templates/apps/frontend-collab/src/locales/de.ts +29 -0
  105. package/templates/apps/frontend-collab/src/locales/en.ts +31 -0
  106. package/templates/apps/frontend-collab/src/locales/index.ts +15 -0
  107. package/templates/apps/frontend-collab/src/pages/layout.tsx +39 -0
  108. package/templates/apps/frontend-collab/src/pages/page.test.tsx +150 -0
  109. package/templates/apps/frontend-collab/src/pages/page.tsx +186 -0
  110. package/templates/apps/frontend-collab/template.json +6 -0
  111. package/templates/apps/frontend-collab/tsconfig.json +11 -0
  112. package/templates/apps/frontend-contact/package.json +7 -7
  113. package/templates/apps/frontend-dashboard/app.config.ts +1 -1
  114. package/templates/apps/frontend-dashboard/package.json +7 -7
  115. package/templates/apps/frontend-dashboard/src/locales/index.ts +1 -1
  116. package/templates/apps/frontend-docs/package.json +7 -7
  117. package/templates/apps/frontend-i18n/package.json +6 -6
  118. package/templates/apps/frontend-landing/package.json +7 -7
  119. package/templates/apps/frontend-portal/package.json +8 -8
  120. package/templates/apps/frontend-portal/src/locales/index.ts +1 -1
  121. package/templates/apps/frontend-saas/README.md +1 -1
  122. package/templates/apps/frontend-saas/app.config.ts +1 -1
  123. package/templates/apps/frontend-saas/package.json +8 -8
  124. package/templates/apps/frontend-saas/src/locales/index.ts +1 -1
  125. package/templates/apps/frontend-spa/app.config.ts +1 -1
  126. package/templates/apps/frontend-spa/package.json +7 -7
  127. package/templates/apps/frontend-spa/src/locales/index.ts +1 -1
  128. package/templates/apps/frontend-spa/src/pages/layout.tsx +1 -1
  129. package/templates/apps/frontend-spa/src/pages/page.tsx +1 -1
  130. package/templates/apps/frontend-ssr/app.config.ts +1 -1
  131. package/templates/apps/frontend-ssr/package.json +7 -7
  132. package/templates/apps/frontend-ssr/src/locales/index.ts +1 -1
  133. package/templates/apps/frontend-ssr/src/pages/layout.tsx +1 -1
  134. package/templates/apps/frontend-ssr/src/pages/page.tsx +1 -1
  135. package/templates/apps/frontend-ssr-api/app.config.ts +1 -1
  136. package/templates/apps/frontend-ssr-api/package.json +8 -8
  137. package/templates/apps/frontend-ssr-api/src/locales/index.ts +1 -1
  138. package/templates/apps/frontend-ssr-api/src/pages/layout.tsx +1 -1
  139. package/templates/apps/frontend-static-blog/package.json +6 -6
  140. package/templates/apps/frontend-status/package.json +8 -8
  141. package/templates/apps/frontend-status/src/locales/index.ts +1 -1
  142. package/templates/apps/mobile-app/README.md +58 -0
  143. package/templates/apps/mobile-app/app.config.ts +33 -0
  144. package/templates/apps/mobile-app/babel.config.js +5 -0
  145. package/templates/apps/mobile-app/metro.config.js +20 -0
  146. package/templates/apps/mobile-app/package.json +33 -0
  147. package/templates/apps/mobile-app/src/app/_layout.tsx +61 -0
  148. package/templates/apps/mobile-app/src/app/index.tsx +62 -0
  149. package/templates/apps/mobile-app/src/app/orders/[id].tsx +19 -0
  150. package/templates/apps/mobile-app/src/app/settings.tsx +51 -0
  151. package/templates/apps/mobile-app/src/client.ts +74 -0
  152. package/templates/apps/mobile-app/src/lib/deeplinks.ts +39 -0
  153. package/templates/apps/mobile-app/src/lib/notifications.ts +31 -0
  154. package/templates/apps/mobile-app/src/lib/sync.ts +29 -0
  155. package/templates/apps/mobile-app/src/persistence.ts +38 -0
  156. package/templates/apps/mobile-app/template.json +6 -0
  157. package/templates/apps/mobile-app/tests/deeplinks.test.ts +32 -0
  158. package/templates/apps/mobile-app/tests/notifications.test.ts +26 -0
  159. package/templates/apps/mobile-app/tests/sync.test.ts +34 -0
  160. package/templates/apps/mobile-app/tsconfig.json +10 -0
  161. package/templates/apps/mobile-app/voltro.mobile.ts +11 -0
  162. package/templates/patches/@effect__cluster@0.60.0.patch +6 -6
  163. package/dist/apiBuild-C8d74T6g.js +0 -2
  164. package/dist/checkCommand-DBvZvCh1.js +0 -2
  165. package/dist/dbCommand-CaIVTp-a.js +0 -2
  166. package/dist/frameworkTableAssembly-BTNirAAk.js +0 -2
  167. package/dist/inspect-DHeaMZak.js +0 -2
  168. package/dist/manifestBuild-Ck_90gZy.js +0 -2
  169. package/dist/serveCommand-BXl8mhZD.js +0 -1489
@@ -1,4 +1,4 @@
1
- # What's new in 0.29.0
1
+ # What's new in 0.30.1
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
@@ -7,276 +7,69 @@ workaround for something that shipped two versions ago.
7
7
 
8
8
  BREAKING entries name a codemod; run `voltro update` to apply it.
9
9
 
10
- ### ⚠ BREAKING
11
-
12
- - **@voltro/web** — **`useLoaderData()` throws where no `loader` is declared, and `useOptionalLoaderData()` is the way to read where one may be absent.**
13
-
14
- `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.
15
-
16
- **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.
17
-
18
- So the return type is unchanged and the absence becomes loud instead:
19
-
20
- - 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.
21
-
22
- **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.
23
-
24
- 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.
25
-
26
- Covered by `loaderDataAbsence.test.tsx` (both render paths, the shield, the empty-result and legitimately-undefined cases), red-verified by removing the marker.
27
-
28
- **`voltro update` carries you across this** — codemod `0.29.0/01_loader-data-absence`.
29
-
30
- ### Added
31
-
32
- - **@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.
33
- - **@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.
34
- - **@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.
35
- - **@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.
36
- - **@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.
37
- - **@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.
38
- - **@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.
39
-
40
- (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.)
41
- - **@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.
42
- - **@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.
43
- - **@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.
44
-
45
- (apiSurface: compatible — one additive optional field on `DispatcherDependencies`; no public symbol removed or resignatured.)
46
- - **@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).
47
-
48
- 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).
49
- - **@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.
50
- - **@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.
51
-
52
- **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`).
53
-
54
- **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.
55
-
56
- **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`).
57
-
58
- **`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.
59
-
60
- 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.
61
-
62
- 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.
63
- - **@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.
64
- - **@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.
65
- - **@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.
66
- - **@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.
67
-
68
- - **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).
69
-
70
- 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.
71
-
72
- **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`).
73
- - **@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.
74
- - **@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.
75
- - **@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.
76
-
77
- 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`).
78
- - **@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.
79
- - **@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.
80
- - **@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.
81
-
82
- (apiSurface: compatible — `CacheLayer.storeLayer` is a new export; nothing removed or resignatured.)
83
- - **@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`.
84
- - **@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.
85
- - **@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.
86
- - **@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.
87
-
88
- - 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).
89
-
90
- codemod: none
91
-
92
10
  ### Fixed
93
11
 
94
- - **@voltro/client** — **`useAction(...).run` forked against the boot-window stub instead of waiting for the api.**
95
-
96
- `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"*.
97
-
98
- 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".
99
-
100
- **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.
101
-
102
- `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.
103
-
104
- 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.
105
-
106
- `isUnresolvedApi(useFrameworkApi(name))` still composes for a caller who wants the readiness bit itself.
107
-
108
- **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:
109
-
110
- | | 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** |
111
-
112
- 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.
113
-
114
- 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.
115
- - **@voltro/cli** — **The atlassian credential codemod now tells you to grep for your own key, not just for `credentialsResolver`.**
116
-
117
- 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.
118
-
119
- 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.
120
- - **@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.
121
- - **@voltro/workflow** — **The cluster first-boot migration gate deadlocked on sqlite — an app on `store: 'sqlite'` with a workflow would never finish booting.**
122
-
123
- 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.
12
+ - **@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.
124
13
 
125
- It presents as a boot that never finishes, not as an error. Nothing logs.
14
+ `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.
15
+ - **@voltro/cli** — **Four things `voltro doctor` knew and would not tell you.** All reported by a consumer, all measured rather than guessed.
126
16
 
127
- 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.
17
+ **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.
128
18
 
129
- **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.
19
+ **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.
130
20
 
131
- `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.
132
- - **@voltro/cli** — **The credential-purge query in two 0.28.0 codemods was postgres-only, and its MySQL/MariaDB translation silently under-reported.**
21
+ **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`.
133
22
 
134
- 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.
23
+ **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.
24
+ - **@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.
135
25
 
136
- 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.
26
+ `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.
27
+ - **@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.
137
28
 
138
- Both notes now use `LOWER(subject) LIKE '%token%'`, which is correct on every dialect we ship.
29
+ **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.
139
30
 
140
- **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.
31
+ **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.
141
32
 
142
- `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.
143
- - **@voltro/cli** — **Outgoing webhooks never delivered on the cluster engine — i.e. in every deployment.**
33
+ **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.
144
34
 
145
- `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
35
+ **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.
36
+ - **@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.
146
37
 
147
- ```
148
- Entity type 'Workflow/voltro.deliverWebhook' not registered
149
- ```
38
+ 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.
150
39
 
151
- **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.
40
+ 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.
152
41
 
153
- 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.
42
+ 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.
154
43
 
155
- **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.**
44
+ **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.
156
45
 
157
- `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.
158
- - **@voltro/cli** — **`voltro serve` warned that framework-provided tables "are not a declared table".**
46
+ **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.
47
+ - **@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.
159
48
 
160
- 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.
49
+ 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.
161
50
 
162
- 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.
51
+ 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.
163
52
 
164
- **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.
53
+ **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`.
54
+ - **@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.
165
55
 
166
- Both paths read the process registry now, pinned by a guard that fails if either reverts to the app-declared set.
167
- - **@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.
168
- - **@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.
169
- - **@voltro/cli, @voltro/i18n** — **`voltro dev` server-renders WITHOUT `<I18nProvider>`, so SSR could not be developed at all for a translated app.**
56
+ 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.
170
57
 
171
- 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`:
58
+ **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.
172
59
 
173
- ```
174
- Error: [React Intl] Could not find required `intl` object.
175
- <IntlProvider> needs to exist in the component ancestry.
176
- ```
60
+ **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.
177
61
 
178
- 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.
62
+ 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.
179
63
 
180
- `@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.
64
+ **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.
181
65
 
182
- 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.
66
+ **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.
67
+ - **@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.
183
68
 
184
- **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.
69
+ `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.
185
70
 
186
- 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.
187
-
188
- codemod: none — no user-authored code changes shape; a page that was crashing now renders.
189
- - **@voltro/cli** — **Every first visit to an SSR page hydration-mismatched, and `<html lang>` was a constant.**
190
-
191
- Two defects on one surface, and the second is why the obvious fix for the first did not work.
192
-
193
- **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.
194
-
195
- 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.
196
-
197
- **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.
198
-
199
- 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'`.
200
-
201
- **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.
202
-
203
- 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.
204
-
205
- 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.
206
- - **@voltro/runtime** — **An undeclared throw reached the client as a ~2 KB decode tree instead of its message.**
207
-
208
- 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.
209
-
210
- **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`.
211
-
212
- 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.
213
-
214
- 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.
215
-
216
- **Measured against `voltro serve`**, an action doing `undefined.runWithEager()`, from the published fixture bundle:
217
-
218
- | | response | |---|---| | before | **543 bytes** of `ExitEncoded<…>` decode tree | | after | **185 bytes** — `{"_tag":"InternalError","message":"Cannot read properties of undefined (reading 'runWithEager')","traceId":…}` |
219
-
220
- 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.
221
-
222
- **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.
223
- - **@voltro/plugin-webhooks** — **The scope lookups read one page of the target table and answered confidently from it.**
224
-
225
- `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.
226
-
227
- Past that many target rows, both callers returned a wrong answer rather than an error:
228
-
229
- - **`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.
230
-
231
- 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.
232
-
233
- `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.
234
-
235
- **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.
71
+ **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.
236
72
 
237
73
  ### Internal (no consumer-facing effect)
238
74
 
239
- - **@voltro/plugin-ai-flows** — **`FlowStep` / `RunStep` are declared interfaces, so the api report stops churning.**
240
-
241
- `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.
242
-
243
- 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.
244
-
245
- **`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.
246
-
247
- Verified byte-identical across exactly the two build scopes that disagreed before.
248
- - **@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.
249
- - **@voltro/plugin-webhooks, @voltro/workflow** — **The delivery workflow is now covered against a REAL SQL cluster engine, not only the in-memory one.**
250
-
251
- `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.
252
-
253
- `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.
254
-
255
- **Two things measured on the way, both worth more than the test itself:**
256
-
257
- - 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.
258
-
259
- **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:
260
-
261
- | 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 |
262
-
263
- 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.
264
- - **@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.
265
- - **@voltro/cli** — **The message-API check called a real chainable member non-existent.**
266
-
267
- `.index()` is a genuine member of the table builder with two overloads. api-extractor prints an overloaded member as a call-signature *object*:
268
-
269
- ```
270
- index: {
271
- <const F extends readonly [...]>(fields: F, options?: …): Table<…>;
272
- <const IxName extends string, …>(name: IxName, …): Table<…>;
273
- }
274
- ```
275
-
276
- 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`.
277
-
278
- **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.
279
-
280
- 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.
281
-
282
- Both directions are now selftest cases, since a rule that quietly stops matching prints exactly like a clean tree.
75
+ - **@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.