@voltro/cli 0.54.0 → 0.56.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (173) hide show
  1. package/CHANGELOG.md +639 -2
  2. package/bin/voltro.mjs +24 -0
  3. package/dist/{apiBuild-DTWp0S_q.js → apiBuild-Bdaetr37.js} +119 -88
  4. package/dist/apiBuild-Vw1figjO.js +2 -0
  5. package/dist/bin.js +1 -1
  6. package/dist/{build-D4ygSbnV.js → build-CI36wL4R.js} +339 -314
  7. package/dist/buildReport-52gHKgfO.js +64 -0
  8. package/dist/{checkCommand-Dg1G7Gwd.js → checkCommand-CAwFXrxA.js} +6 -6
  9. package/dist/{checkCommand-L7DTlpIF.js → checkCommand-D0QV_zM_.js} +1 -1
  10. package/dist/{clusterCmd-DrVFCzSj.js → clusterCmd-CdLB1GkT.js} +1 -1
  11. package/dist/{codegen-DSLM8Su9.js → codegen-Bth5lUTU.js} +76 -64
  12. package/dist/codegen-DbH7NbCR.js +2 -0
  13. package/dist/{codegenCommand-CG_Vx4lc.js → codegenCommand-CidbQzbv.js} +15 -14
  14. package/dist/{codemodRunner-Cd4xkC6u.js → codemodRunner-BlQPfjzA.js} +277 -0
  15. package/dist/{commands-6Kzi92Np.js → commands-CWjfThXv.js} +35 -35
  16. package/dist/{dashboardCommand-Cq1PWvI1.js → dashboardCommand-BekcY5Ls.js} +3 -3
  17. package/dist/{dataCommand-DYzW8vkv.js → dataCommand-2pccgbIy.js} +267 -195
  18. package/dist/{dbCommand-B4NWZtGL.js → dbCommand-DpK_vQET.js} +457 -441
  19. package/dist/dbCommand-DrycGWWt.js +2 -0
  20. package/dist/{dev-cKUiZZsB.js → dev-B9Gz0k85.js} +1 -1
  21. package/dist/{dev-CmuvUKRq.js → dev-Dw263KPu.js} +2598 -2452
  22. package/dist/doctorCommand-BMWs6aVm.js +2 -0
  23. package/dist/{doctorCommand-DCiFVMtZ.js → doctorCommand-aR_bFmIi.js} +353 -251
  24. package/dist/{dormancyCommand-w1TrmgYP.js → dormancyCommand-eXTQMbHU.js} +1 -1
  25. package/dist/{embeddingsCommand-CMgPyRTr.js → embeddingsCommand-CTmiQvwa.js} +1 -1
  26. package/dist/{envCommand-Cyynmcfa.js → envCommand-BDUgV7EM.js} +12 -12
  27. package/dist/{evolveCommand-BwvQ8dVH.js → evolveCommand-YV8qW1LU.js} +2 -2
  28. package/dist/frameworkTableAssembly-CGNC0qr7.js +2 -0
  29. package/dist/{frameworkTableAssembly-D7LJuALW.js → frameworkTableAssembly-DNOFXfEQ.js} +102 -100
  30. package/dist/index.js +1 -1
  31. package/dist/{infoCommand-DlYlUPqs.js → infoCommand-BjVXpMlP.js} +1 -1
  32. package/dist/inspect-CNYvNXPU.js +1484 -0
  33. package/dist/inspect-S2rWy1Ys.js +2 -0
  34. package/dist/{inspectCmd-niF97fAq.js → inspectCmd-CP-G0sVK.js} +1 -1
  35. package/dist/{inspectFetch-EMuhTG_9.js → inspectFetch-BU1NyzxV.js} +36 -24
  36. package/dist/{inspectMetrics-CGF94puw.js → inspectMetrics-BY0Sjb2F.js} +19 -19
  37. package/dist/interruptedReplace-CwnkBb2X.js +41 -0
  38. package/dist/interruptedReplace-qzmFI020.js +2 -0
  39. package/dist/{logsCmd-B6oNsfaZ.js → logsCmd-BU8uCdys.js} +1 -1
  40. package/dist/{manifestBuild-Cqgsx2bM.js → manifestBuild-CEkjfpwc.js} +1 -1
  41. package/dist/manifestBuild-DIa_s6u0.js +2 -0
  42. package/dist/{migrate-BK_Bbx-_.js → migrate-SICulyz1.js} +2 -2
  43. package/dist/precompressAssets-YhTi1aWp.js +40 -0
  44. package/dist/{probeCommand-_C0YU207.js → probeCommand-6HxEkNDG.js} +2 -2
  45. package/dist/{runtimeTrace-CGWx1Q6l.js → runtimeTrace-DgYMc09E.js} +1 -1
  46. package/dist/{scheduleCmd-DQRu6BZC.js → scheduleCmd-DYBUfo_T.js} +1 -1
  47. package/dist/{sdkgen-CDGHQUFj.js → sdkgen-PY-umd6O.js} +1 -1
  48. package/dist/{seedRunner-Dgsiwk_e.js → seedRunner-DISBKow-.js} +16 -16
  49. package/dist/serveCommand-BITS8Hpj.js +2 -0
  50. package/dist/{serveCommand-Bje09q1v.js → serveCommand-DIJ3ma76.js} +951 -909
  51. package/dist/serveEntry.js +1 -1
  52. package/dist/{start-Clz-1BHB.js → start-B9NGB8gn.js} +627 -588
  53. package/dist/{start-B0bnJgxI.js → start-BFQQkL1i.js} +1 -1
  54. package/dist/startEntry.js +1 -1
  55. package/dist/staticCachePolicy-CIyj6DbS.js +15 -0
  56. package/dist/{test-D_kW4KMj.js → test-jipIQ5Mx.js} +1 -1
  57. package/dist/{tracesCmd-DgtgOUdi.js → tracesCmd-BWYDqMy6.js} +1 -1
  58. package/dist/{updateCommand-CRJlAOaM.js → updateCommand-C9n_Z_oG.js} +8 -2
  59. package/dist/updateCommand-DsXEAHbd.js +2 -0
  60. package/dist/webDev-C2dRz9s5.js +2 -0
  61. package/dist/{webDev-DSI9SOhs.js → webDev-C53hJdcL.js} +1265 -1288
  62. package/dist/{webhooksCommand-BvzXNHji.js → webhooksCommand-uuPu8qQX.js} +1 -1
  63. package/dist/{workflowsCmd-BGF-mRZ5.js → workflowsCmd-g-DNpaUc.js} +1 -1
  64. package/package.json +67 -19
  65. package/templates/AGENTS.core.md +20 -1
  66. package/templates/AGENTS.md +21 -2
  67. package/templates/agent-docs/_index.md +1 -1
  68. package/templates/agent-docs/_manifest.json +1 -1
  69. package/templates/agent-docs/authentication.md +49 -6
  70. package/templates/agent-docs/cli.md +216 -11
  71. package/templates/agent-docs/data.md +209 -18
  72. package/templates/agent-docs/database/scaling.md +40 -1
  73. package/templates/agent-docs/deployment.md +53 -1
  74. package/templates/agent-docs/internationalization.md +32 -0
  75. package/templates/agent-docs/local-first-mobile.md +9 -2
  76. package/templates/agent-docs/observability.md +227 -0
  77. package/templates/agent-docs/plugins/billing.md +15 -0
  78. package/templates/agent-docs/plugins/broadcast.md +2 -1
  79. package/templates/agent-docs/plugins/ratelimit.md +6 -1
  80. package/templates/agent-docs/plugins/row-history.md +11 -0
  81. package/templates/agent-docs/plugins.md +65 -8
  82. package/templates/agent-docs/reference.md +5 -3
  83. package/templates/agent-docs/routing.md +18 -0
  84. package/templates/agent-docs/schema-driven-ui.md +125 -0
  85. package/templates/agent-docs/templates/appshells.md +3 -3
  86. package/templates/agent-docs/whats-new.md +408 -106
  87. package/templates/apps/api-ai/package.json +6 -6
  88. package/templates/apps/api-auth/package.json +8 -8
  89. package/templates/apps/api-backend/package.json +7 -7
  90. package/templates/apps/api-backend-deactivation/package.json +7 -7
  91. package/templates/apps/api-backend-mail/package.json +8 -8
  92. package/templates/apps/api-backend-mariadb/package.json +9 -9
  93. package/templates/apps/api-backend-sqlite/package.json +8 -8
  94. package/templates/apps/api-backend-storage/package.json +8 -8
  95. package/templates/apps/api-cms/package.json +9 -9
  96. package/templates/apps/api-collab/package.json +8 -8
  97. package/templates/apps/api-data-advanced/package.json +8 -8
  98. package/templates/apps/api-durable/package.json +8 -8
  99. package/templates/apps/api-feature-flags/package.json +9 -9
  100. package/templates/apps/api-governance/package.json +8 -8
  101. package/templates/apps/api-kv/package.json +8 -8
  102. package/templates/apps/api-moderation/package.json +8 -8
  103. package/templates/apps/api-observability/package.json +8 -8
  104. package/templates/apps/api-ratelimit/package.json +8 -8
  105. package/templates/apps/api-rbac/package.json +8 -8
  106. package/templates/apps/api-rest/package.json +7 -7
  107. package/templates/apps/api-row-history/package.json +8 -8
  108. package/templates/apps/api-saas/package.json +11 -11
  109. package/templates/apps/api-saas-starter/package.json +10 -10
  110. package/templates/apps/api-search/package.json +8 -8
  111. package/templates/apps/api-status/package.json +8 -8
  112. package/templates/apps/api-webhooks/package.json +9 -9
  113. package/templates/apps/changelog/package.json +7 -7
  114. package/templates/apps/changelog/src/pages/[locale]/page.tsx +7 -1
  115. package/templates/apps/edge-functions/package.json +2 -2
  116. package/templates/apps/frontend-admin/package.json +7 -8
  117. package/templates/apps/frontend-admin/src/pages/(marketing)/layout.tsx +1 -2
  118. package/templates/apps/frontend-admin/src/pages/admin/layout.tsx +1 -2
  119. package/templates/apps/frontend-app/package.json +8 -9
  120. package/templates/apps/frontend-app/src/pages/layout.tsx +1 -2
  121. package/templates/apps/frontend-auth/package.json +7 -8
  122. package/templates/apps/frontend-auth/src/components/AuthShell.tsx +1 -2
  123. package/templates/apps/frontend-blank/package.json +6 -7
  124. package/templates/apps/frontend-blank/src/pages/layout.tsx +1 -2
  125. package/templates/apps/frontend-cms/package.json +8 -9
  126. package/templates/apps/frontend-cms/src/pages/(app)/layout.tsx +1 -2
  127. package/templates/apps/frontend-collab/README.md +7 -2
  128. package/templates/apps/frontend-collab/package.json +9 -10
  129. package/templates/apps/frontend-collab/src/pages/layout.tsx +1 -2
  130. package/templates/apps/frontend-collab/src/pages/page.test.tsx +8 -4
  131. package/templates/apps/frontend-collab/src/pages/page.tsx +10 -5
  132. package/templates/apps/frontend-contact/package.json +7 -7
  133. package/templates/apps/frontend-contact/src/components/ContactForm.island.tsx +1 -1
  134. package/templates/apps/frontend-dashboard/package.json +6 -7
  135. package/templates/apps/frontend-dashboard/src/pages/(marketing)/layout.tsx +1 -2
  136. package/templates/apps/frontend-dashboard/src/pages/dashboard/layout.tsx +1 -2
  137. package/templates/apps/frontend-docs/package.json +8 -8
  138. package/templates/apps/frontend-i18n/package.json +6 -6
  139. package/templates/apps/frontend-landing/package.json +7 -7
  140. package/templates/apps/frontend-portal/package.json +7 -8
  141. package/templates/apps/frontend-portal/src/pages/(portal)/layout.tsx +1 -2
  142. package/templates/apps/frontend-saas/package.json +7 -8
  143. package/templates/apps/frontend-saas/src/pages/(marketing)/layout.tsx +1 -2
  144. package/templates/apps/frontend-saas/src/pages/dashboard/layout.tsx +1 -2
  145. package/templates/apps/frontend-spa/package.json +6 -7
  146. package/templates/apps/frontend-spa/src/pages/layout.tsx +1 -2
  147. package/templates/apps/frontend-ssr/package.json +6 -7
  148. package/templates/apps/frontend-ssr/src/pages/layout.tsx +1 -2
  149. package/templates/apps/frontend-ssr-api/package.json +7 -8
  150. package/templates/apps/frontend-ssr-api/src/pages/layout.tsx +1 -2
  151. package/templates/apps/frontend-static-blog/package.json +8 -8
  152. package/templates/apps/frontend-static-blog/src/components/ReadingProgress.island.tsx +1 -1
  153. package/templates/apps/frontend-static-blog/src/pages/[locale]/page.tsx +7 -1
  154. package/templates/apps/frontend-status/package.json +7 -8
  155. package/templates/apps/frontend-status/src/pages/layout.tsx +1 -2
  156. package/templates/apps/mobile-app/package.json +4 -4
  157. package/templates/baselines/compose/docker/api.Dockerfile +61 -5
  158. package/templates/baselines/compose/docker/web.Dockerfile +55 -10
  159. package/templates/baselines/compose-mariadb/docker/api.Dockerfile +61 -5
  160. package/templates/baselines/compose-mariadb/docker/web.Dockerfile +55 -10
  161. package/dist/apiBuild-CeUN55uk.js +0 -2
  162. package/dist/codegen-DjgxEOnD.js +0 -2
  163. package/dist/dbCommand-CSFWs9ev.js +0 -2
  164. package/dist/doctorCommand-J3qu4E0Y.js +0 -2
  165. package/dist/frameworkTableAssembly-IPD1pUnZ.js +0 -2
  166. package/dist/inspect-Bd8-9wsi.js +0 -1193
  167. package/dist/inspect-CuoDInfZ.js +0 -2
  168. package/dist/interruptedReplace-C3O3M1MM.js +0 -28
  169. package/dist/interruptedReplace-CvmiAM9K.js +0 -2
  170. package/dist/manifestBuild-C4-J1-m_.js +0 -2
  171. package/dist/serveCommand-BiPe8BJm.js +0 -2
  172. package/dist/updateCommand-CIoVDKnj.js +0 -2
  173. package/dist/webDev-DlvZO30c.js +0 -2
@@ -1,4 +1,4 @@
1
- # What's new in 0.54.0
1
+ # What's new in 0.56.0
2
2
 
3
3
  Read this FIRST when a task touches an area you have not worked in recently.
4
4
  It is the cheapest way to notice that the framework grew the thing you were
@@ -9,191 +9,493 @@ BREAKING entries name a codemod; run `voltro update` to apply it.
9
9
 
10
10
  ### ⚠ BREAKING
11
11
 
12
- - **@voltro/local-first** — **`CrdtDocHandle.onUpdate` now tells its handler whether the update was LOCAL.** The signature gains a second argument: `onUpdate((update, { local }) => …)`. `local: false` means the blob came from folding somebody else's state through `applyState`.
12
+ - **@voltro/plugin-broadcast, @voltro/cli** — **Three ways the cross-replica bus mishandled a broker that was not there.**
13
13
 
14
- Without it, an echo guard could not be written correctly on the public surface. Applying a peer's update fires the same handler a local edit does, so an app pushing from `onUpdate` re-broadcasts what it just received measured with three tabs open, one keystroke produced three server writes instead of one. It converges (the merge is idempotent), but the amplification scales with the session.
14
+ **Boot no longer dies when the broker is unreachable.** `attachBroadcastBus` awaited its subscribe, and a rejected subscribe threw out of it, out of `wireBroadcastBus`, and out of boot — so a broker that happened to be restarting turned every replica into a crash loop. That contradicted the package's own first paragraph, which promises that a broker outage "degrades cross-replica fan-out only": true for an outage after boot, false for one during it, and the false half is the worse one, because a broker restart is precisely when every pod is dialling at once. It now warns, keeps local reactivity working, retries in the background, and reports the time it spent unsubscribed as a gap — because that is what it is: the fleet went on writing while this replica was not listening, and pub/sub keeps no log to replay.
15
15
 
16
- The only app-level workaround was an `applying` boolean around `applyState`, and that is correct **only** while the backend emits synchronously — a property `CrdtBackend` deliberately does not promise ("the backend decision lives behind our abstraction so it can change"). So the flag had to come from the backend: the yjs one tags its own folds with a symbol origin and reports anything else as local.
16
+ **A peer that restarts under a stable name no longer switches its own gap detection off.** Gap detection compares a peer's serial against a watermark, and the serial restarts at 1 with the process while the NAME survives it — a StatefulSet pod keeps `POD_NAME`, and `VOLTRO_REPLICA_ID` is stable by definition. A receiver holding a watermark of 500 read the new process's 1, 2, 3… as "not newer", never advanced, and reported nothing for the next five hundred changes. The envelope carries an `epoch` now: a different epoch under a known name means a new process, so the watermark follows the process. A restart is not reported as a gap it is not evidence that anything was missed.
17
17
 
18
- `codemod: none` the parameter is ADDITIVE. An existing single-argument handler keeps compiling and keeps behaving exactly as before; there is nothing to rewrite. Read the flag when you push from `onUpdate`, which is what `useCrdtDoc` does for you.
19
- - **@voltro/local-first** — **`RUNTIME_SEAMS` lists ONE seam now, not three** — `['sync-transport-app-tags']`. `RuntimeSeam` narrows with it. The two removed entries left in opposite directions, and `seams.ts` was asserting both halves of the contradiction at once: its `DONE` prose said the presence broker binding shipped while the array beside it still named `presence-broker-binding` as open.
18
+ **A transport that re-dials on its own now says so.** ioredis re-subscribes its channels after a reconnect and nats reconnects underneath the subscription; neither mentions that everything published while they were away is gone. Serial accounting finds that only if a peer publishes again, and on a quiet table "nobody wrote" and "we are stale forever" look identical. Providers report their connection lifecycle through a new optional `BroadcastProvider.onTransportEvent`, and a reconnect is treated as the hole it is.
20
19
 
21
- - `presence-broker-binding` is BUILT. `usePresenceChannel` (`@voltro/plugin-presence/web`) rides the framework's own presence lane, so cross-replica fan-out belongs to the broadcast plugin and there is one presence wire rather than two; the shape is pinned by `presence/channelParity.test-d.ts`. - `wasm-sqlite-durable-adapter` was REJECTED, with the reasoning recorded in `mirror/queryMirror.ts`: the client's query surface is `(tag, input)` and predicates never exist client-side, so a browser SQL engine would evaluate a language the client never sees. A decision is not a gap, and listing it as one invites somebody to close it. A SQLite BACKING beneath `KvStore` remains available and is a different door.
20
+ Two driver defaults changed with it: the nats connection is now `maxReconnectAttempts: -1` and `waitOnFirstConnect: true`. nats.js defaults to giving up after ten attempts, which for a broker down about twenty seconds meant the connection closed for good, every subscription iterator ended, and the replica was deaf until it restarted silently. And neither provider memoises a rejected connection promise any more: a broker briefly unreachable at construction was unreachable forever, because every later attempt awaited the same settled rejection and never dialled again.
22
21
 
23
- `codemod: none` because nothing in the framework reads this constant and nothing asks a user to: it is a documentation manifest that happens to be typed. There is no mechanical rewrite for a removed member of one — the correction is the sentence above.
22
+ **Breaking:** `attachBroadcastBus`'s `onGap` now receives one `BroadcastGap` object instead of `(origin, missed)`. `origin` and `missed` are still exact when they exist, and optional because a hole with no peer to blame is now reachable. See the codemod note.
23
+ - **@voltro/runtime, @voltro/cli** — **`defineReaction`'s `dedupeKey` was enforced by a per-process set, so the same change acted once per replica.**
24
24
 
25
- The docs site's "what's shipped vs. a runtime seam" table carried the same two rows in both languages and now carries one, with both departures stated rather than silently dropped: "we have not built it" and "we decided against it" are different answers and a reader is entitled to know which one applies.
25
+ `dedupeKey` is the one guard `defineReaction` refuses to boot without, and its documented promise is that the same logical change acts exactly once. It was backed by an in-memory `Set` shared across reactions in one process, consulted as `has(key)` then written as `add(key)` after the act returned. Two independent defects in one mechanism:
26
26
 
27
- ### Added
27
+ - **Per-process.** N replicas each held their own set, so a reaction whose act starts a workflow started N workflows, and one with `costBudgetUsd` spent N times. Every test and every single-instance run confirmed the gate worked, which is why it survived — it was real in exactly the configuration that cannot observe it. - **Read-then-write.** Even in one process, two concurrent changes with the same key both passed `has` before either reached `add`, because the act between them is awaited.
28
28
 
29
- - **@voltro/content, @voltro/cli** **Relative images inside markdown content are copied into the build, and their `src` rewritten to the copy.** `![cover](./cover.png)` now lands in `dist/assets/content-media/<hash>.<ext>` and the rendered HTML points there. `voltro dev` serves the same URL shape on demand from the source file, so a page's HTML does not change between development and a build.
29
+ The gate is now a single atomic claim against `_voltro_change_claims` (`insertIgnore` on a `UNIQUE`, the arbiter the cron scheduler uses), taken BEFORE the act. Both boot paths wire it; the per-process set remains only as the fallback for a harness that wires none, and says so loudly at attach time.
30
30
 
31
- Nothing moved those files before: the artifact emit wrote JSON and the renderer emitted the relative src verbatim, so a built blog asked the browser for a path that exists only in the source tree while `routing/assets.md` said, in both languages, that the content pipeline copied and hashed them.
31
+ Taking the claim first is what makes it a gate rather than a report, and the cost is stated rather than hidden: **an act that throws has already consumed its key and is not re-run by a later duplicate.** Durability belongs to the workflow the act starts, not to the trigger.
32
32
 
33
- A reference to a file that does not exist now FAILS the build and names the path. The alternative emit the src and continueis the defect itself: the page renders, the browser 404s, and the build says nothing.
33
+ **Breaking:** `ReactionRunDeps.dedupe` is `{ claim: (key) => boolean | Promise<boolean> }` instead of `{ has, add }`. Nothing to do unless you call `runReaction` yourself`voltro dev` and `voltro serve` build the deps and pass the durable claimer. See the codemod note for why this is not a rename.
34
+ - **@voltro/plugin-billing, @voltro/protocol, @voltro/database, @voltro/plugin-cdc-out, @voltro/cli** — **A CDC meter accrued a usage unit on every replica, so a tenant on two pods was billed twice.**
34
35
 
35
- Absolute (`/…`) and remote sources are untouched; they are not the build's to move. Copy and content-hash onlybuild-time transformation (resize / format) remains a named non-goal for markdown content, because a markdown reference carries no width and no `sizes` to derive one from. The `?image` pipeline stays the answer where that matters.
36
+ `plugin-billing`'s `metering: { from: 'cdc' }` accrues one unit per matched row change, through the plugin `onChangeEvent` tap. That tap is delivered to every replica that is what makes a `changeScope: 'fleet'` store (postgres LISTEN/NOTIFY, mysql binlog) cross-instance in the first place. For a reader that is correct. This is not a reader: `reportUsage` increments a shared counter and the flush reports the delta to the billing provider. So the invoice was multiplied by the replica count, by the plugin whose entire job is counting, with nothing in any log.
36
37
 
37
- Renderers outside the framework's build are unaffected: `@voltro/content` exposes this as a registered resolver (`setContentAssetResolver`), and with none registered a relative src is left exactly as written rather than rewritten to a path nothing serves.
38
- - **@voltro/cli, @voltro/runtime** — **The server-side CRDT compaction threshold is an `app.config.ts` field now, not an environment variable only.** `crdt.compactMaxBytes` (default 512 KiB, `0` disables) decides when a merged `crdtText()` / `crdtDoc()` blob is soft-compacted — re-encoded through a live doc, same lineage, so every outstanding client update still merges. `VOLTRO_CRDT_COMPACT_MAX_BYTES` still wins over it: an operator acting on a running deployment outranks what the project declared.
38
+ The tap now claims each change before accruing, through the same INSERT-wins arbiter behind `defineSubscriber({ once })` and a reaction's `dedupeKey`. An ungated tap (single process, memory store) still accrues and says so once loudly, because an ungated meter is a factor-of-N on an invoice and is indistinguishable from a correct one by looking at the number.
39
39
 
40
- This is the standing rule ("every number the framework picks on your behalf is a config field with a default, plus an env override"), applied to the one knob that had only the environment half. A threshold sized for a document shape is a property of the project, so it belongs in a file a reviewer reads and a deploy carries not in whatever `env:` block somebody remembered to set.
40
+ **Naming a change needed a shared answer, and one already existed in the wrong place.** A fleet change carries no LSN, no commit id and no `traceId` — that last one deliberately, so a local trace is never mis-attributed to a remote write so two replicas have no field they can both name it by. `@voltro/plugin-cdc-out` manufactured the missing identity from content plus its position among content-identical repeats, and it was the only consumer until this defect turned up the second one. `changeDigest`, `composeChangeKey`, `parseChangeKey` and `OccurrenceCounter` now live in **`@voltro/database`**, beside `ChangeEvent` itself.
41
41
 
42
- Resolved by ONE builder both boot paths call (`wireCrdtTunables`), registered into the process slot the merge path reads — the `wireReactiveSocketTunables` shape, for the reason that shape exists: two paths resolving a value separately is how they come to disagree. `setCrdtCompactMaxBytes` was exported and called by nothing before this; the runtime kept a lazy env read for a process that never runs a boot path (a unit test), and that remains the fallback rather than a second answer.
42
+ Keying on the row id instead would have been worse than the defect for an `op: 'update'` meter: two genuine edits to one row share an id, so the meter would count the first and drop every one after it a bill that stops growing while the work continues.
43
43
 
44
- Note `0` is a value here, not an absence it means "do not compact" so the resolver's floor is `>= 0` on both the config and the env side. The `> 0` floor the other tunables use would have silently dropped a declared zero.
45
- - **@voltro/cli** — **The gRPC surface's shutdown drain budget is configurable — `grpc.drainMs` (default 5000, `0` forces immediately, env `VOLTRO_GRPC_DRAIN_MS`).** It was a `5_000` literal in the `stop()` closure.
44
+ **Breaking:** `PluginBindContext` gains a REQUIRED `claimChange(scope, key)`. Every plugin's `bindDataStore` receives it; a plugin that constructs a `PluginBindContext` itself (a unit-test harness) must supply one. Required rather than optional because an absent gate reads exactly like a gate that passed. `@voltro/plugin-cdc-out` no longer re-exports the change-identity helpers import them from `@voltro/database`.
46
45
 
47
- It is a knob rather than a measured constant, and the distinction is worth stating because the framework deliberately refuses knobs elsewhere on the same page (compression levels are fixed "a knob nobody can pick correctly is worse than a measured default"). A compression level has no input outside the process. A drain budget has two, and neither is ours: the orchestrator's termination grace, past which SIGKILL arrives and a longer budget is decorative; and the app's longest legitimately in-flight call, below which every rolling deploy force-closes work that would have finished. The default fits the 30 s grace both kubernetes and `docker stop` default to.
46
+ **`voltro update` carries you across this**codemod `0.56.0/05_plugin_bind_context_claims_a_change`. If you pin versions by hand and never run it, print the notes without changing anything: `voltro update --codemods-only --from <your current version> --dry-run` (this one ships in 0.56.0).
47
+ - **@voltro/cli, @voltro/devtools-ui** — **Every `/_voltro/inspect/*` answer is now an `Observation` — it says what it is about, who answered, and how complete it is.**
48
48
 
49
- The `grpc:` config shape was also declared THREE times — `ApiAppConfig`, `ServeApiOptions` and the shared builder's options — which is how a field lands on two of them. It is one exported `GrpcSurfaceConfig` now, referenced by all three, so the shared builder both boot paths already call is the only thing that reads it.
49
+ ```json
50
+ {
51
+ "data": { "…": "what the route returned before" },
52
+ "scope": { "kind": "process" },
53
+ "origin": { "replicaId": "api-7d9f-x2k", "instanceId": "api-7d9f-x2k@1787…", "version": "0.56.0" },
54
+ "completeness": { "complete": false, "fleetSize": 3, "reason": "process-scoped: this is 1 of 3 replicas" },
55
+ "capturedAt": 1787892497073
56
+ }
57
+ ```
50
58
 
51
- The boot line and the budget-exceeded warning both name the resolved value, so what a process will wait is visible before the shutdown rather than after it.
52
- - **@voltro/cli** — **Partial prerendering's counters are now real metrics — they were incremented by both boot paths and read by nothing, while the observability docs listed them among the exportable ones.** A reader who went looking for them at the OTLP or Prometheus endpoint found nothing there: `pprMetrics()` was a `globalThis` counter slot with no registry bridge, no inspect endpoint and no log line.
59
+ **Why the payload alone was not enough.** `/subscriptions` answers with the subscriptions of the ONE process that received the request. `/schedules` answers with the whole fleet's, read from the shared store. Both were plain JSON with nothing to tell them apart, so on a multi-replica deployment the first is an unlabelled sample and reads exactly like the second. On ONE replica the difference is invisible — which is every development environment, every e2e and every template, so the environment in which they are indistinguishable is the one the framework is built and tested in.
53
60
 
54
- Five series, all labelled `page` the DECLARED route pattern (`/blog/[slug]`), never a resolved URL: `voltro_ppr_shell_serves_total`, `voltro_ppr_hole_passes_total`, `voltro_ppr_hole_settles_total`, `voltro_ppr_hole_errors_total`, and the `voltro_ppr_hole_pass_seconds` histogram. They land in Effect's global `MetricRegistry`, so `@voltro/plugin-prometheus`' `GET /metrics` and `GET /_voltro/inspect/metrics` both see them with nothing to register the same placement `@voltro/database` uses for `voltro_db_*`.
61
+ Four scopes, and the fourth is the one an outside report would not have asked for: `process`, `shared-store`, `fleet`, and `declaration` `/routes`, `/manifest`, `/env` describe the SOURCE TREE, identical on every replica of one version and different across a rolling deploy.
55
62
 
56
- `voltro_ppr_hole_errors_total` is the one to alert on, and it is a separate series rather than a `status` label for a reason: a failed hole pass is invisible from outside. The shell is already on the wire with a `200`, so the page renders and every `<Await>` boundary silently stays on its fallback forever.
63
+ **`fleetSize` is the cheap half, and it needs no aggregation at all.** A process-scoped answer states how many replicas exist, so a bare `curl` now says it is a fraction and of what. When membership cannot say, the field is ABSENT rather than `1`: "I am alone" and "I cannot know" must not render identically.
57
64
 
58
- Two things changed beyond the bridge. The last/max latency PAIR is gone in favour of the histogram a last value plus a monotonic max answers strictly less, and the max never comes back down. And `voltro dev` now counts shell serves too; the counter previously existed only on the `voltro start` path, which for a metric is the silent kind of drift (the series simply reads zero).
65
+ **Default-DENY, because a default is a decision nobody made.** A path with no entry in `ROUTE_SCOPES` is refused with the fix in the message, so a new endpoint cannot ship unlabelled it fails on its first request, in its author's own dev loop. Mutating routes are enumerated as `write` rather than inferred from the HTTP method: `?scope=fleet` on `/agent/call` would run a user's handler once per replica.
59
66
 
60
- The `globalThis` slot is deleted rather than kept beside the registry: two counters for one fact is how a dashboard and a scrape target come to disagree.
61
- - **@voltro/plugin-presence** — `resolveMember` now receives the app's `store`, and `identityFields:` closes the hole in the obvious resolver.
67
+ **Three dispatchers, not one.** Wrapping `handleInspectRequest` looked complete from inside itself while `handleInspectAsyncRequest` and `handleSharedInspectRoute` went on answering bare — `/cluster`, `/schedules` and every workflow read. Nothing in the route table showed it; it was found by asking a running process for every route. `inspectDoors.test.ts` pins all three on every commit and `scripts/observation-e2e.mjs` re-asks a real bundled serve. The same sweep caught a wrapper defect no unit fixture could: a route that builds its response by hand rather than through `json()` was wrapped into an envelope with `data: undefined`, which serialises away — every field around the answer correct, and the answer gone.
62
68
 
63
- Both from a deployment that adopted the hook and reported what it cost them.
69
+ **The dashboards say it on screen.** `ScopeNotice` renders inside the SHARED `devtools-ui` pages, not in each host dashboard, for the reason every omission in this codebase has been invisible: a label each consumer must remember is a label one consumer will not have, and the page that forgot is indistinguishable from a page with nothing to warn about. It renders nothing when the answer is complete a banner over a complete answer trains people to ignore banners.
64
70
 
65
- **The store.** The resolver's whole job is a by-id read, and `app.config.ts` where it is declared is evaluated long before a migrated `DataStore` exists. The workaround is a module cell filled from a `*.startup.ts`; the plugin already receives the store through `bindDataStore`, so it hands it to the resolver instead: `resolveMember: ({ subject, store }) => …`.
71
+ **Breaking:** a caller reading `/_voltro/inspect/*` JSON reads `.data` now. Both dashboards unwrap in their single fetch helper and keep the envelope on `_observation` for the notice. A response without the envelope passes through unchanged, so an app on an older framework version is not a broken app.
66
72
 
67
- **The hole.** Resolved fields merge OVER the caller's `meta`, so a key the resolver does not return keeps whatever the client sent. A resolver returning only what it found `{ userName }` for a user with no avatar therefore leaves a caller-supplied `avatarUrl`, or a `userName` the resolver has never heard of, standing in the roster every other member reads. That is the exact substitution the hook exists to prevent, and the doc comment's promise ("a caller cannot override what the server says about them") was broader than the behaviour: it holds only for the keys returned on that call.
73
+ **`voltro update` carries you across this**codemod `0.56.0/01_inspect_answers_are_observations`. If you pin versions by hand and never run it, print the notes without changing anything: `voltro update --codemods-only --from <your current version> --dry-run` (this one ships in 0.56.0).
74
+ - **@voltro/cli** — **`voltro serve` on a WEB app now refuses. It used to delete the deployment artefact.**
68
75
 
69
- `identityFields: ['userName', 'avatarUrl']` names the keys the SERVER owns. They are stripped from the caller's `meta` BEFORE the merge, so the answer is the same on every call whether or not the resolver produced a value. Ignored without a `resolveMember` — with no server identity to protect, stripping a client field would only delete data the app put there deliberately. Returning every identity key (`null` where you have no value) remains the alternative, and is what the reporting deployment did.
70
- - **@voltro/plugin-queue** — **Queue consumers now export Prometheus series and open one tracing span per message, continuing the producer's trace.** Both were specified and neither was built: `voltro_queue_consumed_total` had zero occurrences anywhere in the repo, and `traceparent` was copied out of the message headers onto `ctx.traceparent` — a value a handler can forward by hand, not a trace. A Kafka hop was where every distributed trace ended.
76
+ `voltro start` (production) and `voltro dev` (development) are unchanged, and an API app is unchanged. What is removed is the web branch of `serve`, which was advertised in the CLI's own help as *"Production server: web app (vite preview) OR API, auto-detected"*. Both halves of that were measured before it was removed:
71
77
 
72
- Four series, all in Effect's global `MetricRegistry` (so `GET /metrics` and `GET /_voltro/inspect/metrics` both see them): `voltro_queue_consumed_total{topic,outcome}`, `voltro_queue_retries_total{topic}`, `voltro_queue_produced_total{topic}` and the `voltro_queue_lag_messages{topic,partition}` gauge.
78
+ **In production it never ran.** The launcher's serve fast path requires `.framework/dist-api/serveBundle/serveEntry.js` an artefact a web build does not produce. A web app got:
73
79
 
74
- `outcome` is a closed two-value union — `ok` and `dead-lettered` together are every message the runner finished with, so the dead-letter rate is a division with no second series to join. A message abandoned by a REBALANCE is deliberately in neither: it was not consumed here, its new owner redelivers and counts it there, and counting it twice would make that ratio wrong in the direction of looking healthy.
80
+ ```
81
+ [voltro] FATAL: production `voltro serve` requires a precompiled serve bundle at
82
+ …/.framework/dist-api/serveBundle/serveEntry.js
83
+ Run `voltro build` before serving
84
+ ```
75
85
 
76
- Lag costs no extra round trip — `highWatermark` already rides along in the fetch response, so it is arithmetic on data the provider was handed. It is recorded in the provider rather than the runner because that is the only layer holding the watermark; the runner could only get it by asking the broker. Offsets are parsed as `BigInt` so a long-lived topic cannot lose precision, and an unparseable pair records nothing rather than a zero: "no lag" and "we could not tell" must not read the same.
86
+ directly after a `voltro build` that had just succeeded.
77
87
 
78
- The per-topic inspect counters stay, and each fact is now moved by exactly ONE recorder that writes both the slot and the series, so the dashboard and the scrape target cannot drift.
88
+ **Below production it destroyed the build.** The web branch ran a SECOND vite build with `plugins: [react()]` only no `@tailwindcss/vite`, no image pipeline, no per-page islands entries — and then `vite preview`, which Vite documents as not for production use. Measured on a fixture:
79
89
 
80
- Each message is processed inside a `queue.consume` span whose parent is the incoming `traceparent`, parsed by the same `externalSpanFromTraceparent` the inbound-webhook path uses — so a malformed or all-zero header means "no parent" (a fresh root span), never a failed message. The span covers the whole message including retries and the dead-letter publish, and carries the OTel `messaging.*` attributes plus `voltro.queue.outcome`. The topic is an attribute, not part of the span name.
90
+ - with Tailwind, the build ABORTS: `[postcss] ENOENT: no such file or directory, open 'tailwindcss'`, surfaced as `unhandled cli error` with a raw stack; - without Tailwind it SUCCEEDS and because both builds write `.framework/dist`, which Vite empties (it sits under its root), it deleted `dist/server`, `island-shells/` and every pre-rendered route directory. Immediately afterwards, `voltro start` refused to boot: *"requires precompiled artefacts: …/dist/server/ssrEntry.js"*.
81
91
 
82
- Known limit, measured rather than assumed: a span opened from DETACHED work does not reach an OTLP exporter, because the framework's tracer is a `Layer` provided only inside the rpc server's scope. This is framework-wide (`cdcOut.deliver` and `plugin.<name>.schedule-fire` are the same shape) and is stated in the queue docs rather than implied away. The metrics are unaffected — the metric registry is a process global.
83
- - **@voltro/runtime, @voltro/voltro** — `setRowFilter({ …, tables: ['bookmarks', 'recentSearches'] })` — declare which tables your filter may narrow, and delta-resume survives everywhere else.
92
+ So `voltro build` `voltro serve` `voltro start` left the deployment unable to start, and the middle command was the one the help recommended.
84
93
 
85
- Delta-resume is excluded for a subscription whose row set is re-resolved per delivery: replaying deltas could serve rows the subject has since lost. But the question the runtime could ask was only "is a filter registered in this process?", so ONE registration disabled cheap reconnects for every subscription in the app a deployment measured a filter narrowing 4 tables costing the feature on all 173 of their query descriptors, 55 of whose source tables the filter never touches.
94
+ The codemod is `manual` with `reach: 'beyond-source'`: the command being replaced lives in Dockerfiles, compose files, CI jobs and runbooks, not in `.ts`. It searches every text file git tracks and says plainly that a step defined in a CI runner's own UI is beyond what any repository scan can see.
86
95
 
87
- The exclusion is per table now. A declared filter keeps resume for every subscription whose source is not in its set; an undeclared filter keeps today's conservative behaviour (the runtime cannot know which tables the predicate may reach, and "unknown" must read as "yes").
96
+ The shipped Dockerfiles were already updated in the same change set and now run the precompiled bundle directly `node .framework/dist-web/startBundle/startEntry.js` for web, `node .framework/dist-api/serveBundle/serveEntry.js` for an api.
97
+ - **@voltro/runtime, @voltro/cli** — **Every outbox effect was delivered once per replica.** `drainOutbox`'s summary line has always said "claim due rows". It did not: it read every `pending` row and ran its handler, and every replica runs the drain. Not on a crash, not on a retry — on the happy path, every time. Measured with two stores over one postgres: one enqueued row, one drain pass each, handler called twice.
88
98
 
89
- **Declared, not probed** and the difference is soundness, not taste. Resolving the scope at subscribe and treating `predicate(ctx, source) === undefined` as safe is the cheaper version: it is wrong, because the predicate is a function of freshly loaded context, so a table it does not narrow now may be narrowed on the next delivery which is the entire reason the filter is re-resolved per delivery. A static list is a promise about every future resolution, and the runtime holds the app to it: a predicate returned for an undeclared table raises the new `RowFilterDeclarationViolated` at the read that did it (the request fails, a subscription is revoked) rather than serving rows under a resume grant the declaration no longer earns.
99
+ The docs told handler authors to be idempotent and justified it with the crash case ("a process that dies between the remote accepting it and us recording that"). That reads as rare. "Your webhook fires once per replica, always" is a different operational fact, and nobody was told it.
90
100
 
91
- Eager loads (`.with(...)`) stay excluded wholesale a relation resolves below the seam that narrows (see the sibling entry).
101
+ `OutboxStatus` has always carried a `'delivering'` member that nothing set. That is the gate now, taken atomically: reclaim claims that have outlived their lease, read the due window, CAS `pending → delivering` stamping the claiming PROCESS, then handle only what came back. A racing replica loses the row because the predicate names the state it is leaving. `plugin-cdc-out` has done exactly this since it shipped, with the same `updateMany` CAS and the same state — two outboxes in one repo, one of them right.
92
102
 
93
- `apiSurface: compatible` the golden also picks up `subscribeDescriptor` gaining its `reauthorize`/`refilter` parameters, which landed in the SSE/gRPC row-filter fix without a regeneration. That member is a framework-internal transport binding: the only implementors are the two boot paths (`serveApi.ts`, `dev.ts`), both already passing the new arguments. No user code implements or calls it, so no code that compiled stops compiling.
94
- - **@voltro/local-first** — **`useCrdtDoc` — the transport half `useCrdtEditor` had no partner for.** `useCrdtEditor({ doc })` takes a `CrdtDocHandle` and owns the editor lifecycle; getting that handle wired to a server was app-level glue until now. Import it from `@voltro/local-first/react`:
103
+ Delivery is still AT-LEAST-ONCE and handlers must still be idempotent: a process can die between the remote accepting and the row being marked, and no claim closes that. What is gone is the routine N-fold duplicate, which was never a guarantee gap it was the word "claim" not having been implemented.
95
104
 
96
- ```tsx
97
- const shared = useCrdtDoc({
98
- cell: { table: 'documents', id, column: 'body' },
99
- remote: row.data?.body ?? null, // the reactive query streaming the row
100
- push: (w) => save.mutate({ id: w.id, update: w.update }),
105
+ `_voltro_outbox` gains `claimedBy` / `claimedAt` (nullable), applied by the declarative differ on `voltro db apply` and on a `voltro dev` boot, every dialect.
106
+
107
+ **Breaking:** `DrainDeps.claimedBy` is REQUIRED. It was optional for one draft, which meant a caller who omitted it silently got the old every-replica behaviour — an exactly-once gate that switches off when you leave a field out is not a gate. `voltro dev` / `voltro serve` pass it for you; only a caller driving `drainOutbox` directly is affected. `claimLeaseMs` (default 5 min) is new and optional.
108
+
109
+ **`voltro update` carries you across this** — codemod `0.56.0/03_outbox_drain_needs_an_identity`. If you pin versions by hand and never run it, print the notes without changing anything: `voltro update --codemods-only --from <your current version> --dry-run` (this one ships in 0.56.0).
110
+
111
+ ### Added
112
+
113
+ - **@voltro/database, @voltro/runtime, @voltro/cli, @voltro/data-transfer** — **`/_voltro/inspect/subscriptions?scope=fleet` — the fleet answer, assembled by READING rather than by asking.**
114
+
115
+ Each replica publishes its counters into `_voltro_replica_observations` on a timer, so any replica can answer a fleet question from the shared store. That is the same mechanism `coordinationState.recentReplicaIds` already used by reading `_voltro_schedule_runs`; this generalises the one case.
116
+
117
+ **Why not a fan-out over the broadcast bus.** The bus can `publish` and `subscribe`. A read-time fan-out would have to build request/response on top: a correlation id, a reply channel, a deadline to guess, a partial-result protocol. And it puts the DIAGNOSTIC on the failure path, so it degrades — by timing out — exactly when it is needed. Writing inverts that: the transport is the database, the one thing that must be up for anything to work, and a dead replica goes STALE rather than silent. Staleness is a number, and a number can be reported; paired with the membership roster, "old" becomes "missing".
118
+
119
+ **Three things make the merge honest, and each is the case a naive `rows.map()` gets wrong:**
120
+
121
+ - A replica membership knows about that has written nothing is **`missing`**, not absent. Dropping it makes a partial answer look complete — the same unlabelled sample this whole feature exists to end, one level up and more expensive, because now the reader believes they asked everybody. - A **stale** row is reported WITH its age rather than filtered out. Removing it hides that the answer is partial; keeping it unmarked presents fiction as current. Neither is available. - **Mixed versions are named.** A rolling deploy spans two shapes, and averaging them silently is wrong in a way nothing downstream can detect.
122
+
123
+ Only the newest GENERATION per replica is counted. A dead process's counters added to its successor's look exactly like a busy replica.
124
+
125
+ **With no shared store the request is refused with `501` and a reason** — never answered with this replica's own numbers. Handing a sample to someone who asked for the fleet in writing would be this feature committing the defect it exists to prevent.
126
+
127
+ `_voltro_replica_observations` holds ONE upserted row per `(replicaId, kind)`, so it is bounded by fleet size × kinds rather than by time. There is no history and must not be: a history needs a retention policy, and this is a cache of the present. It is classified **environment-local** for data transfer — a foreign row would invent a replica that does not exist here, and then report it as present or, once it ages, as one that has stopped answering.
128
+
129
+ The publisher runs on BOTH boot paths, pinned by `bootPathParity.test.ts`: a replica that never publishes is a hole in every other replica's answer, and it reads as "has written nothing", which is indistinguishable from broken.
130
+
131
+ **Measured with two real replicas against one database** (`scripts/fleet-observation-e2e.mjs`): both publish, EITHER answers for BOTH (`responded: 2, expected: 2`), and a killed replica's row keeps being readable with a growing age rather than vanishing — which is the whole design in one assertion, since a dead process going silent is indistinguishable from "nothing happened" and a row that gets old is not.
132
+
133
+ That test found two defects the single-process one could not:
134
+
135
+ - **An unreadable table read as an empty fleet.** Against a database whose schema predated `_voltro_replica_observations`, the merge reported `responded: 0, missing: [every replica]` — a healthy fleet described as gone. The read now raises and the route answers `503` naming the cause and the fix, because "nobody has written yet" and "I could not read the table" produce the same empty list and mean opposite things. - **A check of ours that examined nothing.** The single-process e2e asserted `typeof responded === 'number'`, which `0` satisfies — so it passed for the entire time the write was failing. It asserts `> 0` now.
136
+ - **@voltro/web, @voltro/client, @voltro/cli** — **`web.api.connect: 'lazy'` — a page that reads no data no longer opens a WebSocket.**
137
+
138
+ The default is unchanged (`'eager'`: every declared api connects at mount), so nothing moves unless an app asks for this.
139
+
140
+ Why it exists, measured in a real browser against a `voltro start`:
141
+
142
+ | page | `interactive` | WebSocket attempts | |---|---|---:| | `/pricing` | `'none'` | 0 | | `/info` | `'islands'` | 0 | | `/docs/intro/getting-started` (subscribes to nothing) | default = `'full'` | **6** | | `/` (Todos, subscribes) | `'full'` | 5 |
143
+
144
+ So the two opt-in render modes were already free, and the DEFAULT one connected regardless of whether the page used data. The six are reconnect backoff, not six live sockets — a socket that opens and is held was verified separately (a raw `ws://…/ws` against a running `voltro serve` opens and stays open with no auth, no subscription and no traffic).
145
+
146
+ What a held socket costs is not just a connection: `isIdleNow` returns false while `connectedClients() > 0` (`idleDetector.ts:46` → `singleNodeOrchestrator.ts:147` → `wakeRouter.ts:44`), so **one browser tab on a pricing page prevents scale-to-zero indefinitely** — the feature the framework's own documentation describes two paragraphs above the condition that defeats it.
147
+
148
+ Under `'lazy'` the connection is deferred to the first hook that asks for that api. Demand is expressed in `useFrameworkApi`, the single accessor all 27 data hooks read through — a per-hook signal would be 27 chances to forget one, and a forgotten one is a hook that silently never connects. It is keyed by api NAME, so an app with an analytics api it touches on one page does not open it on every page.
149
+
150
+ Verified on the same page that showed 6: **0 sockets** with `'lazy'`, and the Todos page still 5.
151
+ - **@voltro/runtime, @voltro/cli, @voltro/database** — **`defineSubscriber({ once })` — run a handler once per change across the fleet, instead of once per replica.**
152
+
153
+ A subscriber binds to the change stream on every api instance, so one `INSERT` behind three replicas calls the handler three times. That is the right default and stays the default: a handler that refreshes a search index, warms a local index or drops a process-local cache entry *has* to run everywhere, and a fleet-wide gate would leave every other replica stale. It assumes the handler is idempotent.
154
+
155
+ It is the wrong default for an EFFECT — a notification, a mail, a webhook, a payment — because there is nothing to make idempotent: the effect IS a write, so each run produces another one. `SubscriberDefinition` had no way to say so, while the primitive rubric recommended a subscriber for exactly that job.
156
+
157
+ ```ts
158
+ export default defineSubscriber({
159
+ table: 'absence_requests',
160
+ on: ['insert'],
161
+ once: (event) => String((event.new as { id?: string } | null)?.id ?? ''),
162
+ handler: notifyApprovers,
101
163
  })
102
- // in a CHILD component, so useCrdtEditor is never a conditional hook call:
103
- const editor = useCrdtEditor({ doc })
104
164
  ```
105
165
 
106
- It returns `{ doc, loaded, outstanding, synced, setOnline }`. `doc` is `null` until the mount effect has run — the document is built in an effect, never during render, which is what a rich-text binding gets wrong first (constructing during render crashes a prerender and leaves a second instance alive under StrictMode).
166
+ The key is claimed in the new `_voltro_change_claims` table `insertIgnore` on a `UNIQUE(scope, key)`, the same INSERT-wins arbiter the cron scheduler uses for a (schedule, bucket) firing and exactly one replica wins it. The scope is the subscriber's file id, so two subscribers watching one table never lock each other out.
167
+
168
+ Three things the type states rather than leaves to be discovered:
169
+
170
+ - **The key must tell two genuine changes apart.** A row id is enough for `insert` and `delete`. It is not enough for `update`: two edits to one row produce the same id, and the second is dropped as a duplicate. Use `` `${id}:${updatedAt}` ``. - **AT MOST once, not exactly once.** The claim is taken before the handler runs, so a replica that wins and dies takes the event with it. A claim that cannot be written at all is taken by nobody — fail-closed, because `once` promising "at most once" is what makes it worth having. Both are loud in the log; neither is retried. - **Which coordination each subscriber got is in the boot log**, because the two behaviours differ by a factor of the replica count and were otherwise indistinguishable:
171
+
172
+ ```
173
+ subscriber: registered table=absence_requests on=["insert"] once=fleet
174
+ subscriber: registered table=posts on=["insert","update"] once=per-replica
175
+ ```
176
+
177
+ `_voltro_change_claims` is created for every sql app and swept after an hour (`VOLTRO_CHANGE_CLAIMS_TTL_HOURS`). It rides the declarative differ, so `voltro db apply` and a `voltro dev` boot both create it, on every dialect.
178
+ - **@voltro/cli** — **`voltro doctor` now asks the question a subscriber's author is the only one who can answer: should this effect repeat on every replica?**
179
+
180
+ `store.onChange` is a broadcast. That is CORRECT for a reader — a cache drop, an index refresh, a live query must run everywhere — and a multiplier for an effect, because there is nothing to make idempotent: the effect IS the write, so each run produces another one. One `INSERT` behind two replicas writes two notification rows; with a broadcast bus in front, four.
181
+
182
+ `subscriber-effect-without-once` fires when a `*.subscribe.ts` handler writes (`ctx.store.insert` / `update` / `upsert` / …), publishes (`ctx.publish`), or calls a `notify` / `sendWebhook` / `sendMail`-shaped helper, and the subscriber declares no `once:`. Advisory like every rule in that scan — it prints, it never fails a build, because `once:` on a READER would silence it on every replica but one, and only the handler's author knows which of the two they wrote.
183
+
184
+ It reads the handler through the AST, for two reasons at once. `once:` and the handler sit in ONE object literal, so "this file mentions `once`" is not the question. And the effect has to be found inside the handler under whatever name it gave its context parameter — `(event, ctx)`, `(event, { store })`, or a `handler: notifyApprovers` naming a function in the same file, which is the form the primitive's own doc comment teaches. A handler IMPORTED from another module is not judged: its body is not in the file being read, and a finding about code the rule never opened would be a guess wearing a file path.
185
+
186
+ **And `mutation-tail-effect` no longer fires on the file that took its advice.** That rule matches a `notify(...)`-shaped call in any file with an `export default` — which a `*.subscribe.ts` has. So a subscriber calling `notify(...)` was told to move its effect into a subscriber: our own two rules disagreeing on one file, which is how a whole section of the report gets skipped. Detected by the CALL (`defineSubscriber` / `defineReaction`) rather than the filename, for the reason the adoption check already exists — a file can adopt the primitive under any name.
187
+ - **@voltro/protocol, @voltro/database, @voltro/runtime, @voltro/cli** — **`?replica=<id>` — ask ONE named replica, through the one you can reach.** The answer comes back with that replica's `origin`, process-scoped: the proxy does not launder whose answer it is.
188
+
189
+ **The address comes from the shared store, not from membership** — and that is a correction to the obvious design. Membership rides the broadcast bus, so peer addressing built on it works only for an app that configured one, and the operator who most needs to reach a specific pod is not reliably the one who did. The row already exists, every replica already writes it, and the database is already the thing that must be up. `_voltro_replica_observations` gained a `reachableAt` column.
190
+
191
+ **An endpoint that fetches a URL on request is an SSRF primitive unless it is built not to be.** Four rules, each closing a distinct way this could become one, and each with a test:
192
+
193
+ - **The caller names an ID, never a URL.** The address is resolved from a set we wrote; an unknown id is a `404` and no request leaves the process. "Unknown replica" and "published no reachable address" give the same message, because distinguishing them would tell a caller which ids exist. - **A proxied request carries a hop header and is always answered locally**, so `?replica=a` on A pointing at B pointing at A cannot cycle. The forwarded URL also has `replica` stripped and `scope=process` forced. - **The peer call forwards the caller's token and adds nothing.** A replica is not a more privileged caller than the human who asked. - **Only reads are addressable.** `?replica=` on `/invoke`, `/seeds/run`, `/migrations/rollback` or `/agent/call` is refused before any lookup.
194
+
195
+ A peer that does not answer inside a short deadline becomes a `504` naming it: a diagnostic that hangs is worse than one that says no.
196
+
197
+ **A declared address and a fallen-back one are different facts.** An unset `POD_IP` falls back to `127.0.0.1`, which is a shrug — recorded as NOT reachable, and no peer tries it. `VOLTRO_INSPECT_ADVERTISE_HOST` declares one, including `127.0.0.1` when the peers really are on this machine. This is the same posture the framework takes everywhere: we do not second-guess a declaration, and we do not treat a fallback as one.
198
+
199
+ Measured between two real replicas (`scripts/fleet-observation-e2e.mjs`): A answers for B, B's identity survives the hop, an unknown id is refused with no outbound request, and a mutating endpoint is refused with `400`.
200
+
201
+ **And the consumer that nearly shipped broken.** Both dashboards unwrap the envelope in their single fetch helper; the CLI's `inspectFetch` — which thirty-odd subcommands read through — was missed. `voltro cluster status`, `voltro logs`, `voltro schedules` would each have read `undefined` off an envelope and printed an empty table. Found by asking what ELSE reads these routes, not by a failing test, which is why the seam now has one: the payload comes out, the envelope is kept so a command can say "1 of 3", an answer that predates the envelope passes through unchanged, and a payload that merely HAS a `data` key is not mistaken for one.
202
+ - **@voltro/cli** — **`/_voltro/inspect/stream` events carry `origin`.**
203
+
204
+ A live SSE stream is the same defect as an unlabelled `/logs` response, except it keeps producing it: a viewer watching a tail on a three-replica fleet sees one third of it, continuously, and the connection landed on whichever replica the load balancer chose.
205
+
206
+ On **every event**, not on a handshake — because a consumer that connects late never receives a handshake. A reconnect, a second browser tab, a `curl` piped into `jq` all read the buffer, and a handshake they never saw would leave every buffered line unattributed. That is the assertion the test pins.
207
+
208
+ `ts` is that replica's clock. Two origins must not be ordered by it, which is the same rule `events.origin` already states as "serials are only comparable within it".
209
+ - **@voltro/devtools-ui** — **A Fleet panel, in both dashboards.** `FleetPage` renders what every replica published about itself — the self-hosted DevTools wire it over HTTP, the hosted customer console over the cloud RPC proxy, from one shared component.
210
+
211
+ **It renders THREE populations, and the two after the first are the point.** A page that shows only the replicas which ANSWERED is worse than no page: the reader now believes they asked everybody. So the silent replicas get their own section rather than being omitted ("not shown" and "not there" look identical and mean opposite things), and the stale ones are shown WITH their age (filtering them makes a partial answer look complete; leaving them unmarked presents old numbers as current). A mixed-version note appears when the responders disagree, because a rolling deploy means the numbers span two shapes.
212
+
213
+ The completeness banner is a RATIO — "3 of 5 replicas answered" — not a count. A count invites the reader to believe that is the fleet.
214
+
215
+ **Two guards, one per dashboard, because neither repo can see the other.** Each fails when a page the shared package exports has no nav entry in that dashboard, with a declared exemption list whose stale entries also fail. The asymmetry is the argument: the self-hosted dashboard is opened daily and the customer one is opened when something is wrong, so a panel missing from the second fails in the direction nobody notices.
216
+
217
+ **Measured in a real browser** (`voltro-devtools/scripts/fleet-panel-browser-check.mjs`): two replicas behind one dashboard, the panel renders BOTH, the nav link resolves (a page reachable only by typing a URL is not a panel), and the console is clean. That check immediately earned itself — it caught a `<div>` inside `CardDescription`'s `<p>`, invalid HTML that React reports only in a browser and that the page's own jsdom suite passed over.
218
+
219
+ The devtools-ui catalogue also gained an en/de parity test. English is the fallback, so a missing German string does not crash — it renders English in a German console, which looks like a working UI rather than a hole.
220
+ - **@voltro/cli, @voltro/runtime** — **Four serving changes: pre-compressed assets, a CDN prefix, a keep-alive that survives a proxy, and a preconnect for a cross-origin api.**
221
+
222
+ **Pre-compression (automatic).** `voltro build` writes `.br` (quality 11) and `.gz` beside every content-hashed asset over 1 KB; `voltro start` serves the variant when the client accepts it. Before, every hit compressed from scratch — measured on one 321 KB chunk, three requests each:
223
+
224
+ | | time | bytes | |---|---:|---:| | compressed per request | 5.6 / 5.0 / 4.6 ms | 100 665 | | pre-compressed | 1.6 / 1.7 ms | **86 083** |
225
+
226
+ Faster and smaller at once, because a build can afford q11 where a per-request path cannot (the runtime uses q4 precisely so it never stalls a response). Only hashed assets get variants: a stale `.br` beside a changed original is a corrupted response rather than a slow one. The `ETag` is computed over the UNCOMPRESSED file and passed through, so brotli, gzip and identity share one tag — the invariant `httpResponseWrite.ts` states, which hashing the variant would have broken.
227
+
228
+ **`web.assetPrefix`.** Becomes vite's `base`, so every emitted URL is written with the prefix at build time. Without it an app with a single `ssr` route serves every byte of its bundle from the container — `voltro static`, the documented cost-offload, only applies to an app that is entirely static. Verified on a fixture: all ten asset URLs in the shell AND in every pre-rendered page carry the prefix.
229
+
230
+ **`http.keepAliveTimeoutMs`, default 72000.** Node hangs up an idle keep-alive connection after **5 seconds** — confirmed on a running server (`Keep-Alive: timeout=5`) — while nginx holds one for 75s and ALB/Envoy for 60s. The proxy then sends onto a socket the server is closing and answers 502. Rare per request, certain over time, and invisible in testing. `headersTimeout` is derived above it and a declared value at or below `keepAliveTimeout` is RAISED rather than applied: node measures it from connection start, so honouring it would destroy healthy connections. Applied on both serving surfaces from one resolver. Verified: `Keep-Alive: timeout=72`.
231
+
232
+ **`preconnect` for a cross-origin api.** When an api's `wsUrl` is on another host the shell carries `<link rel="preconnect" … crossorigin>`, so the handshake overlaps the bundle download instead of following it. Same-origin apis emit nothing — the browser already has that connection, and a redundant hint costs a wasted socket. `crossorigin` is required: without it the warmed connection is anonymous and the credentialed one the socket needs is a second handshake.
233
+
234
+ **`web.sourcemaps: 'hidden'`.** Emits `.map` files with no `//# sourceMappingURL` comment. `plugin-sentry` reads `SENTRY_RELEASE` explicitly "for release health AND source maps" and there was no way to produce any, so every production stack trace was minified — from an integration advertising the opposite. Off by default, and named for what it does rather than being a boolean.
235
+
236
+ **And `voltro start` now refuses to serve a `.map` at all**, whether or not one is on disk. The docs say to upload the maps and delete them before the image is built, and "say" is not a mechanism: a hidden map is undiscoverable but perfectly REACHABLE — its URL is the chunk's own name plus `.map`. One forgotten deploy step would publish the app's source. 404, not 403, because a 403 confirms the file exists. Red-verified: with the refusal removed, the same request returns **200 and the map's contents**.
237
+ - **@voltro/i18n, @voltro/cli** — **`<LocaleSwitcher>` now ships unstyled from `@voltro/i18n`, and `voltro doctor` reports the failure that made it necessary.**
238
+
239
+ Counted across the shipped templates: 16 declared `@voltro/ui-shadcn`, and **13 of them never imported `@voltro/ui-shadcn/tokens.css`**. The only kit component they used was `LocaleSwitcher`, which is `h-9 rounded-md border border-input bg-transparent px-2 text-sm shadow-xs …` and nothing else. Tailwind v4 emits a utility only when a CSS entry declares it, and the kit deliberately does not import its own stylesheet (the app owns its CSS entry) — so every one of those class names referred to a rule that did not exist. The control rendered as a bare `<select>` with dead `class` attributes, in templates whose own docs say they do not use the kit.
240
+
241
+ Nothing could have caught it: `voltro build` succeeds (a missing utility is absent bytes, not an error), `tsc` succeeds (the import is real), and the page renders.
242
+
243
+ Two changes:
244
+
245
+ - **`@voltro/i18n` exports `LocaleSwitcher`** — the same behaviour (writes the `voltro:locale` cookie, reloads, `onChange` can suppress the reload) as a native `<select>` you style yourself. It belongs here for the same reason `LOCALE_COOKIE` already moved here: an app on the framework's i18n and not on the kit had no way to reach it. `@voltro/ui-shadcn`'s styled version is unchanged. - **`voltro doctor` reports a kit rendered with no stylesheet** — advisory, and it names the offending files. Quiet when the app imports `tokens.css`, quiet when the app declares Tailwind itself, and quiet on a `import type` (which emits nothing and therefore cannot produce a class attribute).
246
+
247
+ The 13 templates now import the unstyled control and no longer declare `@voltro/ui-shadcn` at all — which also drops that package's `shiki` dependency from every one of them.
248
+
249
+ ### Changed
250
+
251
+ - **@voltro/content, @voltro/ui-shadcn, @voltro/cli** — **The production bundles carried 308 syntax grammars and three cloud SDKs for apps that asked for none of them.**
252
+
253
+ Both shiki callers already restricted their languages correctly — `@voltro/content` to nineteen, `@voltro/ui-shadcn` to eighteen — at RUNTIME. A bundler cannot read a runtime list, and the `shiki` barrel maps all 700+ of its languages to their own dynamic import, so every one was emitted as a chunk. Measured, by intersecting the emitted filenames with `@shikijs/langs` + `@shikijs/themes`: **6.67 MB across 308 files, in BOTH the api serve bundle and the web start bundle**. In a browser bundle it is the same set: an app rendering one `<HighlightedCode>` made 308 chunks reachable.
254
+
255
+ Both now build from `shiki/core` with the grammars and themes imported by name. Every specifier stays dynamic and node-gated — `shiki` is an optional dependency and `@voltro/content` is isomorphic, so a static import would both break an app that never renders markdown and put the highlighter in the browser graph of anything importing `renderMarkdown`.
107
256
 
108
- **It is `createSyncClient`-backed, not new glue.** The four-line hand-rolled version loses the offline queue, the bounded retry, `outstanding`/`synced`, the per-cell coalescence that drains 1000 offline keystrokes as O(1) pushes, durable persistence, and the discipline that a `null` row means NOT LOADED rather than an empty document fold an empty document over a loading row and the first keystroke can push a state that erases what was stored.
257
+ The same shape, one layer out: the api serve bundle also carried the Azure Blob SDK (604 KB) and `@react-email/render` + `react-dom/server` (972 KB) for a fixture that declares one plugin and neither storage nor mail they arrive through `@voltro/cli`'s own dependencies on `plugin-storage` / `plugin-mail`. Each is already reached by a dynamic import inside its plugin and is an optional peer of it, so each joins the runtime-external list beside `ioredis` and `nodemailer`: an app that configured the provider resolves it from its own `node_modules` at boot, one that did not never ships it.
109
258
 
110
- **The echo guard is the part that could not be written outside the package until now.** A `crdtDoc()` handle is mutated by the EDITOR, so local edits arrive as `onUpdate` callbacks — and folding a peer's state through `applyState` fires the same callback. The hook pushes only when `local` is true. Without that, every client re-broadcasts what it just received.
259
+ Measured end to end, on the reference fixtures:
111
260
 
112
- `useCrdtText` and `useCrdtDoc` now build their sync client through one shared internal seam rather than two copies of the same transport wiring.
261
+ | | before | after | |---|---:|---:| | api serve bundle | 26.9 MB / 535 files | **5.72 MB / 138** | | web start bundle | 13.59 MB / 427 files | **1.48 MB / 39** |
262
+
263
+ Both are pinned now (`bundle-budget.mjs --artifacts`), in bytes AND in file count — 427 is a number somebody notices, where "13.6 MB" reads as "it is a bundle".
264
+
265
+ Highlighting is unchanged and verified through a real build: the docs site's pre-rendered pages still carry `class="shiki shiki-themes github-light github-dark-dimmed"`.
266
+ - **@voltro/cli** — **The artefacts that exist to collapse a cold boot were shipped unminified — and nothing enabled node's code cache.**
267
+
268
+ `VOLTRO_BOOT_TIMING=1` says where a boot goes, and the answer is the phase the bundles were built for: `modules` (node init + loading the precompiled bundle) is **46 %** of a `voltro start` and **65 %** of a `voltro serve`. V8 parse time tracks bytes, and neither `webStartBundle.ts` nor `apiBuild.ts` set `minify`, while vite leaves an SSR build unminified by default.
269
+
270
+ Both are on now, plus `enableCompileCache()` in the launcher. Measured on the reference fixtures, five fresh processes each, median:
271
+
272
+ | | before | after | |---|---:|---:| | `voltro start` boot | 166 ms | **83 ms** | | `voltro serve` boot | 287 ms | **222 ms** | | start bundle | 13.59 MB | 11.75 MB | | serve bundle | 26.9 MB | 18.25 MB | | `dist/server/ssrEntry.js` | 1.91 MB | 0.85 MB |
273
+
274
+ `keepNames: true` is not optional and is the one thing to preserve if you touch this: Effect's tags, error `name`s and the boot-refusal marker (`bootRefusal.ts`) are compared as STRINGS across the bundle boundary, and a mangled class name turns a precise refusal into an anonymous one. It costs a few percent of the saving and buys back every diagnostic the bundle exists to keep.
275
+
276
+ The compile cache is only enabled when `NODE_COMPILE_CACHE` is unset, so an operator's directory always wins. In a scale-from-zero container the OS temp dir starts empty, which is why the shipped standalone Dockerfiles point the variable at a directory the image BAKES during its boot smoke — the run that was already happening.
113
277
 
114
278
  ### Fixed
115
279
 
116
- - **@voltro/runtime** — A row filter was bypassed for any table reached through `.with(...)`.
280
+ - **@voltro/database** — **Two replicas booting at once against a schema with work to do killed one of them.**
281
+
282
+ `applyPlan` takes the migration advisory lock, so two writers cannot execute at the same time. What it did not do was ask again once it held the lock. Both replicas plan against the same live state, then queue; the winner applies, and the loser wakes holding a plan for a database that no longer exists:
283
+
284
+ ```
285
+ ═══ applier: statement failed (op=add-check) ═══
286
+ statement: ALTER TABLE "actors" ADD CONSTRAINT "actors_kind_check" …
287
+ db.message: constraint "actors_kind_check" for relation "actors" already exists
288
+ dev server exited — supervisor stopping exitCode: 1
289
+ ```
290
+
291
+ A classic time-of-check/time-of-use: the lock serialised the apply and did not protect what the apply rests on. It self-heals — the pod restarts and finds the schema applied — so what it looked like in production was one crash per replica on every deploy against a schema that had work to do, on a plan that was correct when it was made. A cold fleet start is exactly when every replica has work to do.
292
+
293
+ The first thing under the lock is now `ctx.replan` — the same hook the convergence proof uses at the other end, required for the same reason (only the caller knows the planner inputs). Everything downstream reads the re-planned set: the operations, the resume ledger's comparison, and the recorded fingerprint. Using the stale one for any of those would record that a replica applied work it did not.
294
+
295
+ Where the plan is already current — the ordinary case, nobody raced — the re-plan returns what was passed in and costs one introspection. `applyPlan` is not called at all for an empty plan, so that cost lands only where there was real work.
296
+
297
+ A plan that becomes blocked under the lock is refused as loudly as one that started blocked; the pre-lock check judged a different set.
298
+ - **@voltro/cli** — **A proven change-stream gap now drops the cache, not only the live queries.**
299
+
300
+ Re-running every live subscription repairs what a subscriber sees, and that is the half you look at. Cache invalidation rides `store.onChange` — so while the stream was down, nothing was invalidated — and the dispatcher's recompute re-seeds only the entries a LIVE subscription owns. Everything else keeps serving pre-gap rows until its TTL: a `ctx.cache` read, an ISR page, a cached query nobody is currently subscribed to. On a replica that has just announced, in its own log, that it knows it was behind.
301
+
302
+ The recovery now evicts every registered table before refreshing. Blunt for the same reason `refreshAll` is blunt — we do not know which tables the lost changes touched, and guessing narrower is how the silent staleness comes back. Cache first, then the queries: the recompute reads the store directly and writes its result back, so dropping afterwards would throw the fresh rows away again.
303
+
304
+ Both boot paths, through the shared builder, with the parity asserted.
305
+ - **@voltro/cli** — **`voltro start` sets `Cache-Control`. It sent none at all.**
306
+
307
+ Measured against a running production server, on a chunk whose FILENAME carries its content hash:
308
+
309
+ ```
310
+ $ curl -D - http://localhost:5399/assets/index-7N08IhkU.js
311
+ HTTP/1.1 200 OK
312
+ content-type: application/javascript
313
+ vary: Accept-Encoding
314
+ content-encoding: br
315
+ etag: W/"4bff20f74ba2a65fde4443acd7ed80b6"
316
+ ```
317
+
318
+ No `cache-control` and no `last-modified`. RFC 9111 derives heuristic freshness from `Last-Modified`, so with neither header a browser has nothing to reason about and revalidates. The reference fixture's first load is an entry plus nine `modulepreload`s — ten conditional round-trips before the page is interactive, on every visit, for files that by construction can never change. A CDN or reverse proxy in front of the container could cache nothing at all, for the same reason.
319
+
320
+ The framework already knew the rule: `plugin-storage` serves its public objects with `public, max-age=31536000, immutable` and `plugin-atlassian` its avatars. Only the arm serving our OWN chunks had no policy.
321
+
322
+ Now, from one place (`staticCachePolicy.ts`, so the arms cannot disagree): content-hashed assets get a year and `immutable`; anything else out of `public/` gets an hour; a pre-rendered page gets `max-age=0, must-revalidate`, which the existing ETag answers with a `304`.
323
+
324
+ Nothing gets an `s-maxage` by default — a shared cache holding HTML past a deploy serves the previous build's asset URLs, and there is no purge hook to fix that. An app that owns its CDN opts in through the new `http.cache` block (`htmlSMaxAgeSeconds`, `isrShared`, and both lifetimes; `immutableMaxAgeSeconds: 0` turns the immutable header off entirely).
325
+
326
+ The hash detector is the part with an edge: it requires `/assets/` AND a `-<6..12 chars>` suffix, so a hand-named `page-2.js` is never frozen for a year — a mistake that cannot be undone without renaming the file.
327
+ - **@voltro/plugin-row-history** — **`timing: 'post-commit'` recorded one history version per replica, with different version numbers.**
328
+
329
+ The in-transaction timing is safe by construction: the writing replica records the entry inside its own transaction and the tap returns early. Post-commit has no such writer — on a `changeScope: 'fleet'` store the injected event reaches EVERY replica and each one calls `recordChange`.
330
+
331
+ It did not surface as a conflict. `recordChange` numbers a version as `MAX(version) + 1` and derives the row id from it, so two replicas both computed version 1, one won the primary key, and the loser's RETRY re-read MAX, got 2, and appended a SECOND entry for the same change. That retry exists for a genuine concurrent write to the same row and cannot tell that case from this one.
332
+
333
+ Measured with the gate removed: three replicas, one change, versions `1, 2, 3`; two replicas, three changes, `1, 2, 3, 4, 5, 6`. Not merely doubled — mis-ordered, and `selectAsOf` / `sortHistory` / `diffVersionRows` all read `version`. A duplicate can be deduped; a wrong order cannot even be detected from the data.
334
+
335
+ The tap now claims each change fleet-wide before recording, through the same arbiter behind `defineSubscriber({ once })`. The key names the CHANGE, not the row: a row id would collapse two genuine edits to one row, and for a history trail a silently missing version is the worse direction.
336
+
337
+ Nothing to configure. `timing: 'in-transaction'` is unaffected — it never had this.
338
+ - **@voltro/sql-postgres, @voltro/database, @voltro/cli** — **A postgres replica no longer goes permanently deaf when its LISTEN connection drops.** Under `changeStrategy: 'cdc'` — the multi-replica default on postgres — the CDC consumer holds one dedicated connection. `@effect/sql-pg` registers a no-op `client.on('error')` on it, so when that connection dies the socket error is swallowed, the stream neither fails nor ends, and the fiber draining it stays alive forever. Nothing throws. Nothing is logged. No fiber dies.
339
+
340
+ Measured against a live server: kill the backend holding the LISTEN, write from another connection, wait twenty seconds — nothing arrives. Every subsequent change from every other replica is lost too, until the process restarts. A failover, a proxy recycling an idle socket, an admin `pg_terminate_backend`, or the database pod restarting all produce it, and none of them touch the app process, which is exactly why the app process did not notice.
341
+
342
+ The consumer now carries a watchdog. It cannot wait for an error — there is none — so it probes: after silence on the channel it sends a `pg_notify` through the pool and requires the echo back on the LISTEN stream. Any traffic counts as the answer, including another replica's probe, so a busy channel never pays for one and a fleet pays roughly one probe per idle window however many replicas it has. An unanswered probe means the connection is dead; the consumer re-opens it, retrying with backoff, and does not declare success until it has heard its own heartbeat come back.
343
+
344
+ **And the reconnect declares a gap**, which is the half that makes it a recovery rather than merely a pulse: postgres queues nothing for a listener that is not there, so every change written during the outage is gone. Stores expose that through a new optional `DataStore.onChangeStreamGap`, and both `voltro dev` and `voltro serve` wire it to the same refresh the broadcast bus's gap already used — re-run every live query, which is safe and complete because a query is idempotent.
345
+
346
+ `VOLTRO_CDC_HEARTBEAT_MS` (default 20000) and `VOLTRO_CDC_HEARTBEAT_TIMEOUT_MS` (default 10000) tune it.
117
347
 
118
- The filter is AND-merged onto a read's BASE table by the store middleware; an eager load is resolved BELOW that seam — the memory store recurses through its own raw read, the SQL stores fold the relation into one join so the relation's rows never passed the code that would narrow them. Measured on one data set: a filter restricting `readers` to the caller returned exactly the caller's row on a direct read and BOTH rows through `.with({ readers: true })`.
348
+ `apiSurface: compatible`: `PostgresDataStore`'s constructor gains a TRAILING OPTIONAL parameter (`cdcLiveness`), which the golden renders as a changed line. Every existing call still compiles — the store is built through `makePostgresDataStore` in any case.
119
349
 
120
- Applying the filter inside eager compilation is the real fix and it is a per-dialect change. Until then the read REFUSES rather than serves, naming the table and both ways out (read it as its own query, or drop it from the `.with(...)`). Only relations reaching a table the filter actually narrows are affected; every other eager load is untouched, and an app with no filter pays nothing.
350
+ The mysql binlog reader has had an error-driven reconnect and a watchdog for a silently-dead stream for some time. This is the postgres half of the same idea, on the dialect the documentation steers people to.
351
+ - **@voltro/plugin-broadcast, @voltro/cli** — **A change delivered by the database was re-published onto the broadcast bus, and the amplification was quadratic.**
121
352
 
122
- Silent exposure is the one outcome that must not survive the gap — the same reasoning that makes this module refuse to fail open when `load` fails.
123
- - **@voltro/cli** — **The AGENTS.md seeder executed every app's `app.config.ts`, so seeding one app could stop another from booting.** It walked `apps/<proj>/<app>` for the whole workspace and `import()`ed each config to read its `plugins:` list. An `app.config.ts` is not inert: it imports the app's schema, which calls `databaseHandle(...)`, which REGISTERS every table. Two apps that each declare an `actors` table therefore collided —
353
+ On a dialect with a native change transport postgres LISTEN/NOTIFY, mysql binlog — the store injects every change on EVERY replica; that is what makes the transport cross-instance in the first place. `@voltro/plugin-broadcast`'s outgoing listener then published those injected events again, under its own replica's origin, which is not own-origin for any peer. So every peer injected the change a second time.
354
+
355
+ Per change, with N replicas: N deliveries from the transport, N publishes onto the broker, and N(N-1) more injections from the peers — **N² local deliveries**. Every consumer of the change stream paid it: every `*.subscribe.ts` handler, every live-query wake, every plugin tap, every cache invalidation. At two replicas a subscriber's handler ran four times for one `INSERT`, twice per instance — and the per-instance half needs no cluster to reproduce, which is why it does not read as a clustering problem.
356
+
357
+ The suppression required the bus to be mid-inject (`injecting && …`), which only ever covered the bus's own echo. It is provenance alone now: an event stamped `origin: 'injected'` reached this process through some transport, and forwarding a transport delivery to a second transport is the amplification. A local publish that states `origin: 'inline'` — `publishReactivity` does — is still published, so reactivity channels keep crossing replicas.
358
+
359
+ The boot banner said the same thing the code did. It used to read `both paths active (own-origin skip dedups)`; it now names what each path carries:
124
360
 
125
361
  ```
126
- duplicate table 'actors' registration: two different table descriptors claim the same name.
362
+ reactivity: native LISTEN/NOTIFY (postgres) carries table changes;
363
+ @voltro/plugin-broadcast (redis) carries reactivity channels
364
+ ```
365
+
366
+ Nothing to change in an app. If you run postgres or mysql with a broadcast plugin, the duplicate deliveries stop on upgrade.
367
+ - **@voltro/cli** — **`voltro db plan --json` and `voltro db drift --json` were silently truncated when piped.** `fs.writeSync` is not "write this"; it is "write as much as the fd accepts right now, and return how much that was". On a file that is everything — which is why `> plan.json` worked and every manual check passed. On a pipe the kernel takes one pipe buffer, 64 KiB, and the rest is simply not written.
368
+
369
+ A plan crossing 64 KiB therefore reached `| jq`, or a CI step capturing the command, as exactly 65 536 bytes: valid JSON up to the cut and a parse error after it. It reads like a malformed plan and it is a malformed read — and the documented production route is to review that JSON and apply it.
370
+
371
+ The line it replaced carried a comment saying `writeSync` "always flushes", written as the fix for the previous version of this same bug (`console.log` to a non-TTY is block-buffered, and `process.exit` dropped the buffer, so `--json` emitted *nothing*). That fix was right about `console.log` and wrong about its replacement, turning "nothing on a pipe" into "the first 64 KiB on a pipe" — the more dangerous of the two, because an empty output is noticed at once and a truncated one is noticed by whoever parses it later.
372
+
373
+ `writeAllSync` loops over partial writes. Its test spawns a child with a real pipe, because on a file the defect does not exist.
374
+ - **@voltro/plugin-search** — **The search panel could over-count a fleet's sync stats, because the plugin derived its replica id instead of using the one it was handed.**
375
+
376
+ `_voltro_search_stats` keeps one row per replica and aggregates on READ: SUM under `changeScope: 'local'` (each replica counted a different slice) and MAX under `'fleet'` (every replica received the full stream, so each row is already a fleet-wide count — summing three of those is the 3× inflation the design exists to avoid).
377
+
378
+ That only works if the rows are actually per replica. `dataStoreStatsStore` takes the id as a parameter and the plugin never passed one, so it fell back to the process global — which is correct in production and made the identity undiscoverable to the plugin's own configuration. The id now comes from `PluginBindContext.instanceId`, the same value the event bus stamps and the membership registry announces under, and the reason the context carries it.
379
+
380
+ Found as a red test rather than by reading: the fleet-stats case simulates three replicas in one process, which it used to do by setting `VOLTRO_REPLICA_ID` around construction. Once process identity became memoised — one process, one identity — all three "replicas" shared an id, wrote to one row, and the panel reported 3 where the case asserts 1. The property was right and the simulation had become impossible to express; taking the id from the context makes it expressible again, through the same seam production uses.
381
+ - **@voltro/cli** — **`voltro build` could not produce a serve bundle for any app declaring `@voltro/plugin-sentry`** — 35 errors, all `No loader is configured for ".node" files`, and a `fatal: serve bundle build FAILED — refusing to ship a bundle-less image`.
382
+
383
+ `@sentry/profiling-node` reaches `@sentry/node-cpu-profiler`, which `require()`s a per-platform `.node` binary. It is the strongest possible case for the native-leaf list — BOTH of that list's reasons at once, a compiled binding that cannot be inlined AND a `await import(…)` behind a `profiling: true` flag that already degrades to a warn when absent — and it was simply never added.
384
+
385
+ **The second half is why adding the leaf alone would not have fixed it.** The runtime shim resolves a leaf from a chain of roots: the declared SQL drivers, then `@voltro/cli`, then the app root. An optional peer lives under the plugin that dynamic-imports it, and pnpm strict does not hoist it — so `@voltro/cli` covers the peers of the plugins the CLI itself depends on (mail, storage) and nothing else. `@sentry/profiling-node` is an optionalDependency of `@voltro/plugin-sentry`, which the CLI does not depend on, so no root in the chain could have found it at runtime.
386
+
387
+ The chain now includes **every `@voltro/plugin-*` the app declares**, derived from its `package.json` rather than listed. A hard-coded plugin list is the shape that produced the gap: correct until the next plugin ships an optional peer, and silent when it does.
388
+
389
+ Found by the first `--build` run of the template harness. `voltro test` transpiles and `tsc --noEmit` typechecks; neither runs a build, so an unbuildable template stays green in both.
390
+ - **@voltro/protocol, @voltro/cli, @voltro/plugin-ratelimit** — **Two defaults that are correct for one process and silently wrong for several now say so.** Neither default changes — a process-local store is right on one process, and demanding Redis to run a single instance would be worse. What was missing is the deployment noticing.
391
+
392
+ - **The rate limiter.** `rateLimitPlugin` defaults to a process-local counter, so `100/min` on five pods is 500/min. That is not a performance detail: a limiter is usually what stands between an endpoint and abuse, which makes this the one default whose silent multiplication has a security consequence. - **Read-your-writes.** The RYW position store defaults to a process-local Map. With `DB_REPLICA_URLS` set, read-your-writes then holds only when the next request happens to land on the same pod — a user saves, the load balancer sends them elsewhere, and that pod routes the read to a lagging replica and serves the row as it was before the write. The boot line said `ryw policy 'fallback'` as though the policy were in force.
393
+
394
+ Both warn on the same signal the reactivity audit already used (`replicaEvidence`: `POD_NAME`, `FLY_ALLOC_ID`, `K_REVISION`, … and an explicit `REPLICA_COUNT` as a declaration in both directions), and each names the way out.
395
+
396
+ `replicaEvidence` moves to `@voltro/protocol/identity` — it began in the CLI, a plugin cannot import the CLI, and copying twenty env names is how one question acquires three answers. It is still exported from its old place.
397
+ - **@voltro/cli** — **The outbox runner's shutdown could settle the wrong pass.** `close()` awaits the in-flight drain rather than truncating a delivery mid-flight — that is the half of the shutdown gap a `clearTimeout` cannot cover, and it was already tested. What it awaited was `inFlight`, which `kick()` overwrote on every call including one that immediately returned because a pass was already running. So `close()` could await a promise for a pass that never ran while the real one was still inside its handler, and the next step of the real shutdown sequence is `store.close()`.
398
+
399
+ The window only opened when a second kick landed inside the first pass, which is why it survived: it took adding one round trip to the drain to widen it enough for the existing settle test to catch. An early-returning pass now hands back the pass it deferred to, so `inFlight` always names real work.
400
+
401
+ Found by a test that was already asserting the right thing and had been passing for the wrong reason.
402
+ - **@voltro/protocol, @voltro/runtime, @voltro/cli, @voltro/voltro** — **A peer that restarted stopped being heard by reconnecting clients.** `EventEnvelope.origin` says what it is — *"Publishing instance. Serials are only comparable WITHIN one origin"* — and both boot paths handed it the replica NAME. A name survives a restart; a serial does not. A StatefulSet pod keeps its `POD_NAME` and `VOLTRO_REPLICA_ID` is stable by definition, so a restarted peer publishes serials 1, 2, 3… under an origin whose watermark still reads 500.
403
+
404
+ Two silent losses follow from that one stale number. The watermark never advances, so a reconnecting client is told it missed nothing; and the resume replay filters by `n > lastSeen`, so the new process's events are dropped from the replay entirely. The peer is publishing normally the whole time.
405
+
406
+ The event bus now keys on `instanceId()` — `<replicaId>@<startedAt>.<nonce>`, newly exported from `@voltro/protocol/identity`. It carries the replica name as its prefix, so correlating the subsystems across pods still works.
407
+
408
+ `apiSurface: compatible`, and it covers a second thing: regenerating the goldens caught up drift the fleet-observation work left in the `@voltro/voltro` AGGREGATES, which are re-exports and so do not regenerate when their source package does. The one changed line there is `checkFrameworkCompat`, whose `runningVersion` parameter WIDENED to `string | undefined` and whose success result gained an optional `unverified`. A widened parameter accepts every call that compiled before; an added optional field breaks no reader.
409
+
410
+ The membership registry deliberately keeps the NAME: it detects a restart by comparing `startedAt` under a stable id, and a per-process id would turn every restart into a join plus a silent leave.
411
+ - **@voltro/cli, @voltro/database** — **`_voltro_idempotency` grew without bound, and its own documentation said it did not.**
412
+
413
+ The table's doc comment claimed "a periodic sweep / lazy-TTL drops rows past their window". The lazy TTL is real and fires only when the SAME key is claimed again — and an idempotency key is used once by definition, so the row it leaves behind is never read and never deleted. There was no periodic sweep at all: the table was the one member of its family with no entry in the retention registry.
414
+
415
+ It has one now, and the TTL is a FLOOR rather than a setting that can be turned down. A record dropped while still inside the app's own dedup window would let the duplicate request it exists to stop execute a second time, so `VOLTRO_IDEMPOTENCY_TTL_HOURS` may lengthen the window and may not shorten it below the app's `idempotency.ttlMs` plus a clock-skew margin. When it asks for less, the floor wins and the boot says so — a silently-ignored setting is worse than a refused one.
416
+
417
+ The reaction rate-limiter's slot rows used to live in this table too and now use `_voltro_change_claims`, whose window is an hour rather than the idempotency window. Two row families with different lifetimes cannot share one retention policy: the registry is keyed by table, so one table carries exactly one TTL.
418
+
419
+ Also corrected: the table is created for EVERY sql app (`when: 'always'`), not "only when `idempotency` is set in `app.config.ts`" — that is what the config gates, not what the table registry does.
420
+ - **@voltro/plugin-presence** — **`usePresence` re-announced its membership on every render, which is a write loop.** Measured on a real page against a real api, ONE page load, a fresh browser context: **~9 300 uncaught `RateLimited: presence.heartbeat` pageerrors in 3.5 seconds** — about 2 700 per second, and the same rate of mutations arriving at the server. The 60/min rate limiter was the only thing standing between this and an unbounded write loop.
421
+
422
+ `meta` sat in the join effect's dependency array, and the documented way to call the hook is an inline object:
423
+
424
+ ```tsx
425
+ usePresence('room', { key: me.key, meta: { name: me.name } })
127
426
  ```
128
427
 
129
- and the app being booted was the one that failed. A reference project could not start at all.
428
+ which is a new identity every render. So: leave join roster push re-render leave → join → … The hook already refd its two mutations for exactly this reason and left the one caller-supplied value in.
429
+
430
+ `meta` is refd now, so the interval always sends the CURRENT value, and the join effect is keyed on the membership identity only — a metadata change publishes immediately through a separate effect instead of tearing the membership down and re-announcing it. A serialisation failure degrades to "publish at the next beat" rather than throwing: a roster is not worth a render crash.
431
+
432
+ **Nothing in this package could have seen it.** Closing the loop needs a REAL subscription pushing a real roster back; a mocked transport re-renders once and stops. So the regression test asserts the property that BREAKS the loop — an equal-but-new meta object does not re-announce — and was falsified first: with `meta` back in the deps, two re-renders produce three heartbeats instead of one.
433
+ - **@voltro/protocol, @voltro/runtime, @voltro/cli, @voltro/plugin-billing, @voltro/plugin-search** — **One process, one identity.** `replicaId()` / `processIdentity()` from `@voltro/protocol/identity` is now the only place the framework decides which replica it is running as. It was an expression, sixteen times, in three packages, in FOUR spellings — and the spellings disagreed.
434
+
435
+ Two ignored `POD_NAME` entirely and read `HOSTNAME` alone; one read `HOSTNAME` *before* `POD_NAME`; only one honoured the explicit `VOLTRO_REPLICA_ID` override. The variants landed in different subsystems: the `HOSTNAME`-only one stamps `_voltro_schedule_runs.replicaId` and `claimedBy`, while the `POD_NAME`-first one answers `/_voltro/inspect/cluster`.
436
+
437
+ **So a single inspect response could name the same pod twice, differently.** `instance.replicaId` came from one spelling; `coordinationState.recentReplicaIds` — read back out of the schedule-run rows — came from the other. A dashboard asking "which of these is me" found itself in neither list. The doc comment on that field asserted the two were the same id; it is now true rather than aspirational.
438
+
439
+ This matters beyond tidiness: every aggregation across replicas groups by this id, and grouping by an id that depends on which subsystem wrote it is worse than not aggregating at all.
130
440
 
131
- The seeder parses the config now instead of importing it, resolving each plugin's package by pairing the identifiers called inside the `plugins: [...]` block with the import that introduced them (so a renamed import still lands on the right package), and reading the `{ name: '…' }` form directly. A plugin it cannot attribute is dropped rather than guessed: the index is a filter, and a wrong row is worse than a missing one.
441
+ The identity says four things, because two of them were missing:
132
442
 
133
- The rule this encodes: **a step that writes documentation must not execute application modules.** Nothing about composing an index needs a config's runtime value, and the sibling app-discovery pass was already reading the same file as text for its `type:` check.
443
+ - `replicaId` is the PLACE in the fleet and survives a restart; `instanceId` carries the GENERATION and does not. Conflating them made "one pod restarted forty times" and "there are forty pods" the same number. The generation carries a nonce below the clock's resolution — two generations starting in the same millisecond would otherwise collide, and an aggregation keyed on it would merge two processes into one. - `version` is what this process runs, for the window in which a rolling deploy makes the fleet genuinely mixed. `undefined` when it cannot be determined — never a plausible-looking `0.0.0`, which two of the old resolvers returned and which compares equal to another unknown. - `reachableAt` / `reachable` record where a peer could reach this process, with loopback recorded as NOT reachable the same distinction `resolveRunnerIdentity` already draws as `localhostRisk`.
134
444
 
135
- Worth knowing for the next diagnosis: two different descriptors for one name reads like one module evaluated twice, so the investigation went looking for split module identity (pnpm symlink vs real path, ESM cache keying). It was two DIFFERENT apps, pulled in by a documentation step found by tracing what actually resolved, not by reasoning about what could.
136
- - **@voltro/cli** — **`voltro build` read the PREVIOUS build's config, so every `app.config.ts` change landed one build late.** `loadConfig` prefers the precompiled `.framework/dist/server/appConfig.js` when it exists — correct for `serve` / `start`, which cannot read TypeScript, and exactly wrong for the command that WRITES that file. Anything consumed before the config is recompiled took the stale value: `fonts`, `images`, `seo`, `theme`, `locales`.
445
+ **The framework VERSION had the same disease, with sharper teeth.** Three resolvers: two hunted for `package.json` relative to their own module, and one read `npm_package_version` — the APP's version when started through an npm script, reported as the framework's. The manifest hunt cannot work inside a bundle, where the framework is inlined and `../package.json` belongs to whatever sits there, so a bundled `voltro serve` fell into its `catch` and reported `'0.0.0'`: right under `voltro dev`, wrong in production, which is the one place nobody can go and read it out of the source tree.
137
446
 
138
- Silent in the worst way: the second build is always right, so the symptom is "my change did nothing" followed by "…and now it works", with no error either time. Measured by setting `title` to a probe value, building, and finding the old title in the generated shell.
447
+ And `'0.0.0'` PARSES. It reached `checkFrameworkCompat`, where every plugin declaring a `framework:` range was judged incompatible against a version nobody was running — a warning on every production boot, and a refused boot under `VOLTRO_STRICT_PLUGIN_COMPAT`. That check now treats an unreadable version as **unverified rather than incompatible**, and says so: absence of evidence is not evidence of a mismatch. `voltro build` writes the version into every server bundle's banner (one definition, three bundles — it was a repeated string literal, which is how the injection would have reached one and missed the others).
139
448
 
140
- `voltro dev` already carried this fix, with the reasoning written on the option itself. The build path is the one that makes the artefact, so it is the last place that should trust it.
141
- - **@voltro/local-first** — **`useCrdtEditor` built its editor during RENDER, so a default page could not mount it and StrictMode leaked one.** The Tiptap instance was constructed inside a `useMemo`, which runs while rendering. Two consequences, both real:
449
+ **`mode` gained `'serve'`.** An api under `voltro serve` reported `'start'`, and `voltro start` is web-only it refuses an api with "no web app found". The readout named a command the process could not have been started by, while `/members` reported `meta: { mode: 'serve' }` for the same process: one fact, two endpoints, two answers. It is now a projection of the resolved boot path, stamped on the function the production container command actually enters (`node serveEntry.js` calls `runServe` directly and never passes through the dispatcher — stamped one level up, a bundled serve reported `'cli'`).
142
450
 
143
- - **Server render threw.** Tiptap needs `window`; a page mounting this hook failed prerender with `there is no window object available`. `renderMode` defaults to `'static'`, so that is the ordinary page, not an exotic one. - **React's double-invoked render built TWO editors** and the cleanup destroyed only the last, leaving the first alive holding its Yjs binding. `useCrdtText`'s own comment documents exactly this shape as a bug; the editor had it anyway.
451
+ Measured against a real bundled production serve, not inferred: `{"voltroVersion":"0.55.0","mode":"serve"}` where it previously read `{"voltroVersion":"0.0.0","mode":"start"}`.
144
452
 
145
- Construction moved into an effect, and the cleanup destroys THAT instance rather than whatever the ref currently holdsreading the ref destroys the new editor on a rebuild and leaves the old one running.
453
+ `scripts/check-process-identity.mjs` (CI + `pnpm gate`) fails on any second derivation, and ships a `--selftest` that classifies eight shapes — the four that were really in the tree, plus four benign reads that must not trip it.
454
+ - **@voltro/cli, @voltro/devtools-ui** — A sweep of all 31 dashboard pages in a real browser, against a running api, found four defects nothing else was looking for. None threw where a test could see it; three of them blanked a whole page.
146
455
 
147
- `extensions` is no longer a dependency. Callers pass an inline array literal, which is a fresh identity every render, so listing it rebuilt the whole editor per keystroke; it is read at construction from a ref instead. The trade is stated rather than hidden: changing `extensions` after mount does not rebuild the editor remount with a `key` if you need that.
456
+ **A "structured empty" that was not the structure.** `/_voltro/inspect/database` answered `{ migrations: [], seeds: [] }` when the app had no snapshot to give, while `DatabaseStatus` declares `dialect` and `replication` as present. The page read `status.replication.replicaCount`, threw during render, and the Database page went blank with a console trace. A structured empty exists so a reader can render it WITHOUT branching; one that omits half the structure is a differently-shaped payload wearing the word. It answers the full shape now, and the panel tolerates the short one because a customer app on an older version still sends it — a dashboard that crashes on an old app cannot be used to diagnose one.
148
457
 
149
- **`CrdtDocHandle` is exported now.** It is the parameter type of `useCrdtEditor`, and it was declared, used across the package, and exported from nowherevisible in the built `.d.ts` only as a bare `declare interface`, which no import can reach. An app binding an editor could not type its own variable.
150
- - **@voltro/cli** — `middleware.ts`'s `cspNonce` now reaches the scripts in a STREAMED response's `<head>`. Both boot paths handed the nonce to React — which stamps only the scripts React itself emits — and not to the driver that composes the head, so on the arm a plain `renderMode: 'ssr'` page takes, `renderDeferredRegistryScript()` (executable inline JS) and the `__voltro_state__` payload went out bare under the very `script-src 'nonce-…'` policy the same response set. A deferring page's registry was therefore the one script the browser refused to run. The `renderMode: 'spa'` layout-shell arms were unstamped end to end for the same reason and are fixed with them.
458
+ **One endpoint, two shapes, depending on the app.** `/_voltro/inspect/metrics` answers with the runtime registry snapshot (`MetricSample[]`) for a web app and the rpc collector's aggregate (`{ windowMs, capturedAt, totalSamples, buckets }`) for an api two unrelated types that happen to share the name `MetricSample`. The client declared the first for both, so `samples.filter` threw on every api app, during render, taking the overview down with it.
151
459
 
152
- `stampScriptNonce` also stops mistaking `data-nonce="…"` or a `?nonce=` query parameter for a nonce attribute, which left exactly the tags a policy then blocked unstamped.
460
+ Which shape the endpoint should settle on is a wire decision and is NOT made here. `deriveMetricRows` returning nothing rather than throwing is not a decision: a derivation that cannot read its input must not take the page down.
153
461
 
154
- Still open, and now documented rather than implied: the settle `<script>` an `<Await>` boundary emits inside the streamed body is part of the rendered tree, so neither stamper reaches it `defer()` and `cspNonce` do not compose yet.
155
- - **@voltro/runtime, @voltro/cli, @voltro/plugin-queue, @voltro/plugin-cdc-out** — **A span opened by detached work reached no exporter.** The tracer is a `Layer` provided at `startRpcServer`'s outermost scope, so `Effect.withSpan` inside a handler resolves the configured OpenTelemetry tracer. Work that runs OUTSIDE a request fiber — a broker callback, a schedule firing, a CDC delivery — reaches Effect through its own `runPromise`, which resolves the DEFAULT tracer: it creates spans and hands them to nobody. Measured against a live tracing layer: **0 finished spans**, for three framework span sites that all read as instrumented (`queue.consume`, `cdcOut.deliver`, `plugin.<name>.schedule-fire`).
462
+ **A 500 for "this app has no workflows".** A framework table exists only when the app declares the feature that owns it, so a workflow read on an app with no workflows fails at the driver and three handlers turned that into `500 … query failed`. "The server is broken" for a fact that is simply "there is nothing here". They answer the endpoint's own empty shape plus an `unavailable` reason now. The classifier matches the driver message per dialect, which is not something to build a control path on and is not one: it chooses between two ways of REPORTING, and an unrecognised message falls through to the 500 that was there before — the failure direction is always the old behaviour.
156
463
 
157
- The server now publishes its tracer instance into a process cell (`globalThis` + `Symbol.for`, for the same duplicate-instance reason `coreTablesRegistry` and the row filter are there), and detached work runs under it via `withServerTracer`. That is exactly the correction the LOGGER already had one line over in `rpcServer.ts` detached fibers "start from the default runtime and carry their own", and were given one; the tracer never was.
464
+ **And the reason the server sends is no longer discarded.** The inspect surface answers a dev-only path with a 404 whose body says "this endpoint exists and your deployment does not mount it… this is not a missing token" a sentence written because a deployment reported the bare 404 as unreadable. The dashboard threw it away and rendered `[inspectClient] <url>: HTTP 404`, on six panels against every production app. The CLI's own inspect client had already fixed this exact blind spot and recorded that the fix belongs in the shared helper; this is the same helper on the browser side.
465
+ - **@voltro/cli** — Two defects that produced a console error on every app page of the DevTools dashboard, found by a browser check and by nothing else — neither threw, neither changed a status code any test was watching.
158
466
 
159
- Not a second provider: a per-plugin tracer means a second `NodeTracerProvider` with an exporter nothing flushes at shutdown, and global OTel registration is refused on purpose. There is one tracer, built where it always was.
467
+ **The live inspect stream did not exist under `voltro serve`.** `/_voltro/inspect/stream` was supplied by `dev.ts` and by nothing else, so the URL 404'd in production: the dashboard's log tail, its app-overview feed and the data viewer's CDC refresh all worked while you developed and were dead where it counted. The boot-path parity test had this on its EXCEPTION list, with the reason "there is no overlay" — true about the in-page dev overlay, and wrong about the surface, because the DevTools dashboard opens the same stream against whatever app is registered including a deployed one. An exception list is only as good as the reason on each line, and that line reasoned about one consumer of a surface with two. It is deleted, and the correction is written where it stood.
160
468
 
161
- `withServerTracer` is a no-op when nothing is publisheda unit test, an embedder, a process with no rpc server never a throw and never a second tracer. Pinned by `serverTracer.test.ts`, whose second case drives the defect directly: without the wrapper, the same span never reaches the tracer under test.
162
- - **@voltro/local-first** — **`useCrdtEditor` was unreachable from a published install.** `@voltro/local-first`'s source `exports` map carried `./editor`, its `publishConfig.exports` — the map an npm install actually resolves — carried only `.` and `./react`, and the build emitted no editor bundle. So the rich-text editor binding worked inside this monorepo (where `workspace:*` resolves the source map) and gave every user `ERR_PACKAGE_PATH_NOT_EXPORTED`, while the docs taught it.
469
+ The stream is now mounted by ONE builder both boot paths call (`buildInspectStreamWiring`) the authorize/replay/subscribe quartet is a security surface carrying the DNS-rebinding host guard and the token resolver, and copying that into a second path is how one copy loses a guard. Serve feeds it the channel it genuinely owns (the subscription registry, via the same snapshot builder its HTTP path uses) and tears both down on shutdown: a stream that connects and never emits is worse than the 404 it replaced, because a silent feed reads as "nothing is happening".
163
470
 
164
- `editor` is a build entry now, `./editor` is in the published exports, and it has its own api-extractor report so the surface is checked like the other two. An entry point that is documented and not built is a feature that exists for nobody outside this repo, and the two maps diverging is the shape that hides it: the one you read is not the one users resolve.
165
- - **@voltro/web** — `responseHeaders` and `cspNonce` are on the type users actually write against.
471
+ **The SSE proxy could only ever present its OWN token.** `EventSource` accepts no headers, so the browser cannot attach a per-app bearer the way every other inspect call does the proxy fell through to the dashboard process's `VOLTRO_INSPECT_TOKEN`, which is the right token for an app that process minted and the wrong one for an app registered by URL. `voltro dev` mints a token per project, so that was every app, and the stream answered 401.
166
472
 
167
- The CSP-nonce and response-header feature shipped with its fields declared in the CLI's internal `MiddlewareResult` and NOT in the one `@voltro/web/middleware` publishes the type `defineMiddleware` checks a user's `run` against. The runtime honoured both fields; the compiler refused them. So the documented way to set a `Content-Security-Policy` from middleware produced TS2322, and the only way to use a working feature was to cast around its own type.
473
+ The token now travels as a same-origin cookie scoped to the proxy's own path, `SameSite=Strict`, cleared as soon as the stream opensnot a query parameter, because a bearer in a URL lands in every access log that touches it. It is moved into an Authorization header by the proxy and never reaches the target as a cookie. The precedence (caller's header → stream cookie → this process's own, loopback only) is resolved in ONE function both boot paths call; it had been written out in both, which is how a rule of this kind starts to differ.
168
474
 
169
- Two definitions of one shape is what allowed it: the half that gained the feature is not the half a user writes against. The published type carries both fields now, with the same documented limits (a prerendered `static` file is served without a render, so no middleware runs; `cspNonce` is `ssr` only, because an isr render is cached and a cached nonce is a lie the browser enforces).
475
+ **And a poll that ran before it knew what it was polling.** The app overview guarded its ISR-cache poll with `status === 'ok' && kind !== 'web'`, so while the status was still `pending` i.e. before the app's kind was known every api app fetched the web-only cache endpoint and took a 404. Twice, because the effect re-runs when the status settles.
476
+ - **@voltro/cli** — **`voltro update` said the same thing about two opposite outcomes.** An update that changed nothing printed `codemods: nothing to apply for this jump` whether the jump ships no codemods at all, or ships several and every one of them gated ITSELF out through its own `appliesTo`. A reader takes the first meaning, because that is what the sentence says.
170
477
 
171
- Caught by the `web-layout-loader` fixture, which is the only place that writes this type the way a user does.
172
- - **@voltro/cli** — `voltro mobile <dir>` read its directory with a predicate that treats every flag as valued.
478
+ The second is the state worth naming. A codemod's `appliesTo` is a predicate, and a predicate can be wrong in the direction that stays quiet: a gate reading the wrong files answers "does not apply" for a project that is fully affected. That has happened here — a `manual` codemod's gate searched the ts-morph project for a subject that only ever appears in a shell script or a CI job, which is why `codemodTextScan` exists. That fix made the GATE see more files; it did not make the SUMMARY admit a gate had run and said no.
173
479
 
174
- The command found its root with a hand-rolled scan first argument that does not start with `-` and is not preceded by a `--flag`. Two things go wrong with that shape, and `cliPositionalFlagSafety.test.ts` exists because they have gone wrong before:
480
+ So the summary now separates them: `none ship for this jump` when the range is empty, and otherwise the count plus every skipped id by name, with a line saying that a subject you recognise in that list is a bug in the check rather than a fact about your code. A partial run reports its skipped ones on one line for the same reason — two applied and one silently gated out reads as "all three considered and handled".
175
481
 
176
- - it treats a BOOLEAN flag as consuming the next argument, so `voltro mobile links --help ./app` decided `./app` was `--help`'s value and silently fell back to `process.cwd()`; - it matched with `indexOf`, the FIRST occurrence, so an argument whose text appears twice was judged by the wrong position.
482
+ **A claim this entry made in its first draft was wrong, and it is corrected here rather than quietly dropped.** It said the dialect half of the framework-table rule "was not covered below the planner at all", and announced a new MariaDB test as the fix. Both halves were false: `sql-postgres/__tests__/frameworkTableEvolution.integration.test.ts` and its `sql-mysql` twin have covered exactly this since the rule landed — on BOTH mysql engines, asserting a column RESHAPE (harder than the ADD the new test made), with a premise assertion, the outcome KIND, and convergence. The new test was deleted; it measured less than what was already there, in the wrong package.
177
483
 
178
- It uses `firstPositional(args, VALUED_FLAGS)` now, with the three flags that actually take a value declared rather than inferred.
179
- - **@voltro/database** — Adding a `.default(…)` to an EXISTING `text()` column no longer kills the migration on MySQL. MySQL refuses a DEFAULT on a TEXT/BLOB column outright (`BLOB, TEXT, GEOMETRY or JSON column 'x' can't have a default value`), where MariaDB allows it — so the same declaration applied on one engine and failed mid-migration on the other. `CREATE TABLE` has always answered this by widening such a column to `VARCHAR(255)` (`NVARCHAR(450)` on SQL Server, where the reason is indexability); the ALTER path now applies that same answer, reshaping the column to the declared shape instead of setting a default on whatever the column happened to be. A column therefore ends up with the same type whether the default was declared before or after the table existed. Postgres (TEXT takes a DEFAULT) and SQLite (rebuilds to the declared shape) are unchanged. Note the narrowing: on mysql/mariadb the column becomes `VARCHAR(255)`, so the ALTER fails loudly if an existing row is longer — use `text().maxLength(n)` to choose the width. The same widening rule also reached `ADD COLUMN`, which on SQL Server had emitted `NVARCHAR(MAX)` for a column `CREATE TABLE` renders as `NVARCHAR(450)`.
180
- - **@voltro/cli** — **Validation errors on the no-JavaScript form path rendered in English on every locale.** `validateFields` resolves message ids through a locale whose default is `documentLocale()` — it reads `<html lang>`, and outside a browser that is always `'en'`. The `/form/*` handler runs on the server, so a German page's 422 came back with English field errors while the same form with JavaScript rendered German; the flash carries the resolved strings, so hydration kept them.
484
+ ### Internal (no consumer-facing effect)
181
485
 
182
- The handler now resolves the locale from THIS request through the same resolver the surrounding page render and the ISR cache key already use (cookie › `Accept-Language` the app's default). It is a required option on `makeFormPostHandler` rather than an optional one: both boot paths mount that handler separately, and an option nobody has to pass is one a new mount silently omits — landing straight back on the browser default. An app with no `locales` configured answers `'en'` explicitly.
486
+ - **@voltro/voltro** **`packages/voltro` declares `lib: ["ES2024","DOM","DOM.Iterable"]` now, because it compiles `@voltro/i18n`'s source under its own options.**
183
487
 
184
- **Under URL-prefix i18n the referring path outranks that chain.** There the language IS the URL (`/de/todos`) and the cookie may say something else entirely, so a cookie-only answer renders errors for a page the user is not on. The handler passes the referer's path to the resolver and both boot paths check it against the app's declared `locales` first, through one shared `localeFromPathPrefix` a first segment that is not a declared locale (`/design/…`) falls through to the cookie chain rather than being mistaken for one. This is the "referer path vs. locale cookie" question the no-JS form work left open; the answer is both, in that order.
185
- - **@voltro/client** — `useOutbox`'s `resolveConflict(id, input)` now actually replays the entry it resolved. It replayed against the pre-resolution queue — `replay` reads the queue through a ref, and the `setQueue` beside it had not landed yet — so the entry it was handed was still `conflict`, `replayable()` stopped at it, and nothing was sent. The resolution then sat in the queue as `pending` with no further replay scheduled, and every write queued behind it stayed blocked with it: a conflict that could be resolved in the UI and never left the device.
186
- - **@voltro/runtime, @voltro/cli** — **A subscription opened over SSE or gRPC ignored the registered row filter — every read, not just the first.** `dispatcher.subscribe` resolves row visibility itself and treats a missing `refilter` as "this app has no row filter", so it read the descriptor unnarrowed. The WebSocket path always supplied one (`bindSubscription`'s `defaultRefilter`); the SSE and gRPC projections go through `makeQuerySubscriber`, whose `subscribeDescriptor` callback passed three arguments and dropped the trailing pair. Both boot paths were affected identically, so nothing a dev/serve parity check looks at could see it.
488
+ The aggregate re-exports that package's ROOT entry its browser half so `tsc` follows the path mapping into `../i18n/src/**` and compiles those files with the AGGREGATE's compiler options, not the ones `@voltro/i18n` declares for itself. The moment i18n exported a component touching `document`, `@voltro/voltro#typecheck` failed with TS2584 while `@voltro/i18n#typecheck` stayed green: legal there, illegal here, one file. The aggregate already carried `jsx: react-jsx`, so this completes a decision that was half made.
187
489
 
188
- The same omission dropped the per-delivery guard re-check, which `data/grpc.md` and the 0.53.0 notes stated as a property: a revoked scope did not end a descriptor-query stream on those two transports. Computed queries were never affected (they re-run their handler, guards included), and neither was any app without a registered row filter.
490
+ `apiSurface: compatible`, and this is the part worth stating rather than asserting. DOM declares `Notification`, `Option` and `Cache` as globals, so once they are in scope api-extractor must disambiguate the aggregate's own symbols from them `interface Notification_2` + `export { Notification_2 as Notification }`, `import { Option as Option_2 }`, `class Cache_2` + `export { Cache_2 as Cache }`. Three goldens therefore show REMOVED lines, which is what the narrowing detector reports and it is right to.
189
491
 
190
- Three things changed. `makeDefaultRefilter` moved to `@voltro/runtime`'s `rowFilter` module — it was private to the WebSocket entrypoint, and the transports that could not reach it are exactly the ones that went unfiltered. `QuerySubscriberDeps.subscribeDescriptor` takes the reauthorizer and the refilter as REQUIRED parameters, so a future transport cannot omit them by writing a shorter callback. And `dispatcher.subscribe` now THROWS, by name, when no refilter is passed while a filter is registered: reading unfiltered on purpose is available, but has to be said out loud with `NO_ROW_FILTER`. That is the same correction `ctx.rowFilter` already carries one layer up — `undefined` may mean "this app has none", never "the caller forgot".
492
+ Nothing left the surface. Public names extracted from BOTH the `export const|type|interface X` and the `export { X_2 as X }` forms, on both sides of each golden, are identical sets: 965/965 for `voltro-server`, 293/293 for `voltro-ai`, 26/26 for `voltro-cache`. A consumer importing `Cache`, `Notification` or `Option` from `@voltro/voltro` sees no change; only the report spells them differently.
493
+ - **@voltro/runtime** — The event-bus perf budgets are RATIOS now, not absolute microseconds.
191
494
 
192
- Pinned by `subscriberTransportRefilter.test.ts`, which drives a real dispatcher through a store that evaluates predicates and asserts the foreign row never crosses the wire in ANY frame, with a positive control so an empty stream cannot pass.
193
- - **@voltro/cli** — **A template could not ship a font or an image — the scaffolder corrupted every binary file.** `scaffoldFromTemplate` read each file with `readFile(src, 'utf8')` and wrote the string back. That call does not throw on binary input: it substitutes U+FFFD for every undecodable sequence and returns a string, so a woff2 went in at 15 344 bytes and came out at 27 572, silently, in every scaffolded project.
495
+ `expect(full).toBeLessThan(60)` read 87.6 µs on a release runner and took the run down. Nothing had regressed: the runner was ~19x slower than the machine the constant was written on, and 60 µs against a 4.6 µs local reading left only 13x of headroom. Two more assertions in the file had the same shape and had simply not fired yet — the publish budget sat at 86% of its constant on that runner.
194
496
 
195
- Substitution is now gated on a text-extension allowlist and everything else is copied byte-for-byte. Extension-less files and dotfiles (`Dockerfile`, `.gitignore`, `LICENSE`) count as text — they carry tokens, and treating them as binary would ship a literal `{{appName}}`.
497
+ Each is now a ratio between two measurements taken in the same test on the same machine, so machine speed divides out:
196
498
 
197
- The comment above that line already claimed "we hard-fail if a template author adds one so the failure is visible at boot". There was no such check, and there could not be one on that call. There is now: a file whose extension says text but whose bytes are not valid UTF-8 is REFUSED by name rather than written corrupt, so an allowlist that is wrong about a file fails loudly instead of quietly.
499
+ - `full < raw * 12` the assertion the test is NAMED for ("the wrapper is not where the cost is") and never made. Both numbers were already measured three lines apart, and the file even printed `full - raw` before discarding it. - `warm < cold * 5` the resume test claimed "attaching after 5k publishes must cost about what attaching after 5 does" and then measured only the 5k end. It measures both now, which is the first time it tests its own stated property. - `micros < machineMicros() * 150` — the one claim with no same-code comparand, denominated in a keyed-Map/string reference in a publish's own cost class.
198
500
 
199
- Nothing could see this. `voltro-templates`' own harness classifies files itself and copies non-text ones byte-for-byte, so `pnpm test:templates` was green over a rendering the scaffolder does not performit validated the harness, not the scaffold a user receives.
501
+ The ratio form is also STRICTER than what it replaces: these fire at ~3-6x regressions where the constants needed 13-331x. Each was falsified by tightening its bound and watching it go red a budget that can no longer fail is the failure mode this file exists to avoid.