@voltro/plugin-audit 0.28.0 → 0.30.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 (2) hide show
  1. package/CHANGELOG.md +655 -0
  2. package/package.json +4 -4
package/CHANGELOG.md CHANGED
@@ -39,6 +39,661 @@ _Changes staged for the next release accumulate here (rolled up from
39
39
 
40
40
  ---
41
41
 
42
+ ## [0.30.0] — 2026-08-08
43
+
44
+ ### ⚠ BREAKING
45
+
46
+ - **@voltro/ui-shadcn, @voltro/i18n, @voltro/web, @voltro/cli** — The language-preference cookie is **`voltro:locale`**. It was `voltro:lang`. The exported constant is `LOCALE_COOKIE` (was `LANG_COOKIE`), and `parsePreferenceCookies()` returns `{ theme, locale }` (was `{ theme, lang }`).
47
+
48
+ Every other name in the framework says `locale` — `resolveLocale`, `defaultLocale`, `config.locales`, `[locale]/…` routes, `meta({ locale })`, `RouteContext.locale`. The cookie was the one place the vocabulary broke, while holding a full IETF tag (`fr-CA`) — which is a locale, not a language.
49
+
50
+ That inconsistency was not cosmetic. `voltro dev` shipped for eleven weeks reading `voltro:locale` while every writer wrote `voltro:lang`, so `<html lang>` was the literal `"en"` on every page of a German-default app. A reader and a writer that disagree on a string are invisible to `tsc`; a name nobody types the same way twice is what produced the disagreement.
51
+
52
+ **Your source is migrated by `voltro update`. Your users' browsers are not.** The old cookie in an already-visited browser is no longer read, so each user falls through to `Accept-Language` and then `defaultLocale` once and re-picks their language. Nothing errors and nothing else is lost. There is deliberately no dual-read fallback: a framework that keeps reading the old name forever is one that never finished the rename, which is the exact condition this change removes. If the one-time reset is unacceptable for your users, copy the value forward at your own boot and delete the bridge once they have cycled through:
53
+
54
+ ```ts
55
+ import { LOCALE_COOKIE, getCookie, setCookie } from '@voltro/ui-shadcn'
56
+
57
+ const legacy = getCookie('voltro:lang')
58
+ if (legacy && !getCookie(LOCALE_COOKIE)) setCookie(LOCALE_COOKIE, legacy)
59
+ ```
60
+
61
+ `voltro:theme` is unchanged.
62
+
63
+ **`voltro update` carries you across this** — codemod `0.30.0/01_locale-cookie-rename`.
64
+ - **@voltro/plugin-webhooks, @voltro/cli** — A deferred `ctx.webhooks.emit(...)` is a **transactional outbox row** now, not an in-memory after-commit callback. And `EmitOptions` gains **`immediate: true`** as the named way to opt out.
65
+
66
+ The commit-ordering half shipped in 0.29.0: an emit inside a mutation rides the commit, so a mutation that emits and then throws no longer tells a subscriber about a change that did not happen. That fix was correct about ORDER and silent about DURABILITY — a process dying between COMMIT and the callback dropped the delivery with nothing recorded as owed, which is the at-least-once-FROM-ENQUEUE weakness `@voltro/plugin-cdc-out` documents about itself, arrived at by accident.
67
+
68
+ The enqueue writes through `ctx.store` — inside a mutation, the transactional view — so the intent to deliver commits with the domain write or not at all. A crash is a retry instead of a loss. Delivery stays at-least-once, which is the strongest guarantee available without distributed transactions into the receiver.
69
+
70
+ **What changes for you:** an emit inside a mutation returns `{ event, deliveries: [], deferred: true }` and its delivery rows appear after commit — as it already did in 0.29.0. New is that the deferral survives a crash, and that `{ immediate: true }` exists for the cases that genuinely want the POST now. `immediate` does not make the emit safe; it makes the trade visible at the call site, which the old un-transactional behaviour never did.
71
+
72
+ The framework registers its own `voltro.webhook.emit` outbox handler in BOTH boot paths, gated by one shared `hasWebhookSurface` predicate — a deferral that is durable under `voltro dev` and not under `voltro serve` is exactly the drift the parity guard exists for. An app with no outbox wiring keeps the in-memory callback: ordered, not durable, and it says so.
73
+
74
+ **`voltro update` carries you across this** — codemod `0.30.0/03_webhook-emit-durable-deferral`.
75
+ - **@voltro/protocol, @voltro/runtime, @voltro/cli, @voltro/plugin-webhooks** — **`WorkflowRunHandle.executionId` is nullable and `status` has three more members, because a start no longer always becomes a run.**
76
+
77
+ With declarative flow control a start can be QUEUED (debounce / batch / throttle / concurrency / paused), DROPPED (over a `rateLimit` cap) or SKIPPED (a `singleton: { mode: 'skip' }` key was held). None of those has an execution id, and two of them may never have one.
78
+
79
+ ```ts
80
+ status: 'running' | 'queued' | 'dropped' | 'skipped' // was: 'running'
81
+ executionId: string | null // was: string
82
+ deferral?: { mode, dueAt, retryAfterMs, intentId } // new
83
+ ```
84
+
85
+ Keeping `executionId` a required string was considered and rejected. It would have meant inventing a value — an empty string, or the id the run WOULD have had — and both produce a handle that polls `status: 'unknown'` forever: a wait that never resolves and never errors, which is the worst of the three answers. For a `skipped` singleton it carries the INCUMBENT's execution id, which is a real, pollable run and the entire point of that mode.
86
+
87
+ `ctx.workflows.wait(...)` on a handle with no execution id now throws with a message naming the status and, for `queued`, its `dueAt` — instead of polling forever.
88
+
89
+ Two structural copies of the old shape went stale and are now the protocol type itself rather than hand-copies: `@voltro/plugin-webhooks`' `IncomingWorkflowFacade` and the CLI's `inspectStartWorkflow`. An incoming webhook that starts a debounced workflow gets a `queued` handle, which both copies said could not happen.
90
+
91
+ **`voltro update` carries you across this** — codemod `0.30.0/04_workflow-run-handle-nullable-execution`.
92
+
93
+ ### Added
94
+
95
+ - **@voltro/ai, @voltro/workflow** — **`@voltro/ai/workflow` — `aiStep` / `aiObjectStep`, a model call as a durable step that records what it cost.**
96
+
97
+ ```ts
98
+ import { aiStep } from '@voltro/ai/workflow'
99
+
100
+ const summary = yield* aiStep({
101
+ name: 'summarise-thread',
102
+ prompt: `Summarise:\n${thread}`,
103
+ store: ctx.store,
104
+ tenantId: payload.tenantId,
105
+ offload: true,
106
+ })
107
+ ```
108
+
109
+ Journaling is NOT the difference, and saying otherwise would be selling something the framework already gives away: every `step()` is journaled, so a replay of a plain wrapped `generateText` already returns the recorded completion rather than re-calling the model. Four things are genuinely new:
110
+
111
+ 1. **What did this run cost?** A model call inside a workflow was invisible to `_voltro_ai_usage` unless the app remembered to call `recordAiUsage` by hand — so the spend ledger was systematically missing exactly the calls that run unattended. `aiStep` records it, attributed to the workflow and the step. 2. **The prompt is not silently copied into a second table.** `step({ input })` is written to `_voltro_workflow_run_steps` and rendered in the dashboard; for a prompt built from customer data that is a plaintext copy outside whatever boundary the app established for the source. The default records a DIGEST plus the length; `recordPrompt: 'full'` exists and has to be typed out. 3. **Provider failures retry like provider failures.** The default policy handles a 429 with its `Retry-After` and a 5xx, rather than every app rediscovering that a bare call fails the whole durable run on a rate limit.
112
+
113
+ 4. **`offload: true` frees the worker while the model thinks.** The run SUSPENDS on a durable deferred, the wait lives as a row in `_voltro_ai_inferences`, and a dispatcher owns the socket. Two hundred waiting runs become two hundred rows and four in-flight requests instead of two hundred parked fibers.
114
+
115
+ Nothing here needs a third party to operate an inference tier — it needs something to own the socket while the run sleeps, and a server process is something. Both pieces already existed: durable suspend/resume (`awaitSignalSuspending`, built for human-in-the-loop waits) and a leased work queue with a coordinated drainer (the admission queue's own shape).
116
+
117
+ The cost is stated rather than buried: a suspend/resume round trip adds the dispatcher's poll interval plus one engine wake, so it is a MODE. Under 5% on a six-second call; a doubling on a 200 ms one.
118
+
119
+ Four guarantees, each ruling out a specific way this goes wrong:
120
+
121
+ - the enqueue is idempotent (the row id derives from execution + step, so a replay cannot queue — and pay for — the same call twice); - the claim is a conditional update, so two dispatchers cannot both bill one call; - the order is perform → RESUME the run → mark the row, because a crash the other way round leaves a run waiting for a signal nobody will send again; - a give-up resumes the run WITH the failure — an abandoned queued call that never told its run is the one unrecoverable outcome here.
122
+
123
+ `aiObjectStep({ offload: true })` renders the schema to JSON Schema for the dispatcher (a JavaScript Schema cannot be journaled) and still decodes on the awaiting side, where the real schema exists.
124
+
125
+ The dispatcher rides the ONE shared builder both boot paths call, with a red-verified parity guard: the gap it prevents is the worst variant this repo catalogues — in production every offloaded call would suspend its run and never resume it, with no error and no log line.
126
+
127
+ The Flow tab renders the queue: what is waiting and for how long, calls waiting past two minutes, claims whose dispatcher died, and the dispatcher's own last tick. A run parked on an offloaded call reads `suspended` with no step row yet, so this is the only view of the wait while it is happening.
128
+
129
+ `StepRetryPolicy` is now re-exported from `@voltro/workflow/define` (type-only, so the browser bundle is unaffected): it is the type of a `step()` option, and anything defining a step has to be able to name it.
130
+ - **@voltro/cli** — **`voltro db scan-credentials`** — the credential scan as a command instead of a SQL snippet in an upgrade note.
131
+
132
+ It counts rows whose Subject carries a credential-shaped key (`token` / `secret` / `password` / `apikey` / `credential` / `privatekey`) in `_voltro_audit_log` and `_voltro_row_history`, plus any `--table <name>[:<column>]` you add. Exit `1` on a hit so CI can gate on it.
133
+
134
+ Why it is a command: the same check shipped as documented SQL (`subject::text ILIKE '%token%'`), which is postgres-only. Readers on MySQL/MariaDB translated it to a bare `LIKE` — case-SENSITIVE against the `utf8mb4_bin` collation our own migrator emits for a `json()` column, so `'%token%'` does not match `jiraToken`. A team ran it over 141 rows, got `0`, and nearly filed themselves clean; 117 held a working credential. Every dialect now casts to its own text type before `LOWER`, in code.
135
+
136
+ **And a `0` can no longer mean two things.** Every line prints the number of rows SCANNED beside the number of hits; an empty table says "EMPTY … this is not a clean bill of health"; a missing table reports as missing rather than as zero; and a run that examined nothing exits `2`, not `0`.
137
+ - **@voltro/workflow, @voltro/runtime, @voltro/cli, @voltro/plugin-ai-flows** — **`apiSurface: compatible`, and the reason.** Making `workflow()` a SINGLE call signature (see below — it is what lets a `key` lambda receive the payload type) also means every result is now intersected with its message carrier, including the empty one. `@voltro/plugin-ai-flows`' golden therefore reads
138
+
139
+ Workflow<"flow.run", …, typeof Schema.Never> & WorkflowMessagesCarrier<{ signals: {}, updates: {}, queries: {} }>
140
+
141
+ where it used to stop at the first line. That is an ADDED intersection member, not a narrowing: a value of `T & M` is usable everywhere a `T` was, and nothing outside the package produces a value of that type. The gate flags it because one golden LINE was rewritten, which is the right thing for it to be blunt about — it cannot tell an addition spelled as a rewrite from a removal.
142
+
143
+ It is also an improvement worth naming: before this, a workflow declaring `messages` fell through to the second overload and its payload/success/error types erased to `any`. That erasure is gone.
144
+
145
+ **Flow control is a declaration now — `debounce`, `singleton`, `concurrency`, `throttle`, `rateLimit`, `batch`, `priority`, `timeouts`, `onFailure`, `encryptSteps` on `workflow({...})`.**
146
+
147
+ Every one of these could already be hand-rolled, and that was the problem. A downstream app shipped "fifteen minutes after the last edit, narrate what settled" as ~120 lines: an idempotency key carrying the edit timestamp so every edit minted its own durable run, a re-check loop asking "what is due now and when should I wake next", a round cap so a run could not live forever, and an idempotent round so the superseded runs cost a diff instead of a model call. It works. It costs **twenty sleeping cluster entities to express "one job, latest deadline"**. It is now one line:
148
+
149
+ ```ts
150
+ debounce: { key: (p) => `tour:${p.rowId}`, period: '15 minutes' }
151
+ ```
152
+
153
+ The reason the obvious version is wrong is the same for all of them: **the decision has to be made before the run exists.** Once a run is enqueued the only tools left are cancel and sleep, and neither un-spends the entity. So this is not a primitive you call inside the body — it is a property of the declaration, evaluated at the ONE boundary every start funnels through (`start`, `child`, `run`, a trigger, a reaction, a cron).
154
+
155
+ **One decision function, two callers.** `decideAdmission` is pure — no store, no clock, no service. The arrival path and the drainer call it with state read by the same two queries, so they cannot disagree; a disagreement would surface as a workflow running twice under a limit of one, on a replica nobody is attached to, under load.
156
+
157
+ **Nothing is silent.** Every decision is a row in `_voltro_workflow_admissions` with its key, reason, `collapsed` count and `waitedMs`. A debounce that collapses nineteen starts into one is indisputably correct AND indistinguishable from nineteen starts vanishing unless something writes it down. `voltro workflows flow` and `GET /_voltro/inspect/workflows/flow-control` show it.
158
+
159
+ Also in this change set:
160
+
161
+ - **`awaitEvent({ event, schema, match })`** — wait on a CORRELATION rather than on an execution id. `awaitSignal` requires the sender to already know which run to wake, so a workflow waiting on a webhook that carries an issue key needed an app-maintained lookup table. The predicate is ordinary JavaScript over the decoded event, and the compiler checks it. - **`sleepUntil({ name, until })`** — the instant is journaled first, so a run that suspends and replays does not recompute the delta against a now that is already past the target and sleep the whole period again. - **`onFailure`** fires for every way a run fails to deliver, including the two that produce no run row at all (`timeouts.start` expiring a queued start; the workflow renamed away while starts were queued) — which is exactly why polling `listRuns({ status: 'failed' })` could never see them. - **`encryptSteps: true`** encrypts the journaled step `input` / `output` / `errorCause` with the cipher `governancePlugin({ fieldEncryption })` already registers. Declaring it without that plugin is a boot refusal, not a warning: a plaintext fallback would leave the declaration reading as protection. - **`voltro workflows pause|unpause <name>`** — a paused workflow COLLECTS. Never discards.
162
+
163
+ A workflow that declares no control takes exactly the path it took before this existed, and an undeclared control costs zero round trips.
164
+ - **@voltro/cli** — A failed `voltro dev` SSR render now reports how many hot updates the process has absorbed since boot, and every failure carries `x-voltro-ssr-generation`.
165
+
166
+ Not telemetry — PROVENANCE for a measurement. A consumer filed and unfiled the same item twice in one afternoon, in both directions, because the same route on the same code answered 200 and 500 depending only on which edits the watcher had processed since boot. Their conclusion is the right one and it belongs to both sides: otherwise two parties judge one item against two different module graphs and each concludes the other was careless.
167
+
168
+ A hard restart on every edit would trade one broken feedback loop for a slower one — a 224-page app is not free to reboot. What costs nothing is letting every failing response say which graph produced it. `0` means nothing has changed since this process started, which is the only state in which a dev-SSR measurement is worth reporting; anything else prints an explicit instruction to restart and measure once from a fresh boot.
169
+
170
+ Counted for suppressed hot updates too: a module we chose not to reload is still one whose bytes on disk no longer match what this process serves, which is precisely the divergence the number exists to expose.
171
+ - **@voltro/cli** — `voltro doctor` flags a framework cookie name written as a string literal (`voltro:locale`, `voltro:theme`, or the pre-0.30.0 `voltro:lang`) and names the constant to import instead.
172
+
173
+ A cookie name the FRAMEWORK reads and the APP writes is a public API — and the only kind where both sides can disagree with nothing failing. Nothing throws, no page breaks: the resolver finds nothing and falls back to `Accept-Language`, so the symptom is a language preference that quietly stops working for the subset of users whose browser language differs from their choice. The least likely thing anyone tests.
174
+
175
+ Raised by a consumer ahead of the `voltro:lang` → `voltro:locale` rename, in their words: *"your codemod will presumably rewrite the literal. Ours were two bare strings in two components, which is exactly the shape a codemod misses one of."* The codemod does rewrite every literal it can see. This rule covers what a codemod structurally cannot — and, more usefully, the NEXT rename, for which no codemod has been written yet.
176
+ - **@voltro/cli** — New `mobile` template kind + scaffolder support for Expo (React Native) apps. `voltro create-project <name> --mobile` (defaults to the `mobile-app` template) and `voltro add-app <name> --template=mobile-app` scaffold an Expo app that consumes your api with the same typed hooks. A `mobile` app deliberately gets NO port and is NOT part of `voltro dev`'s orchestration — Expo owns Metro (`expo start` / `expo run:ios`); the app connects to the sibling api over the network. `list-templates` shows the new kind; the template validation harness (`test-templates.mjs`) skips `kind: mobile` from its default sweep LOUDLY (the Expo/RN toolchain is heavy and simulator-bound — the template's pure logic is covered by its own tests). codemod: none — additive, no user-authored code changes. (The forward-looking design + the M0 gap — no RN-safe client boot yet — are in `plans/open/mobile/`.)
177
+ - **@voltro/client, @voltro/web** — `@voltro/client` now exports `buildApiRuntime` — the transport-level construction of one api's client stack (an rpc-client-over-WebSocket, its ManagedRuntime, a SubscriptionCache, an error bus, per-connection auth-header seeding). The WebSocket constructor is an INJECTED dependency, so React Native can build the SAME `ApiHandle` pieces the web client uses without pulling in `@voltro/web` — the keystone for mobile support (plans/open/mobile M0). `@voltro/web`'s `buildRuntimeAndClient` now DELEGATES to it (one implementation, no duplicate path; the web client-builder test suite stays green), and its `ResolvableHeaders` type is re-exported from `@voltro/client` (the owning lower layer) rather than defined locally. Also exported: `BuildApiRuntimeOptions`, `BuiltApiRuntime`, `ResolvableHeaders`. Additive — no consumer migration.
178
+ - **@voltro/cli** — `voltro dev` now tells you WHICH of two causes produced *"[React Intl] Could not find required `intl` object"*.
179
+
180
+ That error is byte-identical whether there is no `<I18nProvider>` above the consumer or a provider built from a SECOND physical `react-intl` copy — React contexts are identified by object identity, so a duplicate library has a duplicate context and the provider is present and invisible. The two causes have opposite fixes, and no red/green experiment in the app can separate them: the app's own provider comes from the app's own import, i.e. the instance its `useT()` already uses.
181
+
182
+ The dev server knows something the error does not — whether it supplied an `outerWrap` for that request. When it did, the 500 body and the log line now carry the duplicate-copy diagnosis and the one command that confirms it (`pnpm ls -r --depth 10 @voltro/i18n react-intl`), plus an explicit statement that the diagnosis is wrong if both resolve to a single version. It stays silent for an app that configures no locales, where "no provider" is the correct state.
183
+ - **@voltro/cli** — `voltro update --dry-run` now lists the codemods the target version puts **in range**, without installing anything and without touching your tree.
184
+
185
+ The obstacle was not the one we thought. The codemods for a jump ship INSIDE the target `@voltro/cli`, which is not installed when the preview runs — so the target VERSION is known before installing and the target REGISTRY is not. A preview that confused the two would list the codemods of the version you are leaving.
186
+
187
+ The registry is therefore republished as package METADATA (`voltro.codemods` in the published `package.json`, generated by `scripts/gen-codemod-manifest.mjs`, drift-checked in CI) and read with the SAME registry query that already resolves the latest version — project package manager first, `npm view` last. No tarball fetch, no temp install, no second package-manager surface. yarn and bun fall straight through to npm on purpose: `yarn npm info …` parses as `yarn run npm` on yarn classic and executes a same-named script, and that risk is not worth taking for a preview.
188
+
189
+ Two honesty properties, both load-bearing:
190
+
191
+ - **"In range" is not "will apply".** `appliesTo` is a function and cannot cross a registry query, so the list is the upper bound on what a run can touch. The output says so. - **"Could not look" never prints as "nothing to do".** A target published before this field existed, or an unreachable registry, produces an explicit *"This is NOT the same as no codemods"* — because the whole reason to preview is to decide whether to stash a dirty tree.
192
+
193
+ Asked for twice by a consumer who established the answer by grepping their own call sites instead.
194
+ - **@voltro/plugin-webhooks, @voltro/cli, @voltro/devtools-ui** — The Webhooks panel's Events tab shows **two** facts side by side: whether the event was ever DELIVERED, and whether `emit(...)` ever RAN.
195
+
196
+ `everDelivered: false` conflates three different things — no emit call site, a call site that ran before anyone subscribed, and one whose payload every target's filter excluded (or every target was paused). Only the first is a defect, and it is the one a consumer spent a week finding by hand: seven of eleven advertised events had no emit call site anywhere. Delivery history also ages out at 90 days, so a quiet-but-working event decays into looking dead.
197
+
198
+ `_voltro_webhook_event_stats` carries one row per event, stamped on every emit **regardless of whether any target matched** — the axis delivery history structurally cannot see. Not tenant-scoped (the question is whether the CODE has a live call site, not whether a tenant has triggered it) and not retention-swept (a quarterly event must not read as dead). The write is best-effort and silent on failure: this is telemetry for a dashboard column and must never be the reason a delivery does not go out.
199
+
200
+ **An unreadable stats table reports as UNKNOWN, never as "never".** "We did not look" and "it never fired" are different answers and only one is a finding.
201
+
202
+ Two corrections rode along:
203
+
204
+ - The existing activity label read *"{n} subscribed · NEVER emitted"* while being derived from delivery history. It says *never DELIVERED* now — it may only claim what it actually knows. - **The cloud dashboard never received `eventActivity` at all.** The proxy's output schema did not name the field, so Effect's decode dropped it silently and the column rendered locally but not in the cloud — the four-layer drift the maintainer rule exists to prevent, shipped since 0.29.0. Both dashboards now get it.
205
+ - **@voltro/cli, @voltro/devtools-ui** — **Bulk cancel and bulk replay — `voltro workflows cancel-many` / `replay-many`, plus a dashboard panel.**
206
+
207
+ A bad deploy leaves four thousand runs that must all stop, or four thousand that must all be re-driven once the downstream is fixed. Doing that one run at a time through a dashboard is not a workflow, and doing it with raw SQL is how a `_voltro_workflow_runs` row ends up marked `cancelled` while the engine keeps executing it.
208
+
209
+ ```
210
+ voltro workflows cancel-many --workflow tourNarration --reason "bad deploy"
211
+ voltro workflows cancel-many --workflow tourNarration --reason "bad deploy" --commit
212
+ voltro workflows replay-many --status failed --mode redrive --limit 200 --commit
213
+ ```
214
+
215
+ Three decisions are deliberately stricter than the obvious design:
216
+
217
+ - **`--limit` is required and there is no "all".** The cap IS the blast radius, and it costs one number. `truncated` in the result says whether more matched, so "did I get all of them" stays answerable without an unbounded verb ever existing. A result of "1000 cancelled" reads as "all of them" otherwise, at the exact moment that mistake is most expensive. - **It is a DRY RUN unless `--commit` is passed.** That is the opposite of the usual `--dry-run` flag, and deliberate: the default for a verb that can stop a thousand runs should be the one that stops none. The dashboard panel enforces the same order — the apply button does not exist until a preview has returned a number, because "this will cancel 412 runs" is a different sentence from "412 runs were cancelled". - **`--reason` is required for a cancel.** It lands on every affected run's `run-cancelled` event, so "why did four thousand runs stop on the 8th" has an answer in the same table an operator is already reading.
218
+
219
+ The result is per-run, not a count: `succeeded`, `failed` (with the reason for each) and `skipped` (with what made each ineligible) are three different outcomes. A bulk op that reports "4000 cancelled" while forty failed is how people learn not to trust bulk ops.
220
+
221
+ Eligibility follows the verb rather than a flag: a cancel acts on `running` and `suspended`; `replay --mode redrive` on `failed` only (redrive resumes from the step that died, which only exists for a failure); `replay --mode retry` on `failed` and `cancelled`. `--mode` has no default because the two cost very different amounts.
222
+
223
+ Each verb delegates to the SINGLE-run operation beside it — the shared canceller, `retry`, `redrive` — so a bulk path cannot end up performing a different set of side effects from the button next to it.
224
+
225
+ The dashboard panel is gated on a NEW capability, `canBulkOperateRuns`, rather than on `canPauseWorkflow`. The argument that made pause safe to expose is exactly why: a pause COLLECTS starts and never discards one, so its worst outcome is a backlog. A bulk cancel destroys work already in flight. In the cloud dashboard it is `owner`-only.
226
+ - **@voltro/workflow, @voltro/cli, @voltro/devtools-ui** — **`cancelOn` — stop a workflow's live work when a correlated event arrives.**
227
+
228
+ ```ts
229
+ cancelOn: [{
230
+ event: 'jira.issue.deleted',
231
+ schema: JiraIssueDeleted,
232
+ match: (event, payload) => event.issueKey === payload.issueKey,
233
+ }]
234
+ ```
235
+
236
+ Both sides are typed: the event from the entry's own `schema`, the payload from the workflow's.
237
+
238
+ **Why a declaration rather than a race inside the body.** "Stop when the issue is deleted" is expressible with `awaitEvent` and an interrupt, and that works while the body is RUNNING. It does not work while the run is sleeping for six hours, suspended on a signal, or still sitting in the admission queue — which is the case cancellation was wanted for. The event has to reach a run whose fiber is not executing anything, and only something outside the body can do that. So it is swept: a coordinated tick reads events published since a durable watermark (`_voltro_workflow_watermarks`), resolves each declaring workflow's live runs, and cancels the ones that correlate.
239
+
240
+ **It also discards QUEUED starts of the same workflow.** Cancelling only the running one leaves a debounced or concurrency-queued duplicate to start seconds later against the row that was just deleted — the exact outcome the declaration was meant to prevent, arriving late enough that nobody connects the two.
241
+
242
+ Three rules that are stricter than they look, each protecting against a way this would otherwise be silently wrong:
243
+
244
+ - **`match` is required.** The omitted case would mean "cancel every live run of this workflow", which is a legitimate thing to want and a catastrophic thing to acquire by forgetting a line. `match: () => true` says it out loud. - **A run that started AFTER the event is never cancelled.** A sweep catching up after a deployment gap reads an hour of history; without this it kills runs that started in the meantime, and the symptom looks nothing like the cause. - **An event that fails to decode is REPORTED and never matched.** Cancelling on an event you could not read is cancelling blind.
245
+
246
+ The cancel itself goes through the same code the operator's cancel button uses — engine interrupt, row flipped, `run-cancelled` recorded with the event name, children closed — because a second implementation would inevitably have done three of those four.
247
+
248
+ Wired through the one shared builder both `voltro dev` and `voltro serve` call, and shown in the dashboard as a `cancelOn:<event>` badge on the declaring workflow, so "which event stops this" is answerable without reading the source.
249
+
250
+ Also in this change set, from a review of the above:
251
+
252
+ **A DISCARDED queued start now writes a ledger row.** Both paths that drop one — an operator's discard button and a `cancelOn` event — deleted the pending row and recorded nothing. That is precisely the failure `_voltro_workflow_admissions` exists to prevent, committed by the feature that argues against it: from the outside, a start deliberately discarded and one that silently vanished are the same observation, a row that is no longer there. `outcome: 'discarded'` is a new member of the ledger's enum (a `_voltro_*` column change, so it rides the declarative differ on `voltro db apply` and on a `voltro dev` boot, on every dialect — no codemod).
253
+
254
+ **The `cancelOn` sweep reports its own health**, in the Flow tab rather than only in a log line. `problems` is the field that matters: an event whose SHAPE changed makes cancellation silently stop firing — the run keeps going, which is the safe direction, and nothing about the run says a cancellation was attempted and could not be evaluated.
255
+
256
+ Two bounds the first version was missing: the live-run read is paged (oldest-first, so a bounded sweep makes progress instead of re-reading the same page) and reports when it filled up; and a LISTING failure now HOLDS the watermark, because a tick that never evaluated those events must not advance past them. A cancel that was attempted and refused still advances — those are different failures and only one of them is worth retrying.
257
+ - **@voltro/workflow, @voltro/cli, @voltro/devtools-ui** — **`concurrency.pool` — one budget shared across workflows.** Without it, a concurrency limit bounds one workflow's runs; five workflows that each call a rate-limited provider hold five separate budgets nobody meant to multiply. Declaring the same pool name makes them compete for ONE:
258
+
259
+ ```ts
260
+ // embeddings.workflow.tsx AND summarize.workflow.tsx
261
+ concurrency: { limit: 10, pool: 'openai' }
262
+ ```
263
+
264
+ `key` still partitions WITHIN the pool (`(p) => p.tenantId` in each member → a per-tenant shared budget). Every member must declare the SAME `limit` — the boot fails on a disagreement, naming every workflow involved, because two numbers for one budget is a contradiction and silently picking either would enforce a limit somebody did not write.
265
+
266
+ Mechanically, the pool is spelled into the stored concurrency key (`pool<NUL><name><NUL><key>` — NUL separators so an app key function cannot collide with it by accident), so the pending row, the ledger row and the drainer all group pool-wide without any of them knowing pools exist. The count query drops its per-workflow filter exactly when a pool is declared; two UN-pooled workflows with a coincidentally-equal key stay separate budgets, and a test pins that boundary in both directions. The dashboard renders the pooled spelling as `pool:<name> · <key>`.
267
+
268
+ Also in this change: the unreleased `concurrency.scope: 'replica'` option is GONE before ever shipping. It was resolved and then read by nothing — a knob that did nothing distinguishable — and it cannot be coherent in this model: deferred starts queue in the SHARED pending table and are drained by whichever replica has capacity, so a per-process count has no meaning. The limit is deployment-wide, enforced through the shared admissions ledger, full stop.
269
+
270
+ codemod: none — `pool` is additive and `scope` never appeared in a published release.
271
+ - **@voltro/runtime, @voltro/cli, @voltro/devtools-ui** — **Server-side run filtering + a throughput/failure chart, across both dashboards.**
272
+
273
+ The runs surface used to fetch the newest N rows and filter in the browser — fine at a hundred runs, useless at a hundred thousand, where the five failed runs you are hunting have long scrolled out of the fetched page.
274
+
275
+ - **`ctx.workflows.listRuns(...)` and `GET /_voltro/inspect/workflows/runs`** gain composable server-side filters: `statuses` (several at once), `source`, `tagContains` (`q=` — the search-box semantic, where `tag` stays exact), `idPrefix` (matches the run id OR the execution id, so an operator never has to know which kind their log line carried), and a `startedAfter`/ `startedBefore` time range. The dashboards' filter bars send exactly these; the shared `WorkflowsPage` keeps its client-side filtering as a second layer, so an older api that ignores the params still renders a correctly-filtered page — just off a larger fetch.
276
+
277
+ - **`GET /_voltro/inspect/workflows/stats`** returns ~48 buckets of run activity over a trailing window (`hours` up to 168, optional `tag`), each with started/succeeded/failed/cancelled counts plus per-workflow totals. Computed by the app itself and PROXIED to the cloud dashboard, so both dashboards render the same aggregation instead of two derivations that drift. When the window exceeded the scan cap the response says `truncated: true`, and the chart renders that as a warning — a silently-truncated chart shows throughput dropping at exactly the moment it spiked.
278
+
279
+ - **`WorkflowThroughputChart`** (devtools-ui) — a dependency-free SVG stacked-bar chart (green delivered / red failed / grey cancelled / blue in-flight), rendered on the Runs tab and in per-workflow detail mode in the local AND cloud dashboards.
280
+
281
+ The cloud runs subscription (`apps.inspectWorkflowRuns`) accepts the same filters — time range included — and applies them inside the reactive predicate, so deltas for filtered-out runs never reach the browser.
282
+
283
+ - **The filter bar grows a TIME RANGE** (two `datetime-local` inputs), URL-persisted like the other filters. Deliberately NOT part of saved views: an absolute range goes stale the moment it is saved — "last Tuesday" is a moment, not a view — and silently re-applying it later filters to an empty page that reads as "no runs".
284
+
285
+ - **The overview chart lists the busiest workflows** in the window (per-tag started/ok/failed), each linking into that workflow's detail view.
286
+
287
+ - **`voltro workflows list`** gains the same triage flags (`--statuses a,b`, `--q`, `--source`, `--id-prefix`, `--since`/`--until` — an unparseable instant fails loudly at the flag rather than returning an empty page), and **`voltro workflows stats`** renders the chart in the terminal: a unicode sparkline for started/failed plus per-workflow totals, with the same never-silent truncation warning.
288
+
289
+ codemod: none — all additive.
290
+
291
+ ### Fixed
292
+
293
+ - **@voltro/cli** — **Re-issued the credential-purge query, because the correction to it could not reach the people who ran the wrong one.**
294
+
295
+ `0.28.0/04_audit-redacts-subject-metadata` originally printed `subject::text ILIKE '%token%'` — postgres-only, and its natural MySQL/MariaDB translation (`LIKE`) is case-SENSITIVE against the `utf8mb4_bin` collation our own migrator emits for a `json()` column. `'%token%'` therefore does not match `jiraToken`. A team ran it over 141 rows, got `0`, and nearly filed themselves clean; 117 of those rows held a working credential.
296
+
297
+ The 0.28.0 note was corrected — and that correction is unreachable for everyone it concerns. `selectCodemods` picks `from < version <= to`, so a project that has already crossed 0.28.0 never runs a 0.28.0 codemod again, however wrong its note turned out to be. **A codemod note is delivered once, at a version boundary, and is not a document you can revise.** When one is found wrong after its version ships, the correction has to be re-issued under a version users have not yet landed on. `0.30.0/02_audit-purge-query-recheck` is that re-issue.
298
+ - **@voltro/database** — A `bytes()` / `crdtText()` column read over a reactive subscription or query threw on the CLIENT: `rowSchema`'s wire mapping used `Schema.Uint8ArrayFromSelf`, whose encode leaves a raw `Uint8Array` — `JSON.stringify` turns that into a numeric-keyed object (`{"0":1,…}`) the decoder then rejects. Every other column type in that module already crosses in a JSON-safe form (timestamp → epoch-ms number, bigint → decimal string); bytes was the outlier. It now crosses as a base64 string (Uint8Array in the handler, string on the wire), regression-covered by a full JSON round-trip for both `bytes()` and nullable `crdtText()`. codemod: none — the prior behaviour threw, so there is no working consumer to migrate. (Surfaced while building the api-collab/frontend-collab CRDT templates.)
299
+ - **@voltro/cli** — The `@effect/cluster@0.60.0` patch cast a message's `deliver_at` to `BigInt` for EVERY dialect (the fix was for mssql's tedious driver, which infers INT and overflows post-2001 epochs). But `@effect/sql-sqlite-node` runs `safeIntegers(true)`, where a bigint `deliver_at` breaks the due-message comparison — the cluster workflow engine polls forever, never delivers the message, and the workflow HANGS. This silently broke every cluster/workflow integration path on sqlite since the 0.60.0 bump (the whole sql-sqlite cluster suite timed out at ~95s and read as "flaky under load"). The cast is now dialect-conditional — `BigInt` only for mssql, plain number elsewhere (the pre-0.60.0 behaviour pg/mysql/sqlite always accepted). sql-sqlite: 86/86 in 12s (was 7 hanging at 96s); mssql's overflow fix preserved.
300
+ - **@voltro/cli** — `voltro dev`'s console capture no longer destroys the error it is passing through.
301
+
302
+ Node's `console.error` formats every argument with `util.inspect`. A React SSR failure carries the element/props graph, inspecting it can exceed V8's string cap, and `inspect` then throws `RangeError: Invalid string length` from `markNodeModules` — which REPLACES the error being reported.
303
+
304
+ **The framework is what made that fatal rather than merely ugly.** `voltro dev` installs a console wrapper on every boot and its first act was an unguarded pass-through, so the RangeError propagated out of `console.error` itself. A consumer chased a one-line dev-SSR i18n bug across two rounds through this mask and only recovered the real message by neutralising `console.error` from their own app code.
305
+
306
+ The pass-through now retries with bounded arguments and says that it did. Truncation that announces itself is the point: a message that silently stops looks like a short message, and the reader draws conclusions from it. Ordinary console output is untouched — the guard is a fallback, not a filter, and a wrapper that reshaped every line would be the mask with extra steps.
307
+
308
+ Red-verified: restoring the unguarded call turns two of the three new tests red.
309
+ - **@voltro/runtime** — A malformed CRDT update written to a `crdtText()` column no longer crashes the mutation with a cryptic `Unexpected end of array` from deep inside Yjs, and can no longer be stored raw to poison later reads. The server merge now validates every incoming update — folding it against the stored state, or an EMPTY state on a first write (previously a first write stored the bytes unchecked) — and a non-decodable update throws a clear, column-named error naming what a client must send. Surfaced while exercising the api-collab CRDT template.
310
+ - **@voltro/cli** — The `events: declared but not wired` check no longer calls every webhook event dead when an app emits through a shared helper.
311
+
312
+ Two independent defects produced that, both fixed:
313
+
314
+ - **The emitter test required the webhooks service within 400 CHARACTERS of the `emit(`.** That is a claim about file layout, not about code. An app that funnels every emit through one helper has `import { useWebhooks as webhooks }` at the top and the call a hundred lines below. A consumer's only `.emit(` in their entire api reads `webhooks(ctx).emit(descriptor, payload)` and matched neither alternative. The qualifier now has to appear anywhere in the file, the same shape the bare `publish(` rule already used, plus the package specifier for the aliased-import case where no service identifier survives into the body.
315
+
316
+ - **A funnel names no event, because the descriptor arrives as a VALUE.** A text scan cannot follow a value across a call boundary. That is not weak evidence of a dead event — it is no evidence, in either direction, and the check reported it as the strongest kind: 29 of 29 events flagged "never published" on every boot, for an app where all 29 were live.
317
+
318
+ The producer half now **abstains** for webhook events once an indirect emitter is found, and says so: `N webhook event(s) NOT verified … Not a warning, and not a pass either.` Abstaining silently would be its own defect — a check that stops reporting is indistinguishable from a codebase that got fixed.
319
+
320
+ The abstention is scoped to the webhook audience. An in-app event still has `publish(` to find, and a genuinely dead webhook event is still reported in a project whose emit sites name their events.
321
+ - **@voltro/cli** — `react-intl` joins `react` / `react-dom` in Vite's `resolve.dedupe`, in `voltro dev` and in every `voltro build` SSR config.
322
+
323
+ It carries a React CONTEXT whose two ends resolve from different roots: the framework builds `<I18nProvider>` by loading `@voltro/i18n` through Vite's SSR loader from its own dir, while the app's `useT()` imports it from the app root. Two physical copies means the provider is present and INVISIBLE — `useIntl` reads the other instance's context and throws *"[React Intl] Could not find required `intl` object"*, byte for byte the error you get when there is no provider at all.
324
+
325
+ A single-app fixture cannot surface this (only one copy ever exists), which is why the guard is the config rather than a test. Dev and build dedupe the same set on purpose — an app that renders in one and not the other is the boot-path divergence class.
326
+ - **@voltro/runtime** — **`column(...)` in an `.aggregate({})` spec crashed the memory store.** The documented way to project a grouped key (`groupBy(['status']).aggregate({ status: column('status'), n: count() })`) has always compiled on every SQL dialect — and threw `computeAggregates: unknown op 'column'` on `store: 'memory'`. Worse, only once the table held a row: an empty table never reaches the evaluator, so the aggregate looked healthy exactly until it had data. Found live against the reference app's `orderStats` aggregate; the memory evaluator now answers the op from the group's key (every row in the bucket shares it by construction), pinned by a parity test.
327
+
328
+ codemod: none.
329
+ - **@voltro/database** — **In-memory Date predicates compared by REFERENCE, so every range boundary was off by one row.** `evaluatePredicate`'s comparator checked `lhs === rhs` before `>` — reference equality for objects — so two Date objects holding the SAME instant compared as "less than". `gte(startedAt, T)` EXCLUDED a row whose value was exactly T, `lt(startedAt, T)` INCLUDED it, and `eq`/`neq`/`in`/`notIn` never matched a Date at all unless it was literally the same object. SQL never had the bug (the compiler emits `>=`/`<`), which is what kept it invisible: the same query returned different rows on the memory store than on postgres, only at the boundary millisecond.
330
+
331
+ Same defect class as the analytics sink that lost same-millisecond events — an instant-boundary comparison whose failure is one row, at one millisecond, in one store. All comparators now normalise Dates to their instant (`equalsValue` / `compareNumeric`), and `datePredicateBoundary.test.ts` pins every operator on both sides of the boundary.
332
+
333
+ Affects everything the in-memory evaluator serves: the `store: 'memory'` store, the reactive engine's pre-filter, and unit-test fixtures — which also means a test that "passed" against a memory fixture and failed against SQL at a time boundary was this, not your code.
334
+
335
+ codemod: none.
336
+ - **@voltro/cli** — **An event published from a MUTATION never reached its durable audience — no event-log row, no triggered workflow, while the mutation reported success.** Two independent defects, one symptom, both boot paths:
337
+
338
+ 1. **The events facade wrote through the mutation's TRANSACTION.** `publish` correctly defers the durable half to `lifecycle.afterCommit` — but by then the transaction is closed, so the `_voltro_workflow_events` insert failed (or vanished into a discarded overlay) and the deliberate `.catch(() => {})` on the emit hid it. The facade writes through the BASE store now: post-commit facts do not belong to a closed transaction. (The OUTBOX intent stays on the transactional view on purpose — it is written DURING the handler and must die with a rollback.)
339
+
340
+ 2. **The trigger's workflow start was deferred TWICE.** The start closure wrapped itself in the post-commit facade even though it is only ever reached post-commit — so it pushed its real `start` onto an afterCommit drain that had already finished. The delivery row optimistically said `started` with a minted execution id, and the engine never saw the run: no run row, no admission entry, no error.
341
+
342
+ Found LIVE, not by a test: the reference durable app's advertised chain (mutation → `order.placed` → trigger → `orders.fulfill`) placed orders that never fulfilled. The action-shaped bridge tests stayed green throughout, because outside a transaction both stores are the same object and nothing defers — which is exactly the shape the new regression test builds: a mutation-formed context with a lifecycle and a tx store that refuses writes after commit, asserting the event row exists AND the workflow really started. Both halves red-verified.
343
+
344
+ Publishes from actions, schedules, startup hooks and workflow bodies were never affected.
345
+
346
+ codemod: none — no user-authored code changes; the fix restores the documented behavior.
347
+ - **@voltro/runtime, @voltro/cli** — **`guards.rateLimit` on a reaction was neither per-key nor a limit — two defects, both reported from production.**
348
+
349
+ It reads as a per-key cap. The runner keyed the limiter on the **reaction name**, so one cap covered every row and every tenant that reaction watched: an app with a hundred tenants got a hundredth of the throughput it declared, and the busiest tenant starved the rest.
350
+
351
+ And the limiter was **in-memory, per process**. With three replicas the effective cap was 3×, and nothing in the declaration said so — the same config produced a different limit depending on how many pods happened to be running.
352
+
353
+ ```ts
354
+ rateLimit: { limit: 10, windowMs: 60_000, key: (e) => e.new.tenantId }
355
+ ```
356
+
357
+ `key` partitions the cap; omitting it keeps the GLOBAL meaning, which is a legitimate thing to want (a cap on a scarce downstream) — just not what the field appeared to offer. The limiter is now a claim in the shared store, using the same INSERT-wins arbiter the cron scheduler relies on, so the cap holds across replicas. A read-then-write would not: two replicas both read N-1, both fire, and the cap is exceeded by exactly the number of concurrent replicas.
358
+
359
+ That forces a FIXED window (a sliding one needs prior timestamps, i.e. a read), with the standard artefact: up to 2× the limit can fire across a bucket boundary. Stated rather than hidden, and a far smaller error than the N× it replaces — 2× transiently at a boundary versus N× permanently.
360
+
361
+ Where no durable claimer is wired (dev on the memory store) the per-process fallback remains, and `attachReactions` now says so ONCE at boot rather than leaving it to be discovered. The partition key applies there too, so the per-entity half of the fix survives.
362
+
363
+ Also: **`act` can shape the workflow's payload.**
364
+
365
+ ```ts
366
+ act: { kind: 'workflow', workflow: 'tourNarration', payload: (e) => ({ rowId: e.new.id }) }
367
+ ```
368
+
369
+ Without it the workflow's payload schema was dictated by the watched TABLE's row shape — every column travelling whether the workflow wanted it or not, and a `timestamp()` column arriving as a `Date` on MariaDB and a number elsewhere, so apps were normalising on both sides of an idempotency key. Omitting `payload` keeps the changed row, exactly as before.
370
+ - **@voltro/runtime, @voltro/voltro, @voltro/cli** — **`apiSurface: compatible`, and the reason:** `bindMutation` gained a seventh parameter and it is OPTIONAL. Every existing call site compiles and behaves exactly as before — omitting it skips the new check entirely, which is the deliberate default for a caller that cannot name a schema. `@voltro/voltro`'s golden churns only because it re-exports runtime. Nothing was removed, narrowed, or renamed.
371
+
372
+ A TAGGED error a procedure does not DECLARE no longer reaches the browser as the full `ExitEncoded<…>` decode tree.
373
+
374
+ It was a third category neither guard could see: the untagged-failure catch skips it (it has a `_tag`), `INFRA_ERROR_TAGS` skips it (it is not on a curated list), and the rpc encoder then cannot match it against the descriptor's `error:` union and ships the whole tree — ~2 KB for a one-line cause, with the message at the END so every tool that truncates shows the useless half. A consumer met it with `TenantScopeViolation`.
375
+
376
+ **Adding that tag to the infra list would have been wrong**, and that is the interesting part. `effectStore.ts` documents `error: Schema.Union(TenantScopeViolation, StoreOperationFailed, MyDomainError)` as a supported declaration, so an app that DECLARES it must still receive it typed. Collapsing unconditionally would break that app to fix the other one.
377
+
378
+ So the rule is a predicate, not a longer list: **tagged AND not representable by THIS descriptor's declared union** — `Schema.is(descriptor.error)`. The union IS the contract, so asking it directly cannot drift from what the encoder accepts. A call site that supplies no schema keeps the old behaviour exactly, rather than collapsing errors it cannot classify. Wired in dev AND serve: a sanitiser active on one boot path only is the drift class the parity guard exists for.
379
+
380
+ `defectMessage` now prefixes the `_tag` when there is one. A `Schema.TaggedError` with no `message` field rendered as an empty string, so the collapsed `InternalError` arrived correct, small AND useless — half a fix for the tree it replaces.
381
+ - **@voltro/plugin-webhooks, @voltro/cli** — **`webhooks.subscribe(...)` could not write its own table.** From any authenticated executor it died with:
382
+
383
+ ```
384
+ TenantScopeViolation: cannot insert into tenant-scoped table without an
385
+ authenticated tenant — subject.tenantId is null. Either authenticate first or
386
+ pass tenantId explicitly in the row (admin tooling).
387
+ ```
388
+
389
+ `_voltro_webhook_targets` carries `.with(tenant())`; the mixin scopes by the REQUEST subject; the service is built once at boot with the app-level store and no subject. The READ path got its binding in 0.29.0 (`EmitOptions.tenantId`, from the acting subject). The WRITE path had the identical gap and no equivalent — and **both escapes the error message named were unreachable**: you cannot "authenticate first" against a subject-less service, and `SubscribeInput` had no `tenantId` to pass.
390
+
391
+ `ctx.webhooks.subscribe(...)` now binds the acting subject's tenant, exactly as `emit` does, and `SubscribeInput.tenantId` exists for the admin tooling the message mentions. An explicit value at the call site wins; an explicit `null` survives (a deliberate system-wide subscription) rather than being replaced.
392
+
393
+ **What this invalidates, and it cuts both ways:** a `count(*) FROM _voltro_webhook_targets` of `0` did not mean "unused". It meant "never worked". A consumer read their zero as "the feature is unannounced"; we read it as "not exposed". Neither was true, and the empty table looked like evidence to both of us. Anything downstream that rested on that zero — an exposure assessment, a "nothing to purge" — has to be re-asked now that a subscription can exist.
394
+
395
+ ### Internal (no consumer-facing effect)
396
+
397
+ - **@voltro/cli** — Guard test (`clusterPatchDialectGuard.test.ts`) that fails fast if a dependency bump re-vendors the `@effect/cluster` patch with an unconditional `BigInt(deliver_at_in)` cast — the exact shape that hung the sql-sqlite cluster/workflow suite for a week (safeIntegers(true) + a bigint deliver_at → message never delivers → workflow hangs, misread as flakiness). Self-tested: the negative matcher catches the buggy line and passes the mssql-only conditional. Test-only, no consumer effect.
398
+ - **@voltro/cli** — One shared `walkSourceFiles`, and a guard that makes source-tree guards use it.
399
+
400
+ Our codegen and agent suites create scratch fixtures INSIDE `packages/cli/src` (`mkdtemp(join(here, '.agent-fixtures-…'))`) because the codegen imports them through vite's module graph, which is rooted at the package. A guard that walks `src/` concurrently races them, and the failure is always the same shape: the whole FILE dies at COLLECTION time with `ENOENT` on a path nobody recognises, and it is green when re-run alone — the signature people write off as flake.
401
+
402
+ **Third occurrence, and that is why this is a function rather than another paragraph.** `ledgerReadPortability` hit it with `readdirSync` + `statSync` (two syscalls, one gap) and `packages/cli/CLAUDE.md` gained "any new guard that walks a source tree must do both". `broadcastNamespaceCoverage` then hit it while that rule was written down and current: it had `withFileTypes` — half the rule — and descended into a `.scan-fixtures-…` directory another suite had just removed.
403
+
404
+ `walkSourceFiles` has three properties, each load-bearing: one syscall per entry, dot-directories skipped (a scratch dir is never source, so this is right on its own terms), and a directory that vanishes mid-walk is skipped rather than fatal.
405
+
406
+ Six guards migrated — one of which still carried the ORIGINAL `readdirSync` + `statSync` shape. `sourceWalkDiscipline.test.ts` fails if a file reads a package `src/` without importing the shared walker; its first version flagged four files that had just been migrated correctly (a single-level `readdirSync` enumerating package directories is the shape we WANT), so the rule is "import the walker", not "never call readdirSync".
407
+ - **@voltro/cli** — The cross-replica latency test can now tell a dropped MESSAGE from a dropped CONNECTION.
408
+
409
+ It asserted zero loss over a raw subscribe — a stronger claim than the transport makes. Redis pub/sub has no retention, so when a subscriber's broker connection blips, everything published during the blip is gone by design. The shortfall looks identical to real loss, and the assertion reported the first as the second: `expected 163 to be 200` inside a full gate run (80 packages plus an 11-service docker stack on 12 cores), while the same test passed 8/8 in isolation — including under 12 busy loops.
410
+
411
+ **A loss check that a contended machine can trip cannot be trusted about loss, which is the only thing it exists to say.**
412
+
413
+ The bus already publishes the fact needed to separate them: `kind: 'gap'` with a PROVEN `missed` count. The test now records gaps and, when any occurred, skips the loss assertion LOUDLY with the count — a run that could not measure must not read like a run that measured nothing wrong. With no gap, a shortfall IS loss and still fails; red-verified by dropping every fifth delivery. The latency budget applies either way, guarded by a floor so the percentiles are never computed over a sample too small to mean anything.
414
+
415
+ Production recovery for a real gap is unchanged and covered elsewhere (`busGapDetection.test.ts`): a live query is idempotent, so the bus detects the gap and re-runs.
416
+
417
+ ---
418
+
419
+ ## [0.29.0] — 2026-08-07
420
+
421
+ ### ⚠ BREAKING
422
+
423
+ - **@voltro/web** — **`useLoaderData()` throws where no `loader` is declared, and `useOptionalLoaderData()` is the way to read where one may be absent.**
424
+
425
+ `useLoaderData()` was `useContext(LoaderDataContext) as LoaderData<T>` — a cast over a context whose default was `undefined`. At a level with no `loader`, `const { project } = useLoaderData<Data>()` died at `Cannot destructure property 'project' of undefined`: a message naming the property rather than the mistake, and under `renderMode: 'ssr'` a throw that fails the entire server render instead of degrading. Reported by a consumer who spent a cycle on it.
426
+
427
+ **What this is NOT: a `| undefined` return type.** That was the obvious fix and it is wrong. The router never renders a page that declares a `loader` without its data — a settled loader commits its data and the displayed route together, a pending one renders the `Pending` skeleton (or keeps the previous page), and one that threw renders the error subtree; three separate branches. Widening the type would have taxed every correct call site to model a state the router already prevents.
428
+
429
+ So the return type is unchanged and the absence becomes loud instead:
430
+
431
+ - a level with **no `loader`** throws, naming the cause and pointing at `useOptionalLoaderData()`; - **`useOptionalLoaderData()`** returns `undefined` there — for the one legitimate case, a component genuinely mounted both under routes that declare a loader and routes that do not; - a loader that legitimately resolves to `undefined` does **not** throw. Declaring a loader and returning nothing is a choice; having no loader is not.
432
+
433
+ **An empty RESULT is not an absence.** A loader returning `{ items: [] }` returns exactly that through both hooks. `undefined` never means "the query found nothing" — only "there is no loader at this level". The wire already made this distinction (`pageLoaderRan`, documented as "not derivable from loaderData"); the hooks now honour it too.
434
+
435
+ Internally the provider carries a `NO_LOADER_DATA` marker instead of `undefined`. It is still a provider, deliberately: dropping it for loader-less levels would let a page fall through to its layout's data and silently render a neighbour's value — worse than the crash it replaces, because nothing would report it. Server and client set the marker from the same fact (`descriptor.loader` / `segment.loader`), so a component cannot render on one side and throw on the other.
436
+
437
+ Covered by `loaderDataAbsence.test.tsx` (both render paths, the shield, the empty-result and legitimately-undefined cases), red-verified by removing the marker.
438
+
439
+ **`voltro update` carries you across this** — codemod `0.29.0/01_loader-data-absence`.
440
+
441
+ ### Added
442
+
443
+ - **@voltro/runtime, @voltro/cli** — aggregate-derivation (sharpened): the mutation/action interceptor `meta` now carries the descriptor's DECLARED write-target table names (`meta.target`) for an audit/derivation consumer — purely additive, omitted when no target is declared. And `voltro doctor` gains a junction-FK finding: it flags a link/junction table whose FK wiring is suspect (composite-PK member unwired, an unwired id beside a real `reference()` sibling, pure link-only table), reading real declared column types (not regex) with a tight non-FK-id exclusion so a lone foreign id on a normal entity stays silent. The FK-walk DSL + `voltro audit map` are deferred (single-consumer). apiSurface compatible — additive exports + type-alias renumbering only. codemod: none.
444
+ - **@voltro/plugin-billing** — Stripe Checkout now collects a customer's VAT / tax ID (`tax_id_collection`), and reuses an existing customer's captured name + address so the tax ID attaches correctly. Additive; `automatic_tax` was already on.
445
+ - **@voltro/database, @voltro/protocol, @voltro/runtime** — New `rule()` primitive: `table(...).rule(name, predicate, { severity? })` declares a cross-table transactional invariant evaluated INSIDE the mutation transaction (predicate reads share the write's MVCC snapshot via the dialect-neutral query layer — correct on all four dialects, no per-dialect code). A violation rolls the transaction back and fails with the typed, wire-preserved `BusinessRuleViolation` (auto-merged into every mutation's error union at `mutationToRpc`, like `ScopeError`); `severity: 'warning'` logs + commits instead. codemod: none.
446
+ - **@voltro/cli** — Three CLI commands: `voltro typecheck` (runs `tsc --noEmit` using the APP's own TypeScript, resolved from the app's node_modules), `voltro info` (CLI/node/pm/dialect + every installed `@voltro/*` version, flags lockstep skew with a non-zero exit), and `voltro new <query|mutation|action|workflow|page> <name>` (scaffolds the correct file convention incl. the descriptor/executor split; refuses overwrite without `--force`). codemod: none.
447
+ - **@voltro/cli** — DNS-rebinding Host guard on the `voltro dev` inspect surface (the API dev server binds 0.0.0.0 and had no Host validation, unlike the web dev server's vite `allowedHosts`). Allows loopback names + any IP literal (an IP can't be DNS-rebound, so phone-on-LAN testing keeps working) + an operator `VOLTRO_INSPECT_ALLOWED_HOSTS` allowlist; any other Host domain → 403. Dev-only by design (prod is token-gated + served on a public hostname). codemod: none.
448
+ - **@voltro/datetime** — New `@voltro/datetime` package (Phase 1): UTC-storage + timezone-aware helpers on the TC39 Temporal standard, plus a request-scoped timezone-context seam (`@voltro/datetime/context`). The `.` entry is browser-safe (no `effect`, no `node:*`); `effect` is an optional peer for the context seam. `interval()`/`rrule()`/schema DSL types are deferred to later phases.
449
+ - **@voltro/runtime** — `defineExpectation()` — reactive data-quality contracts as standing reactions. An expectation over a table (`freshness`/`nullRate`/`rowCount`/`valueBounds`) is maintained incrementally from `store.onChange` CDC deltas by the same IVM engine that backs `defineAggregate({ incremental })` (O(1) per write, no re-query); it tips `holding`↔`violated` with the provenance (traceId/subject/procedure) of the write that caused it, observable via `ExpectationRegistry`. `freshness` also re-compares its IVM-maintained max against the moving clock (so "writes stopped" is detectable — a metric re-comparison, not a data poller). `*.expectation.ts` file discovery wires in the CLI. codemod: none.
450
+
451
+ (apiSurface: compatible — the runtime golden churn this session is additions plus api-extractor renumbering its internal `Row_N` dedup alias; no public symbol was removed or resignatured, so no consumer breaks.)
452
+ - **@voltro/runtime** — `defineExperiment()` — online A/B / holdout experiments as live IVM aggregates. Per-variant metrics (count/sum/avg/conversionRate) are maintained incrementally from a table's CDC by grouping rows onto a synthetic variant column through one `AggregateMaintainer` (real-time lift/diff vs a baseline, no batch pipeline); assignment is a salted FNV-1a hash → `[0,1)` (client-reproducible, no `node:crypto`), holdout carved off the top so treatments don't perturb it. Observable via `ExperimentRegistry`/`useExperiment`. Correctness pinned by a brute-force oracle (incremental == full recompute) over insert/update/delete for all four metrics. `*.experiment.ts` file discovery wires in the CLI. codemod: none.
453
+ - **@voltro/cli** — `voltro doctor` gains two findings over declared events. **Delivery-semantics visibility** (informational): it now NAMES each declared event's delivery mode (`each` vs `latest`) — the mode decides what a MISSING message means (`each` counts a drop as a loss and tells the subscriber; `latest` supersedes and says nothing), and it was invisible after the fact everywhere except the devtools panel. **A scale WARN** for two declared-but-won't-scale shapes: a routing key with 3+ fields (every field fragments the subscriber set — distinct routes are the product of the fields' value spaces, so a payload discriminator smuggled into the key multiplies routes for nothing), and a `webhook:` block on a per-frame-looking event (`player.moved`, `cursor.moved`, `*.frameRendered` — every publish becomes N HTTP deliveries per second per target, and the plugin DEFERS the excess as pending rows rather than failing, so the symptom is a growing table). Both read the REAL declared descriptors — the routing-key field count comes from the same top-level schema-property reader the runtime validation uses, so it cannot disagree with the key the event routes on. Advisory, never blocking (same ladder as the orphan audit); surfaced in `--json` as `eventDelivery`. codemod: none.
454
+ - **@voltro/runtime, @voltro/cli** — Reactive-finops now EMITS cost events on real reactive work — the deferred second half of `attachFinops`. The Dispatcher gained a `recordCost?: (e: CostEvent) => void` sink (sibling to `recordDelivery`); each time a change re-runs an affected subscription and pushes it a delta, it records one `{ unit: 'recompute', amount: 1 }` event attributed to the subscription's tenant + traceId (both the row-set and computed-query delivery paths). Both boot paths (`voltro dev`, `voltro serve`) thread the finops runner's `record` into the dispatcher as `recordCost` — ONLY when `*.budget.ts` cost budgets were discovered, so an app with none allocates no `CostEvent` on the reactive hot path. Live attribution is observable via `CostRegistry`. codemod: none.
455
+
456
+ (apiSurface: compatible — one additive optional field on `DispatcherDependencies`; no public symbol removed or resignatured.)
457
+ - **@voltro/database, @voltro/runtime, @voltro/local-first** — local-first (deepened toward a working vertical): `crdtText()` is now a real database column type (`@voltro/database`). It stores an encoded CRDT state as `bytes` + a pure `crdtManaged` marker, so the declarative differ treats it as an ordinary nullable `bytes` column — no special DDL, and it round-trips through a plan on every dialect with zero churn (`crdtColumn.test.ts`). The authoritative server-side merge is wired into the runtime write path: the MutationStore folds an incoming encoded update into the stored state with `mergeCrdtStates` (`@voltro/local-first`) before writing, so two concurrent clients converge, and the reactive engine broadcasts the merged result (`crdtMerge.test.ts`, in-memory store, order-independent convergence). `@voltro/local-first` also gains a client persistence CONTRACT — `PersistenceAdapter` + `createInMemoryPersistence()` — and `loadPersistedSyncQueue`, which drains the offline sync queue into it so writes survive a reload. The browser-safe merge primitives stay separate from any `database` handle (the column type is server-side in `@voltro/database`; the merge core is the pure `@voltro/local-first` `.` entry).
458
+
459
+ Still seamed (documented, not built): the durable persistence backing (WASM-SQLite / Turso), the bi-directional sync WIRE transport, the presence channel (Redis/NATS), and the higher-level `localFirst` table mixin + client codegen discovery. codemod: none (purely additive — no user-authored code is affected; a `crdtText()` column is opt-in).
460
+ - **@voltro/local-first** — New opt-in `@voltro/local-first` package (Phase 4, first slice): CRDT + local-first primitives. `crdtText()` is a Yjs-backed CRDT text field behind our own `CrdtBackend` abstraction (the swap point for Loro later — nothing above the backend file imports `yjs`), with the deterministic merge primitive `mergeCrdtStates(a, b)` at its core (concurrent inserts converge order-independently, idempotent re-merge, empty-state identity). Ships the offline sync-queue as a pure reducer (`syncQueueReducer` — enqueue offline, FIFO drain on reconnect, requeue-on-fail with attempt counts), the connection-lifecycle state machine (`connectionReducer` + `deriveSyncStatus`), and `conflictPolicy()` / `lastWriteWins` for non-CRDT fields (deterministic, convergent tiebreak). React wrappers `useSyncQueue()` / `useConnectionStatus()` live under the `./react` subpath (`react` is an optional peer, kept off the pure `.` path). The `.` entry is browser-safe (no `node:*`, no `effect`). Client SQLite persistence (Turso/WASM), the bi-directional sync wire, the presence channel, and the `crdtText()` schema-DSL / `localFirst` mixin codegen wiring are declared as type-level seams (`./seams`) — deferred, not faked.
461
+ - **@voltro/local-first, @voltro/database, @voltro/runtime** — local-first (the vertical, integrated with existing framework infra): three of the four seams from the first slice are now BUILT against real, tested wiring, and the `localFirst` table mixin ships.
462
+
463
+ **Bi-directional sync wire (`@voltro/local-first`).** `createSyncClient({ transport })` maps the pure sync-queue reducer onto a `SyncTransport` (two functions an app binds to its EXISTING primitives — `push` to the client's mutation caller writing the `crdtText()` column, `onRemoteState` to the reactive subscription streaming the row). A local edit merges optimistically + queues; reconnect drains to `push` with retry/attempt-bump; incoming merged state folds back via the CRDT. Tested against an in-memory dispatcher that mirrors the runtime's authoritative merge — offline edit drains on reconnect, a remote edit arrives and merges, two concurrent offline edits converge (`syncClient.test.ts`).
464
+
465
+ **Presence / awareness (`@voltro/local-first` + `./react`).** `usePresence(roomId, { cursor, name }, { channel })` returns `{ presence, others, setPresence }` over a `PresenceChannel` — the SAME dumb string-payload pub/sub shape as the framework's `BroadcastProvider`, so a runtime binding forwards straight onto the app's broker (in-memory locally; Redis/NATS at scale, already shipped). Join/leave, announce-back discovery, cursor propagation, and TTL expiry live in the pure `createPresenceRoom`; `createInMemoryPresenceChannel` is the test/local transport. Tested pure (`room.test.ts`) and in a real DOM (`usePresence.test.tsx`): two peers see each other, updates propagate, a leaver drops, a silent peer expires.
466
+
467
+ **Durable persistence (`@voltro/local-first`).** `createIndexedDbPersistence()` is a durable `PersistenceAdapter` over IndexedDB — no WASM, no added dependency. The IDB implementation is injected, so it is tested against a fake backend that survives a reopen — the durability the in-memory adapter lacks (`indexedDb.test.ts`).
468
+
469
+ **`localFirst()` table mixin (`@voltro/database` + `@voltro/runtime`).** A marker mixin (adds no column) that opts a table into local-first sync + persistence; `isLocalFirst()` / `localFirstTables()` are pure discovery helpers, and the runtime SchemaRegistry reflects it as `hasLocalFirst(table)` (beside the existing `crdtColumns(table)`) — the discovery surface, with NO codegen change (a marker mixin rides `.with()` like any column type). The registry id is re-declared and pinned to the mixin by `localFirstMixinId.test.ts`, exactly like tenant/expires.
470
+
471
+ Browser/server boundary preserved: the sync client, presence, and persistence are browser-safe (no `node:*`, no `@voltro/database`, no runtime); the authoritative CRDT merge stays server-side in `@voltro/runtime`. codemod: none — purely additive, all opt-in.
472
+
473
+ What GENUINELY remains a runtime seam (infra + a thin app binding, not un-built framework code): a `SyncTransport` bound to a specific running app's mutation/subscription, a `PresenceChannel` bound to a provisioned Redis/NATS broker at scale, and (optional) a wa-sqlite/Turso durable adapter for cross-tab SQL. All three sit behind interfaces the tested code already speaks.
474
+ - **@voltro/cli** — Native mobile SDK generators: `voltro build api --target swift` emits a Swift Package and `voltro build api --target kotlin` a Kotlin Multiplatform module, generated FROM the app's capability manifest (the same procedure descriptors + JSON Schemas the TypeScript client codegen reads — no source is re-parsed). Each package ships type-safe models (Codable structs / `@Serializable` data classes + enums), a one-shot HTTP client (query/mutation/action), a WebSocket subscription client (streams), an auth/tenant-context helper, and a push-registration stub. Faithful type mapping (string/number/boolean/array/nested-object/enum, optional → Swift `Optional` / Kotlin nullable). Flags: `--target`, `--out`, `--name`, `--kotlin-package`; default output `<appDir>/sdk/<target>`. The generated SOURCE is golden-string tested; cross-language COMPILE (swiftc / Gradle) and the native runtime (native modules, the APNs/FCM push sender, OTA build pipeline) are out of scope. codemod: none.
475
+ - **@voltro/cli, @voltro/web** — Page `export const preload` convention: a page declares `ReadonlyArray<string | { tag; input?(params) }>` and the SSR render (dev + start; inert for SSG) runs each subscription server-side and seeds it, so a `usePreloadedSubscription` on that page renders with data on first paint instead of re-fetching on mount. Read directly from the page module in the render loops (a purely server-side directive; the client never needs it). codemod: none.
476
+ - **@voltro/client, @voltro/web** — SSR-preloaded subscriptions: `usePreloadedSubscription(api, tag, input)` (`@voltro/client`) — `useSubscription` that reads its FIRST value from the SSR hydration payload instead of flashing an empty state and re-fetching on mount, then upgrades to the live WebSocket stream. The value is seeded server-side during a render (a loader, a layout loader) with `seedPreloadedSubscription(api, tag, input, value)`, keyed by the SAME `stableKey([tag, input])` the SubscriptionCache uses, and carried into the hydration payload alongside the store seeds (mirroring their request-scoped-bag + resolver inversion; the `node:async_hooks` scoping stays in `@voltro/web/ssr`). Because the value flows through `useSubscription`'s `initialSnapshot` render branch — read identically on the server and the client hydration render — there is no hydration mismatch. When no seed exists for the key (a client-side SPA navigation the server never rendered), it behaves exactly like `useSubscription`. codemod: none.
477
+ - **@voltro/react-native** — **New package `@voltro/react-native` — the credential-free mobile primitives.** The React client already runs in React Native (the runtime has no DOM dependency); this adds the mobile plumbing on top of it that needs no per-tenant Apple/Firebase credentials and no native runtime.
478
+
479
+ - **Device registration** — a `_voltro_devices` table (`@voltro/react-native/schema`: tenant + user scope, platform/token/locale/timezone, `(platform, token)` unique upsert target, per-user fan-out index) plus a `registerDevice(upsert, input)` client function. `resolveDeviceRegistration` normalises locale/timezone (input → env → ambient → `en`/`UTC` floor) into the row; `userId`/`tenantId` are the server's to stamp, never trusted from the client. - **`useBackgroundSync()`** — the interval / foreground-trigger state machine. The OS background-fetch registration stays the app's; the hook is a thin wrapper over a pure reducer (`backgroundSyncReducer` + `shouldSync`: single-flight, foreground-gated, interval-gated, forced triggers bypass only the interval). - **Offline-first defaults** (`offlineFirstDefaults`: local-first opt-out on mobile, sync on foreground + interval, status surfaced) and a standalone `useMobileConnectionStatus` (`connected | degraded | offline`) — deliberately not coupled to a transport or the in-flight local-first package. - **`defineDeepLink({ pattern, handler })`** descriptor + a pure matcher (`matchDeepLink('/orders/:id', '/orders/42')` → `{ id: '42' }`; segment-exact, scheme/host/query/trailing-slash normalised; params typed from the pattern literal).
480
+
481
+ The root export is RN-safe (no `node:*`, no `@voltro/database`; React is an optional peer reached only through the hooks). The `_voltro_devices` declaration is the server-side `@voltro/react-native/schema` subpath.
482
+
483
+ **Deferred as documented seams** (flagged in the package, not built): APNs/FCM **sender** adapters (need per-tenant Apple Developer / Firebase credentials); native module bindings (camera, biometrics, secure storage — need a native runtime); Swift/Kotlin SDK generators (open product decision); universal-links / App-Links file automation; and the `*.deepLink.ts` codegen discovery wiring (one additive file, landed after the current release — the descriptor shape is final, so until then links register via `matchFirstDeepLink`).
484
+ - **@voltro/runtime** — `defineCostBudget()` + `attachFinops()` — reactive FinOps: per-tenant / per-subscription compute-cost attribution + budgets. A `CostAccountant` folds each `CostEvent` (`{ tenantId, subscriptionId?, unit, amount, … }`) in O(1) into a standing per-tenant attribution accumulator (`total` + `byUnit` + `bySubscription` — the chargeback/showback answer) and every budget that watches its unit. A budget is a POLICY holding EVERY tenant to the same ceiling independently (mirroring `requireAiBudget`); its per-`(budget,tenant)` windowed counter crosses `ok`→`warn`→`exceeded` with the provenance of the causing event, recovers on a tumbling-window rollover (event- AND clock-driven) or an explicit `reset(tenantId)`, and is observable via `CostRegistry` (same shape as `ExpectationRegistry`: snapshot / get / breaches / subscribe). The engine is store-free + unit-testable; the descriptor + registry Tag are browser-safe. `*.budget.ts` file discovery, the `attachFinops` call in both boot paths, and the dispatcher/query cost-event taps are the forthcoming CLI wiring. codemod: none.
485
+ - **@voltro/cache, @voltro/ai** — Reactive semantic cache: `SemanticCache` (`@voltro/cache/semantic`) — an embedding-keyed LLM cache with a cosine-similarity vector index over the existing `CacheStore`, dependency-set capture as tags (`rowDep`/`tableDep`), and eviction by source change (`onSourceChange`/`onTableChange`, insert evicts table-coarse only). `@voltro/ai/semanticCache` wraps it: `semanticGenerateText`/`semanticGenerateObject` embed→lookup→hit-returns-cached (zero tokens) / miss-generates-and-stores under the captured deps, best-effort (a cache outage degrades to always-generate). Firing eviction on live writes is a one-line CLI-facade sink (documented; no runtime change). codemod: none.
486
+ - **@voltro/database, @voltro/runtime, @voltro/cli** — Field-level read permissions: a new `.readableBy(...scopes)` column modifier (Part B of the column-wire-visibility seam). A column marked `.readableBy('billing:read')` is stripped from query + subscription wire OUTPUT for any subject that holds NONE of the listed scopes, and present for one holding ANY of them — checked against the subject's EFFECTIVE scope set (raw subject scopes ∪ rbac role-derived scopes), with the `admin:full` bypass seeing every such column. It is the graded middle of the wire-exposure axis between a plain column (visible to everyone) and `.serverOnly()` (hidden from every client); the two compose (`.serverOnly()` still wins — hidden from everyone including admins). Enforced at the SAME Dispatcher read chokepoint as `.serverOnly()` (initial snapshot + every reactive delta) and at the `publicApi` one-shot REST GET in both boot paths (`voltro dev`, `voltro serve`). Subject-independent — and therefore memo-sharing — for any table that declares no `.readableBy(...)` column. Server-internal reads (`ctx.store.query`) still see the value; the strip is a wire concern only. Declaration rejects `.readableBy()` with no scope (that is `.serverOnly()`) and a blank scope string. codemod: none.
487
+
488
+ apiSurface note: the one changed golden line is `OneShotQueryRunnerDeps.queryRows`, which gained a second `context: ServeRequestContext` parameter so the one-shot runner can apply the subject-aware strip. It is a callback the CONSUMER supplies, so an existing `(descriptor) => …` still satisfies the wider `(descriptor, context) => …` type — the change cannot turn compiling code into non-compiling code. Everything else is a pure addition (`readableBy`, `readableByColumns`, `ReadableByColumn`, `forbiddenColumnsForSubject`, `stripForbiddenForWire`).
489
+ - **@voltro/cli** — `voltro evolve` — schema-evolution copilot for changing EXISTING schema safely. Given a change (`rename-column`/`retype-column`/`split-column`/`drop-column`/`rename-table`) it reads the OBSERVED graph (`app.graph.observed.generated.json`) + the app manifest to enumerate the real blast radius (handlers that actually touch the table; declared-but-unexercised ones flagged UNKNOWN, never assumed safe), then proposes a reviewable plan: a codemod (rename-column gets a real transform that renames the `*.entity.ts` field AND chains `.renamedFrom('old')` so the differ plans a catalog RENAME not a lossy drop+create, and annotates the handler sites the blast radius found; reshaping kinds get a `manual` codemod with generated steps) + a branch-verified backfill plan (per-kind SQL tied to `planBranchProvision`, snapshotting the exact tables the affected handlers touch) + a `voltro check` verify step. Dry-run by default; `--write` applies via the existing `runCodemods` toolkit; `--json` for CI. codemod: none.
490
+ - **@voltro/env** — Live secret rotation: `refreshEnvValue(key, value, { previous, graceMs })` installs a re-resolved env value a running process serves immediately while holding the OLD value for a grace window (lazy prune-on-read, no timer); `rotateSecretLive(key, { graceMs })` (`@voltro/env/server`) re-resolves through the backend + does the cutover, and `getSecretWithOverlap(key)` returns `{ current, previous }` — the current/previous verifier pattern for app env. Bounds (documented): this updates what code reading a secret PER USE sees (outbound keys, webhook-signing, field-encryption); it does not reconnect a live DB pool built with the old credential. codemod: none.
491
+ - **@voltro/cache, @voltro/cli** — The reactive semantic cache (`SemanticCache`) is now wireable as a framework-managed opt-in. Set `cacheSemantic: true` in `app.config.ts` and both boot paths build a `SemanticCache` over the SAME `CacheStore` the query `Cache` uses (`CacheLayer.storeLayer` is now exported so one store instance is shared by both), provide it as a `yield*`-able handler service, AND wire row-granular eviction off the runtime's existing `store.onChange` — a live DB write to a source row drops every semantic entry that depended on it (`onSourceChange`). Gated end-to-end: an app that leaves `cacheSemantic` off builds no vector index, no service, and no eviction sink. codemod: none.
492
+
493
+ (apiSurface: compatible — `CacheLayer.storeLayer` is a new export; nothing removed or resignatured.)
494
+ - **@voltro/web, @voltro/cli, @voltro/ui** — Framework SEO + a11y primitives: `seoAlternates()` (reciprocal absolute canonical + hreflang alternates + x-default, browser-safe), `PageMeta.noIndex` (emits robots noindex on every render path + excludes the route from the sitemap), build-time `dist/sitemap.xml` + `dist/robots.txt` generation (per-locale alternates, `WebAppConfig.seo.siteUrl`, `VOLTRO_SEO_NOINDEX` staging override, never overwrites a user `public/` copy), a dev-server disallow-all robots. Accessible `<Field>` defaults filled: Schema-`description` hints wired via `aria-describedby`, the RadioWidget error now associated, required marker `aria-hidden` + `aria-required`.
495
+ - **@voltro/cli** — File-convention discovery + boot attach for the standing primitives: `*.expectation.ts` (defineExpectation), `*.budget.ts` (defineCostBudget), `*.experiment.ts` (defineExperiment) are now discovered like `*.aggregate.ts` and attached in BOTH `voltro dev` and `voltro serve` (parity), each providing its registry (ExpectationRegistry/CostRegistry/ExperimentRegistry) as a handler Layer + a `GET /_voltro/inspect/{expectations,budgets,experiments}` snapshot. This is what makes the three primitives user-reachable via file convention. (Cost-EVENT emission — the dispatcher recordCost tap — remains the deferred secondary half, so budgets are declarable+observable but attribution stays 0 until it lands.) codemod: none.
496
+ - **@voltro/ai, @voltro/cli** — `voltro eval` — replay real recorded agent/AI runs and gate the deploy on the result. `defineEval({ name, cases, assert?, judge? })` (`@voltro/ai`) declares golden cases from recorded runs; `voltro eval` discovers `*.eval.ts`, replays each case against the CURRENT model, judges with HARD assertions (`contains`/`matches`/`equals`/`nonEmpty`/`maxLatencyMs`) plus an optional LLM judge (`generateObject`-backed, schema-constrained verdict), and exits 1 on any regression — a deploy-gate signal, `--json` feedable into CI. Reuses run-recording (`runAndPersist`/`threads`), the data-branch identity machinery (`branchNamespaceName`, `--branch`), and mirrors `voltro check`'s gate shape. The runner (`runEval`/`scoreCase`/`evaluateAssertions`) is pure over an injected replay + judge, so it is fully unit-testable without a provider. `*.eval.ts` is read only by `voltro eval` — it is deliberately not a boot/browser file convention. codemod: none.
497
+ - **@voltro/workflow, @voltro/cli** — Workflow **resume-from-step** — rewind a terminally-`failed` run to an operator-chosen step and re-execute from there, past the point it actually died. The generalisation of `redrive` (which only re-runs the failed step): resume resets the target step **and every step after it** (succeeded ones included), so a step that completed cleanly but on stale/wrong external state re-runs too, while the steps *before* the target replay from the durable journal. For the dead-letter case where the failure point is not the right recovery point.
498
+
499
+ - Engine adapter `resumeRunFromStep` on `@voltro/workflow/cluster` (sibling to `redriveFailedRun`, sharing the one `@effect/cluster`-coupling core — a live-cluster contract test asserts the step before the target REPLAYS while the target + downstream RE-RUN). - `voltro workflows resume-from-step <runId> <stepName>` + the inspect action `POST /_voltro/inspect/workflows/runs/:id/resume-from-step` `{ step }`, wired into **both** `voltro dev` and `voltro serve`. Refuses a non-`failed`/discarded run and an unknown step; declines cleanly (no journal / still running / already succeeded).
500
+
501
+ codemod: none
502
+
503
+ ### Fixed
504
+
505
+ - **@voltro/client** — **`useAction(...).run` forked against the boot-window stub instead of waiting for the api.**
506
+
507
+ `useSubscription` survives that window by design — it reads through the loading cache, reports no data, and delivers when the real client arrives. `run` had no such backstop: called from a mount effect it threw *"rpc / cache calls are not invokable on a not-yet-resolved api"*.
508
+
509
+ A component that fetches once on mount therefore had a race it could not see. It usually lost on a cold load and won on an HMR reload, so the page "worked when you looked at it".
510
+
511
+ **And the message REPLACED the real one**, which is the expensive half: one app reported api resolution while its upstream was answering `403`, and the `403` was invisible because the call never left the browser. SSR sharpened it — seeding a subscription via `initialSnapshot` makes `isAuthenticated` true on the very first render, so guards that gated mount effects behind "we have a user" stopped gating anything.
512
+
513
+ `run` now waits for the api, which is what a caller expects and what the sibling primitive already does. The wait is **bounded** (15s) and the timeout says what happened: an api that never resolves is a real condition — a name matching no configured api, a supervisor that gave up — and hanging forever would trade a confusing error for no error at all.
514
+
515
+ The callback also stays stable across the window now: it reads the handle through a ref at call time rather than being recreated the moment the api resolves.
516
+
517
+ `isUnresolvedApi(useFrameworkApi(name))` still composes for a caller who wants the readiness bit itself.
518
+
519
+ **Measured in a real browser against a real api process**, not only unit-covered — `node scripts/browser-action-boot-window.mjs` (chromium, `e2e-fixtures/web-action-boot` → `memory-api`), with the pre-fix shape restored as a negative control:
520
+
521
+ | | on a natural cold load | with a 3s `authHeaders` resolver | |---|---|---| | before | `ERR: rpc / cache calls are not invokable on a not-yet-resolved api` after **17 ms** | same error after **3 ms** | | after | `OK: {"ok":true}` after **34 ms** | `OK: {"ok":true}` after **3039 ms** |
522
+
523
+ The window really is only ~15–30 ms wide on a warm machine, so the page takes a negative control in the same instant the call is issued — invoking `handle.client` directly, which still throws. Without it, "run succeeded" would be indistinguishable from "the api had already resolved". The bound is exercised too: a resolver that outlasts the budget settles at 15006 ms with the timeout message, rather than never.
524
+
525
+ One thing this does NOT claim, because an earlier draft did and was wrong: `run` waits for the api to RESOLVE, not to be REACHABLE. With the api process killed the supervisor still hands over a client in ~20 ms (an rpc client is built from a layer; nothing there needs a live socket), so the call goes out and fails with a genuine `Error in socket` — which is the point of the fix, a real transport error instead of a stub message that displaced it.
526
+ - **@voltro/cli** — **The atlassian credential codemod now tells you to grep for your own key, not just for `credentialsResolver`.**
527
+
528
+ A team doing this migration found **four** call sites reading the PAT off the Subject and only one of them was the resolver: a session strategy stamping it into `metadata`, two delegation helpers building synthetic Subjects that carried it, and an avatar fetch. Fixing the resolver alone leaves the credential on the identity and the leak intact — which is the entire point of the change.
529
+
530
+ The note said as much in passing and was easy to read past. It now says it first, and names the reason: the resolver is where the credential is READ, not where it got onto the Subject. It also passes on what the reporter did afterwards — an invariant test that fails if anything puts a token-shaped key into a metadata bag again, mutation-tested by restoring the old line.
531
+ - **@voltro/workflow** — First-deploy cluster convergence: N runners started simultaneously against a fresh database (no `@effect/cluster` schema yet) no longer silently fail to converge. Root cause was `@effect/cluster`'s first-boot storage migration racing the pg catalog (its Migrator creates the tracking table without `IF NOT EXISTS`, and its `LOCK TABLE` guard only exists AFTER that table does). A new `clusterMigrationGateLayer` serializes the FIRST migration behind a cross-dialect advisory lock pinned to a single reserved connection (so acquire+release share a backend and auto-release on crash — the pooled `withMigrationLock` leaks here because storage build checks out several connections), building the storages sequentially. Warm boots skip it. New knob `VOLTRO_CLUSTER_MIGRATION_LOCK_TIMEOUT_MS` (default 60s). codemod: none.
532
+ - **@voltro/workflow** — **The cluster first-boot migration gate deadlocked on sqlite — an app on `store: 'sqlite'` with a workflow would never finish booting.**
533
+
534
+ The gate serializes `@effect/cluster`'s first schema migration behind an advisory lock pinned to a *reserved* connection, because acquire and release must land on the same backend. On sqlite the lock is a no-op on both sides — single-writer, single-process, no sibling to serialize against — but the reservation around it was not: `sql.reserve` takes the ONE connection an in-process sqlite client has, and the locked work is the library's storage build, which then asks the pool for another and waits on a connection its own caller is holding.
535
+
536
+ It presents as a boot that never finishes, not as an error. Nothing logs.
537
+
538
+ Sqlite now runs the migration without reserving. Every other dialect is unchanged — the reservation is load-bearing there, and removing it would leak a session lock onto an idle pooled connection that every late runner then blocks on.
539
+
540
+ **Never released** (it landed after 0.28.0), but worth reading for how it was found. The webhook delivery suite is the only place we build the cluster engine against `:memory:` sqlite; five of its tests sat at their 30 s timeout while the *same* tests on postgres, mysql, mariadb and mssql passed, because those pools hand out a second connection. So the one configuration with no infrastructure — the likeliest first thing a new user runs — was also the only one nothing else covered.
541
+
542
+ `clusterMigrationGate.test.ts` pins the connection count, not the outcome: a test asserting only "the work ran" passes on the broken code as long as its fake pool is willing to hand out a second connection, which is exactly the assumption the real sqlite client does not satisfy.
543
+ - **@voltro/cli** — **The credential-purge query in two 0.28.0 codemods was postgres-only, and its MySQL/MariaDB translation silently under-reported.**
544
+
545
+ Codemods `03_atlassian-credentials-context` and `04_audit-redacts-subject-metadata` both told you to check your existing rows with `subject::text ILIKE '%token%'`. `::text` and `ILIKE` do not run on MySQL/MariaDB, so the natural translation is a bare `LIKE` — which is case-**sensitive** against the `utf8mb4_bin` collation our own migrator emits for a `json()` column. `'%token%'` therefore does not match `jiraToken`, and a credential key is almost always camelCase.
546
+
547
+ A team ran the translated query against 141 rows, got **0**, and nearly reported themselves clean. 117 of those rows held a working credential; they caught it only because the count looked implausible and they printed a sample row.
548
+
549
+ Both notes now use `LOWER(subject) LIKE '%token%'`, which is correct on every dialect we ship.
550
+
551
+ **Why this is worse than a syntax error, which is the part worth keeping:** a query that fails to run gets fixed. A query that runs and returns good news when the answer is wrong is read as an all-clear — in the security-relevant half of a security-relevant codemod.
552
+
553
+ `codemodSqlPortability.test.ts` now scans every codemod note for postgres-only spellings (`::text`, `ILIKE`, `table_schema = 'public'`). It distinguishes SQL a user would copy from prose ABOUT sql by the backtick, because the first version fired on the very sentence warning against the construct — and it carries a selftest, since a scan that silently stopped matching reads exactly like a clean tree.
554
+ - **@voltro/cli** — **Outgoing webhooks never delivered on the cluster engine — i.e. in every deployment.**
555
+
556
+ `voltro.deliverWebhook` was provided per-emit: `execute(input).pipe(Effect.provide(deliverWebhookWorkflow.toLayer(…)))`, built fresh inside the emit callback. The in-memory engine tolerates that, because there the layer IS the registry. The **cluster** engine does not: a workflow must be registered as an entity type while the runtime is constructed, and an emit happens long afterwards. So every delivery died with
557
+
558
+ ```
559
+ Entity type 'Workflow/voltro.deliverWebhook' not registered
560
+ ```
561
+
562
+ **after** the mutation had already returned `200`. Zero deliveries, zero rows in `_voltro_webhook_deliveries`, nothing in the calling service's logs. Reported by a consumer on MariaDB + cluster-sql for whom the feature had never once delivered in any environment.
563
+
564
+ The layer is now built at boot and registered in `allWorkflowLayers` alongside the app's own workflows, in both boot paths; the emit closure runs on that runtime. An app with outgoing webhooks and no workflows of its own now builds the workflow runtime too — otherwise the fix becomes a different silent failure.
565
+
566
+ **The axis is the part worth keeping.** Both boot paths carried the *identical* construction, so no dev/serve parity check could see it — those compare the two paths to each other, and here they agreed. The difference was IN-MEMORY vs CLUSTER, and it looked like dev-vs-serve only because `voltro dev` defaults to the in-memory engine while a deployment uses the cluster one. **A difference between two configurations of ONE path is invisible to every guard that compares paths.**
567
+
568
+ `deliverWebhookRegistration.test.ts` pins the boot registration across both paths (red-verified by removing it from one). It is the source half; the behavioural half needs a real SQL cluster engine and is not something a fake engine could stand in for.
569
+ - **@voltro/cli** — **`voltro serve` warned that framework-provided tables "are not a declared table".**
570
+
571
+ The stale-`source` audit is called by both boot paths. `voltro dev` passed `allRegisteredTables()` — the process registry, which includes framework- and plugin-provided tables. `voltro serve` passed `discovered.tables`, which is only what the APP declares. So a query naming `_voltro_agent_messages` (or the audit trail, or the notification inbox) was reported as naming a table that does not exist — about a table that does.
572
+
573
+ Same codebase, same version, two boots: dev silent, serve warning. It is the false positive fixed for dev in 0.27.0, still live on the serve path — now only where nobody is watching a terminal.
574
+
575
+ **Why no existing guard saw it.** It is not a ctx field and not a missing call, so neither the derived boot-path audit nor its ctx-key axis applies: both paths call the *same* function and hand it *different sets*. That is the same variant as the schedule-subject divergence — each call site internally consistent, the difference visible only by comparing them. Reported by a consumer who noticed the two boots disagreeing on identical source.
576
+
577
+ Both paths read the process registry now, pinned by a guard that fails if either reverts to the app-declared set.
578
+ - **@voltro/cli** — `.serverOnly()` columns are now stripped from a `publicApi` query's buffered REST GET response. The runtime dispatcher already strips every WS / `POST /rpc` snapshot (which is what the SSR web-router loaders and `usePreloadedSubscription` seeds fetch through, so the `__voltro_state__` hydration payload was already safe), but a query projected to a public REST endpoint has no subscription to drive it — it read the store directly and shipped the raw row, including any `.serverOnly()` credential column (e.g. `keyHash`), to the caller. Both boot paths (`voltro dev`, `voltro serve`) now route the one-shot public read through the same `stripServerOnlyForWire` choke point. codemod: none.
579
+ - **@voltro/runtime** — `.serverOnly()` columns are now stripped from ALL query + subscription OUTPUT at the Dispatcher's read boundary (initial snapshot + every reactive delta), not just `crud.*` echoes + the boot audit — so a hand-written query/subscription returning a raw row no longer leaks a server-only column to the wire. Server-internal reads (`ctx.store.query`) still see the column; the strip is wire-only and subject-independent, so it shares the read memo. codemod: none.
580
+ - **@voltro/cli, @voltro/i18n** — **`voltro dev` server-renders WITHOUT `<I18nProvider>`, so SSR could not be developed at all for a translated app.**
581
+
582
+ There are three server renderers and each arranged the i18n wrapper for itself: `voltro build`'s prerender picked a wrap per locale, `voltro start` called the `i18n.resolve` baked into the generated `ssrEntry.ts`, and `voltro dev` — which loads `@voltro/web/ssr` directly and therefore has no generated entry to call — passed **no `outerWrap` at all**. Any component calling `useT()` / `<T>` rendered fine under `voltro start` and threw on the server under `voltro dev`:
583
+
584
+ ```
585
+ Error: [React Intl] Could not find required `intl` object.
586
+ <IntlProvider> needs to exist in the component ancestry.
587
+ ```
588
+
589
+ Reported by a consumer whose 51 `renderMode: 'ssr'` pages were every one of them serving a spinner — and two further defects sat behind this one, because nobody could get a page far enough to see them.
590
+
591
+ `@voltro/i18n/server` gains **`makeSsrI18nResolver`** (cookie `voltro:lang` > `Accept-Language` > default → the matching wrapper), and both the generated entry and the dev server now call it. The dev copy and the generated copy were going to be two hand-written versions of the same five lines, which is how they diverged in the first place. It stays React-free so the CLI takes no React dependency; dev loads the React half through Vite's SSR loader, as the prerender already did.
592
+
593
+ Both dev render branches are covered — the page, and `prepareSpaLayoutShell`, which builds its own `renderInput` and matters because a translated ROOT LAYOUT above a client-only page hits `useT()` on the server exactly as a page does.
594
+
595
+ **And the dev SSR failure path no longer hands the raw error to the logger.** A React SSR error carries the element/props graph; formatting it through `util.inspect` can exceed V8's ~512 MB string cap, at which point `RangeError: Invalid string length` from `inspect` *becomes* the reported error and the real message is gone. The consumer had to monkey-patch `console.error` from application code to recover a one-line i18n error. `boundedErrorText` reads `stack`/`message` only, caps the result, names the truncation, and includes `cause` / `AggregateError` children.
596
+
597
+ Verified against a real `voltro dev` process rendering a fixture with two locales: the marker appears in the SERVER body, the `voltro:lang` cookie selects the German catalog (so the wrap is per-request, not a fixed default), and both branches were red-verified by removing their spread.
598
+
599
+ codemod: none — no user-authored code changes shape; a page that was crashing now renders.
600
+ - **@voltro/cli** — **Every first visit to an SSR page hydration-mismatched, and `<html lang>` was a constant.**
601
+
602
+ Two defects on one surface, and the second is why the obvious fix for the first did not work.
603
+
604
+ **1. The halves disagreed on the no-cookie case.** The generated client entry resolved the locale from the cookie only — correctly refusing `navigator.languages`, which can diverge from what the server saw. But *dropping* the `Accept-Language` signal is not the same as *agreeing* with the server about it. With no `voltro:lang` cookie yet — every first visit — the server negotiated `Accept-Language` while the client fell through to `defaultLocale`. An English browser on a German-default app hydrated `de` over an `en` tree, so React discarded the whole server render: exactly what SSR was enabled to buy. It stopped the moment anything wrote the cookie, which is why one language switch made it un-reproducible for that developer.
605
+
606
+ The client now **adopts what the server resolved**, from `<html lang>`, before falling back to the cookie and the default. `navigator.languages` is still never read.
607
+
608
+ **2. `<html lang>` never carried the resolved locale.** `voltro dev` read a `voltro:locale` cookie. Nothing writes that name — `resolveLocale`, the generated entry, `@voltro/ui-shadcn`'s ProfileMenu and the docs all use `voltro:lang` — so the lookup always missed and the attribute was the literal `"en"` on every page of a German-default app. Measured by a consumer with three different `Accept-Language` values against `/login`: `<html lang="en">` all three times.
609
+
610
+ That is wrong on its own terms: `<html lang>` is what a screen reader pronounces in, what Chrome offers to translate *from*, and what hyphenation uses. It is now the locale THIS request resolved — the same value the `<I18nProvider>` renders with — falling back to `voltro:lang`, then the app's `defaultLocale`, never a hardcoded `'en'`.
611
+
612
+ **It also cost the reporter a wrong fix**, which is the part worth keeping: they shipped "adopt `<html lang>` as the client fallback" with green tests, because the tests asserted their belief about what the attribute contained. A `curl` is what caught it. Both halves are now asserted against a running `voltro dev`, red-verified by restoring the old cookie name.
613
+
614
+ The docs said the client "mirrors cookie and default for hydration safety" — a sentence that reads as a guarantee and described the opposite of what happened. Corrected in both languages.
615
+
616
+ Still open, narrower: `voltro start`'s `<html lang>` prefers the resolved locale but falls back to `'en'` rather than `defaultLocale` when an app configures a locale whose catalog file is missing.
617
+ - **@voltro/runtime** — **An undeclared throw reached the client as a ~2 KB decode tree instead of its message.**
618
+
619
+ An executor threw a plain `TypeError`. The server logged it correctly. What the client got was the entire `ExitEncoded<…>` transformation — every member of the descriptor's `error:` union, the full type, and the actual cause on the *last* line. One consumer's account page rendered that verbatim where a reason belonged, and every app otherwise has to condense it heuristically to avoid putting a schema on screen.
620
+
621
+ **The channel is the part worth keeping, and only a deployed process settled it.** The first attempt guarded the DEFECT channel — reasonable, and inert: an executor that throws is settled as a FAILURE by the async wrapper, so the encoded cause reads `_tag: "Fail"` and a defect-channel catch never fires. The existing `isInfraError` guard missed it too, because a plain `TypeError` has no `_tag`.
622
+
623
+ So the rule is on the failure channel and is not a heuristic: **every error an app DECLARES carries a `_tag`** — that is the wire contract the client pattern-matches on — so an `Error` without one is exactly the set the descriptor's `error:` union cannot contain. A tagged error passes through untouched.
624
+
625
+ An undeclared defect now collapses to the same small tagged `InternalError` the infra path already produced, carrying the message the server just logged. `message` only: no stack, no `cause` chain, no own fields — the same reasoning as `wireErrorFromCause`, where a nested object can hold a DSN or a token. Bounded at 500 chars with the truncation marked.
626
+
627
+ **Measured against `voltro serve`**, an action doing `undefined.runWithEager()`, from the published fixture bundle:
628
+
629
+ | | response | |---|---| | before | **543 bytes** of `ExitEncoded<…>` decode tree | | after | **185 bytes** — `{"_tag":"InternalError","message":"Cannot read properties of undefined (reading 'runWithEager')","traceId":…}` |
630
+
631
+ The server log line and the client message are now identical, which was the ask. The unit test was green through BOTH states, because it exercised the pure function and not the channel it hangs on.
632
+
633
+ **The asymmetry with `isInfraError` is deliberate.** A `SqlError` still collapses to the generic `'internal server error'`, because its message names internal `table.column` detail. An arbitrary app defect has no such known shape, and withholding its text too would leave the app exactly where it started — with a reason it cannot show.
634
+ - **@voltro/plugin-webhooks** — **The scope lookups read one page of the target table and answered confidently from it.**
635
+
636
+ `scope` is an app-defined JSON blob, so matching it cannot be a SQL predicate — a JSON comparison is dialect-divergent, and on MariaDB a `json()` column carries `utf8mb4_bin`, which has already produced a case-sensitive `LIKE` that reported a clean `0` over 141 dirty rows. Filtering in JS is the right call. Reading only the first 1000 rows to filter was not.
637
+
638
+ Past that many target rows, both callers returned a wrong answer rather than an error:
639
+
640
+ - **`subscribe` minted a fresh secret for a LIVE endpoint.** Growing an endpoint inherits its secret precisely because the receiver verifies one signature for one URL. Not seeing the endpoint's rows meant inventing a new key, so half its rows then sign with a key the receiver does not hold — and the "these are not one endpoint" refusal never fires, because it only inspects what was fetched. - **`resolveTargets` threw `no target matches scope … the operation would have silently done nothing`** for a scope that does match. That message sits three lines under a comment about exactly this failure shape.
641
+
642
+ Both now page until the table is exhausted, ordered by `id` (unique — `OFFSET` over a non-unique order can repeat or skip rows between pages, and mssql refuses `OFFSET` without an `ORDER BY`). Past 100k rows the scan THROWS: a scan that gives up quietly is the thing being fixed.
643
+
644
+ `emit`'s fan-out pages too. Its cap was per-event (it has a real `event` predicate) and carried the comment *"sane bound — 1000 targets per event is plenty"* — but a bound whose overflow is a silent non-delivery is not a bound, it is a data-loss ceiling nobody is told about. Paging costs nothing in the normal case: one page, one round trip.
645
+
646
+ **Why nothing caught it.** Every test harness in the package returns its whole row array from `query()` and ignores `take`/`skip` — which is precisely what a paging bug looks like from the inside. `targetScan.test.ts` uses a harness that honours them, and that is the only reason its assertions mean anything.
647
+
648
+ ### Internal (no consumer-facing effect)
649
+
650
+ - **@voltro/plugin-ai-flows** — **`FlowStep` / `RunStep` are declared interfaces, so the api report stops churning.**
651
+
652
+ `type FlowStep = typeof FlowStep.Type` is an alias to a mapped type, and TypeScript's declaration emit expands such an alias structurally rather than printing its name. Both types are reached from an exported table (`aiFlows.steps: json<ReadonlyArray<FlowStep>>()`), so a ~60-line expansion sat inline in `etc/plugin-ai-flows.api.md` — and its member order depends on which other packages were built in the same turbo run. Measured: a full-monorepo build and a single-package `--force` build emit `params` and `schema` (structurally identical, both `Schema.optional(Json)`) in different positions, so the pre-push drift gate rejected whichever order was committed. CI does not build the scope a dev machine does.
653
+
654
+ An `interface` is a real declaration TypeScript prints by name. Both are pinned to their schemas by an `Equals` check that fails to compile on divergence, so the hand-written shape cannot drift from the runtime one.
655
+
656
+ **`apiSurface: compatible`, and the reason matters more than the label:** the 36 removed golden lines are the collapsed expansion, not a removed capability. The type is structurally identical — the `Equals` pin proves exactness in both directions — so no consumer expression changes meaning. What changed is how the report SPELLS the same type.
657
+
658
+ Verified byte-identical across exactly the two build scopes that disagreed before.
659
+ - **@voltro/cli** — boot-validate (sharpened): a `pnpm boot-validate:sqlite` lane (driver-gated, degraded-boot: probes the sqlite driver chain, DEGRADED+exit-0 when absent, else a real embedded-sqlite durable-CRUD round-trip incl. reopen) with a `--self-test`; plus completing the internal `ApiAppConfig.store` union with `'sqlite'` (the resolver already supported it). The Tier-B service lanes (mysql/mssql/clickhouse/redis + a boot-validate compose) are the remainder. Ships with a new `api-backend-sqlite` template.
660
+ - **@voltro/plugin-webhooks, @voltro/workflow** — **The delivery workflow is now covered against a REAL SQL cluster engine, not only the in-memory one.**
661
+
662
+ `deliverWorkflow.integration.test.ts` runs the delivery workflow against every dialect with the workflow ENGINE in `memory` — a documented, defensible trade (a cluster cold start made it slow and flaky under CI contention). It is also why a cluster-only defect shipped: a suite named "deliverWorkflow end-to-end per dialect" reads like coverage and was structurally blind to entity registration.
663
+
664
+ `deliverWorkflowTwoRunners.integration.test.ts` stands up a real SQL-backed cluster engine on MariaDB and asserts the workflow resolves AND its body runs. `purgeClusterState` is exported from `@voltro/workflow/cluster-suite` so a suite outside the dialect packages can use it. It is ONE file on purpose: two suites purging the same `cluster_*` tables wipe each other's runners mid-run, which fails as something that looks nothing like shared state.
665
+
666
+ **Two things measured on the way, both worth more than the test itself:**
667
+
668
+ - The old per-emit shape is what the shared cluster suite itself uses (`Effect.provide(handler.pipe(Layer.provideMerge(engine)))`) and it works *there* — because there is exactly one runner. In a deployment the boot runner owns the shard, and an ad-hoc participant registering the entity does not change where the message routes. That is the mechanism behind `Entity type 'Workflow/voltro.deliverWebhook' not registered`, and it is why no single-runner test could have caught it. - **A cluster test that hangs is usually not the cluster.** A stub handler returning `void` against a `success` schema of `{ finalStatus, attempts }` cannot be encoded, so the message is redelivered forever and `execute` never resolves — a 120s timeout with the row still in `cluster_messages`. Accumulated cluster state was blamed first, the purge added, and it still hung; counting the rows settled it. Look at the handler's return type before the cluster.
669
+
670
+ **And the two-runner case IS covered now** — `deliverWorkflowTwoRunners.integration.test.ts` stands up a boot runner and a dispatcher against one live MariaDB and asserts BOTH directions:
671
+
672
+ | boot runner A | dispatch | result | |---|---|---| | built WITHOUT the workflow's layer | B provides it at dispatch (the shipped shape) | **`not registered`**, body never ran | | built WITH it (what both boot paths do now) | same B | run completes, body executed |
673
+
674
+ The negative control is the point: a test that can only pass cannot tell a registered entity from an unregistered one, and every single-runner test in this repo passes on the broken code. The defect case costs ~60s — the ENGINE retries an unroutable message before the failure surfaces — against ~3s for the fix. That is the price of having a reproduction at all; do not lower the timeout to tidy it.
675
+ - **@voltro/cli** — dev.ts refactor (dev-ts-decomposition Step 1+3): the SSE subscription-snapshot push now reuses the shared `buildSubscriptionsInspect` builder instead of an inlined byte-identical copy; the `_voltro_workflow_runs` refetch-and-emit shared by redrive + resume-from-step is one helper; and a live-span-leak GATE test asserts the inspect door gate sits above every ungated live-data branch. Pure refactor, zero behaviour change.
676
+ - **@voltro/cli** — **The message-API check called a real chainable member non-existent.**
677
+
678
+ `.index()` is a genuine member of the table builder with two overloads. api-extractor prints an overloaded member as a call-signature *object*:
679
+
680
+ ```
681
+ index: {
682
+ <const F extends readonly [...]>(fields: F, options?: …): Table<…>;
683
+ <const IxName extends string, …>(name: IxName, …): Table<…>;
684
+ }
685
+ ```
686
+
687
+ which matches neither of the check's line-shaped rules (`foo(` / `foo: (…) =>`). So a correct comment naming it was reported as naming something that does not exist, and the static check went red on `main`.
688
+
689
+ **A false alarm is not the harmless direction here.** This check exists to be believed — its own failure text says "fix the message, or build the thing it promises". One that cries wolf gets its finding argued with instead of read.
690
+
691
+ The lookahead is the part worth recording: matching the generic precisely does **not** work, because `<const F extends … Array<…>>` nests `>`, so a `<[^>]*>` character class stops inside it. The first version of the branch therefore matched nothing and looked like a fix. A call signature simply *starts* with `<` or `(` once trimmed; a data member starts with an identifier or `readonly` — which is what keeps `index?: { readonly where: string }` out, and with it the data-property bug the original rules exist to reject.
692
+
693
+ Both directions are now selftest cases, since a rule that quietly stops matching prints exactly like a clean tree.
694
+
695
+ ---
696
+
42
697
  ## [0.28.0] — 2026-08-06
43
698
 
44
699
  ### ⚠ BREAKING
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@voltro/plugin-audit",
3
- "version": "0.28.0",
3
+ "version": "0.30.0",
4
4
  "description": "Audit plugin — ships the `audit()` schema mixin (createdAt/updatedAt/createdBy/updatedBy → Actor) plus an optional mutation interceptor that records every call to a configurable sink (console / memory / custom function).",
5
5
  "keywords": [
6
6
  "voltro",
@@ -37,9 +37,9 @@
37
37
  "node": ">=24.0.0"
38
38
  },
39
39
  "dependencies": {
40
- "@voltro/database": "0.28.0",
41
- "@voltro/logger": "0.28.0",
42
- "@voltro/protocol": "0.28.0"
40
+ "@voltro/database": "0.30.0",
41
+ "@voltro/logger": "0.30.0",
42
+ "@voltro/protocol": "0.30.0"
43
43
  },
44
44
  "peerDependencies": {
45
45
  "effect": "^3.22.0"