@voltro/cli 0.55.0 → 0.57.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 (175) hide show
  1. package/CHANGELOG.md +691 -0
  2. package/bin/voltro.mjs +24 -0
  3. package/dist/apiBuild-B83Cb2Rv.js +2 -0
  4. package/dist/{apiBuild-CMvLJM_K.js → apiBuild-DDr2aNFd.js} +119 -88
  5. package/dist/bin.js +1 -1
  6. package/dist/{build-S0QOzqPT.js → build-QKP6Bm0J.js} +308 -279
  7. package/dist/buildReport-52gHKgfO.js +64 -0
  8. package/dist/{checkCommand-DNkY5kwF.js → checkCommand-BISqx1OJ.js} +1 -1
  9. package/dist/{checkCommand-fbj9GDjN.js → checkCommand-DUtMWjcR.js} +6 -6
  10. package/dist/{clusterCmd-DrVFCzSj.js → clusterCmd-CdLB1GkT.js} +1 -1
  11. package/dist/{codegen-SIepQtUl.js → codegen-Bth5lUTU.js} +2 -1
  12. package/dist/codegen-DbH7NbCR.js +2 -0
  13. package/dist/{codegenCommand-3TDJezom.js → codegenCommand-DnVuDxwT.js} +11 -11
  14. package/dist/{codemodRunner-C2zxZUIw.js → codemodRunner-BlQPfjzA.js} +222 -0
  15. package/dist/{commands-BBYJ7Q3B.js → commands-CRbxgxv0.js} +35 -35
  16. package/dist/{dashboardCommand-D2kmyCLL.js → dashboardCommand-Bf_-Ne3P.js} +5 -5
  17. package/dist/{dataCommand-BEPPQiTl.js → dataCommand-BoBJJ-Gb.js} +3 -3
  18. package/dist/{dbCommand-DZTmOFT4.js → dbCommand-CMAIz-Bf.js} +457 -441
  19. package/dist/dbCommand-DHi_RuDl.js +2 -0
  20. package/dist/dev-Bl9HqtV7.js +3 -0
  21. package/dist/{dev-Ca_A_S9v.js → dev-DOEJXicj.js} +2623 -2485
  22. package/dist/{doctorCommand-CGZJK_4o.js → doctorCommand-BrWu67JZ.js} +524 -280
  23. package/dist/doctorCommand-DrQv9SL3.js +2 -0
  24. package/dist/{dormancyCommand-DY2rYpTa.js → dormancyCommand-xn2y-pJm.js} +1 -1
  25. package/dist/{embeddingsCommand-BoCqZsgp.js → embeddingsCommand-Cn5MbRDM.js} +1 -1
  26. package/dist/emptyResultHeadline-Csa5fZOF.js +18 -0
  27. package/dist/{envCommand-Bxy2fOjc.js → envCommand-UJmJIbs9.js} +8 -8
  28. package/dist/{evolveCommand-BsbZ-XDg.js → evolveCommand-Db30twUy.js} +2 -2
  29. package/dist/frameworkTableAssembly-B96WCNJA.js +2 -0
  30. package/dist/{frameworkTableAssembly-Do-cf6RJ.js → frameworkTableAssembly-vfkzuzEo.js} +131 -104
  31. package/dist/index.js +1 -1
  32. package/dist/{infoCommand-EmM3jPKD.js → infoCommand-BjVXpMlP.js} +1 -1
  33. package/dist/inspect-B0hL41s0.js +2 -0
  34. package/dist/inspect-DZnan87F.js +1500 -0
  35. package/dist/{inspectCmd-niF97fAq.js → inspectCmd-CP-G0sVK.js} +1 -1
  36. package/dist/{inspectFetch-EMuhTG_9.js → inspectFetch-BU1NyzxV.js} +36 -24
  37. package/dist/{inspectMetrics-CGF94puw.js → inspectMetrics-BY0Sjb2F.js} +19 -19
  38. package/dist/{logsCmd-B6oNsfaZ.js → logsCmd-C5y9HyrG.js} +59 -54
  39. package/dist/{manifestBuild-DjX5MoXy.js → manifestBuild-DnbFKF6w.js} +1 -1
  40. package/dist/manifestBuild-UXrnUcXP.js +2 -0
  41. package/dist/{migrate-CGFZS-1a.js → migrate-DtC3lu7H.js} +4 -4
  42. package/dist/precompressAssets-YhTi1aWp.js +40 -0
  43. package/dist/{probeCommand-Bs3iVBSL.js → probeCommand-C5fuN6Z2.js} +2 -2
  44. package/dist/{runtimeTrace-C1BTpHGQ.js → runtimeTrace-BQL_lfz6.js} +1 -1
  45. package/dist/{scheduleCmd-DQRu6BZC.js → scheduleCmd-DYBUfo_T.js} +1 -1
  46. package/dist/{sdkgen-CXMwLg9n.js → sdkgen-CMUPrDjH.js} +1 -1
  47. package/dist/{seedRunner-Dgsiwk_e.js → seedRunner-DISBKow-.js} +16 -16
  48. package/dist/serveCommand-B_w-Mpb_.js +2544 -0
  49. package/dist/serveCommand-Dtb48ffg.js +2 -0
  50. package/dist/serveEntry.js +1 -1
  51. package/dist/{start-EOV7s1NZ.js → start-YaUehtDV.js} +580 -558
  52. package/dist/{start-DH7cat4-.js → start-s25GAIgn.js} +1 -1
  53. package/dist/startEntry.js +1 -1
  54. package/dist/staticCachePolicy-CIyj6DbS.js +15 -0
  55. package/dist/{test-DO27-x2P.js → test-jipIQ5Mx.js} +1 -1
  56. package/dist/{tracesCmd-DgtgOUdi.js → tracesCmd-C1qKa94g.js} +69 -64
  57. package/dist/{updateCommand-C_jN1w18.js → updateCommand-C9n_Z_oG.js} +8 -2
  58. package/dist/updateCommand-DsXEAHbd.js +2 -0
  59. package/dist/webDev-B6ZMX42w.js +2 -0
  60. package/dist/{webDev-B7vNj4Bq.js → webDev-BgdkyjP6.js} +1379 -1303
  61. package/dist/{webhooksCommand-B1LVcyO3.js → webhooksCommand-CYXTNvXq.js} +2 -2
  62. package/dist/{workflowsCmd-BGF-mRZ5.js → workflowsCmd-g-DNpaUc.js} +1 -1
  63. package/package.json +55 -19
  64. package/templates/AGENTS.core.md +20 -1
  65. package/templates/AGENTS.md +21 -2
  66. package/templates/agent-docs/_index.md +1 -1
  67. package/templates/agent-docs/_manifest.json +2 -2
  68. package/templates/agent-docs/ai.md +1 -1
  69. package/templates/agent-docs/authentication.md +34 -19
  70. package/templates/agent-docs/cli.md +170 -0
  71. package/templates/agent-docs/configuration.md +29 -4
  72. package/templates/agent-docs/data.md +145 -5
  73. package/templates/agent-docs/database/scaling.md +50 -2
  74. package/templates/agent-docs/deployment.md +75 -1
  75. package/templates/agent-docs/internationalization.md +32 -0
  76. package/templates/agent-docs/local-first-mobile.md +9 -2
  77. package/templates/agent-docs/observability.md +227 -0
  78. package/templates/agent-docs/plugins/audit.md +21 -5
  79. package/templates/agent-docs/plugins/billing.md +17 -0
  80. package/templates/agent-docs/plugins/broadcast.md +2 -1
  81. package/templates/agent-docs/plugins/ratelimit.md +6 -1
  82. package/templates/agent-docs/plugins/row-history.md +11 -0
  83. package/templates/agent-docs/plugins.md +19 -0
  84. package/templates/agent-docs/reference.md +1 -0
  85. package/templates/agent-docs/scheduling.md +23 -0
  86. package/templates/agent-docs/schema-driven-ui.md +81 -0
  87. package/templates/agent-docs/templates/appshells.md +3 -3
  88. package/templates/agent-docs/whats-new.md +133 -77
  89. package/templates/apps/api-ai/package.json +6 -6
  90. package/templates/apps/api-auth/package.json +8 -8
  91. package/templates/apps/api-backend/package.json +7 -7
  92. package/templates/apps/api-backend-deactivation/package.json +7 -7
  93. package/templates/apps/api-backend-mail/package.json +8 -8
  94. package/templates/apps/api-backend-mariadb/package.json +9 -9
  95. package/templates/apps/api-backend-sqlite/package.json +8 -8
  96. package/templates/apps/api-backend-storage/package.json +8 -8
  97. package/templates/apps/api-cms/package.json +9 -9
  98. package/templates/apps/api-collab/package.json +8 -8
  99. package/templates/apps/api-data-advanced/package.json +8 -8
  100. package/templates/apps/api-durable/package.json +8 -8
  101. package/templates/apps/api-feature-flags/package.json +9 -9
  102. package/templates/apps/api-governance/package.json +8 -8
  103. package/templates/apps/api-kv/package.json +8 -8
  104. package/templates/apps/api-moderation/package.json +8 -8
  105. package/templates/apps/api-observability/package.json +8 -8
  106. package/templates/apps/api-ratelimit/package.json +8 -8
  107. package/templates/apps/api-rbac/package.json +8 -8
  108. package/templates/apps/api-rest/package.json +7 -7
  109. package/templates/apps/api-row-history/package.json +8 -8
  110. package/templates/apps/api-saas/package.json +11 -11
  111. package/templates/apps/api-saas-starter/package.json +10 -10
  112. package/templates/apps/api-search/package.json +8 -8
  113. package/templates/apps/api-status/package.json +8 -8
  114. package/templates/apps/api-webhooks/package.json +9 -9
  115. package/templates/apps/changelog/package.json +7 -7
  116. package/templates/apps/changelog/src/pages/[locale]/page.tsx +7 -1
  117. package/templates/apps/edge-functions/package.json +2 -2
  118. package/templates/apps/frontend-admin/package.json +7 -8
  119. package/templates/apps/frontend-admin/src/pages/(marketing)/layout.tsx +1 -2
  120. package/templates/apps/frontend-admin/src/pages/admin/layout.tsx +1 -2
  121. package/templates/apps/frontend-app/package.json +8 -9
  122. package/templates/apps/frontend-app/src/pages/layout.tsx +1 -2
  123. package/templates/apps/frontend-auth/package.json +7 -8
  124. package/templates/apps/frontend-auth/src/components/AuthShell.tsx +1 -2
  125. package/templates/apps/frontend-blank/package.json +6 -7
  126. package/templates/apps/frontend-blank/src/pages/layout.tsx +1 -2
  127. package/templates/apps/frontend-cms/package.json +8 -9
  128. package/templates/apps/frontend-cms/src/pages/(app)/layout.tsx +1 -2
  129. package/templates/apps/frontend-collab/README.md +7 -2
  130. package/templates/apps/frontend-collab/package.json +9 -10
  131. package/templates/apps/frontend-collab/src/pages/layout.tsx +1 -2
  132. package/templates/apps/frontend-collab/src/pages/page.test.tsx +8 -4
  133. package/templates/apps/frontend-collab/src/pages/page.tsx +10 -5
  134. package/templates/apps/frontend-contact/package.json +7 -7
  135. package/templates/apps/frontend-contact/src/components/ContactForm.island.tsx +1 -1
  136. package/templates/apps/frontend-dashboard/package.json +6 -7
  137. package/templates/apps/frontend-dashboard/src/pages/(marketing)/layout.tsx +1 -2
  138. package/templates/apps/frontend-dashboard/src/pages/dashboard/layout.tsx +1 -2
  139. package/templates/apps/frontend-docs/package.json +8 -8
  140. package/templates/apps/frontend-i18n/package.json +6 -6
  141. package/templates/apps/frontend-landing/package.json +7 -7
  142. package/templates/apps/frontend-portal/package.json +7 -8
  143. package/templates/apps/frontend-portal/src/pages/(portal)/layout.tsx +1 -2
  144. package/templates/apps/frontend-saas/package.json +7 -8
  145. package/templates/apps/frontend-saas/src/pages/(marketing)/layout.tsx +1 -2
  146. package/templates/apps/frontend-saas/src/pages/dashboard/layout.tsx +1 -2
  147. package/templates/apps/frontend-spa/package.json +6 -7
  148. package/templates/apps/frontend-spa/src/pages/layout.tsx +1 -2
  149. package/templates/apps/frontend-ssr/package.json +6 -7
  150. package/templates/apps/frontend-ssr/src/pages/layout.tsx +1 -2
  151. package/templates/apps/frontend-ssr-api/package.json +7 -8
  152. package/templates/apps/frontend-ssr-api/src/pages/layout.tsx +1 -2
  153. package/templates/apps/frontend-static-blog/package.json +8 -8
  154. package/templates/apps/frontend-static-blog/src/components/ReadingProgress.island.tsx +1 -1
  155. package/templates/apps/frontend-static-blog/src/pages/[locale]/page.tsx +7 -1
  156. package/templates/apps/frontend-status/package.json +7 -8
  157. package/templates/apps/frontend-status/src/pages/layout.tsx +1 -2
  158. package/templates/apps/mobile-app/package.json +4 -4
  159. package/templates/baselines/compose/docker/api.Dockerfile +61 -5
  160. package/templates/baselines/compose/docker/web.Dockerfile +55 -10
  161. package/templates/baselines/compose-mariadb/docker/api.Dockerfile +61 -5
  162. package/templates/baselines/compose-mariadb/docker/web.Dockerfile +55 -10
  163. package/dist/apiBuild-Cl0IDx8c.js +0 -2
  164. package/dist/codegen-CN6vMM4J.js +0 -2
  165. package/dist/dbCommand-BTyBGhIA.js +0 -2
  166. package/dist/dev-DfVZaoys.js +0 -3
  167. package/dist/doctorCommand-djmqEcDC.js +0 -2
  168. package/dist/frameworkTableAssembly-Df2Ymp2f.js +0 -2
  169. package/dist/inspect-DCqILJ1G.js +0 -1197
  170. package/dist/inspect-DGJwpOAb.js +0 -2
  171. package/dist/manifestBuild-CJ2zvPvT.js +0 -2
  172. package/dist/serveCommand-C7IrCD58.js +0 -2445
  173. package/dist/serveCommand-Cjt5S9hD.js +0 -2
  174. package/dist/updateCommand-nnFjDbl4.js +0 -2
  175. package/dist/webDev-1XpVnYkW.js +0 -2
@@ -1,4 +1,4 @@
1
- # What's new in 0.55.0
1
+ # What's new in 0.57.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
@@ -7,136 +7,192 @@ workaround for something that shipped two versions ago.
7
7
 
8
8
  BREAKING entries name a codemod; run `voltro update` to apply it.
9
9
 
10
- ### ⚠ BREAKING
10
+ ### Added
11
+
12
+ - **@voltro/client, @voltro/web** — <!-- `apiSurface: compatible` rather than `additive`: the golden line for `useFormBinding` CHANGED rather than being added, because the second parameter WIDENED from `string` to `string | ((values) => string)`. A widened parameter cannot turn a call site that compiled into one that does not — every existing `useFormBinding('app', 'todos.create', …)` still satisfies the union. The `@voltro/web` aggregate re-exports it, which is why it is listed: its golden describes a surface defined in `@voltro/client`, and regenerating the source package's golden does not regenerate the aggregate's. -->
13
+
14
+ **`useFormBinding`'s mutation may now be a FUNCTION of the current values.**
15
+
16
+ ```tsx
17
+ useFormBinding('app', (v) => (v.repeats ? 'calendarRecurringEvent.create' : 'calendarEntries.create'), { defaults })
18
+ ```
19
+
20
+ Some forms only learn their target from what the user does: a calendar entry becomes a recurring series the moment "repeats" is ticked, and the series mutation takes eleven more fields. Neither way out worked. Deriving the tag outside the binding is not available — the values belong to the binding and do not exist before it — and re-mounting with a different tag resets the engine, throwing away everything the user typed.
21
+
22
+ The schema in force follows the tag, so `fields` and validation always match what will actually be submitted, and the values survive the switch because the engine is constructed once and never rebuilt.
23
+
24
+ Two details that are decisions, not accidents. On the first render there are no values yet, so the function is called with the raw `defaults` — not the SEEDED ones, because seeding reads the schema and the schema comes from the tag being resolved. And the accessibility ids are pinned to the first resolved tag: they are DOM ids, and letting them move on the keystroke that flips the branch would remount every field, taking the focus and the caret with it — the opposite of what "switch the tag without losing the values" is for.
25
+
26
+ **Also: every hand-made `errorBus` in the client's own tests now uses the real `RpcErrorBus`.** Seven of them were `{ emit: () => {} }`, and they all broke the moment the bus grew a second channel — which is the cheap version of the failure a paraphrasing fake produces. A fake standing in for a seam that has a shared implementation should import it.
27
+ - **@voltro/plugin-audit** — **`redact*: 'shape'` now buckets a string's length below a floor: `string(<16)` rather than `string(6)`.**
28
+
29
+ The mode exists so an audit row can end a diagnosis the payload itself cannot be shown for — the motivating case is a 113-character value where 44 were due, and the length IS the whole finding. A length is a small disclosure at that size and not a small one at six: a TOTP reported as `string(6)`, or a four-digit PIN as `string(4)`, tells a reader with log access exactly what shape to try.
30
+
31
+ The floor is where the two stop overlapping. Nothing this mode is FOR lives under it, so the default costs no diagnostic value. `auditPlugin({ redactionLengthFloor })` moves it — `0` for a deployment whose audited payloads are ids and tokens and every character of length is worth having, higher for one holding short human-entered secrets.
32
+
33
+ Two details that are decisions. **An empty string stays exact** (`string(0)`): "the field arrived empty" is a real diagnosis, an empty string is not a secret, and collapsing it would hide the one short length worth seeing. And the floor applies to described KEYS as well as values — a key that failed the declared-field test is a key carrying data, so its length is the same disclosure.
34
+ - **@voltro/cli** — **`voltro logs --url` and `voltro traces --url` can read a deployed app now — and when they cannot, they say so instead of blaming your filters.**
35
+
36
+ Two defects, and the second is the cheap one that costs the most time.
37
+
38
+ **The flag existed for the case it could not serve.** `--url` addresses a deployed app; a deployed app runs `voltro serve`, which mounted neither `/_voltro/inspect/logs` nor `/_voltro/inspect/traces`. Our own docs name both commands as the way to debug a running app. A reader with cluster access can route around it by reading pod stdout — a customer on a hosted Voltro cannot, because inspect *is* the access.
39
+
40
+ `inspect: { logs, traces }` in `app.config.ts` arms a bounded in-process ring (`VOLTRO_INSPECT_LOGS` / `VOLTRO_INSPECT_TRACES` override per deployment, taking a size or `on`/`off`). **Off by default**, because a ring is memory on every replica for data most deployments already collect from stdout — the argument against a second span sink was an argument for a default, not for an absence. The endpoint handlers are the ones `voltro dev` already calls, and the span FILTER that decides what reaches the ring is now shared rather than copied: `traceRingSink.ts`, because a copied filter is a second definition nothing keeps in step, and this one is what stops `_voltro_traces` from recording its own INSERTs.
41
+
42
+ **"Found nothing" and "looked nowhere" read the same.** An empty result printed `no records matched the given filters.` — a claim that your filters were too narrow — with the truth in a `#` comment line below it, which is the first thing a pipe or a `| grep` drops. The invited reaction is to widen `--tail`, drop `--level`, extend `--since`, and get the identical sentence every time. When every target failed, the headline now says `NOTHING WAS SEARCHED` and carries the reason the endpoint gave — which the CLI was collapsing to `HTTP 404` and throwing away.
43
+
44
+ The 404 for these two also stopped saying "this endpoint is served by `voltro dev`". That was true and is now false in the one direction that matters: it tells a reader to stop looking when one config field stands between them and the answer. It names the switch.
45
+ - **@voltro/cli** — **Two `voltro doctor` rules for defects that are completely decidable from the source — and were reported because nothing decided them.**
46
+
47
+ **`executor-builds-an-undeclared-field`.** Since 0.37 a descriptor's `output` IS the serializer, so a key the struct does not declare is stripped on the way out. Nothing errors and nothing warns: every reader downstream gets `undefined` and renders a blank. A deployment hit it twice in one session — a list executor built two fields, declared neither, and every picker showed empty options.
11
48
 
12
- - **@voltro/protocol, @voltro/client, @voltro/runtime, @voltro/cli** **A multi-reference field now flips as instantly as a scalar one.** A mutation target's declared `relations:` reconciled the junction inside the server's transaction and nothing else: the client learned about the link change only when the delta came back. On the same submit, the renamed title flipped immediately and the assigned stores did not the half of the promise that was never stated.
49
+ **`nullable-column-a-mutation-cannot-clear`.** A nullable column written through an input field that does not accept `null` can be set once and never emptied. The mutation succeeds, the column keeps its old value, and the user's second attempt looks like a UI bug. A deployment found five real cases only after a formatter happened to wrap one of their own line-based checks which is the argument for doing it here: their rule was blind to exactly the inputs small enough to fit on one line, and stayed blind silently.
13
50
 
14
- The declaration now drives BOTH. `useMutation`'s auto-optimistic stages a patch on every subscription sourced on the junction table, reconciling that anchor's links against `input[field]` — surplus links removed, new ones staged, surviving links left untouched with their real ids (a diff, mirroring `store.relationLinks(...).set`, not a drop-and-restage that would blink every unchanged row).
51
+ Both are ADVISORY, like everything in that scan, and both REFUSE rather than guess. The refusals are where the work went:
15
52
 
16
- The patches ride the ordinary optimistic lane, staged under the mutation id, so the rollback rule holds by construction: reverted on failure, kept on success until the base actually moves. Nothing here is on a timer.
53
+ - A spread on either side of the output comparison means the key set is not knowable; reporting the visible half would name the field the author can already see and miss the ones they cannot. - An `output` that is a named schema rather than a literal struct is UNJUDGEABLE, not empty — the second reading makes every field a finding. - Declared names are flattened across nesting, because a literal inside `versions: Schema.Array(Schema.Struct({ version, op }))` builds names the contract declares one level in. - A literal that shares NO name with the declared output is not the output: a declarative query executor returns `{ descriptor: { table, order, take } }`, which the framework runs. - A table declaration the scan cannot find produces no finding, because "this table has no nullable columns" and "I could not look" lead to opposite conclusions.
17
54
 
18
- **Migration`relations:` values are objects now:**
55
+ The last two exist because the first version of the output rule was measured against four real apps and flagged two files, both wrong one query descriptor and one nested row. Both are pinned as regression cases, each beside a falsification proving the fix did not turn into accepting anything. After them: 156 real files, zero findings; and injecting an undeclared field into a real fixture makes the rule name exactly that file.
19
56
 
20
- ```ts
21
- // before
22
- target: { table: 'employees', op: 'update',
23
- relations: { assignedStores: 'employee_assigned_stores' } }
24
-
25
- // after
26
- target: { table: 'employees', op: 'update',
27
- relations: { assignedStores: {
28
- junction: 'employee_assigned_stores',
29
- anchorColumn: 'employeeId', // the junction reference() pointing at `employees`
30
- targetColumn: 'storeId', // the junction's other reference()
31
- } } }
57
+ ### Fixed
58
+
59
+ - **@voltro/runtime, @voltro/cli** — **A schedule run whose process died suppressed every later firing of that schedule for six hours, on every replica, and logged `overlap skip` while doing it.**
60
+
61
+ The cross-instance overlap guard asks whether an occurrence is already running anywhere in the cluster. Its only evidence was the run row's `firedAt` against a constant, because that is all a row carried — and a constant that must never cut off a live run has to be longer than the longest one. So a pod killed mid-run left a `running` row that read as a live occurrence until the window expired. Against a half-hourly cron that is twelve missed occurrences from one restart.
62
+
63
+ `_voltro_schedule_runs` now carries **`heartbeatAt`**, bumped while the handler is in flight, and liveness is measured rather than assumed: silent for three beats is dead. A row with no beat — one written by a process still on the previous version during a rolling deploy — is dated by `firedAt` and bounded by the schedule's own `maxRuntimeMs`, because the watchdog records anything past it as `failed`. Same field and the same three cases as `_voltro_replace_in_progress.heartbeatAt`; it is deliberately not a lease, and nothing takes ownership of the run.
64
+
65
+ **The log line now carries what it measured.** `schedule: overlap skip` with `scope: 'cluster'` described two states — a peer genuinely working, and a corpse holding the schedule shut — while the word "overlap" asserted the first, and telling them apart meant reading the scheduler's source. It reports `heldBy` and `lastSeenMs` now.
66
+
67
+ **Two more defects found in the same function.** The query took the 200 most recent `running` rows across EVERY schedule and matched the name in JavaScript, so past 200 concurrent rows a live run falls out of the page and the guard silently stops guarding; the name is in the predicate now. And a `finishRun` whose update matched nothing returned in silence — the row stays `running`, which is exactly the state that suppresses the schedule, so the one write whose absence stops a cron had no failure signal at all.
68
+
69
+ `scheduling.scheduleHeartbeatMs` (env `VOLTRO_SCHEDULE_HEARTBEAT_MS`, default 30 s) is the cadence. Both boot paths pass the resolved value; a run shorter than one interval writes no beat and costs nothing.
70
+ - **@voltro/cli** — **`voltro dev` crashed on boot for every API-only app.**
71
+
72
+ `tryRunWebDev` asks `loadConfig` for the app's WEB config and gets `null` whenever there isn't one — which is every `type:'api'` project, and any config that fails to import. That null is expected and handled: the function returns `{ ran: false }` and the caller boots the api alone.
73
+
74
+ The `health` block's resolution was inserted ABOVE that guard, so the first thing the function did was read a field off the null:
75
+
76
+ ```
77
+ TypeError: Cannot read properties of null (reading 'health')
32
78
  ```
33
79
 
34
- The columns are declaration data because the optimistic patch runs in the BROWSER, which has no table registry to derive them from — `@voltro/database` is server-only by construction, and guessing a column from a table name is exactly what `store.relationLinks` refuses to do. They are not taken on trust: before it writes, the server compares the declaration against the junction's real reference columns and refuses, naming the correct pair, if they disagree. A wrong declaration is a loud error carrying its own fix, never a client that patches one column while the server writes another.
80
+ Two things about how it stayed invisible are worth more than the one-line fix.
35
81
 
36
- Semantics unchanged and now shared by both sides: an absent input field touches nothing (absent empty), an empty array is the explicit clear. The client uses `input.id` for an update and, for an insert, the same optimistic id it stamped on the new row the server's `output.id` is not knowable before the response.
82
+ **`tsc` had no chance.** The read is written `(config as { health?: }).health`, and a cast on a maybe-null value erases exactly the nullability the compiler would have refused. The guard has no type-level protection, so it needed a behavioural one — `webDevNoWebConfig.test.ts` asserts the CONTRACT (`{ ran: false, exitCode: 0 }`, no throw) rather than the line order, and fails with the production error when the guard is moved back.
37
83
 
38
- **`voltro update` carries you across this**codemod `0.55.0/01_target-relations-declare-columns`. 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.55.0).
39
- - **@voltro/client, @voltro/ui, @voltro/ui-shadcn, @voltro/web, @voltro/cli** — **A rich-text field, and the sanitizing contract is the point of it.** `RichTextDocument` (`@voltro/client`) is the value; `widget: 'rich-text'` renders it; `<RichTextView>` (`@voltro/ui`, re-exported by `@voltro/web`) displays it.
84
+ **And every symptom pointed somewhere else.** Eight integration files went red together, and all of them reported a TIMEOUT — `both replicas must boot`, at 300 s because what a fixture observes is a process that never starts listening. A crash at boot and a slow machine are the same observation from outside, and the second is the expensive diagnosis to start with.
85
+ - **@voltro/plugin-billing** — **A production boot with no `STRIPE_SECRET_KEY` silently selected the MOCK billing provider. It says so now.**
40
86
 
41
- The contract, stated plainly because the alternatives all look reasonable until you name who the attacker is:
87
+ `resolveProvider` picks `stripe` when the key is present and `mock` when it is not. The zero-config default is right — add the plugin, see checkout flows, no account needed. What was wrong is how it failed later.
42
88
 
43
- - **The value is a closed document tree, not an HTML string.** There is no `html` node, no raw-markup escape hatch, no attribute bag. Anything that is not one of the declared node types fails to decode. - **The boundary is the `Schema` decode**, which is the server's existing, non-bypassable input boundary the same one every mutation input already passes through. So the guarantee is not "somebody remembered to sanitize this"; it is that a document which reached the database is one of these shapes. - **A link's `href` is the one field that points outward, and it is allowlisted** `http(s)`, `mailto:`, a `#fragment`, a `/path`; nothing else. Control characters and whitespace are stripped before the check, because `java\tscript:` navigates exactly like `javascript:` and a check on the raw string passes it. - **Client-side is not a boundary and is not treated as one.** The widget's parser runs in the browser for the editing experience; every property it maintains is re-established by the decode on the server. - **Rendering never uses `dangerouslySetInnerHTML`.** Nodes become React elements, text becomes React children — so markup typed into the box is markup the reader SEES. `<RichTextView>` also drops an href that would not survive a decode, for the value that never went through one.
89
+ `STRIPE_SECRET_KEY` is a deployment variable, and one that silently stops being set is a routine event: a rotated secret, a typo in a values file, a CI variable nobody ever created. When that happens, an app that has been charging customers keeps answering every charge, subscription and invoice call SUCCESSFULLY, reaches nobody, and leaves nothing in the log to find afterwards.
44
90
 
45
- Rejected, for the record: sanitizing an HTML string on write (ships an HTML parser and the mXSS surface that comes with it), escaping at render (makes safety a property of every read site), and declaring an unenforced boundary (a convention, not a guarantee).
91
+ A mock chosen by ABSENCE, under `NODE_ENV=production`, now warns — naming the consequence rather than the selection, and both ways out (set the key, or declare `provider: 'mock'` so the reader knows it was a decision). An explicitly declared mock stays silent: warning about a choice somebody made trains the reader to ignore the line that matters.
46
92
 
47
- The built-in widget is a `<textarea>` over a small, CLOSED markdown subset headings, `**bold**`, `*italic*`, `` `code` ``, `[text](href)`, lists, blockquote, fenced code with everything unrecognised left as literal text. That also closes the no-JS loop: the textarea posts source, `/form/*` parses it, and the same decode validates it. A WYSIWYG belongs at rung 2 (register a `rich-text` widget); the stored value shape does not change.
93
+ `NODE_ENV` decides only whether it is worth SAYING, never what is selected. Both environments resolve the same provideran env-conditional behaviour is the shape a staging box sails past, and it is pinned as its own case.
94
+ - **@voltro/cli, @voltro/protocol** — **A plugin's `declaredEnv: [{ required: true }]` now ABORTS THE BOOT when the value does not resolve. Nothing checked it before.**
48
95
 
49
- **Not** the collaborative case: `crdtDoc()` + `useCrdtEditor` remains the multi-writer path (a CRDT bytes column, a sync lane, Tiptap). This is one column, one writer, ordinary JSON the server can validate and diff, and no new dependency in the default kit.
96
+ The field has existed for a long time and ten-odd plugins fill it in. Three consumers read it — the env manifest, `voltro env`, and the secret MINT (which only handles `generate`, a secret that is OURS to invent). None validated `required`. Its own doc said "declaration only metadata, not a read path", while `PluginEnvVar.generate`'s said "in production a missing secret refuses the boot": true for the minted kind, false for the one that matters a third-party credential nobody can invent.
50
97
 
51
- **Migration:** `WidgetKind` gained `'rich-text'`. Only a registry typed as a TOTAL map (`Record<WidgetKind, Widget>`) notices add one entry pointing at the exported `RichTextWidget`. Partial registries need nothing.
98
+ Declared, documented, read by three consumers, enforced by none. Every surface read as wired.
52
99
 
53
- Also fixed alongside: the capability manifest's `MANIFEST_WIDGET_KINDS` is a hand copy of `WidgetKind` (a CLI module cannot import `@voltro/client`) and had silently drifted by two kinds since 0.53.0, telling coding agents a smaller set than the renderer accepts. It is complete again and pinned by a test that reads the union out of the client's source.
100
+ Every required entry now resolves before any plugin activates, on BOTH api boot paths, and a failure names the PLUGIN the variable name alone appears in no file the reader owns, so without the attribution the next step is a grep through `node_modules`.
54
101
 
55
- **`voltro update` carries you across this** codemod `0.55.0/02_widget-kind-gained-rich-text`. 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.55.0).
102
+ **A `secret: true` variable resolves through the configured secrets backend**, not just `process.env`, because that is where its value lives when one exists. A backend that cannot answer therefore fails the boot too — reported as `unreadable` rather than as an absent variable, because those are different facts and one of them sends the reader to the wrong file. A non-secret variable never touches the backend, so a misconfigured vault cannot fail variables that never used it.
56
103
 
57
- ### Added
104
+ Merging, when two plugins declare the same variable: `required` takes the stricter (a plugin that can live without the value must not weaken one that cannot), `secret` takes `true` (over-classifying hides something that did not need hiding; under-classifying prints somebody's credential into a dashboard — it is the only direction with a failure mode on one side). An app that declares the variable in `app.config.ts` overrides the plugin entirely.
58
105
 
59
- - **@voltro/database, @voltro/data-transfer, @voltro/cli, @voltro/voltro** A staged `--mode replace` now RECORDS the scratch tables it creates, and a boot collects the ones nobody is coming back for.
106
+ **Upgrade note.** One first-party declaration is affected: `plugin-notifications`' `VOLTRO_VAPID_PRIVATE_KEY`, declared `required: true` and reachable only when web push is enabled. `voltro dev` mints it, as before; a production `voltro serve` with web push and no key now refuses to start, which is what that declaration has always said.
107
+ - **@voltro/client** — **A consecutive-failure ceiling was counting a tab's LIFETIME, and the screen it stranded had nothing left to move it.**
60
108
 
61
- Staging tables are `_voltro_staging_`-prefixed, so the differ correctly ignores them and nothing else mentioned them either. A run that died between the load and the swap left a full copy of a bundle that only a hand-written introspection could find, on a database whose boot said nothing. The marker row now names the set, carries a heartbeat the run refreshes while rows land, and records whether the run was started `--no-atomic`. That is what lets a boot tell the three cases apart: silent past the threshold and not resumable the tables are dropped; still beating → an import is loading into them, here or on another replica; resumable its staging IS the resume point and is left alone however stale. `VOLTRO_STAGING_STALE_MINUTES` moves the threshold (default 30). A staging record is reported, never a refusal a staged replace destroys nothing until one short server-side swap, so refusing a boot over it would be an alarm on a healthy database. `voltro data clear-staging` reads the same records and labels each table with what its own run says, instead of listing them flat under a warning that it could not tell a leftover from an import in flight.
109
+ `wireAuthRefresh` rebuilds the transport when a call comes back `Unauthenticated`, and `maxConsecutive` bounds it: after that many refreshes *with no successful call in between*, it stops, because at that point the credential is not stale it is refused. "With no successful call in between" was carried by `AuthRefreshHandle.noteSuccess()`, a method the HOST had to call, and the only host never called it.
62
110
 
63
- Two smaller things came with it. The raw-SQL seam the staged swap runs on (`DataStore.run`) is a DECLARED optional capability now, in the shape `emptyTables` established, with a parity assertion across the four dialect stores it was duck-typed against an interface that never mentioned it, so a store that dropped it would have fallen out of the feature detection and taken the slower path forever, on that one dialect, in silence. And a registered staging clone can no longer reach the differ's DECLARED side: `--mode replace` registers each staging table as a clone of its target for the length of the load (a typed write resolves its columns by name), and a plan computed while an import was in flight proposed `create-table _voltro_staging_notes`.
111
+ The consequence is not the loop the ceiling guards against. It is the opposite: three token rotations over an afternoon exhaust the budget, and from then on the tab never refreshes again. Every later expiry rejects every subscription the page opens, a rejected entry is terminal for its transport by design, so the screen waits on a spinner with nothing left to wait for.
64
112
 
65
- Also measured rather than assumed: the staging table `createStagingSql` builds on **sqlite** (`CREATE TABLE AS SELECT * FROM t WHERE 0`) and on **SQL Server** (`SELECT * INTO WHERE 1 = 0`) carries the target's columns and ZERO foreign keys, which is what the load needs. Those were the two dialects the postgres and mysql-family measurements had not covered.
66
- - **@voltro/client, @voltro/ui, @voltro/web** — **`useFormField(path)` finds its binding.** `<FormBindingProvider binding={form}>` (mounted for you by `<AutoForm>`) makes the narrow per-field subscription reachable without threading the binding down to every field component as a prop. That thread was blocking incremental adoption: a codebase moving a hundred-plus forms one at a time keeps its own field context and swaps engines per form, and being asked to prop-drill to ~30 field components at once meant taking the binding and declining the optimisation they had the most to gain from.
113
+ `RpcErrorBus` has a SUCCESS channel now (`onSuccess` / `emitSuccess`), emitted by the same pipeline that emits the errors a snapshot in the subscription cache, a settled mutation, a settled action. `wireAuthRefresh` subscribes to it itself, so no host has to remember anything; `noteSuccess()` stays for a host with a better signal of its own and is no longer what the policy depends on.
67
114
 
68
- **Message ids a real catalogue needed**, each because the generic answer is worse at the point of use:
115
+ **`useSubscriptionHealth(apiName)` is new**, and it exists because the state it reports cannot be derived from any single hook. A refused subscription never retries, so there is no second error to react to, and `SubscriptionFailed` presents `data: undefined` — the value every reading layer derives `loading` from. A wrapper hook passing `{ data, loading }` through therefore turns a refusal into a permanent skeleton, and passing those two through is the natural shape to write.
69
116
 
70
- - `betweenLength {min,max}` when a field carries BOTH bounds — "at least 2" is a half-truth for a rule that is "between 2 and 50" — and `exactLength {amount}` when they are equal. Read from the schema, not the failing issue: piping nests the later refinement outermost, so the sibling bound is not reachable from the issue that failed. - `invalidEmail` / `invalidUrl` / `invalidUuid` when the refinement declares a JSON-Schema `format`. A bare regex cannot name its own rule, and "Invalid format" beside an email box tells nobody anything. - `minDate` / `maxDate`, because a date bound rendered as a number bound reads "must be at least 2026-01-01". - `invalidFileType` / `fileTooLarge` — not produced by any refinement, carried so an app's own `ctx.validation.fail('doc', 'validation.fileTooLarge')` renders a sentence rather than an id.
117
+ const { healthy, failed } = useSubscriptionHealth('app') if (!healthy) return <Banner tags={failed.map((f) => f.tag)} />
71
118
 
72
- `apiSurface: compatible` `useFormField` goes from a const arrow to an overloaded function so it can take `(path)` as well as `(binding, path)`. The golden line for the old signature is replaced rather than removed: every existing `useFormField(form, path)` call compiles unchanged, because that overload is still declared first-class. Only code capturing the function's exact TYPE (rather than calling it) sees a difference.
119
+ Keyed by TAG, not counted two failures of one call are one broken thing and scoped to the api's runtime, reset when that runtime is rebuilt: carrying a failure across a transport swap would report a call as broken that has not been tried since.
120
+ - **@voltro/plugin-notifications, @voltro/cli** — **About one generated VAPID key in 256 was 31 bytes, and a 31-byte key can never send a push.**
73
121
 
74
- **Counting rules pass `count`.** `minItems` / `maxItems` carry `{ count }` beside `{min}`/`{max}`: i18next selects a plural form on a parameter named exactly `count`, so ids passing only `{min}` could not be pluralised at all.
75
- - **@voltro/runtime, @voltro/cli** — **The resume census — `/_voltro/inspect/subscriptions` now carries `resume`.** Per query label: how many subscriptions recorded a delta-resume ring, and how many were excluded, counted per reason (`computed`, `row-filter`, `eager-load`, `uncanonical-input`, `not-offered`). `voltro dev` also logs each verdict once per label under the `voltro:resume` scope — debug is the default level outside production, so it is already on where the tuning happens and off where it is served.
122
+ `createECDH('prime256v1').getPrivateKey()` returns the private scalar the way OpenSSL stores a BIGNUM minimal length, leading zero bytes stripped. A P-256 scalar is a fixed-width field element, so a draw whose high byte is zero comes back 31 bytes (measured: 82 in 20 000), and two zero bytes gives 30 (1 in 20 000). Both generators had it: the plugin's `generateVapidPrivateKey`, and the `p256` branch of `voltro dev`'s secret mint.
76
123
 
77
- It exists because the two failure shapes are indistinguishable from outside. A subscription excluded by a row filter and one whose executor returns a **value** rather than a descriptor both reconnect with a fresh snapshot and rows on the screen, so an app measuring its own reconnects cannot tell which of its queries a `tables:` declaration is even capable of helping. The answer is which of the two bind paths the executor took, and nothing on the wire carries it.
124
+ The consumers were already right, which is what made this survivable and also what hid it. `vapidPublicKeyFor` REFUSES a non-32-byte scalar a wrong VAPID key must fail loudly rather than at the first delivery. But the refusal happens inside `sendWebPush`'s `try`, so it came back as `{ kind: 'failed', status: 0 }`: the same outcome as the push service being unreachable. Nothing named the key. And the minted value is written to `.env.local` and stays, so an affected project's push sending is broken permanently and looks like a network problem forever.
78
125
 
79
- The reasons are the load-bearing part, not the counts: `computed` means no declaration can ever change this query, `row-filter` means the filter narrows its source and the exclusion is the point, and `eager-load` is reported ONLY when the base table is not itself narrowed — so that verdict always means "drop the `.with(...)` and this one resumes". Counts rather than one verdict per label, because resumability is not purely a property of the label: an input that does not canonicalise is a property of the value, so one query can be resumable for one subscriber and excluded for the next. A label nobody has subscribed to is ABSENT rather than reported as zero — "nothing has subscribed yet" and "every query is excluded" must not read the same.
126
+ Both generators left-pad now, and both packages assert the width on their OWN generator deliberately not one reading the other's source, because a cross-package guard does not run when the suite is filtered to a single package.
80
127
 
81
- **And a correction to what `tables:` was documented to buy.** The 0.54.0 notes, the `tables:` doc comment and the row-level-security page all said one registration cost delta-resume on every query descriptor in an app, with a count beside it. The count was real; the sentence around it claimed those descriptors would have HAD the feature, and that was never measured. A query only has a delta chain when its executor returns a descriptor one that maps its rows or wraps them in a page envelope re-runs an opaque handler and emits snapshots, filter or no filter. So the number a declaration gives back is the number of descriptor-returning subscriptions, not the number of queries. The docs now say that where the decision is made, and the census is how you find out which shape each of yours took.
128
+ **Worth recording is how nearly it was written off.** It surfaced as a single test failing in a full run and passing alone, roughly 1.6 % of the time the exact signature of a saturated machine. Isolation "cleared" it twice. What settled it was not another isolated run but generating 20 000 keys and counting the widths: 0.41 % at 31 bytes is not noise, it is 1/256, and that number names its own cause.
129
+ - **@voltro/runtime, @voltro/cli, @voltro/client, @voltro/kv, @voltro/plugin-broadcast** — **Seven reported points, all from one round. Every one of them is information the process already had, not reaching the person who needed it.**
82
130
 
83
- ### Changed
131
+ **A `SqlError` reaching the client as a Defect carried no detail.** The log line said `Failed to execute statement` — no table, no column, no operation — while the trace exporter had written `db.query.text` for the same statement. The handler-failure logger has walked the driver cause for a long time; the DEFECT frame did not, so a failure that reaches the client without passing through that logger left an operator with nothing. It leads with the driver's own message now and points at `_voltro_traces`.
84
132
 
85
- - `dataTransfer.stagingStaleMinutes` in `app.config.ts` how long a staged import's silence has to last before a boot treats its scratch tables as abandoned. Previously `VOLTRO_STAGING_STALE_MINUTES` only; the env var still overrides the declaration, on the rule every other tunable here follows.
133
+ **A defect that repeats IDENTICALLY ends the resumable stream.** The reconnect loop is built for a transport drop, and a transport drop does not repeat itself verbatim; a broken statement does. So one broken query spent the whole `maxReconnects` budget, and the user watched a spinner and then read "connection lost" — describing the opposite of what happened. TWICE, not once: the first failure is genuinely ambiguous, the second identical one is not. Also documented: `reconnects` is the RUN TOTAL and `maxReconnects` bounds CONSECUTIVE reconnects without progress, so "attempt {reconnects} of {maxReconnects}" renders `5/2`.
86
134
 
87
- The reason this was not already a field was recorded as "the boot check runs off the store alone, before the app config is threaded to it". That described the function's signature, not the boot: both paths already held the config three lines above the call. Resolution lives inside `stagingLeftoversAtBoot` rather than at either call site, so the two cannot disagree about what a declared value means, and a source-reading assertion fails if either path stops handing the config over.
135
+ **`voltro build` reported a config that failed to LOAD as "no web app found".** It used the wrapper that discards the import error, so an `app.config.ts` that IS an api and simply threw came out as a claim about the app's TYPE. The message now distinguishes the two, prints the error, and names both types.
88
136
 
89
- ### Fixed
137
+ **The `subscriber-effect-without-once` rule found one of four.** The helper name matched by EQUALITY, so `notifyAbsenceRequested` fell through while `notify` reported — a prefix now. And it read only the handler body, so a write one call level down inside the same file was invisible; the file is already parsed. What it still cannot see (a write in another module) is SAID: the report prints how many subscribers declare no `once:` against how many the rule found an effect in, because "1 file(s)" read as an inventory.
138
+
139
+ **`voltro dev` had no probe surface on a web app**, and `/internal/*` did not 404 — it fell into the page router. An SPA shell answered 200 with HTML, a loader redirect answered 303, an auth guard answered 303 to `/login`; a kubelet reads all three as PASS. One of those pages had a loader calling the api, so the WEB pod's readiness hung on the API's reachability once per probe interval. All three boot paths now answer through one matcher, before routing — and `health: { path, liveness, readiness }` in `app.config.ts` makes the paths and the answers the app's, for both app types. "Ready" is a dependency ping for an api and its opposite for a screen that must keep showing its last frame.
140
+
141
+ **Every hot reload ended as a crash.** ioredis emits `error` on an EventEmitter, and node turns an unhandled `error` event into an uncaught exception — so a client with no listener made every close with commands in flight a process crash. Functionally harmless (the supervisor restarts) and every reload read as a crash loop in Kubernetes. Both `@voltro/kv` and `@voltro/plugin-broadcast` register one on every client. The watcher also ignores a tool's scratch file (`vitest.config.ts.timestamp-…mjs`), which was restarting the app twice per test run.
142
+
143
+ **A row filter's resolution is now reported.** Between "refuses everything" (which an older version did, loudly) and "applies nothing" there was nothing to see: every read would widen and no log, test or `doctor` would say so. The framework says so the FIRST time a filter resolves on a live read path — a fact from the running system rather than an echo of the declaration. And a new advisory `doctor` rule reports a handler that hand-writes a predicate on a table the registered filter already covers: never a leak (two identical predicates ANDed are as tight as one), but an authorization rule maintained in places the framework already knew about.
144
+ - **@voltro/client** — **A mutation, workflow start or upload fired from a mount effect hit the client boot window and failed with the stub's message. Only `useAction` waited it out.**
145
+
146
+ Between the first client commit — children render against the loading stub so `hydrateRoot` can adopt the SSR DOM — and the moment the supervisor resolves the real rpc client, `handle.client` is a proxy that throws on any access. Measured in real chromium at ~15–30 ms, and wider in an app that mints a token in `authHeaders` before connecting. `useEffect(() => { mutate(...) }, [])` is the ordinary shape that lands inside it.
90
147
 
91
- - **@voltro/protocol, @voltro/cli, @voltro/client, @voltro/plugin-notifications, @voltro/plugin-comments, @voltro/plugin-presence, @voltro/plugin-search, @voltro/plugin-flags** **Installing a plugin under an `alias` now moves its client too.** `alias` exists for one problem your app already publishes `notifications.*` and cannot install a plugin that wants the same namespace and it has to move four surfaces or it is worse than not existing. It moved three.
148
+ `useAction.run` was fixed for exactly this, with a unit test and a browser measurement. `useMutation.mutate`, `useWorkflow`'s five callbacks and `useUpload.upload` had the identical defect for as long, and the failure was worse than a delay: the stub's `not-yet-resolved api` DISPLACED whatever the real outcome would have been, so the message a developer read described our plumbing rather than their call.
92
149
 
93
- The two that did not:
150
+ All four seams go through one `awaitResolvedApi` now, and each reads the handle from a ref at CALL time. A `useCallback` dep list cannot solve this however correct it is — the callback a mount effect fires was built on the first render, before the re-render that would rebuild it.
94
151
 
95
- - **The generated client sent the tag the PLUGIN authored.** Every lifter is `Rpc.make(descriptor.name, …)`, so the wire tag comes from the descriptor, not from the tag the codegen computed. Under `alias: 'inbox'` the exported identifier became `inboxInboxRpc`, the `appDescriptors` key became `inbox.inbox`, the type key became `inbox.inbox` — and the browser still asked a server that had stopped serving it for `notifications.inbox`. The codegen now lifts every plugin descriptor through `withRpcTag(…)`, unconditionally, so the aliased and un-aliased cases are one code path rather than a branch nothing exercises. - **The plugin's own hooks spelled their namespace as a literal.** `useInbox()`, `useUpload()`, `useComments()`, `usePresence()`, `useFlag()`, `useSearch()` all carried strings like `'notifications.inbox'`, which no alias could reach. `voltro dev` now writes a `registerPluginAliases({ … })` declaration into `rpcGroup.generated.ts` — the module the web client already loads value-level — and every plugin hook resolves its tag through `pluginTag(baseName, route)` from `@voltro/protocol` at call time, not at module load. Aliasing a plugin needs no change at any call site.
152
+ `useMutation` waits BEFORE staging optimistic patches, not just before the network call: the stub carries a different `cache` instance, so patches staged against it would land in a cache no live subscription reads and then be reverted against the wrong one.
96
153
 
97
- Two installs of one plugin (`name: 'ops'`) with no un-suffixed primary make `pluginTag` **refuse** rather than pick: a hook has no way to name an install, and guessing would address the wrong one silently. The error names both candidates and points at the full-tag call that says which you mean.
154
+ **The rule is asserted over the SET** (`bootWindowSeams.test.ts`), because a per-hook test can only demonstrate one member. A fifth imperative hook inherits it by being written rather than by being remembered which is precisely what did not happen the first time.
155
+ - **@voltro/cli** — **The boot's `reactivity:` line reported what the dialect COULD run, not what was running — so the deployment with no cross-replica fan-out was the one told it had none missing.**
98
156
 
99
- `pluginAlias` / `pluginSlug` moved from the CLI into `@voltro/protocol` (and are re-exported from their old path) because the browser has to derive the same namespace the server registered, and a second copy on the client would be a second definition of the rule with nothing comparing them.
157
+ `wireBroadcastBus` derived it as `postgres || mysql || mariadb`. A dialect is capable of a native transport; that is not the same as one being started. With `CDC=0` which any deployment whose database grants no REPLICATION privilege has to set the change reader never runs, and the boot still announced
100
158
 
101
- Also corrected, in the same seam: the list of plugins that deliberately do NOT accept `tables: false` read as exhaustive and omitted `_voltro_storage_grants`, which decides who may read an object. It is named now — along with the reason the option would not have reached it anyway (storage's tables are framework tables, not `extendSchema` contributions).
102
- - `voltro build` could not produce an api serve bundle on 0.53.0 or 0.54.0.
159
+ reactivity: cross-instance via native binlog CDC (mariadb)
103
160
 
104
- `@voltro/content`'s `get.ts` reaches its render pipeline through a dynamic `import('./serverLoad')`. That is deliberate an unresolvable-at-build-time specifier is what keeps marked and the shiki grammars out of a consumer's client chunk graph. But `serverLoad` was not in the package's entry map, so nothing emitted `dist/serverLoad.js`, and `dist/index.js` shipped an import of a file beside it that was not there.
161
+ The crooked sentence is the smaller half. Without a broadcast plugin this line is the ONLY thing said about cross-replica reactivity, and the branch it displaced is the WARNING that there is none. It is also the line our own guidance names as the check for whether the native-CDC amplification applies to you, and there it answered yes where the truth was no.
105
162
 
106
- It resolved in this repo every time, because the workspace `exports` point at `src/` and `serverLoad.ts` sits next to `get.ts`. A consumer resolves `publishConfig.exports` to `dist/index.js`, and the same line cannot resolve. esbuild does not honour `@vite-ignore` that is a vite directive so the serve bundle refused to ship. The refusal was right; nothing had ever triggered it.
163
+ It reads `store.changeScope` now `'fleet'` exactly when change capture is running, and the same value `remoteChangesVisible` takes two calls later, so the honest signal was already in scope. Derived rather than enumerated: a sixth dialect with a native transport joins by itself, and an app that turned capture off because every table is `.nonReactive()` is covered without anyone remembering that path exists.
107
164
 
108
- Two things worth knowing, both measured rather than reasoned:
165
+ The enumeration was also wrong in the other direction — **mssql (Change Tracking) was absent**, so a deployment running a real cross-instance transport was being warned it had none.
109
166
 
110
- - **The build was stopped by a dependency that contributes nothing to it.** An api serve bundle reaches `@voltro/content` through `serveCommand → dev → webDev → contentWiring`, and esbuild resolves before it tree-shakes. After shaking, the content pipeline is **0 bytes** of a 14.42 MB bundle. So an api app with no markdown anywhere was blocked by a markdown loader whose code it would never have carried. - **Shipping the file does not bloat anything.** Same measurement with the fixed package resolved as a consumer resolves it: 14.42 MB, content still 0 bytes. The dynamic import stays shaken away.
167
+ And the warning that fires in the CDC-off case names the cause instead of the dialect. The old fallback told a mariadb operator to "use ... mariadb (binlog CDC)", which reads as boilerplate to the one person who needed to act on it.
168
+ - **@voltro/runtime** — **A row-filtered table reached through `.with(...)` is narrowed now, where it used to be refused — and before that, served unfiltered.**
111
169
 
112
- `scripts/check-dist-internal-specifiers.mjs` now bundles every emitted file of every publishable package from `.publish/`, the tree users receive with bare specifiers external, and fails if any relative specifier does not resolve. GATE-2 (`publint`) answers "does a declared subpath resolve"; this is one level below it, where `./serverLoad` lives.
113
- - **@voltro/client** — Three places still taught the pre-fix contract for a cold-start failure.
170
+ An eager load resolves BELOW the seam that AND-merges the filter onto a read's base table: the stores expand the eager tree themselves, the memory store by recursing through its own raw read and the SQL stores by folding the relation into one join or JSON aggregate. Measured when it was found: a filter restricting a relation target to the caller returned exactly the caller's row on a direct read and BOTH rows through `.with()` on the same data.
114
171
 
115
- `SubscriptionFailed` gives it its own state `loading: false`, `failed: true`, `error` non-optional precisely so a component branching on `loading` alone cannot render a skeleton forever. But `SubscriptionMeta.error`'s doc comment and two docs pages still said the opposite ("leaves `loading` TRUE check `error` to break out of it"), which is the sentence a deployment quoted back at us as evidence for the defect that had already been fixed.
172
+ That shipped as a refusal, on the reasoning that the real fix meant applying the filter inside eager compilation across four dialect stores. **It is not that change.** Every eager branch already accepts a `where`, and both resolvers already honour it — the walker ANDs it into the relation's lookup descriptor, the JSON compiler emits it into the correlated subquery on all five dialects. So the middleware writes the filter where a caller could have written it, and every resolver applies it without knowing a row filter exists. One rewrite, six execution paths, no dialect emitter touched.
116
173
 
117
- A comment that predicts a trap the code no longer has is worse than no comment: it teaches the defensive shape as if it were still required, and it invites the reading that `loading` is unreliable. All four now describe the state that exists, with the old behaviour kept only as the history that explains why the field is there.
118
- - **@voltro/client, @voltro/web, @voltro/cli, @voltro/ui** — Four defects a real migration found, all of which passed `tsc` and a full test suite and only showed up against a running system.
174
+ A caller's own `where` is kept and the filter goes UNDER it: a branch you narrowed stays narrower, and nothing a caller writes can widen the filter. Nested `.with(...)` is narrowed at every level.
119
175
 
120
- **A server render derived a different form than the browser.** Nothing mounts a runtimes provider during SSR, so `useFormBinding` resolved its input schema from an EMPTY descriptor map: no fields, `required: false`. The browser then rendered the real ones and React discarded the subtree — "Hydration failed" on every server-rendered page carrying a bound form, with the diff pointing at a `Mui-required` class. `@voltro/client` keeps a process-global SSR descriptor registry now, and both boot paths fill it before rendering (`voltro dev` and `voltro start`, pinned as a parity test a CLI module cannot import `@voltro/client`, so the call goes through `@voltro/web/ssr`, which both already load).
176
+ **One case still refuses: a row-filtered `manyToMany` JUNCTION.** A branch `where` is a predicate on the relation's TARGET, so a filter narrowing the junction has nowhere to be expressed. Narrow and honest beats a rewrite that looks complete.
121
177
 
122
- **A rejected `submit()` had nowhere to go.** A form calls it from an `onSubmit` handler that cannot await it, so the rejection surfaced as `Uncaught (in promise)` while the form sat there looking saved. `submit()` resolves `undefined` now and the failure is state: `state.submitError`, plus an optional `onError`. That covers a composed `onSubmit` whose follow-up write fails on its OWN mutation handle — a failure the binding never saw, and the common shape (create the row, then its first child).
178
+ Verified on the walker (unit, in-memory store) AND on the JSON-aggregation path against a real postgres, because those are two separate implementations and the suites that cover them each mock the other's half.
179
+ - **@voltro/client** — **Two dev-time messages said something the code had not measured.**
123
180
 
124
- **Undeclared fields went on the wire.** The binding validated the mapped input and then sent the object unchanged; the client decode ignores excess properties while the server has refused them since 0.37, so a form carrying anything beyond the mutation's input passed validation and was rejected on the wire with a message pointing at no field. The payload is restricted to the declared keys now — the rule the no-JS path already followed, so the two submits agree with a dev warning naming what was dropped.
181
+ **The store's equals-footgun warning blamed the selector.** It read *"the selector returns a NEW object each call"* and the site cannot observe that: an identical state returns from the cache before the selector runs, so everything reaching the warning arrives with a CHANGED state. Shallow-equal-but-not- identical there has two producers, and the message asserted one of them:
125
182
 
126
- **`setValue` with an unchanged value produced a new `values`.** Every React state source is expected to no-op on that; this one did not, so an effect depending on `values` that re-set a field to the value it already held never settled ("Maximum update depth exceeded" on a form mirroring toggles out of a multi-select).
127
- - A plugin's dashboard panel no longer disappears when the app aliases the plugin.
183
+ - the selector BUILDS (`s => ({ a: s.a })`), or - the STORE wrote an equivalent new value `set({ crumbs: [] })` on leaving a page and again on entering the next is two different empty arrays.
128
184
 
129
- `alias` moves `plugin.name`, and the inspect mount is derived from it, so `alias: 'inbox'` on notifications moved its panel to `/_voltro/inspect/plugins/inbox/...` while both dashboards ask for `/plugins/notifications/...` with the path compiled in. They live in other repositories and cannot follow. The field's own doc comment stated this as a cost you accept in nine plugins, the protocol helper and the docs.
185
+ Both waste the render and both are fixed by `{ equals: shallow }`, so the advice was right while the diagnosis pointed at the wrong half and a deployment reading the second case went looking at a selector that had been returning the same reference all along. The warning now MEASURES which one it is: it re-runs the selector on the previous state (dev only, at most once per store, on a selector required to be pure anyway) and says either "the selector builds" or "the store is writing an equal value".
130
186
 
131
- `makePluginInspectRegistry` now mounts each plugin's `inspectEndpoints` under its CANONICAL slug as well, in a second pass so an effective mount always wins the path. Added only where unambiguous: a base name carried by more than one installed plugin gets no shared mount, because showing either install under it would hand a dashboard the other one's rows under a name that looks right the hazard `pluginTag` refuses rather than guesses. `/_voltro/inspect/plugins` now reports `baseName` and `inspectSlug` per plugin, which is how a caller reaches a specific install.
132
- - **@voltro/cli, @voltro/data-transfer** — **`voltro data restore --drill` failed every healthy backup of a real app.** It compared the restored schema's fingerprint against the backup stamp's `schemaFingerprint`, which records the SOURCE database's whole live schema — and the artifact never carries that schema. `pg_dump` / `mariadb-dump` exclude `_voltro_replace_in_progress` and `_voltro_data_transfers` on purpose, and the backup command opens a run row in the second one before it dumps, so on any database the framework has run against, the artifact is two tables short of the value it was being measured against. The drill answered:
187
+ **And a validation template rendered its own placeholder.** `interpolate` left an unfilled `{param}` in place, so a template whose parameter was missing put a literal `Mindestens {min}` into the interface. It reports the hole now and the caller falls through to the issue's own rendered sentence, which is complete by construction. The reported route is closed twice over: counting rules supply BOTH `count` (the name an i18n layer pluralises on) and `min` (the built-in template's), so a framework-derived issue cannot reach it; a hand-written annotation still can, and a raw placeholder is never the right answer.
188
+ - **@voltro/cli, @voltro/ai** — **`@voltro/ai`'s resumable-stream tables were declared, bounded, documented — and never added to the migration set.**
133
189
 
134
- FAIL restored N table(s), but the schema fingerprint (…) does NOT match the backup's stamp (…). The restore did not reproduce the schema that was backed up the artifact is inconsistent.
190
+ `dataStoreResumableStreamStore` writes to `_voltro_stream_events` and `_voltro_stream_state`. `frameworkTableAssembly.ts` imports six `@voltro/ai` tables and did not import these two, so `voltro db plan` answered *schema is up to date* while both were absent, and the first user's turn died inside the store rather than at boot.
135
191
 
136
- about an artifact that was exactly right. A drill exists to be wired to a CI cron, and one that is red on every healthy input gets switched off taking its two real failures with it.
192
+ They are assembled now for any app that DEPENDS on `@voltro/ai`, so all four schema-declaring paths plan them.
137
193
 
138
- The stamp now carries a second value, `dumpFingerprint`: the same snapshot minus `dumpExcludedTables(dialect)` what a faithful restore must reproduce. The drill compares against that. A stamp written before this field degrades to a PARTIAL pass that says so, rather than falling back to the value that produces the false failure. `dumpExcludedTables` is per-dialect because only two of the five backup paths carry an exclusion flag at all: the sqlite/turso copy and the mssql export carry everything, and subtracting a set from those would invent the same bug in the other direction.
194
+ **The gate is the dependency, not a file convention, and that is the half worth knowing.** Every other flag in `FeatureMix` reads a filename `*.agent.tsx`, `*.connection.ts` and `dataStoreResumableStreamStore(ctx.store)` has none: it is an ordinary call, reachable from an action, a query, a workflow step. Gating on `agents` is the obvious guess and it is wrong, because an app can stream without declaring a single agent file. The manifest is the superset that CAN reach the store, it comes from the source tree, and every process in one deployment computes it identically the constraint the declared set is under.
139
195
 
140
- **The drill also checks the one boot-fatal condition a schema comparison cannot see.** `voltro serve`'s boot gate reads the newest `_voltro_migration_plans` row and refuses with `prod-mismatch` when there is none — so a ledger table that restores with exactly the right columns and zero rows is a database no source tree can boot, and its fingerprint is identical to a healthy one's. That is now a FAIL with the reason named. A restored database with no ledger table at all is not a voltro-managed schema and is reported as such, not failed.
196
+ `loadDiscovered` now REQUIRES a `root` for the same reason: the flag is read from a manifest, so it cannot be derived from the file list, and a mix that quietly answered `false` because nobody passed a root would declare a smaller schema than the same tree declares elsewhere. An optional root would read exactly like one that was supplied.
141
197
 
142
- There is deliberately no app boot in the drill. The boot gate is a comparison, not a startup sequence, so the part that generalises is reachable with a SELECT; booting a fixture app instead would prove something about our fixture rather than about your backup.
198
+ **And the documentation said to do something that does not work.** The JSDoc and the docs page both instructed *"add them to the database barrel (or they ride along with a `*.agent.tsx` app)"* — both halves wrong, since discovery is file-based (`*.entity.ts`) and the store is not agent-only. That sentence had reached the shipped agent guide, so it was teaching every downstream coding agent a remedy with no effect on the plan. Corrected in the JSDoc, in both languages of the docs site, and regenerated.
@@ -12,12 +12,12 @@
12
12
  "dependencies": {
13
13
  "@effect/platform": "^0.97.0",
14
14
  "@effect/rpc": "^0.76.0",
15
- "@voltro/ai": "0.55.0",
16
- "@voltro/cli": "0.55.0",
17
- "@voltro/database": "0.55.0",
18
- "@voltro/env": "0.55.0",
19
- "@voltro/protocol": "0.55.0",
20
- "@voltro/runtime": "0.55.0",
15
+ "@voltro/ai": "0.57.0",
16
+ "@voltro/cli": "0.57.0",
17
+ "@voltro/database": "0.57.0",
18
+ "@voltro/env": "0.57.0",
19
+ "@voltro/protocol": "0.57.0",
20
+ "@voltro/runtime": "0.57.0",
21
21
  "effect": "^3.22.0"
22
22
  },
23
23
  "devDependencies": {
@@ -13,17 +13,17 @@
13
13
  "dependencies": {
14
14
  "@effect/platform": "^0.97.0",
15
15
  "@effect/rpc": "^0.76.0",
16
- "@voltro/cli": "0.55.0",
17
- "@voltro/database": "0.55.0",
18
- "@voltro/env": "0.55.0",
19
- "@voltro/plugin-auth": "0.55.0",
20
- "@voltro/protocol": "0.55.0",
21
- "@voltro/runtime": "0.55.0",
22
- "@voltro/sql-postgres": "0.55.0",
16
+ "@voltro/cli": "0.57.0",
17
+ "@voltro/database": "0.57.0",
18
+ "@voltro/env": "0.57.0",
19
+ "@voltro/plugin-auth": "0.57.0",
20
+ "@voltro/protocol": "0.57.0",
21
+ "@voltro/runtime": "0.57.0",
22
+ "@voltro/sql-postgres": "0.57.0",
23
23
  "effect": "^3.22.0"
24
24
  },
25
25
  "devDependencies": {
26
- "@voltro/testing": "0.55.0",
26
+ "@voltro/testing": "0.57.0",
27
27
  "typescript": "^6.0.3",
28
28
  "@vitest/coverage-v8": "^4.1.10",
29
29
  "vitest": "^4.1.10"
@@ -16,16 +16,16 @@
16
16
  "dependencies": {
17
17
  "@effect/platform": "^0.97.0",
18
18
  "@effect/rpc": "^0.76.0",
19
- "@voltro/cli": "0.55.0",
20
- "@voltro/database": "0.55.0",
21
- "@voltro/env": "0.55.0",
22
- "@voltro/plugin-multitenancy": "0.55.0",
23
- "@voltro/protocol": "0.55.0",
24
- "@voltro/runtime": "0.55.0",
19
+ "@voltro/cli": "0.57.0",
20
+ "@voltro/database": "0.57.0",
21
+ "@voltro/env": "0.57.0",
22
+ "@voltro/plugin-multitenancy": "0.57.0",
23
+ "@voltro/protocol": "0.57.0",
24
+ "@voltro/runtime": "0.57.0",
25
25
  "effect": "^3.22.0"
26
26
  },
27
27
  "devDependencies": {
28
- "@voltro/testing": "0.55.0",
28
+ "@voltro/testing": "0.57.0",
29
29
  "typescript": "^6.0.3",
30
30
  "@vitest/coverage-v8": "^4.1.10",
31
31
  "vitest": "^4.1.10"
@@ -13,16 +13,16 @@
13
13
  "dependencies": {
14
14
  "@effect/platform": "^0.97.0",
15
15
  "@effect/rpc": "^0.76.0",
16
- "@voltro/cli": "0.55.0",
17
- "@voltro/database": "0.55.0",
18
- "@voltro/env": "0.55.0",
19
- "@voltro/plugin-deactivation": "0.55.0",
20
- "@voltro/protocol": "0.55.0",
21
- "@voltro/runtime": "0.55.0",
16
+ "@voltro/cli": "0.57.0",
17
+ "@voltro/database": "0.57.0",
18
+ "@voltro/env": "0.57.0",
19
+ "@voltro/plugin-deactivation": "0.57.0",
20
+ "@voltro/protocol": "0.57.0",
21
+ "@voltro/runtime": "0.57.0",
22
22
  "effect": "^3.22.0"
23
23
  },
24
24
  "devDependencies": {
25
- "@voltro/testing": "0.55.0",
25
+ "@voltro/testing": "0.57.0",
26
26
  "typescript": "^6.0.3",
27
27
  "@vitest/coverage-v8": "^4.1.10",
28
28
  "vitest": "^4.1.10"
@@ -13,18 +13,18 @@
13
13
  "dependencies": {
14
14
  "@react-email/components": "^1.0.12",
15
15
  "@react-email/render": "^1.4.0",
16
- "@voltro/cli": "0.55.0",
17
- "@voltro/database": "0.55.0",
18
- "@voltro/env": "0.55.0",
19
- "@voltro/plugin-mail": "0.55.0",
20
- "@voltro/plugin-multitenancy": "0.55.0",
21
- "@voltro/protocol": "0.55.0",
22
- "@voltro/runtime": "0.55.0",
16
+ "@voltro/cli": "0.57.0",
17
+ "@voltro/database": "0.57.0",
18
+ "@voltro/env": "0.57.0",
19
+ "@voltro/plugin-mail": "0.57.0",
20
+ "@voltro/plugin-multitenancy": "0.57.0",
21
+ "@voltro/protocol": "0.57.0",
22
+ "@voltro/runtime": "0.57.0",
23
23
  "effect": "^3.22.0",
24
24
  "react": "^19.0.0"
25
25
  },
26
26
  "devDependencies": {
27
- "@voltro/testing": "0.55.0",
27
+ "@voltro/testing": "0.57.0",
28
28
  "typescript": "^6.0.3",
29
29
  "@vitest/coverage-v8": "^4.1.10",
30
30
  "vitest": "^4.1.10"